269 lines
14 KiB
JavaScript
269 lines
14 KiB
JavaScript
// ==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(); });
|
||
})();
|