// ==UserScript== // @name 阅湾招聘 · 今日职位一键复制 // @namespace dww11.today-jobs // @version 1.0.1 // @description 只读取职位列表前两页,按北京时间的发布时间筛选今日职位,补齐城市薪资并一键复制。 // @match https://www.dww11.com/source/plugin/zimu_zhaopin/admins/* // @match https://dww11.com/source/plugin/zimu_zhaopin/admins/* // @grant GM_setClipboard // @run-at document-idle // @noframes // ==/UserScript== (() => { 'use strict'; const MAX_PAGES = 2; const FOOTER = '阅湾在线招聘版块升级啦!可在线聊天可查阅简历,免费发布信息。\nhttps://www.dww11.com/source/plugin/zimu_zhaopin/h5/'; const clean = value => String(value ?? '').replace(/\s+/g, ' ').trim(); const today = () => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()); function publicationDate(text) { return text.match(/发布\s*[::]\s*(\d{4}-\d{2}-\d{2})(?=\s|$)/)?.[1] || ''; } function parseRows(root = document) { const unique = new Map(); for (const row of root.querySelectorAll('.el-table__body-wrapper tr.el-table__row')) { const link = row.querySelector('a[href*="model=viewjob"][href*="jid="]'); if (!link) continue; const url = new URL(link.getAttribute('href'), location.href); const id = url.searchParams.get('jid'); if (!/^\d+$/.test(id || '')) throw new Error('列表职位 ID 无法识别'); const cell = link.closest('td'); const company = clean(cell?.querySelector('div.ele-text > span.ele-text')?.textContent); const date = publicationDate(row.innerText || row.textContent || ''); const title = clean(link.textContent); if (!date || !company || !title) throw new Error(`职位 ${id} 的发布时间、公司或职位名称缺失,请检查页面结构`); unique.set(id, { id, company, title, date }); } return [...unique.values()]; } function parseDetail(html, job) { const doc = new DOMParser().parseFromString(html, 'text/html'); const title = clean(doc.querySelector('.job-name .name')?.textContent); const salary = clean(doc.querySelector('.job-name .salary')?.textContent).replace(/\s+/g, ''); const city = clean(doc.querySelector('[title="工作地区"]')?.textContent).replace(/市$/, ''); // 只读取职位主体,避免误取页面下方推荐职位的薪资、城市。 if (!title || !city || !salary) throw new Error(`职位 ${job.id} 详情缺少城市、薪资或名称(可能登录失效或页面结构改变)`); if (title !== job.title) throw new Error(`职位 ${job.id} 的详情名称与列表不一致,请重新采集`); return { ...job, city, salary }; } function formatJobs(jobs) { return jobs.map(job => `${job.city}高薪招聘|${job.company}/${job.title}${job.salary}\nhttps://zhaopin.dww11.com/jobs/${job.id}`) .concat(FOOTER).join('\n\n'); } let controller = null; let complete = false; let panel, status, output, collectButton, copyButton, cancelButton; const onList = () => /^#\/job\/index(?:[/?]|$)/.test(location.hash); const visible = element => Boolean(element?.getClientRects().length); const pagination = () => [...document.querySelectorAll('.el-pagination')].find(visible); const pageNumber = () => Number(pagination()?.querySelector('.el-pager .active')?.textContent || 0); const signature = () => parseRows().map(job => job.id).join(','); const say = text => { status.textContent = text; }; function check(signal) { if (signal.aborted) throw new DOMException('已取消', 'AbortError'); if (!onList()) throw new Error('页面已离开职位列表,采集已停止'); } async function waitForTable(signal, { page, oldSignature, changed = false } = {}) { const started = Date.now(); let previous = '', stableSince = Date.now(); while (Date.now() - started < 30000) { check(signal); const current = signature(); const loading = [...document.querySelectorAll('.el-loading-mask')].some(visible); if (current !== previous || loading) { previous = current; stableSince = Date.now(); } const minimum = changed ? 350 : 1200; if (!loading && Date.now() - started >= minimum && Date.now() - stableSince >= 250 && (!page || pageNumber() === page) && (!changed || current !== oldSignature)) { if (!current && ![...document.querySelectorAll('.el-table__empty-text')].some(visible)) { throw new Error('未识别到职位表格,请等待列表加载后重试'); } return; } await new Promise(resolve => setTimeout(resolve, 100)); } throw new Error('等待列表加载超时,未复制不完整结果,请重试'); } async function resetList(signal) { if ([...document.querySelectorAll('.el-dialog__wrapper')].some(visible)) { throw new Error('请先关闭后台弹窗,再整理职位'); } const reset = [...document.querySelectorAll('button')] .find(element => visible(element) && clean(element.textContent) === '重置' && !panel.contains(element)); if (!reset) throw new Error('无法识别重置筛选按钮'); reset.click(); await waitForTable(signal, { page: 1 }); // “重置”应恢复全部审核状态;恢复失败时拒绝把筛选子集当成全部。 const all = [...document.querySelectorAll('.el-radio-button')] .find(element => clean(element.textContent) === '全部'); if (!all) throw new Error('无法识别“全部”审核状态'); if (!all.querySelector('input')?.checked) { all.click(); await waitForTable(signal, { page: 1 }); } const keyword = document.querySelector('input[placeholder="请输入关键词"]'); const filters = ['请选择职位状态', '请选择职位性质', '工作地区', '职位类别', '请选择来源', '刷新状态']; if (keyword?.value || filters.some(placeholder => document.querySelector(`input[placeholder="${placeholder}"]`)?.value)) { throw new Error('重置后仍存在筛选条件,请手动清空后重试'); } } async function detailFor(job, signal) { // 使用已经核实的本站公开详情路由;不调用后台写入接口。 const url = new URL('/plugin.php', location.origin); url.search = new URLSearchParams({ id: 'zimu_zhaopin', model: 'viewjob', jid: job.id, from: 'manage' }); let lastError; for (let attempt = 0; attempt < 2; attempt++) { check(signal); const timeout = AbortSignal.timeout(20000); try { const response = await fetch(url.href, { credentials: 'same-origin', signal: AbortSignal.any([signal, timeout]) }); if (!response.ok) throw new Error(`HTTP ${response.status}`); if (new URL(response.url).origin !== location.origin) throw new Error('详情跳转到其他域名'); return parseDetail(await response.text(), job); } catch (error) { check(signal); lastError = error; } } throw lastError; } async function copyResult() { if (!complete || !output.value) return; try { if (typeof GM_setClipboard === 'function') { GM_setClipboard(output.value, 'text'); } else { await navigator.clipboard.writeText(output.value); } say('已复制前两页的整理结果,可直接粘贴。'); } catch (error) { output.focus(); output.select(); say('自动复制失败,已选中完整结果,请按 Ctrl+C / ⌘C。'); } } async function collect() { if (controller) return; controller = new AbortController(); const { signal } = controller; const date = today(); complete = false; output.value = ''; copyButton.disabled = true; collectButton.disabled = true; cancelButton.disabled = false; try { say(`正在读取 ${date} 的职位;将重置筛选,只读取第1、2页……`); await resetList(signal); const pageSize = Number(pagination()?.querySelector('.el-pagination__sizes input')?.value.match(/^\d+/)?.[0]); if (!Number.isInteger(pageSize) || pageSize < 1) throw new Error('无法识别当前每页条数'); const found = new Map(); const visited = new Set(); let expectedTotal = null; const allIds = new Set(); while (true) { check(signal); const page = pageNumber(); const rows = parseRows(); const key = signature(); const pager = pagination(); if (!pager || !page) throw new Error('无法识别列表分页'); const count = Number(pager.querySelector('.el-pagination__total')?.textContent.replace(/\D/g, '')); if (!Number.isFinite(count)) throw new Error('无法识别职位总数'); if (expectedTotal === null) expectedTotal = count; if (count !== expectedTotal) throw new Error('采集期间职位总数发生变化,请重新整理以免漏项'); if (visited.has(key) && rows.length) throw new Error('分页内容重复,已停止,避免漏项'); visited.add(key); for (const job of rows) { allIds.add(job.id); if (job.date === date) found.set(job.id, job); } const pages = Math.min(MAX_PAGES, Math.max(1, Math.ceil(count / pageSize))); say(`正在扫描第 ${page}/${pages} 页;已找到今日职位 ${found.size} 条。`); if (page >= MAX_PAGES) break; const next = pager.querySelector('.btn-next'); if (!next) throw new Error('找不到下一页按钮'); if (next.disabled || next.getAttribute('aria-disabled') === 'true') break; next.click(); await waitForTable(signal, { page: page + 1, oldSignature: key, changed: true }); } const expectedCount = Math.min(expectedTotal, pageSize * MAX_PAGES); if (allIds.size !== expectedCount) throw new Error(`前两页应有 ${expectedCount} 个唯一职位,只读到 ${allIds.size} 个,列表可能发生变化,请重新整理`); if (!found.size) { say(`前两页内没有 ${date} 发布的职位。`); return; } const jobs = [...found.values()].sort((a, b) => Number(b.id) - Number(a.id)); const details = []; const failures = []; for (const job of jobs) { check(signal); say(`已完成前两页扫描,正在补齐城市和薪资 ${details.length + failures.length + 1}/${jobs.length}……`); try { details.push(await detailFor(job, signal)); } catch (error) { check(signal); failures.push(`${job.id}:${error.message}`); } } check(signal); if (today() !== date) throw new Error('已跨过北京时间零点,请重新整理今日职位'); if (failures.length) { output.value = `以下 ${failures.length} 条详情获取失败,请重新整理:\n${failures.join('\n')}`; throw new Error(`找到 ${jobs.length} 条今日职位,其中 ${failures.length} 条详情失败,未复制不完整结果`); } output.value = formatJobs(details); complete = true; copyButton.disabled = false; say(`整理完成,共 ${details.length} 条,正在复制……`); await copyResult(); } catch (error) { say(error.name === 'AbortError' ? '已取消,未复制。可重新开始。' : `停止:${error.message}`); } finally { controller = null; collectButton.disabled = false; cancelButton.disabled = true; } } function mount() { if (panel) { panel.hidden = !onList(); return; } panel = document.createElement('section'); panel.id = 'dww-today-jobs'; const shadow = panel.attachShadow({ mode: 'open' }); shadow.innerHTML = `
按北京时间的“发布”日期筛选。将重置后台筛选,保留当前每页条数,只读取第1、2页;后续页面不采集。采集时请勿操作列表。
“高薪招聘”为你指定的固定标题,不代表脚本对薪资的判断。