// ==UserScript== // @name 智联招聘 · 投递助手 v3(按 /jobs/ 真实 DOM) // @namespace local.zhaopin.apply.helper // @version 3.4.1 // @description 针对 zhaopin.com/jobs 左右分栏版:点职位卡 → 详情面板投递 → 处理成功弹窗。限速、可演练、风控即停。 // @author you // @match https://www.zhaopin.com/jobs/* // @match https://www.zhaopin.com/recommend* // @match https://sou.zhaopin.com/* // @match https://www.zhaopin.com/sou/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant unsafeWindow // @run-at document-idle // @noframes // @license Apache-2.0 // ==/UserScript== /* * ============================================================ * 结构来源:2026-10 实际打开 https://www.zhaopin.com/jobs/ 抓的 DOM * ============================================================ * 页面:左右分栏。左边 .job-list-panel 里 20 个 .job-card, * 点某张卡 → 右边 .job-detail-card 加载详情。 * * 职位卡: * .job-card 卡片(选中的加 .job-card--active) * .job-card__title-main 标题 * .job-card__salary 薪资(未登录显示 **-**元) * .job-card__company-name 公司 * .job-card__location span 地点 * * 投递按钮在【右侧详情面板】里,不在卡片上: * button.job-detail-summary__apply 文案「立即投递」 * 详情标题 .job-detail-summary__title-text (用来校验选中了哪一条) * * 投递后弹窗(页面里预渲染,display:none): * .deliver-greeting-modal * .deliver-greeting-modal__title "已向对方发送简历和打招呼语" * .deliver-greeting-modal__btn--secondary "留在此页" * .deliver-greeting-modal__btn--primary "继续沟通" * .deliver-greeting-modal__close 关闭 * * 数据源:window.__INITIAL_STATE__ * positionList[] hasAppliedPosition / number / salary / name * selectedJobId 当前选中的职位编号(点卡后变化,权威) * hasMore 是否还能加载更多 * * 未登录时页面有 .job-list-login-gate("登录查看更多相关职位")。 * ============================================================ */ (function () { 'use strict'; const W = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; // ---------------- 配置 ---------------- const CFG = { DRY_RUN: true, API_MODE: false, MAX_PER_RUN: 5, MIN_DELAY_MS: 8000, MAX_DELAY_MS: 16000, MAX_SCROLL_ROUNDS: 6, CONFIRM_EACH: false, }; // 接口地址(从 i.zhaopin.com/widgets/job-apply 源码还原) const API = { prep: 'https://fe-api.zhaopin.com/c/pc/alan/jobs/application/preparation', apply: 'https://fe-api.zhaopin.com/c/pc/alan/jobs/application', }; // ---------------- 选择器(全部实测) ---------------- const SEL = { card: '.job-card', cardActive: '.job-card--active', title: '.job-card__title-main', salary: '.job-card__salary', company: '.job-card__company-name', location: '.job-card__location span', detailTitle: '.job-detail-summary__title-text', applyBtn: '.job-detail-summary__apply', modal: '.deliver-greeting-modal', modalTitle: '.deliver-greeting-modal__title', modalStay: '.deliver-greeting-modal__btn--secondary', modalChat: '.deliver-greeting-modal__btn--primary', loginGate: '.job-list-login-gate', }; const BTN_APPLY = /立即投递/; const BTN_DONE = /(继续沟通|已投递|已申请)/; const RISK_SEL = ['[class*="captcha"]', '[id*="captcha"]', 'iframe[src*="captcha"]', '[class*="geetest"]']; const RISK_WORDS = ['操作过于频繁', '安全验证', '访问异常', '请完成验证', '环境异常']; // ---------------- 状态 ---------------- const S = { running: false, stop: false, done: 0, skip: 0, fail: 0, filtered: 0, t0: 0, applied: new Set(), net: null, via: '' }; const K_TODAY = 'zph_v3_today'; const K_LASTREQ = 'zph_v3_lastreq'; const K_DONE = 'zph_v3_done'; const today = () => new Date().toISOString().slice(0, 10); const todayCount = () => { const r = GM_getValue(K_TODAY, { d: '', n: 0 }); return r.d === today() ? r.n : 0; }; const bumpToday = () => GM_setValue(K_TODAY, { d: today(), n: todayCount() + 1 }); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const rand = (a, b) => Math.floor(a + Math.random() * (b - a)); const T = (s) => (s || '').replace(/\s+/g, ' ').trim(); const state = () => W.__INITIAL_STATE__ || {}; const isVisible = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return r.width > 0 && r.height > 0 && cs.display !== 'none' && cs.visibility !== 'hidden'; }; // ---------------- 日志 ---------------- const lines = []; function log(msg, lv = 'i') { const t = new Date().toLocaleTimeString('zh-CN', { hour12: false }); lines.push(`[${t}] ${msg}`); if (lines.length > 400) lines.shift(); const box = document.getElementById('zph-log'); if (box) { const d = document.createElement('div'); d.className = 'zph-l zph-l--' + lv; d.textContent = `[${t}] ${msg}`; box.appendChild(d); box.scrollTop = box.scrollHeight; while (box.childElementCount > 300) box.removeChild(box.firstChild); } console.log('[ZPH3]', msg); } let statsTick = 0; function stats() { const el = document.getElementById('zph-stats'); if (!el) return; const sec = S.t0 ? Math.round((Date.now() - S.t0) / 1000) : 0; el.textContent = S.running ? `运行 ${sec}s|成功 ${S.done}|跳过 ${S.skip}|筛掉 ${S.filtered}|失败 ${S.fail}` : `就绪|本次 ${S.done}|今日 ${todayCount()} 份|卡片 ${document.querySelectorAll(SEL.card).length}`; // 筛选预览:每 2 秒算一次就够,不必每秒遍历 const fi = document.getElementById('zph-finfo'); if (fi && statsTick++ % 2 === 0) { const all = cards(); let pass = 0; let appliedN = 0; for (const c0 of all) { const c = readCard(c0); if (!c.title) continue; if (!filterCheck(c).ok) continue; if (alreadyApplied(c)) appliedN++; else pass++; } fi.textContent = `本页 ${all.length} 个|可投 ${pass}|已投 ${appliedN}|筛掉 ${all.length - pass - appliedN}`; } } // ---------------- 读取页面 ---------------- const cards = () => Array.from(document.querySelectorAll(SEL.card)); function readCard(el) { return { el, title: T(el.querySelector(SEL.title)?.textContent), salary: T(el.querySelector(SEL.salary)?.textContent), company: T(el.querySelector(SEL.company)?.textContent), location: T(el.querySelector(SEL.location)?.textContent), }; } function loggedIn() { if (isVisible(document.querySelector(SEL.loginGate))) return false; const u = state().user; return !!(u && (u.userId || u.userName)); } function riskHit() { for (const s of RISK_SEL) if (document.querySelector(s)) return '页面出现验证组件'; const b = document.body ? document.body.innerText : ''; for (const w of RISK_WORDS) if (b.includes(w)) return '页面提示「' + w + '」'; return null; } /** 记录已投(跨刷新持久化,按职位编号存) */ function markDone(card) { S.applied.add(card.title + '|' + card.company); const pos = findPosition(card); const key = pos && pos.number ? pos.number : card.title + '|' + card.company; const arr = GM_getValue(K_DONE, []); if (!arr.includes(key)) { arr.push(key); GM_setValue(K_DONE, arr.slice(-500)); } } const donePersisted = () => new Set(GM_getValue(K_DONE, [])); /** 该职位是否已投:本次记录 + 历史记录 + 页面状态标记 */ function alreadyApplied(card) { const key = card.title + '|' + card.company; if (S.applied.has(key)) return true; const pos = findPosition(card); if (!pos) return false; // 本机投过的(按职位编号持久化,刷新不丢) if (pos.number && donePersisted().has(pos.number)) return true; // 服务端状态:这个账号投过的(手机端投的也算) return !!(pos.hasAppliedPosition || (pos.stateInfo && pos.stateInfo.state && pos.stateInfo.state.hasAppliedPosition)); } // ---------------- 标题筛选 ---------------- // 智联搜索是模糊匹配:搜「大数据开发」也会返回「前端软件开发」(因为 JD 里提到大数据)。 // 这里按标题做 必含 / 排除 两道过滤,命中排除即弃。 const K_FILTER = 'zph_v3_filter'; const FLT = { include: [], exclude: [], incSq: [], excSq: [] }; /** 拆分关键词:空格、逗号、顿号、分号、竖线都算分隔符 */ const parseWords = (s) => String(s || '') .split(/[\s,,、;;|]+/) .map((x) => x.trim()) .filter(Boolean); /** 匹配前把空格全去掉(“大数据 开发” 要能匹配 “大数据开发”) */ const squeeze = (s) => String(s || '').toLowerCase().replace(/[\s\u3000]+/g, ''); function setFilter(inc, exc, save) { FLT.include = parseWords(inc); FLT.exclude = parseWords(exc); FLT.incSq = FLT.include.map(squeeze); FLT.excSq = FLT.exclude.map(squeeze); if (save) GM_setValue(K_FILTER, { include: FLT.include, exclude: FLT.exclude }); } function loadFilter() { const r = GM_getValue(K_FILTER, null); if (r) { setFilter((r.include || []).join(' '), (r.exclude || []).join(' '), false); return; } // 第一次用:拿地址栏的搜索词做「必含」初值 const kw = new URLSearchParams(location.search).get('kw') || ''; setFilter(kw, '', false); } /** @returns {{ok:boolean, why:string}} */ function filterCheck(card) { const t = squeeze(card.title); if (!t) return { ok: false, why: '无标题' }; const ex = FLT.excSq.find((k) => k && t.includes(k)); if (ex) return { ok: false, why: `含排除词「${ex}」` }; if (FLT.incSq.length && !FLT.incSq.some((k) => t.includes(k))) { return { ok: false, why: '标题不含必含词' }; } return { ok: true, why: '' }; } const describeFilter = () => (FLT.include.length ? '必含 ' + FLT.include.join('/') : '必含不限') + (FLT.exclude.length ? '|排除 ' + FLT.exclude.join('/') : ''); /** 去重键:优先用职位编号,找不到再退回 标题+公司 */ function cardKey(card) { const pos = findPosition(card); return pos && pos.number ? pos.number : `${card.title}|${card.company}`; } // ---------------- 网络录制:抓真实投递请求 ---------------- function installNetHook() { const code = `(function(){ if (window.__zphHooked) return; window.__zphHooked = 1; function send(o){ try { window.postMessage(Object.assign({__zph:1}, o), '*'); } catch(e){} } var of = window.fetch; if (of) { window.fetch = function(input, init){ var url = typeof input === 'string' ? input : (input && input.url) || ''; var method = (init && init.method) || (input && input.method) || 'GET'; var body = init && init.body; var p = of.apply(this, arguments); if (url.indexOf('/jobs/application') !== -1) { p.then(function(r){ r.clone().text().then(function(t){ send({url:url, method:method, body:String(body==null?'':body), status:r.status, res:t.slice(0,3000)}); }); }).catch(function(e){ send({url:url, method:method, body:String(body==null?'':body), error:String(e)}); }); } return p; }; } var XO = XMLHttpRequest.prototype.open, XS = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(m,u){ this.__z={m:m,u:u}; return XO.apply(this, arguments); }; XMLHttpRequest.prototype.send = function(b){ var self = this; if (this.__z && this.__z.u && this.__z.u.indexOf('/jobs/application') !== -1) { this.addEventListener('load', function(){ send({url:self.__z.u, method:self.__z.m, body:String(b==null?'':b), status:self.status, res:String(self.responseText||'').slice(0,3000)}); }); } return XS.apply(this, arguments); }; })();`; try { const s = document.createElement('script'); s.textContent = code; (document.head || document.documentElement).appendChild(s); s.remove(); log('网络录制已开启(只记录 /jobs/application)', 'n'); } catch (e) { log('注入网络钩子失败:' + e.message, 'e'); } window.addEventListener('message', onNetMsg); } function onNetMsg(e) { const d = e.data; if (!d || d.__zph !== 1) return; const path = String(d.url).replace(/^https?:\/\/[^/]+/, ''); const isFinal = /\/application$/.test(path); log(`网络 ${d.method} ${path} → ${d.status != null ? d.status : d.error}`, 'n'); if (d.res) log(' 返回 ' + d.res.replace(/\s+/g, ' ').slice(0, 400), 'n'); if (isFinal && d.body) { GM_setValue(K_LASTREQ, { url: d.url, method: d.method, body: d.body, status: d.status, res: d.res, time: Date.now(), }); let parsed = null; try { parsed = JSON.parse(d.res || ''); } catch { parsed = null; } let jobNumbers = []; try { jobNumbers = JSON.parse(d.body).jobNumbers || []; } catch { jobNumbers = []; } S.net = { t: Date.now(), ok: !!(parsed && parsed.error === false && (parsed.message === 'SUCCESS' || parsed.actionCode === 200)), msg: parsed ? parsed.message || '' : '', taskId: parsed ? parsed.taskId || '' : '', raw: parsed, jobNumbers, }; log(' 已保存这次投递请求,可点「复制请求」导出', 'n'); } } // ---------------- 接口模式 ---------------- const cookie = (n) => { const m = document.cookie.match(new RegExp('(?:^|; )' + n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)')); return m ? decodeURIComponent(m[1]) : ''; }; /** 标题归一:卡片标题会被 CSS 截断成「…」,要先去尾再比 */ const normTitle = (s) => T(s).replace(/[….]+$/, '').trim(); /** 卡片 → __INITIAL_STATE__.positionList 里对应的职位对象 */ function findPosition(card) { const list = state().positionList || []; if (!list.length) return null; const t = normTitle(card.title); const c = T(card.company); if (!t) return null; // ① 标题 + 公司 完全一致 let hit = list.find((p) => T(p.name) === card.title && T(p.companyName) === c); if (hit) return hit; // ② 标题被截断(… 结尾):前缀匹配 + 公司一致 hit = list.find((p) => normTitle(p.name).startsWith(t) && T(p.companyName) === c); if (hit) return hit; // ③ 只按前缀找,唯一才认 const cands = list.filter((p) => normTitle(p.name).startsWith(t)); return cands.length === 1 ? cands[0] : null; } let resumeCache = null; /** 预检:拿登录态和默认简历号 */ async function apiPrepare(force) { if (resumeCache && !force) return resumeCache; const at = cookie('at'); const rt = cookie('rt'); if (!at || !rt) return { ok: false, msg: '页面没有 at/rt,未登录' }; try { const res = await fetch(apiUrl(API.prep), { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ at, rt, jobCount: 1, rootOrgId: '', staffId: '', isShowAttachmentSelect: false, actionId: '', }), }); const j = await res.json(); const d = (j && j.data) || {}; // error 为 true 时 data 可能仍带 loggedIn:true(例如 208 挤号),必须优先报 message if (j && j.error) return { ok: false, msg: j.message || '接口返回错误' }; if (!d.loggedIn) return { ok: false, msg: j.message || '登录失效' }; // 实测:defaultResume 常为 null,简历号在 resumes[0].number const first = (d.resumes && d.resumes[0]) || {}; const resumeNumber = (d.defaultResume && (d.defaultResume.number || d.defaultResume.resumeNumber)) || first.number || ''; resumeCache = { ok: !!resumeNumber, resumeNumber, resumes: d.resumes || [], multiple: !!d.multipleResume, msg: resumeNumber ? '就绪' : '没有可用简历', }; return resumeCache; } catch (e) { return { ok: false, msg: '预检请求失败:' + e.message }; } } /** 接口 URL:at/rt 挂在 query 上,_v 是随机数(实测格式 0.xxxxxxxx) */ function apiUrl(base) { const at = encodeURIComponent(cookie('at')); const rt = encodeURIComponent(cookie('rt')); return `${base}?at=${at}&rt=${rt}&_v=${Math.random().toFixed(8)}`; } /** 直接调接口投一份:优先复用录到的真实 body 当模板 */ async function apiApply(job, resumeNumber) { const at = cookie('at'); const rt = cookie('rt'); let body = null; const rec = GM_getValue(K_LASTREQ, null); if (rec && rec.body) { try { const tpl = JSON.parse(rec.body); body = { ...tpl, jobNumbers: [job.number], cityIds: [job.cityId], at, rt }; } catch { body = null; } } if (!body) { body = { jobNumbers: [job.number], cityIds: [job.cityId], resumeNumber, at, rt, language: 1, batched: false, inviteCode: '', ignoreIntention: 1, ignoreBlackType: '', deliveryChannelType: 1, extraApplyParams: {}, actionId: '', jobSource: 'SEARCH', }; } const res = await fetch(apiUrl(API.apply), { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const j = await res.json().catch(() => ({})); return { http: res.status, json: j, body }; } // ---------------- 核心:投一份 ---------------- async function applyOne(card) { if (CFG.API_MODE) return await applyViaApi(card); return await applyViaUi(card); } /** 接口投递:不点页面,直接 POST */ async function applyViaApi(card) { const pos = findPosition(card); if (!pos || !pos.number) { S.skip++; log(`接口模式:在 positionList 里找不到这条职位,跳过:${card.title}`, 'w'); return false; } if (card.title && alreadyApplied(card)) { S.skip++; log(`已投过,跳过:${card.title}`, 'm'); return false; } if (CFG.DRY_RUN) { log( `[演练/接口] ${card.title}|${card.company}|${card.salary} → POST jobNumbers=["${pos.number}"]`, 'd' ); return true; } const prep = await apiPrepare(); if (!prep.ok) { log('接口预检失败:' + prep.msg + '(需要登录且有默认简历)', 'e'); stop('接口预检未通过'); return false; } if (CFG.CONFIRM_EACH && !confirm(`接口投递?\n\n${card.title}\n${card.company}`)) { log('已取消', 'm'); return false; } let out; try { out = await apiApply( { number: pos.number, cityId: pos.cityId }, prep.resumeNumber ); } catch (e) { S.fail++; log('接口请求异常:' + e.message, 'e'); return 'unknown'; } const j = out.json || {}; const d = j.data || {}; const n = pos.number; const inArr = (arr) => Array.isArray(arr) && arr.some((x) => (typeof x === 'string' ? x === n : x && (x.jobNumber === n || x.number === n))); // 实测:成功时 data.successful 可能是空数组,真标志是 message=SUCCESS / actionCode=200 const okFlag = j.error === false && (j.message === 'SUCCESS' || j.actionCode === 200 || j.status === 200) && !inArr(d.failed); if (okFlag || (Array.isArray(d.successful) && d.successful.length)) { S.done++; markDone(card); bumpToday(); log(`接口成功:${card.title}|HTTP ${out.http}|taskId=${j.taskId || '-'}`, 'ok'); return true; } if (inArr(d.repeated)) { S.skip++; markDone(card); log(`接口返回「重复投递」,已标记跳过:${card.title}`, 'm'); return false; } if (inArr(d.failed) || j.error) { S.fail++; log(`接口失败:${card.title}|message=${j.message || '(空)'}`, 'e'); if (j.faceRecognition) log(' 该职位要求人脸识别,接口模式无法完成', 'w'); if (d.blockInfo) log(' 拦截信息:' + JSON.stringify(d.blockInfo).slice(0, 200), 'w'); log(' 原始返回:' + JSON.stringify(j).slice(0, 400), 'n'); return 'unknown'; } S.fail++; log(`接口返回无法判定:HTTP ${out.http}|${JSON.stringify(j).slice(0, 300)}`, 'e'); return 'unknown'; } /** UI 投递:点卡片 → 点投递 → 处理弹窗 */ async function applyViaUi(card) { // 1) 选中卡片。注意:__INITIAL_STATE__.selectedJobId 不会随点击更新, // 所以用「该卡片拿到 .job-card--active」+「详情标题一致」双重校验。 const target = card.el.querySelector(SEL.title) || card.el; target.click(); const ok = await waitUntil(() => { const active = card.el.classList.contains('job-card--active'); const dtitle = T(document.querySelector(SEL.detailTitle)?.textContent); return active && dtitle === card.title; }, 5000); if (!ok) { S.fail++; log(`点卡片后详情没切换,跳过:${card.title}`, 'e'); return false; } await sleep(rand(500, 900)); // 2) 找投递按钮 const btn = document.querySelector(SEL.applyBtn); if (!btn) { S.skip++; log(`详情里没有投递按钮,跳过:${card.title}`, 'm'); return false; } const label = T(btn.textContent); if (BTN_DONE.test(label)) { S.skip++; markDone(card); log(`已是「${label}」,跳过:${card.title}`, 'm'); return false; } if (!BTN_APPLY.test(label)) { S.skip++; log(`按钮文案异常「${label}」,跳过:${card.title}`, 'w'); return false; } if (CFG.DRY_RUN) { log(`[演练] ${card.title}|${card.company}|${card.salary}|${card.location} → 会点「${label}」`, 'd'); return true; } if (CFG.CONFIRM_EACH && !confirm(`投递?\n\n${card.title}\n${card.company}\n${card.salary}`)) { log('已取消', 'm'); return false; } // 3) 点击投递 btn.click(); log(`已点击投递:${card.title}`, 'i'); // 4) 等结果:接口返回 / 成功弹窗 / 轻提示 / 风控 const outcome = await waitResult(5000); if (outcome === 'risk') { S.fail++; stop('投递后触发风控'); return false; } if (outcome === 'dialog') { S.fail++; log('出现了未知弹层(不是成功弹窗),已停止。请把日志发我。', 'e'); stop('出现未知弹层,需人工确认'); return false; } if (outcome === 'apifail') { S.fail++; log(`投递被拒:${card.title}|${S.net && S.net.msg}`, 'e'); return 'unknown'; } if (outcome !== 'success') { S.fail++; log(`未确认成功:${card.title}(接口无返回、无弹窗、无提示)`, 'e'); return 'unknown'; } // 5) 成功:关掉可能存在的弹窗 const mt = T(document.querySelector(SEL.modalTitle)?.textContent); log( `成功:${card.title}|判定依据:${S.via}` + (mt && isVisible(document.querySelector(SEL.modal)) ? `|弹窗「${mt}」` : ''), 'ok' ); S.done++; markDone(card); bumpToday(); const stay = document.querySelector(SEL.modalStay); if (stay) { stay.click(); await sleep(400); } const close = document.querySelector('.deliver-greeting-modal__close'); if (isVisible(document.querySelector(SEL.modal)) && close) close.click(); await sleep(rand(800, 1500)); return true; } function waitUntil(fn, timeout) { return new Promise((res) => { const t0 = Date.now(); (function tick() { let v = false; try { v = fn(); } catch { v = false; } if (v) return res(true); if (Date.now() - t0 > timeout) return res(false); setTimeout(tick, 180); })(); }); } /** 返回 'success' | 'apifail' | 'dialog' | 'risk' | 'timeout' */ async function waitResult(timeout) { const t0 = Date.now(); let verdict = 'timeout'; let toastSeen = ''; S.net = null; S.via = ''; await waitUntil(() => { if (riskHit()) { verdict = 'risk'; return true; } // ① 接口返回最可靠:成功不一定会弹窗(实测有两次只弹 toast) if (S.net && S.net.t >= t0) { if (S.net.ok) { verdict = 'success'; S.via = '接口返回 SUCCESS'; return true; } if (S.net.msg && S.net.msg !== 'SUCCESS') { verdict = 'apifail'; return true; } } // ② 成功弹窗 const m = document.querySelector(SEL.modal); if (m && isVisible(m)) { verdict = 'success'; S.via = '成功弹窗'; return true; } // ③ 轻提示 const toast = findToast(); if (toast && toast !== toastSeen) { toastSeen = toast; log('页面提示:' + toast, 'w'); if (/投递成功|已投递|已发送简历/.test(toast)) { verdict = 'success'; S.via = '页面提示「' + toast + '」'; return true; } } const other = findOtherDialog(); if (other) { console.warn('[ZPH3] unknown dialog:', other.outerHTML.slice(0, 1500)); verdict = 'dialog'; return true; } return false; }, timeout); if (verdict === 'timeout') { const btn = document.querySelector(SEL.applyBtn); log( `未确认成功:按钮「${btn ? T(btn.textContent) : '无'}」` + (toastSeen ? `|最后提示「${toastSeen}」` : '|无提示') + '|接口无返回', 'w' ); } if (verdict === 'apifail') log('接口返回失败:' + S.net.msg, 'e'); return verdict; } /** 页面上的轻提示(toast / message / tip) */ function findToast() { const cands = document.querySelectorAll( '[class*="toast"],[class*="Toast"],[class*="message"],[class*="Message"],[class*="notice"],[class*="Notice"],[class*="tip-"]' ); for (const el of cands) { if (!isVisible(el)) continue; if (el.closest('#zph-panel,.header-nav,.home-header')) continue; const t = T(el.textContent); if (t.length < 2 || t.length > 80) continue; if (/^消息\s*\d/.test(t)) continue; // 头部“消息 1”这种不是提示 return t; } return null; } function findOtherDialog() { const cands = document.querySelectorAll( '[class*="dialog"],[class*="Dialog"],[class*="modal"],[class*="Modal"],[role="dialog"]' ); for (const el of cands) { if (!isVisible(el)) continue; if (el.closest('#zph-panel')) continue; if (el.matches(SEL.modal) || el.closest(SEL.modal)) continue; const t = T(el.textContent); if (t.length < 4) continue; if (/简历|确认|投递|选择/.test(t)) return el; } return null; } // ---------------- 加载更多 ---------------- async function loadMore() { const before = cards().length; if (!state().hasMore) return false; window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); await sleep(1500); const grew = await waitUntil(() => cards().length > before, 6000); if (grew) log(`加载更多:${before} → ${cards().length}`, 'i'); return grew; } // ---------------- 主流程 ---------------- async function run() { if (S.running) return; if (!loggedIn()) { log('未登录(页面有登录遮罩 / 无用户信息),投递会跳登录。请先登录再运行。', 'e'); return; } S.running = true; S.stop = false; S.done = S.skip = S.fail = S.filtered = 0; S.seen = new Set(); S.t0 = Date.now(); syncUI(); stats(); log(`开始${CFG.DRY_RUN ? '(演练模式,不会真投)' : ''}|本页 ${cards().length} 个职位`, 'ok'); let emptyRounds = 0; for (let round = 0; round <= CFG.MAX_SCROLL_ROUNDS; round++) { let candidates = 0; for (const el of cards()) { if (S.stop) break; if (S.done >= CFG.MAX_PER_RUN) break; const risk = riskHit(); if (risk) { stop(risk); break; } const card = readCard(el); if (!card.title) continue; // 加载更多会把新卡片追加在末尾,cards() 每轮重查 → 老卡片会重复出现。 // 用职位编号(找不到就用标题+公司)去重,保证每个职位本轮只判一次。 const key = cardKey(card); if (S.seen.has(key)) continue; S.seen.add(key); // 先过标题筛选:不符合的直接略过,不计入「跳过」(那是已投) const f = filterCheck(card); if (!f.ok) { S.filtered++; // 只细看前 5 条,否则一页 20 条刷屏;完整清单在「扫描本页」里 if (S.filtered <= 5) log(`筛掉:${card.title}(${f.why})`, 'd'); continue; } if (alreadyApplied(card)) { S.skip++; log(`跳过:${card.title}(已投过)`, 'm'); continue; } candidates++; const did = await applyOne(card); stats(); // 演练只是预览,不必按真实节奏等待 if (did && !CFG.DRY_RUN) { const gap = rand(CFG.MIN_DELAY_MS, CFG.MAX_DELAY_MS); log(`等待 ${(gap / 1000).toFixed(1)}s…`, 'm'); await sleep(gap); } } if (S.stop || S.done >= CFG.MAX_PER_RUN) break; if (CFG.DRY_RUN) break; // 演练不加载更多 // 本页筛得一个不剩时别连着翻页——短时间连续拉列表也是机器人特征 emptyRounds = candidates ? 0 : emptyRounds + 1; if (emptyRounds >= 2) { log('连续 2 轮没有可投职位,停止翻页(检查下必含词是不是太窄)', 'm'); break; } await sleep(rand(1200, 2600)); if (!(await loadMore())) { log('没有更多职位了', 'i'); break; } } S.running = false; syncUI(); stats(); log(`结束:成功 ${S.done}|跳过 ${S.skip}|筛掉 ${S.filtered}|失败 ${S.fail}|今日 ${todayCount()} 份`, 'ok'); } function stop(reason) { S.stop = true; S.running = false; syncUI(); log('已停止:' + reason, 'e'); toast('已停止:' + reason, true); } // ---------------- 扫描 ---------------- function scan() { const box = document.getElementById('zph-log'); if (box) box.innerHTML = ''; const list = cards(); log(`扫描到 ${list.length} 个 .job-card`, 'ok'); if (!list.length) { log('0 个卡片。说明页面还没渲染完,或结构又变了。', 'e'); return; } // 全量统计,只打印前 25 条 let pass = 0; let doneN = 0; let filt = 0; const rows = []; list.forEach((el) => { const c = readCard(el); const f = filterCheck(c); const done = alreadyApplied(c); let mark = ''; let lv = 'i'; if (!f.ok) { filt++; mark = `[筛掉:${f.why}]`; lv = 'd'; } else if (done) { doneN++; mark = '[已投]'; lv = 'm'; } else { pass++; } rows.push({ c, mark, lv }); }); rows.slice(0, 25).forEach(({ c, mark, lv }, i) => { log(`${i + 1}. ${c.title}|${c.company}|${c.salary}|${c.location}${mark}`, lv); }); log(`筛选结果:本页 ${list.length} 个|可投 ${pass}|已投 ${doneN}|筛掉 ${filt}|规则:${describeFilter()}`, 'ok'); if (list.length >= 8 && filt / list.length >= 0.8) { const pct = Math.round((filt / list.length) * 100); log(`注意:${pct}% 被筛掉,必含词可能过窄。必含是「标题含这个字串」才算过,`, 'w'); log(' 长词会漏掉同义岗位(如「数据开发」漏掉「大数据工程师」「ETL开发」)。', 'w'); log(' 建议拆成多个短词,任一命中即可,例如:大数据 数据开发 数据工程 ETL 数仓 BI', 'w'); } const st = state(); log(`登录:${loggedIn() ? '是' : '否'}|selectedJobId:${st.selectedJobId || '-'}|hasMore:${st.hasMore}`, loggedIn() ? 'ok' : 'e'); const btn = document.querySelector(SEL.applyBtn); log(`详情面板投递按钮:${btn ? '「' + T(btn.textContent) + '」' : '未找到'}`, btn ? 'ok' : 'e'); const m = document.querySelector(SEL.modal); log(`成功弹窗节点:${m ? '存在(当前' + (isVisible(m) ? '可见' : '隐藏') + ')' : '未找到'}`, m ? 'ok' : 'w'); log(`操作提示:先看上面数据对不对,再开演练点「开始」`, 'm'); } // ---------------- UI ---------------- GM_addStyle(` #zph-panel{position:fixed;right:14px;bottom:14px;width:360px;z-index:2147483647; font:12px/1.55 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;color:#e6e8ea; background:#1e2023;border:1px solid #34383d;border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.5)} #zph-panel *{box-sizing:border-box} #zph-hd{display:flex;align-items:center;gap:8px;padding:8px 10px;background:#272a2e; border-radius:10px 10px 0 0;cursor:move;user-select:none} #zph-hd b{flex:1;font-size:12px} #zph-hd button{background:none;border:0;color:#9aa0a6;cursor:pointer;font-size:14px;line-height:1} #zph-bd{padding:9px;display:flex;flex-direction:column;gap:7px} #zph-panel.min #zph-bd{display:none} .zph-r{display:flex;align-items:center;gap:6px} .zph-r label{flex:1;color:#aeb4ba;font-size:11px} .zph-r input[type=number]{width:64px;background:#141618;border:1px solid #3a4046;color:#e6e8ea; border-radius:5px;padding:2px 5px;font-size:11px} .zph-r input[type=checkbox]{accent-color:#3b7ddd;margin:0} .zph-f{display:flex;flex-direction:column;gap:3px} .zph-f label{color:#aeb4ba;font-size:11px} .zph-f input{width:100%;background:#141618;border:1px solid #3a4046;color:#e6e8ea; border-radius:5px;padding:3px 6px;font-size:11px} .zph-f input:focus{outline:none;border-color:#3b7ddd} #zph-finfo{color:#8b9298;font-size:10px} .zph-btns{display:flex;gap:5px} .zph-btns button{flex:1;padding:6px 0;border-radius:6px;border:1px solid #3a4046;background:#2b2f33; color:#e6e8ea;cursor:pointer;font-size:11px} .zph-btns button:hover{background:#343941} .zph-btns button.pri{background:#2f6fd0;border-color:#2f6fd0} .zph-btns button.pri:hover{background:#3b7ddd} .zph-btns button:disabled{opacity:.45;cursor:not-allowed} #zph-stats{color:#8b9298;font-size:10px} #zph-log{height:170px;overflow-y:auto;background:#131517;border:1px solid #292d31;border-radius:6px; padding:5px;font-family:Consolas,Menlo,monospace;font-size:10.5px} .zph-l{margin:1px 0;word-break:break-all;color:#c6cbd0} .zph-l--ok{color:#5fcf80}.zph-l--w{color:#e5c05e}.zph-l--e{color:#f0736f} .zph-l--m{color:#7b8289}.zph-l--d{color:#79b3ff}.zph-l--n{color:#c792ea} #zph-toast{position:fixed;left:50%;top:20px;transform:translateX(-50%);z-index:2147483647; background:#2b2f33;color:#fff;padding:8px 14px;border-radius:6px;font-size:12px; border:1px solid #3f454b;opacity:0;transition:opacity .2s;pointer-events:none} #zph-toast.show{opacity:1} #zph-toast.err{border-color:#a33;background:#3a2224} `); function toast(msg, err) { let t = document.getElementById('zph-toast'); if (!t) { t = document.createElement('div'); t.id = 'zph-toast'; document.body.appendChild(t); } t.textContent = msg; t.className = 'show' + (err ? ' err' : ''); clearTimeout(t._h); t._h = setTimeout(() => (t.className = ''), 2800); } function buildUI() { const p = document.createElement('div'); p.id = 'zph-panel'; p.innerHTML = `