commit 2ccf0a3a4ed26a1045fb0f3ce5e6428e803894bc Author: Longtail Date: Sat Oct 31 13:55:04 2026 +0800 Initial commit diff --git a/today-jobs.user.js b/today-jobs.user.js new file mode 100644 index 0000000..b658ccd --- /dev/null +++ b/today-jobs.user.js @@ -0,0 +1,268 @@ +// ==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页;后续页面不采集。采集时请勿操作列表。

等待整理;复制内容按职位 ID 倒序排列,包含固定结尾。

“高薪招聘”为你指定的固定标题,不代表脚本对薪资的判断。

`; + status = shadow.querySelector('.status'); + output = shadow.querySelector('textarea'); + collectButton = shadow.querySelector('#collect'); + copyButton = shadow.querySelector('#copy'); + cancelButton = shadow.querySelector('#cancel'); + collectButton.addEventListener('click', collect); + copyButton.addEventListener('click', copyResult); + cancelButton.addEventListener('click', () => controller?.abort()); + shadow.querySelector('#toggle').addEventListener('click', event => { + const body = shadow.querySelector('.body'); + body.hidden = !body.hidden; + event.currentTarget.textContent = body.hidden ? '展开' : '收起'; + event.currentTarget.setAttribute('aria-label', body.hidden ? '展开面板' : '折叠面板'); + }); + document.body.append(panel); + panel.hidden = !onList(); + } + + mount(); + window.addEventListener('hashchange', () => { if (!onList()) controller?.abort(); mount(); }); +})();