// ==UserScript== // @name BOSS海投助手(自动沟通 · 自动加载更多 · 暂停控制) // @namespace https://local/boss-auto-apply // @version 1.2.1 // @description BOSS直聘职位列表自动投递:逐个点击卡片上的「立即沟通」,已沟通过的自动跳过,本页点完自动滚动加载更多;浮动面板实时统计沟通数量,支持暂停/继续/停止 // @author you // @match https://www.zhipin.com/* // @match https://zhipin.com/* // @run-at document-idle // @noframes // @license MIT // ==/UserScript== /** * BOSS海投助手 v1.0.0 * * 工作流程(全自动,可随时暂停,全程不用跳页): * 1. 在职位列表页扫描所有职位卡片 * 2. 逐个点击卡片上的「立即沟通」 * 3. 弹窗自动点「留在此页」(留在列表,继续下一个) * 4. 已经是「继续沟通 / 已沟通」的卡片 → 直接跳过 * 5. 本页卡片点完 → 自动滚动到底部加载更多 * 6. 加载不出新职位 → 自动停止并汇报结果 * * 设计要点(都是牛客版踩过的坑,这里预先规避): * - 点击采用三级升级:合成鼠标事件 → 原生 click() → 直接调用 React/Vue 的 onClick * - 选按钮时优先真正的
就绪
0已沟通
0已跳过
0失败
当前:—
设置
`; document.body.appendChild(p); const css = document.createElement('style'); css.textContent = ` #boss-ht-panel{position:fixed;top:80px;right:18px;width:286px;z-index:2147483600;background:#fff;color:#1f2937; border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 10px 30px rgba(0,0,0,.18); font:13px/1.5 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;} #boss-ht-panel *{box-sizing:border-box;} #boss-ht-head{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;background:#00bebd;color:#fff;border-radius:11px 11px 0 0;cursor:move;} #boss-ht-head .boss-ht-title{font-weight:600;font-size:13px;} #boss-ht-head em{font-style:normal;opacity:.75;font-size:11px;} .boss-ht-head-btns button{width:20px;height:20px;margin-left:4px;border:0;border-radius:50%;background:rgba(255,255,255,.25);color:#fff;cursor:pointer;font-size:12px;line-height:1;} #boss-ht-body{padding:10px;} #boss-ht-panel.boss-min #boss-ht-body{display:none;} #boss-ht-status{padding:6px 8px;border-radius:8px;background:#e6fffe;color:#0f766e;font-size:12px;margin-bottom:8px;word-break:break-all;} .boss-ht-stats{display:flex;gap:6px;margin-bottom:8px;text-align:center;} .boss-ht-stats>div{flex:1;background:#f8fafc;border:1px solid #eef2f7;border-radius:8px;padding:6px 0;} .boss-ht-stats b{display:block;font-size:17px;color:#00bebd;} .boss-ht-stats span{font-size:11px;color:#94a3b8;} #boss-ht-current{font-size:12px;color:#64748b;margin-bottom:8px;word-break:break-all;max-height:32px;overflow:hidden;} .boss-ht-actions{display:flex;gap:6px;margin-bottom:8px;} .boss-ht-actions button{flex:1;padding:7px 0;border:1px solid #d1d5db;border-radius:8px;background:#fff;cursor:pointer;font-size:12px;} .boss-ht-actions button.boss-ht-primary{background:#00bebd;border-color:#00bebd;color:#fff;font-weight:600;} .boss-ht-actions button.stop{background:#ef4444;border-color:#ef4444;color:#fff;} .boss-ht-actions button:disabled{opacity:.45;cursor:not-allowed;} #boss-ht-settings summary{cursor:pointer;font-size:12px;color:#00bebd;margin-bottom:6px;} #boss-ht-settings label{display:flex;align-items:center;justify-content:space-between;gap:6px;font-size:12px;color:#475569;margin-bottom:5px;} #boss-ht-settings label.boss-ht-check{justify-content:flex-start;} #boss-ht-settings label.boss-ht-greet{display:block;} #boss-ht-settings input[type=number]{width:80px;padding:3px 6px;border:1px solid #d1d5db;border-radius:6px;} #boss-ht-settings textarea{width:100%;margin-top:4px;padding:5px 6px;border:1px solid #d1d5db;border-radius:6px;font-size:12px;resize:vertical;} .boss-ht-actions2{display:flex;gap:6px;margin:6px 0 8px;} .boss-ht-actions2 button{flex:1;padding:5px 0;border:1px solid #d1d5db;background:#fff;border-radius:6px;cursor:pointer;font-size:11px;color:#475569;} #boss-ht-log{height:110px;overflow-y:auto;background:#0f172a;color:#cbd5e1;border-radius:8px;padding:6px 8px;font-size:11px;line-height:1.6;font-family:Consolas,monospace;} #boss-ht-log div{word-break:break-all;} #boss-ht-log .warn{color:#fbbf24;} #boss-ht-log .err{color:#f87171;} #boss-ht-log .ok{color:#4ade80;} `; document.head.appendChild(css); UI.panel = p; UI.status = p.querySelector('#boss-ht-status'); UI.applied = p.querySelector('#boss-ht-applied'); UI.skipped = p.querySelector('#boss-ht-skipped'); UI.failed = p.querySelector('#boss-ht-failed'); UI.current = p.querySelector('#boss-ht-current'); UI.btnStart = p.querySelector('#boss-ht-start'); UI.btnPause = p.querySelector('#boss-ht-pause'); UI.btnStop = p.querySelector('#boss-ht-stop'); UI.log = p.querySelector('#boss-ht-log'); UI.btnStart.addEventListener('click', () => { if (!isListPage()) { addLog('当前不是 BOSS 职位列表页,请打开 zhipin.com/web/geek/jobs 后点开始', 'warn'); return; } S.running = true; S.paused = false; S.startedAt = Date.now(); // 上一轮失败的职位,这一轮重新尝试(已成功的保留,不会重复沟通) const retry = Object.keys(S.attempted).length; if (retry) { S.attempted = {}; addLog(`上一轮有 ${retry} 个职位失败,本轮会重新尝试`); } saveNow(); addLog('▶ 开始运行'); setStatus('运行中'); render(); startLoop(); }); UI.btnPause.addEventListener('click', () => { if (!S.running) return; S.paused = !S.paused; addLog(S.paused ? '⏸ 已暂停(点「继续」恢复)' : '▶ 已继续'); saveNow(); setStatus(S.paused ? '已暂停' : '运行中'); render(); if (!S.paused) startLoop(); }); UI.btnStop.addEventListener('click', () => stopRun('⏹ 已手动停止')); p.querySelector('#boss-ht-min').addEventListener('click', () => p.classList.toggle('boss-min')); p.querySelector('#boss-ht-hide').addEventListener('click', () => (p.style.display = 'none')); const bindNum = (sel, key, min) => { const el = p.querySelector(sel); el.addEventListener('change', () => { const n = parseInt(el.value, 10); if (!isNaN(n) && n >= min) { S[key] = n; saveState(); addLog(`设置已更新:${key} = ${n}`); } else { el.value = S[key]; } }); }; bindNum('#boss-ht-interval', 'interval', 1500); bindNum('#boss-ht-limit', 'limit', 1); const bindCheck = (sel, key) => { const el = p.querySelector(sel); el.addEventListener('change', () => { S[key] = el.checked; saveState(); addLog(`设置已更新:${key} = ${el.checked}`); }); }; bindCheck('#boss-ht-skipcard', 'skipCardChatted'); bindCheck('#boss-ht-dry', 'dryRun'); bindCheck('#boss-ht-resume', 'autoResume'); p.querySelector('#boss-ht-greeting').addEventListener('change', (e) => { S.greeting = e.target.value.trim(); saveNow(); addLog(S.greeting ? '已设置打招呼语:' + S.greeting.slice(0, 20) : '已清空打招呼语(使用 BOSS 默认招呼语)'); }); p.querySelector('#boss-ht-diag').addEventListener('click', diagnose); p.querySelector('#boss-ht-export').addEventListener('click', exportRecords); p.querySelector('#boss-ht-clear').addEventListener('click', () => { if (!confirm('确定清空已沟通/已跳过记录吗?(清空后同一批职位会被重新沟通一遍)')) return; S.done = {}; S.attempted = {}; S.appliedLog = []; S.stats = { applied: 0, skipped: 0, failed: 0 }; saveNow(); render(); addLog('已清空记录'); }); makeDraggable(p, p.querySelector('#boss-ht-head')); render(); } function setStatus(t) { statusText = t; if (UI.status) UI.status.textContent = t; } function render() { if (!UI.panel) return; UI.applied.textContent = S.stats.applied; UI.skipped.textContent = S.stats.skipped; UI.failed.textContent = S.stats.failed; UI.current.textContent = '当前:' + (S.lastTitle || '—'); UI.btnStart.disabled = S.running; UI.btnStart.textContent = S.running && !S.paused ? '运行中…' : '▶ 开始'; UI.btnPause.disabled = !S.running; UI.btnPause.textContent = S.paused ? '▶ 继续' : '⏸ 暂停'; UI.btnPause.className = S.paused ? 'boss-ht-primary' : ''; UI.btnStop.className = S.running ? 'stop' : ''; document.querySelector('#boss-ht-interval').value = S.interval; document.querySelector('#boss-ht-limit').value = S.limit; document.querySelector('#boss-ht-skipcard').checked = !!S.skipCardChatted; document.querySelector('#boss-ht-dry').checked = !!S.dryRun; document.querySelector('#boss-ht-resume').checked = !!S.autoResume; document.querySelector('#boss-ht-greeting').value = S.greeting || ''; if (S.running) setStatus(S.paused ? '已暂停' : statusText); } function addLog(msg, type) { const line = `[${new Date().toLocaleTimeString()}] ${msg}`; S.logs.push(line); if (S.logs.length > 80) S.logs = S.logs.slice(-80); saveState(); if (UI.log) { const d = document.createElement('div'); if (type) d.className = type; d.textContent = line; UI.log.appendChild(d); UI.log.scrollTop = UI.log.scrollHeight; while (UI.log.childNodes.length > 200) UI.log.removeChild(UI.log.firstChild); } console.log('[BOSS海投]', msg); } function renderLogsFromState() { if (!UI.log) return; UI.log.innerHTML = ''; S.logs.slice(-40).forEach((line) => { const d = document.createElement('div'); d.textContent = line; UI.log.appendChild(d); }); UI.log.scrollTop = UI.log.scrollHeight; } function makeDraggable(panel, handle) { let dragging = false; let sx = 0; let sy = 0; let ox = 0; let oy = 0; handle.addEventListener('mousedown', (e) => { dragging = true; sx = e.clientX; sy = e.clientY; const r = panel.getBoundingClientRect(); ox = r.left; oy = r.top; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!dragging) return; panel.style.left = ox + (e.clientX - sx) + 'px'; panel.style.top = oy + (e.clientY - sy) + 'px'; panel.style.right = 'auto'; }); document.addEventListener('mouseup', () => (dragging = false)); } /* ====================================================================== * 五、职位卡片识别 * ==================================================================== */ /** 收集"像按钮"的可点元素(文案短的) */ function collectClickables(scope) { const sel = 'button, a, [role="button"], input[type="button"], input[type="submit"], [class*="btn"], [class*="Btn"], [class*="button"], [class*="Button"]'; const out = []; let all = []; try { all = (scope || document).querySelectorAll(sel); } catch (e) { return out; } for (const el of all) { const t = txt(el); if (!t || t.length > 16) continue; if (!visible(el)) continue; if (el.closest('#boss-ht-panel')) continue; out.push(el); } return out; } /** * 找职位卡片。 * 策略1:常见类名直接命中; * 策略2(兜底):从每个职位链接往上爬,找到最近的"像卡片"的祖先 * —— 这样即使 BOSS 改了类名也不会全军覆没。 */ function scanCards() { const sels = [ 'li.job-card-box', '.job-card-wrapper', 'ul.job-list-box > li', '.job-list-container li[class*="card"]', '[class*="job-card"]', '[class*="jobcard"]', ]; let nodes = []; for (const sel of sels) { let found = []; try { found = Array.from(document.querySelectorAll(sel)); } catch (e) { continue; } found = found.filter((el) => el.getClientRects().length > 0); if (found.length > nodes.length) nodes = found; if (nodes.length >= 3) break; } // 策略2:从职位链接反推卡片 if (nodes.length < 2) { const links = []; try { links = Array.from(document.querySelectorAll('a[href*="/job_detail/"]')); } catch (e) {} const fromLinks = []; for (const a of links) { if (!a.getClientRects().length) continue; let node = a.parentElement; let picked = null; for (let d = 0; node && node !== document.body && d < 7; d++, node = node.parentElement) { if (looksLikeCard(node) && node.querySelector('a[href*="/job_detail/"]')) { picked = node; // 继续往上找一层更大的容器,尽量覆盖到整张卡片(含底部按钮) const up = node.parentElement; if (up && up !== document.body && looksLikeCard(up)) picked = up; } } if (picked) fromLinks.push(picked); } if (fromLinks.length > nodes.length) nodes = fromLinks; } if (!nodes.length) return []; // 去掉嵌套(外层卡片里还套着内层卡片) const outer = nodes.filter((n) => !nodes.some((o) => o !== n && n.contains(o))); const out = []; const seen = new Set(); for (const card of outer) { const info = cardInfo(card); if (!info) continue; if (seen.has(info.key)) continue; seen.add(info.key); out.push(info); } return out; } /** 读取卡片信息:key / 标题 / 公司 / 沟通按钮 / 当前状态 */ function cardInfo(card) { if (!card) return null; const link = card.querySelector('a[href*="/job_detail/"]') || (card.tagName === 'A' && /job_detail/.test(card.getAttribute('href') || '') ? card : null); const href = link ? link.getAttribute('href') || '' : ''; let id = ''; const m = href.match(/job_detail\/([^.?]+)\.html/) || href.match(/job_detail\/([^.?]+)/); if (m) id = m[1]; if (!id && card.dataset) id = card.dataset.jobid || card.dataset.lid || ''; const nameEl = card.querySelector('.job-name') || card.querySelector('[class*="job-name"]') || card.querySelector('[class*="jobName"]') || card.querySelector('[class*="job-title"]') || link; const companyEl = card.querySelector('.company-name') || card.querySelector('[class*="company-name"]'); const title = nameEl ? txt(nameEl).slice(0, 40) : ''; const company = companyEl ? txt(companyEl).slice(0, 30) : ''; const key = id || (title + '|' + company).slice(0, 100) || null; if (!key) return null; // 卡片上的沟通按钮:先直接找,找不到就"悬停"一下再找(很多按钮 hover 才出现) let btn = pickBestButton(findCardChatButtons(card)); if (!btn) { hoverCard(card); btn = pickBestButton(findCardChatButtons(card)); } const btnText = btn ? txt(btn) : ''; return { key, id, title, company, card, btn, btnText, href, noButtonLogged: false }; } /** 卡片按钮状态:chat(可沟通)/ chatted(已沟通)/ none(没有按钮,可能需要进详情) */ function cardState(info) { if (!info || !info.btn) return 'none'; if (CHATTED_RE.test(info.btnText)) return 'chatted'; if (CHAT_LOOSE_RE.test(info.btnText)) return 'chat'; return 'none'; } /** 卡片结构快照(排障用) */ function dumpCardStructure(card, tag) { try { const html = (card.outerHTML || '').replace(/\s+/g, ' ').slice(0, 400); addLog(`${tag}:${html}`, 'warn'); } catch (e) {} } /** 整页关键结构快照(排障用):职位链接数量 + 页面主要容器类名 */ function dumpPageStructure() { try { const links = document.querySelectorAll('a[href*="/job_detail/"]'); addLog('页面里 job_detail 链接数:' + links.length, 'warn'); if (links.length) { const a = links[0]; let node = a.parentElement; const chain = []; for (let d = 0; node && node !== document.body && d < 6; d++, node = node.parentElement) { const cls = typeof node.className === 'string' ? node.className.slice(0, 40) : ''; chain.push(`<${node.tagName.toLowerCase()} ${cls}>`); } addLog('首个职位链接的祖先链:' + chain.join(' ← '), 'warn'); dumpCardStructure(a.closest('li, [class*="card"], div') || a.parentElement, '链接所在容器结构'); } } catch (e) {} } /* ====================================================================== * 六、弹窗处理 * ==================================================================== */ function findVisibleModal() { const sels = [ '[role="dialog"]', '[class*="dialog"]', '[class*="Dialog"]', '[class*="modal"]', '[class*="Modal"]', '[class*="popup"]', '[class*="Popup"]', '[class*="greet"]', ]; for (const sel of sels) { let nodes = []; try { nodes = document.querySelectorAll(sel); } catch (e) { continue; } for (const el of nodes) { if (!visible(el)) continue; const r = el.getBoundingClientRect(); // 阈值放宽一些:BOSS 的打招呼弹窗有时不大,别把它当成"不存在" if (r.width < 120 || r.height < 50) continue; if (el.closest('#boss-ht-panel')) continue; return el; } } return null; } function findConfirmButton(scope) { const root = scope || document; const cands = []; let nodes = []; try { nodes = root.querySelectorAll('button, a, [role="button"], [class*="btn"], [class*="Btn"]'); } catch (e) { nodes = []; } for (const el of nodes) { const t = txt(el); if (!t || t.length > 10) continue; if (!visible(el)) continue; if (STAY_RE.test(t) || LEAVE_RE.test(t)) cands.push(el); } if (!cands.length) return null; const enabled = cands.filter((el) => !isDisabled(el)); return pickBestButton(enabled.length ? enabled : cands); } function dumpModalDetails(modal, tag) { try { const parts = []; parts.push('全文=' + txt(modal).slice(0, 120)); const btns = []; try { modal.querySelectorAll('button, a, [role="button"], [class*="btn"], [class*="Btn"]').forEach((el) => { const t = txt(el); if (!t || t.length > 14) return; const cls = typeof el.className === 'string' ? el.className.slice(0, 28) : ''; btns.push(`<${el.tagName.toLowerCase()} ${cls}|${isDisabled(el) ? '禁用' : '可点'}>${t}`); }); } catch (e) {} parts.push('按钮=' + (btns.slice(0, 8).join(' § ') || '无')); addLog(`${tag}:${parts.join(' | ')}`, 'warn'); } catch (e) {} } /* ====================================================================== * 七、投递(沟通)流程 * ==================================================================== */ function markApplied(key, title) { if (S.done[key] === 'applied') return; S.done[key] = 'applied'; S.stats.applied++; S.appliedLog.push({ key, title: title || S.lastTitle || '', url: location.href, time: new Date().toLocaleString(), }); saveNow(); render(); } function markSkipped(key, reason) { if (S.done[key]) return; S.done[key] = 'skip'; S.stats.skipped++; saveNow(); render(); if (reason) addLog(`跳过:${reason}`); } function markFailed(key, reason) { if (S.attempted[key]) return; S.attempted[key] = reason || '失败'; S.stats.failed++; saveNow(); render(); addLog(`失败:${reason}`, 'err'); } /** 每个职位的处理入口 */ async function applyCard(info) { S.lastTitle = info.title || info.company || info.key; saveNow(); render(); setStatus(`处理中:${S.lastTitle}`); let state = cardState(info); addLog(`[${info.title || info.key}] 按钮「${info.btnText || '无'}」→ ${state}`); if (state === 'chatted') { markSkipped(info.key, `「${S.lastTitle}」已经沟通过了`); return false; } // 卡片上没有沟通按钮 → 先把卡片结构打出来(排障),再点进详情页找一次 if (state === 'none') { if (!S._noButtonDumped) S._noButtonDumped = {}; if (!S._noButtonDumped[info.key]) { S._noButtonDumped[info.key] = 1; dumpCardStructure(info.card, '卡片结构(未找到沟通按钮)'); const texts = []; try { info.card.querySelectorAll('a, button, [role="button"], span, i').forEach((el) => { const t = txt(el); if (t && t.length <= 16 && texts.indexOf(t) === -1) texts.push(t); }); } catch (e) {} addLog('卡片内可点文案:' + (texts.slice(0, 20).join(' | ') || '(无)'), 'warn'); } const ok = await openDetailAndChat(info); return ok; } if (S.dryRun) { addLog(`🧪 演练:识别到「${info.btnText}」,未真实点击`, 'warn'); markSkipped(info.key, '演练模式:未真实沟通'); return false; } // 点击「立即沟通」(先把鼠标移上去,确保 hover 才显示的按钮已就绪) const btn = info.btn; hoverCard(info.card); await sleep(150); const beforeText = info.btnText; const res = await smartActivate( btn, () => { const t = txt(btn); if (t && t !== beforeText && CHATTED_RE.test(t)) return true; if (findVisibleModal()) return true; if (pageHasSuccess()) return true; if (/\/web\/geek\/chat/.test(location.pathname)) return true; return false; }, `点击「${beforeText}」`, 2200 ); addLog('点击尝试:' + res.tried.join(' → ') + (res.how ? `(生效:${res.how})` : '(都没生效)')); const done = await handleAfterClick(info); if (done.ok) { markApplied(info.key, info.title); addLog(`✅ 已沟通:${S.lastTitle}`, 'ok'); return true; } markFailed(info.key, done.reason || '未确认沟通成功'); return false; } /** 点击「立即沟通」之后:处理弹窗、可选发招呼语、判断结果 */ async function handleAfterClick(info) { const deadline = Date.now() + 12000; let modalHandled = 0; let clickedStay = false; while (Date.now() < deadline) { if (!S.running) return { ok: false, reason: '已停止' }; await sleep(300); // 风控/上限提示 → 立刻停(只扫弹窗和轻提示元素,不整页扫文本) if (pageHasLimitWarning()) { pauseRun('检测到 BOSS 的频率/次数限制提示,已自动暂停,请稍后再试'); return { ok: false, reason: '触发频率限制' }; } // 成功信号 if (pageHasSuccess()) return { ok: true, reason: 'success-text' }; if (cardState(info) === 'chatted') return { ok: true, reason: 'button-chatted' }; if (clickedStay && !findVisibleModal()) return { ok: true, reason: 'modal-closed' }; // 跳到了聊天页 → 回列表(视为沟通成功) if (/\/web\/geek\/chat/.test(location.pathname)) { addLog('跳转到了聊天页,返回列表继续'); goBackToList(); return { ok: true, reason: 'chat-page' }; } const modal = findVisibleModal(); if (modal) { modalHandled++; const modalText = txt(modal); if (SUCCESS_RE.test(modalText)) return { ok: true, reason: 'modal-success' }; // 可选:发送自定义招呼语 if (S.greeting) { const sent = await trySendGreeting(modal); if (sent) addLog('已发送自定义招呼语'); } const btn = findConfirmButton(modal); if (btn && !isDisabled(btn)) { const t = txt(btn); const isStay = STAY_RE.test(t); addLog(`弹窗按钮「${t}」→ ${isStay ? '留在列表页' : '注意:可能跳走'}`); const r = await smartActivate(btn, () => !findVisibleModal() || cardState(info) === 'chatted', `弹窗「${t}」`, 2000); addLog('点击尝试:' + r.tried.join(' → ') + (r.how ? `(生效:${r.how})` : '(都没生效)')); if (isStay) clickedStay = true; continue; } // 弹窗里没有可点的按钮,等一会儿;实在不行就把结构打出来 if (modalHandled === 4) dumpModalDetails(modal, '弹窗详情'); if (modalHandled > 12) { dumpModalDetails(modal, '弹窗详情'); return { ok: false, reason: `弹窗一直不关(${modalText.slice(0, 40)})` }; } await sleep(400); } } return { ok: false, reason: '超时未确认沟通成功' }; } /** 可选:把打招呼语填进弹窗/聊天输入框并发送 */ async function trySendGreeting(scope) { if (!S.greeting) return false; let input = null; try { input = (scope || document).querySelector('textarea, [contenteditable="true"], .chat-input'); } catch (e) {} if (!input) return false; const already = (input.value || input.textContent || '').trim(); if (already) return false; // 已经有内容(多是 BOSS 的默认招呼语),不覆盖 try { input.focus(); if (input.tagName === 'TEXTAREA') { const setter = Object.getOwnPropertyDescriptor(input.constructor.prototype, 'value'); if (setter && setter.set) setter.set.call(input, S.greeting); else input.value = S.greeting; input.dispatchEvent(new Event('input', { bubbles: true })); } else { document.execCommand('insertText', false, S.greeting); } await sleep(200); const sendBtn = (scope || document).querySelector('.btn-send, .send-btn, [class*="send"] button') || null; if (sendBtn) humanClick(sendBtn); await sleep(600); return true; } catch (e) { return false; } } /** 卡片上没有沟通按钮时:进详情页找一次 */ async function openDetailAndChat(info) { const href = info.href; if (!href) { markFailed(info.key, '卡片上没有沟通按钮,也没有详情页链接'); return false; } addLog('卡片上没有「立即沟通」,尝试进详情页…'); if (S.dryRun) { markSkipped(info.key, '演练模式:未真实沟通'); return false; } // 关键:现在这个页面才是列表页,记下来,返回时才有地方可回 if (isJobListPage()) { S.listUrl = location.href; } S.detailNavKey = info.key; // 记住是哪个职位跳过去的 S.detailFallback = true; saveNow(); const url = new URL(href, location.origin).href; location.href = url; return false; // 跳转后由页面重新接管 } const DEFAULT_LIST_URL = 'https://www.zhipin.com/web/geek/jobs'; /** 计算"该返回到哪个页面"(单独抽出来便于测试) */ function resolveBackTarget() { let target = S.listUrl || ''; // 绝不能把详情页当列表页返回 —— 那会变成"详情页↔详情页"无限打转 if (!target || isDetailPage(target)) target = DEFAULT_LIST_URL; // 注意:target 与当前页相同不算问题(等于刷新列表页),保留用户筛选条件,别退回默认地址 return target; } /** 返回职位列表页(带防死循环保护) */ function goBackToList() { const target = resolveBackTarget(); if (S.listUrl && isDetailPage(S.listUrl)) { addLog('记录的"列表页"其实是详情页,改用默认职位列表页', 'warn'); } // 60 秒内返回超过 8 次,说明页面在来回跳,停下来别糟蹋次数 const now = Date.now(); if (!S.backWindowStart || now - S.backWindowStart > 60000) { S.backWindowStart = now; S.backCount = 0; } S.backCount = (S.backCount || 0) + 1; saveNow(); if (S.backCount > 8) { pauseRun('检测到页面来回跳转异常,已暂停。请手动回到职位列表页后点「继续」'); return; } addLog('返回列表页:' + target); setTimeout(() => { location.href = target; }, 600); } /** 收集页面上的"轻提示"元素(toast / message / tip / warn),用于判断成功或风控 */ function findTipElements() { const sels = [ '[class*="toast"]', '[class*="Toast"]', '[class*="message"]', '[class*="Message"]', '[class*="tip"]', '[class*="Tip"]', '[class*="warn"]', '[class*="Warn"]', '[class*="error"]', '[class*="Error"]', '[class*="notice"]', ]; const out = []; for (const sel of sels) { let nodes = []; try { nodes = document.querySelectorAll(sel); } catch (e) { continue; } for (const el of nodes) { if (txt(el).length > 60) continue; if (!visible(el)) continue; if (el.closest('#boss-ht-panel')) continue; out.push(el); } } return out; } /** 页面是否出现成功提示 */ function pageHasSuccess() { try { const modal = findVisibleModal(); if (modal && SUCCESS_RE.test(txt(modal))) return true; } catch (e) {} if (collectClickables().some((el) => SUCCESS_RE.test(txt(el)))) return true; return findTipElements().some((el) => SUCCESS_RE.test(txt(el))); } /** 页面是否出现"频率过高/次数上限"之类的风控提示 */ function pageHasLimitWarning() { try { const modal = findVisibleModal(); if (modal && LIMIT_RE.test(txt(modal))) return true; } catch (e) {} if (findTipElements().some((el) => LIMIT_RE.test(txt(el)))) return true; return false; } /* ====================================================================== * 八、滚动加载更多 * ==================================================================== */ function cardFingerprint() { return scanCards().map((c) => c.key).join(','); } function findScrollableContainer() { let best = null; let bestScore = 0; let nodes = []; try { nodes = document.querySelectorAll('div, main, section, ul'); } catch (e) { return null; } for (let i = 0; i < nodes.length && i < 1200; i++) { const el = nodes[i]; try { if (el.scrollHeight - el.clientHeight < 200) continue; const st = getComputedStyle(el); if (!/(auto|scroll|overlay)/.test(st.overflowY)) continue; const score = el.scrollHeight - el.clientHeight; if (score > bestScore) { bestScore = score; best = el; } } catch (e) {} } return best; } function pageSaysEnd() { try { const t = (document.body.innerText || '').slice(-3000); return END_RE.test(t); } catch (e) { return false; } } /** 往下滚动,尝试加载更多职位卡片 */ async function loadMoreCards() { const before = cardFingerprint(); const countBefore = scanCards().length; for (let attempt = 0; attempt < 5; attempt++) { if (!S.running || S.paused) return false; const scroller = findScrollableContainer(); try { if (scroller) scroller.scrollTop = scroller.scrollHeight; } catch (e) {} try { window.scrollTo({ top: document.body.scrollHeight, behavior: 'auto' }); } catch (e) {} // 有些站点用 IntersectionObserver 监听底部哨兵,来回滚一下更容易触发 await sleep(400); try { window.scrollBy(0, -200); await sleep(200); window.scrollTo({ top: document.body.scrollHeight, behavior: 'auto' }); } catch (e) {} for (let i = 0; i < 10; i++) { await sleep(300); const now = cardFingerprint(); if (now && now !== before) { addLog(`已加载更多职位(${countBefore} → ${scanCards().length} 个)`); return true; } } } return false; } /* ====================================================================== * 九、主循环 * ==================================================================== */ let loopRunning = false; async function runLoop() { if (loopRunning) return; loopRunning = true; try { while (S.running) { if (S.paused) { await sleep(500); continue; } if (!hasLock()) { setStatus('另一个标签页正在运行,本页待命'); await sleep(1500); continue; } heartbeatLock(); if (S.stats.applied >= S.limit) { stopRun(`已达到单次上限 ${S.limit} 个,自动停止(可改大上限后重新开始)`); break; } const cards = scanCards(); if (!cards.length) { // 列表可能是异步渲染的,先等一等再判"没有卡片" let waited = 0; let found = []; while (waited < 12000 && S.running && !S.paused) { await sleep(1000); waited += 1000; found = scanCards(); if (found.length) break; } if (!found.length) { addLog('等了 12 秒仍未识别到职位卡片,请确认页面已加载出职位列表', 'warn'); dumpPageStructure(); pauseRun('未找到职位卡片,已暂停'); break; } continue; } // 卡片上就写着"继续沟通"的,直接跳过 if (S.skipCardChatted) { let pre = 0; for (const c of cards) { if (S.done[c.key] || S.attempted[c.key]) continue; if (cardState(c) === 'chatted') { S.done[c.key] = 'skip'; S.stats.skipped++; pre++; } } if (pre) { saveNow(); render(); addLog(`卡片显示已沟通,直接跳过 ${pre} 个`); } } const fresh = cards.filter((c) => !S.done[c.key] && !S.attempted[c.key]); if (!fresh.length) { addLog('本页职位已全部处理,尝试加载更多…'); const grew = await loadMoreCards(); if (!grew) { if (pageSaysEnd()) stopRun('页面提示没有更多职位了,任务完成 🎉'); else stopRun('滚动加载不出新职位了,任务完成 🎉'); break; } continue; } const target = fresh[0]; try { await applyCard(target); } catch (e) { markFailed(target.key, '异常:' + e.message); addLog('处理异常:' + e.message, 'err'); } await sleep(S.interval + rand(0, S.jitter)); } } finally { loopRunning = false; } } function startLoop() { if (loopRunning) return; runLoop(); } function stopRun(msg) { S.running = false; S.paused = false; releaseLock(); saveNow(); if (msg) addLog(msg, 'ok'); addLog(`本轮结束 → 已沟通 ${S.stats.applied} · 已跳过 ${S.stats.skipped} · 失败 ${S.stats.failed}`); setStatus('已停止'); render(); } function pauseRun(msg) { S.paused = true; saveNow(); if (msg) addLog(msg, 'warn'); setStatus('已暂停'); render(); } /* ====================================================================== * 十、诊断与导出 * ==================================================================== */ function diagnose() { addLog('=== 诊断 ==='); addLog('URL:' + location.href); addLog('识别为:' + (isListPage() ? 'BOSS 职位列表页' : '其它页面(请在职位列表页使用)')); const cards = scanCards(); addLog( '识别到职位卡片:' + cards.length + (cards.length ? '(前3个:' + cards .slice(0, 3) .map((c) => `${c.title || c.key}[按钮=${c.btnText || '无'}]`) .join(',') + ')' : '') ); const clickables = collectClickables(); const texts = Array.from(new Set(clickables.map((el) => txt(el)))).slice(0, 30); addLog('页面按钮文案:' + (texts.join(' | ') || '(无)'), 'warn'); const modal = findVisibleModal(); addLog('可见弹窗:' + (modal ? txt(modal).slice(0, 50) : '无')); if (modal) dumpModalDetails(modal, '弹窗详情'); const c0 = cards[0]; if (c0 && c0.btn) { addLog( `首个卡片按钮详情:<${c0.btn.tagName.toLowerCase()}> class="${String(c0.btn.className).slice(0, 40)}" ` + `框架=${detectFramework(c0.btn) || '未检测到'} 禁用=${isDisabled(c0.btn)}` ); } else if (c0) { addLog('首个卡片没有识别到沟通按钮,下面是它的结构:', 'warn'); dumpCardStructure(c0.card, '卡片结构'); const texts = []; try { c0.card.querySelectorAll('a, button, [role="button"], span, i').forEach((el) => { const t = txt(el); if (t && t.length <= 16 && texts.indexOf(t) === -1) texts.push(t); }); } catch (e) {} addLog('卡片内可点文案:' + (texts.slice(0, 20).join(' | ') || '(无)'), 'warn'); } if (!cards.length) dumpPageStructure(); addLog('页面是否提示"没有更多":' + (pageSaysEnd() ? '是' : '否')); addLog('运行状态:' + (S.running ? (S.paused ? '已暂停' : '运行中') : '未运行')); } function exportRecords() { const rows = [['职位', '时间', '链接']]; S.appliedLog.forEach((r) => rows.push([r.title, r.time, r.url])); const csv = rows.map((r) => r.map((c) => '"' + String(c == null ? '' : c).replace(/"/g, '""') + '"').join(',')).join('\r\n'); const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `BOSS沟通记录_${new Date().toISOString().slice(0, 10)}.csv`; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 2000); addLog(`已导出 ${S.appliedLog.length} 条沟通记录`); } /* ====================================================================== * 十一、启动 * ==================================================================== */ let booted = false; function boot() { if (booted) return; if (!document.body) { setTimeout(boot, 300); return; } if (!/zhipin\.com$/.test(location.hostname) && !/\.zhipin\.com$/.test(location.hostname)) return; booted = true; buildPanel(); injectCardCss(); renderLogsFromState(); addLog(`BOSS海投助手 v${VERSION} 已加载`); // 记住列表页地址(用于从详情页/聊天页返回);注意详情页绝不能被记成"列表页" if (isJobListPage()) { S.listUrl = location.href; // 回到列表页了,重置"来回跳转"计数 S.backCount = 0; saveNow(); } if (S.running && !S.paused) { addLog('检测到未完成的任务,自动继续'); setStatus('运行中'); startLoop(); } else if (S.running && S.paused) { setStatus('已暂停(点「继续」恢复)'); } else { setStatus('就绪:在职位列表页点「开始」'); } render(); // 后台心跳(多标签页互斥) setInterval(() => { if (S.running && !S.paused) heartbeatLock(); }, 2500); // 详情页兜底:如果是从卡片跳过来的,就在详情页找一次「立即沟通」 if (isDetailPage() && S.running && !S.paused) { // 不是我们跳过来的(比如你自己点进来的),就别乱动,交给用户 if (S.detailNavKey) handleDetailFallback(); else addLog('当前在职位详情页(非脚本跳转),返回列表页继续'); if (!S.detailNavKey) setTimeout(goBackToList, 500); } window.addEventListener('beforeunload', () => { if (S.running) saveNow(); }); // 控制台调试出口 try { window.__BOSS_HT__ = { version: VERSION, state: S, scanCards, cardState, applyCard, loadMoreCards, smartActivate, detectFramework, isJobListPage, isDetailPage, resolveBackTarget, diagnose, addLog, }; } catch (e) {} } /** 卡片没按钮时跳到详情页,在这里补一次点击 */ async function handleDetailFallback() { addLog('详情页兜底:寻找「立即沟通」按钮…'); // key 要和卡片扫描的规则保持一致(都用 job_detail 后面的 id),避免同一职位被记两次 const m = location.pathname.match(/job_detail\/([^./?]+)/); const key = m ? m[1] : location.pathname + location.search; const deadline = Date.now() + 12000; let btn = null; while (Date.now() < deadline && S.running && !S.paused) { const cands = collectClickables().filter((el) => CHAT_RE.test(txt(el)) && !isDisabled(el)); btn = pickBestButton(cands); if (btn) break; if (collectClickables().some((el) => CHATTED_RE.test(txt(el)) && !isDisabled(el))) { addLog('详情页显示已沟通过,返回列表'); // 记下来,避免回到列表后又被同一个职位拽回详情页,来回死循环 if (!S.done[key]) { S.done[key] = 'skip'; S.stats.skipped++; saveNow(); render(); } S.detailNavKey = ''; saveNow(); goBackToList(); return; } await sleep(400); } if (!btn) { addLog('详情页也没找到「立即沟通」,返回列表', 'warn'); S.detailNavKey = ''; saveNow(); goBackToList(); return; } const beforeText = txt(btn); if (S.dryRun) { addLog('🧪 演练:详情页识别到「' + beforeText + '」,未真实点击', 'warn'); S.detailNavKey = ''; saveNow(); goBackToList(); return; } addLog(`详情页点击「${beforeText}」`); const res = await smartActivate( btn, () => { const t = txt(btn); if (t && t !== beforeText && CHATTED_RE.test(t)) return true; if (findVisibleModal()) return true; if (pageHasSuccess()) return true; return false; }, `详情页「${beforeText}」`, 2200 ); addLog('点击尝试:' + res.tried.join(' → ') + (res.how ? `(生效:${res.how})` : '(都没生效)')); const info = { key, title: txt(document.querySelector('h1') || document.body).slice(0, 30), btn, btnText: beforeText }; const done = await handleAfterClick(info); if (done.ok) { markApplied(key, info.title); addLog(`✅ 已沟通:${info.title}`, 'ok'); } else { markFailed(key, done.reason || '未确认沟通成功'); } S.detailNavKey = ''; saveNow(); goBackToList(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } boot(); setInterval(boot, 1500); })();