Files
2026-10-31 13:55:04 +08:00

269 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==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 = `<style>
:host{position:fixed;right:20px;bottom:20px;z-index:2147483000;width:min(420px,calc(100vw - 40px));font:14px/1.5 system-ui,sans-serif;color:#172338}
:host([hidden]){display:none}*{box-sizing:border-box}.box{background:#fff;border:1px solid #d5deea;border-radius:12px;box-shadow:0 8px 36px #10204030;padding:16px}
header{display:flex;align-items:center;justify-content:space-between;font-weight:700;font-size:16px}p{margin:8px 0;color:#58677e;font-size:12px}textarea{width:100%;height:185px;resize:vertical;border:1px solid #ccd6e3;border-radius:6px;padding:9px;font:12px/1.6 ui-monospace,monospace;color:#172338;background:#f9fbfd}
.actions{display:flex;gap:8px;margin:10px 0}button{cursor:pointer;border:1px solid #cbd5e1;border-radius:6px;background:white;padding:8px 10px;color:#24344d}button.primary{background:#2563eb;color:white;border-color:#2563eb}button:disabled{opacity:.45;cursor:default}.status{white-space:pre-wrap;overflow-wrap:anywhere;min-height:36px;font-size:12px}#toggle{padding:2px 8px} .body[hidden]{display:none}
</style><div class="box"><header>今日职位整理<button id="toggle" type="button" aria-label="折叠面板">收起</button></header><div class="body"><p>按北京时间的“发布”日期筛选。将重置后台筛选,保留当前每页条数,只读取第1、2页;后续页面不采集。采集时请勿操作列表。</p><div class="actions"><button id="collect" class="primary" type="button">整理并一键复制</button><button id="copy" type="button" disabled>复制结果</button><button id="cancel" type="button" disabled>取消</button></div><div class="status" role="status" aria-live="polite">等待整理;复制内容按职位 ID 倒序排列,包含固定结尾。</div><textarea aria-label="今日职位整理结果" readonly placeholder="整理成功后在这里预览完整文案"></textarea><p>“高薪招聘”为你指定的固定标题,不代表脚本对薪资的判断。</p></div></div>`;
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(); });
})();