diff --git a/packages/boss-auto-browse-and-chat/chat-handler.mjs b/packages/boss-auto-browse-and-chat/chat-handler.mjs index fc47092..97d7334 100644 --- a/packages/boss-auto-browse-and-chat/chat-handler.mjs +++ b/packages/boss-auto-browse-and-chat/chat-handler.mjs @@ -9,6 +9,7 @@ import { sleep, sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs' import { getResumeData, extractResumeText } from './resume-extractor.mjs' import { createHumanCursor } from './humanMouse.mjs' +import { safeClickElement, dismissBlockingOverlays } from './dialog-dismisser.mjs' import { debug as logDebug, info as logInfo, warn as logWarn } from './logger.mjs' import { CANDIDATE_ITEM_SELECTOR, @@ -65,7 +66,7 @@ export async function viewCandidateDetail (frame, candidateItem, options = {}) { if (getInterceptedData) { const intercepted = getInterceptedData() - const resumeResult = await getResumeData(frame, intercepted) + const resumeResult = await getResumeData(frame, intercepted, { getCapturedText: options.getCapturedText }) if (resumeResult.source === 'api' && resumeResult.data) { resumeSource = 'api' resumeText = typeof resumeResult.data === 'string' ? resumeResult.data : JSON.stringify(resumeResult.data) @@ -314,34 +315,28 @@ export async function startChatWithCandidate (frame, _candidate, _greetingMessag // 1. 点击"打招呼"按钮(拟人轨迹);在 iframe frame 内按 candidateIndex 找到对应 item // Puppeteer 24.x boundingBox() 已自动叠加 iframe 偏移,返回 page 绝对坐标,直接使用 + // 点击前先扫一下主页面是否有挡住操作的浮层(治理公告 / 意向沟通 等突发弹窗) + if (mainPage && mainPage !== frame) { + await dismissBlockingOverlays(mainPage, { maxRounds: 2 }).catch(() => 0) + } try { + let startBtn if (typeof candidateIndex === 'number' && CANDIDATE_ITEM_SELECTOR) { const items = await frame.$$(CANDIDATE_ITEM_SELECTOR) const item = items[candidateIndex] - if (!item) { - return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' } - } - const startBtn = await item.$(CHAT_START_BUTTON_SELECTOR) - if (!startBtn) { - return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' } - } - const box = await startBtn.boundingBox() - if (box) { - await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 }) - } else { - await startBtn.click() - } + if (!item) return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' } + startBtn = await item.$(CHAT_START_BUTTON_SELECTOR) } else { - const startBtn = await frame.waitForSelector(CHAT_START_BUTTON_SELECTOR, { timeout: 8000 }) - if (!startBtn) { - return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' } - } - const box = await startBtn.boundingBox() - if (box) { - await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 }) - } else { - await startBtn.click() - } + startBtn = await frame.waitForSelector(CHAT_START_BUTTON_SELECTOR, { timeout: 8000 }) + } + if (!startBtn) return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' } + // 「打招呼」按钮在 iframe 内,遮挡几乎都来自主页面 —— 这里直接 cursor.click, + // safeClickElement 在 frame 上下文做 elementFromPoint 不可靠,改为提前 sweep 主页面 + const box = await startBtn.boundingBox() + if (box) { + await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 }) + } else { + await startBtn.click() } } catch { return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' } @@ -365,21 +360,37 @@ export async function startChatWithCandidate (frame, _candidate, _greetingMessag } // 2. 等待"已向牛人发送招呼"弹窗并点击"知道了"(弹窗在主页面,不在 iframe 内) + // 优先用 known selector;找不到则用启发式扫描兜底(应对弹窗结构变动)。 + // 若弹窗始终未出现(selector 超时 + 启发式也没关掉任何浮层),视为招呼可能未发出, + // 返回 GREETING_SENT_DIALOG_NOT_APPEARED 而非乐观地返回 OK,防止误计成功次数。 if (GREETING_SENT_KNOW_BTN_SELECTOR) { + let handled = false try { const knowBtn = await mainPage.waitForSelector(GREETING_SENT_KNOW_BTN_SELECTOR, { timeout: 6000 }) if (knowBtn) { - const box = await knowBtn.boundingBox() - if (box) { - await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 }) - } else { - await knowBtn.click() - } + const r = await safeClickElement({ + ctx: mainPage, page: mainPage, element: knowBtn, cursor, + logPrefix: '[chat-handler:greet-know]' + }) + // 仅当真正点击成功时认为已处理;clicked=false 时落到下方启发式兜底 + handled = r.clicked === true && !r.error + if (r.error) logWarn('[chat-handler] safeClickElement 报告 error=', r.error) } await sleepWithRandomDelay(500) } catch { - // 弹窗未出现不是致命错误,继续 - logWarn('[chat-handler] "知道了"弹窗未出现,继续尝试后续步骤') + // selector 超时,留给启发式兜底 + } + if (!handled) { + const closed = await dismissBlockingOverlays(mainPage, { maxRounds: 2 }).catch(() => 0) + if (closed > 0) { + logInfo('[chat-handler] 启发式关闭了发送招呼后的浮层') + handled = true + } else { + // 弹窗既没有通过 selector 出现,也没有被启发式识别到—— + // 不能确认招呼已发,提前返回失败,避免误报成功 + logWarn('[chat-handler] 招呼确认弹窗("知道了")始终未出现,招呼可能未被发送') + return { success: false, reason: 'GREETING_SENT_DIALOG_NOT_APPEARED' } + } } } diff --git a/packages/boss-auto-browse-and-chat/chat-page-processor.mjs b/packages/boss-auto-browse-and-chat/chat-page-processor.mjs index 3cc24e3..3cf5963 100644 --- a/packages/boss-auto-browse-and-chat/chat-page-processor.mjs +++ b/packages/boss-auto-browse-and-chat/chat-page-processor.mjs @@ -7,6 +7,7 @@ import { sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs' import { readConfigFile, getMergedJobConfig } from './runtime-file-utils.mjs' import { setupNetworkInterceptor, parseGeekInfoFromIntercepted } from './resume-extractor.mjs' import { createHumanCursor } from './humanMouse.mjs' +import { dismissBlockingOverlays } from './dialog-dismisser.mjs' import { openOnlineResume, getOnlineResumeText, @@ -22,7 +23,6 @@ import { BOSS_CHAT_PAGE_URL, CHAT_PAGE_ACTIVE_NAME_SELECTOR, CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, - CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR, CHAT_PAGE_ITEM_SELECTOR, CHAT_PAGE_ITEM_UNREAD_SELECTOR, CHAT_PAGE_ALL_FILTER_SELECTOR, @@ -323,6 +323,7 @@ export default async function startBossChatPageProcess (hooksFromCaller, options page: existingPage, getCapturedText, clearCapturedText, + peekCapturedText, jobId = null, retryCandidate = null, processContext = null @@ -494,6 +495,7 @@ export default async function startBossChatPageProcess (hooksFromCaller, options } // 关闭「意向沟通」提示弹窗(BOSS 每次新浏览器会话打开某些会话时会弹出,遮挡右侧操作按钮) + // 优先用已知 selector 快速路径;失败则启发式扫描兜底(应对 BOSS 弹窗结构变动) { const intentKnowBtn = await page.$(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR).catch(() => null) if (intentKnowBtn) { @@ -506,24 +508,22 @@ export default async function startBossChatPageProcess (hooksFromCaller, options await intentKnowBtn.click().catch(() => {}) } } catch { - const closeIconEl = await page.$(CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR).catch(() => null) - if (closeIconEl) { - const closeIconBox = await closeIconEl.boundingBox().catch(() => null) - if (closeIconBox) { - await cursor.click({ x: closeIconBox.x + closeIconBox.width / 2, y: closeIconBox.y + closeIconBox.height / 2 }) - } else { - await closeIconEl.click().catch(() => {}) - } - } + // 留给启发式兜底 } try { await page.waitForSelector(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, { hidden: true, timeout: 3000 }) logDebug(`${LOG} → 「意向沟通」弹窗已关闭`) } catch { - logWarn(`${LOG} → 「意向沟通」弹窗 3s 内未消失,继续执行(可能影响按钮点击)`) + logWarn(`${LOG} → 「意向沟通」弹窗 3s 内未消失,启发式兜底...`) } await sleepWithRandomDelay(200, 400) } + // 启发式兜底:扫一遍主页面剩余浮层(治理公告补刀 / 未知新弹窗) + const closed = await dismissBlockingOverlays(page, { maxRounds: 2 }).catch(() => 0) + if (closed > 0) { + logInfo(`${LOG} → 启发式额外关闭了 ${closed} 个浮层`) + await sleepWithRandomDelay(200, 400) + } } // 阶段一:初步信息筛选(点击会话后 geek/info 已触发,从拦截数据取结构化字段) @@ -637,7 +637,9 @@ export default async function startBossChatPageProcess (hooksFromCaller, options let stableCount = 0 while (Date.now() < canvasDeadline) { await new Promise(r => setTimeout(r, POLL_INTERVAL_MS)) - const currentCount = await page.evaluate(() => (window.__canvasCapturedText || []).length) + const currentCount = typeof peekCapturedText === 'function' + ? await peekCapturedText(page) + : await page.evaluate(() => (window.__canvasCapturedText || []).length) if (currentCount > 0 && currentCount === lastCount) { stableCount++ if (stableCount >= STABLE_POLLS_NEEDED) break diff --git a/packages/boss-auto-browse-and-chat/chat-page-resume.mjs b/packages/boss-auto-browse-and-chat/chat-page-resume.mjs index bf91f5b..f5ca2d8 100644 --- a/packages/boss-auto-browse-and-chat/chat-page-resume.mjs +++ b/packages/boss-auto-browse-and-chat/chat-page-resume.mjs @@ -13,6 +13,7 @@ import { extractResumeText, parseGeekInfoFromIntercepted } from './resume-extractor.mjs' import { sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs' import { createHumanCursor } from './humanMouse.mjs' +import { dismissBlockingOverlays } from './dialog-dismisser.mjs' import { CHAT_PAGE_ONLINE_RESUME_SELECTOR, CHAT_PAGE_ONLINE_RESUME_IFRAME_SELECTOR, @@ -26,8 +27,7 @@ import { CHAT_PAGE_ATTACH_RESUME_DIALOG_CLOSE_SELECTOR, CHAT_PAGE_TAB_ALL_SELECTOR, CHAT_PAGE_ITEM_SELECTOR, - CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, - CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR + CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR } from './constant.mjs' /** @@ -162,6 +162,7 @@ export async function requestAttachmentResume (page, options = {}) { const cursor = options.cursor ?? await createHumanCursor(page) // 请求前先检测并关闭 tutorial/意向沟通弹窗,避免遮挡附件简历按钮或误点 + // 已知 selector 优先 → 启发式扫描兜底(应对未知新弹窗) const intentKnowBtn = await page.$(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR).catch(() => null) if (intentKnowBtn) { console.log('[requestAttachmentResume] 检测到意向沟通/tutorial 弹窗,先关闭...') @@ -170,11 +171,15 @@ export async function requestAttachmentResume (page, options = {}) { await page.waitForSelector(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, { hidden: true, timeout: 3000 }) console.log('[requestAttachmentResume] tutorial 弹窗已关闭') } catch { - const closeIcon = await page.$(CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR).catch(() => null) - if (closeIcon) await closeIcon.click().catch(() => {}) + // 留给启发式兜底 } await sleepWithRandomDelay(300, 600) } + const extraClosed = await dismissBlockingOverlays(page, { maxRounds: 2 }).catch(() => 0) + if (extraClosed > 0) { + console.log('[requestAttachmentResume] 启发式额外关闭了', extraClosed, '个浮层') + await sleepWithRandomDelay(200, 400) + } // 检查是否有残留的确认弹窗(上一个候选人流程遗留,v-if 未关闭) // 若存在则先等它消失;若等不到则视为卡死,直接报错,不继续执行 diff --git a/packages/boss-auto-browse-and-chat/dialog-dismisser.mjs b/packages/boss-auto-browse-and-chat/dialog-dismisser.mjs new file mode 100644 index 0000000..bc69952 --- /dev/null +++ b/packages/boss-auto-browse-and-chat/dialog-dismisser.mjs @@ -0,0 +1,337 @@ +/** + * 通用弹窗 / 遮挡层自动识别与关闭。 + * + * 设计目标:减少手动维护「治理公告」「意向沟通」之类一次性弹窗 selector 的成本。 + * 思路: + * 1) 启发式扫描页面顶层 fixed / 高 z-index 浮层; + * 2) 在浮层内按文本(我已知晓/我知道了/知道了/确定/好的/关闭/取消/跳过/×)+ + * aria-label / class(close|dismiss|confirm-btn|btn-sure)匹配关闭按钮; + * 3) safeClickAt:点击前用 elementFromPoint 检测目标坐标是否被遮挡,被挡则先尝试关闭遮挡再重试。 + * + * 注意:所有浏览器侧逻辑都在一次 evaluate 内做完,避免多次 round-trip;返回结果含被关闭浮层的 outerHTML 摘要供日志审计。 + */ + +import { sleep } from '@geekgeekrun/utils/sleep.mjs' +import { debug as logDebug, info as logInfo } from './logger.mjs' + +/** 关闭按钮文本(按优先级) */ +const CLOSE_TEXTS = [ + '我已知晓', '我知道啦', '我知道了', '知道了', '我知道', + '好的', '确定', '确认', + '关闭', '取消', + '跳过', '稍后再说', '稍后', '不再提示', + '我已阅读并同意', '同意并继续' +] + +/** 单字关闭符号 */ +const CLOSE_GLYPHS = ['×', '✕', '✖', '⨯', '╳'] + +/** + * 浏览器侧的弹窗识别 / 关闭脚本。 + * 一次 evaluate 内完成扫描 + 点击,避免多次 round-trip。 + * + * @returns {{ + * dismissed: boolean, + * reason?: 'TEXT' | 'CLASS' | 'ARIA' | 'GLYPH', + * text?: string, + * outerHtml?: string, + * overlaySignature?: string + * }} + */ +function dismissInPageBody (closeTexts, closeGlyphs) { + const isVisible = (el) => { + if (!el || !el.getBoundingClientRect) return false + const cs = getComputedStyle(el) + if (cs.visibility === 'hidden' || cs.display === 'none') return false + // opacity 既可能是 '0' 也可能是 '0.0'/'0.00' 等,用 parseFloat 兜底 + if (parseFloat(cs.opacity) <= 0) return false + const r = el.getBoundingClientRect() + return r.width > 1 && r.height > 1 + } + + const vw = window.innerWidth + const vh = window.innerHeight + + // 1) 扫描候选浮层。先用窄选择器(CSS class/role 显式标记 dialog/popup 等的元素); + // 命中即可避免对整页 querySelectorAll('*') 做样式计算 —— 在大型 SPA 上能省下 O(N) 的 getComputedStyle。 + // 若窄查询过滤后仍为空,再回退到全量扫描(带元素数上限),覆盖无标记的 hand-rolled 浮层。 + const NARROW_SEL = '[class*="dialog"],[class*="popup"],[class*="modal"],[class*="mask"],[class*="overlay"],[class*="drawer"],[role="dialog"],[role="alertdialog"]' + const FULL_SCAN_CAP = 5000 + const collectFrom = (nodes) => { + const out = [] + let scanned = 0 + for (const el of nodes) { + if (++scanned > FULL_SCAN_CAP) break + if (!isVisible(el)) continue + const cs = getComputedStyle(el) + if (cs.position !== 'fixed' && cs.position !== 'absolute') continue + const r = el.getBoundingClientRect() + const area = r.width * r.height + if (area < vw * vh * 0.05) continue + if (r.right < 0 || r.left > vw || r.bottom < 0 || r.top > vh) continue + const z = parseInt(cs.zIndex || '0', 10) || 0 + const cls = (el.className && typeof el.className === 'string') ? el.className : (el.getAttribute && el.getAttribute('class')) || '' + const looksLikeDialog = /dialog|popup|modal|mask|overlay|drawer/i.test(cls) + if (z < 100 && !looksLikeDialog) continue + // 排除:业务流程主动打开、不该被自动关闭的 dialog(在线/附件简历预览、索取简历确认) + if (/resume-common-dialog|ask-for-resume-confirm|c-resume/i.test(cls)) continue + out.push({ el, z, area, looksLikeDialog }) + } + return out + } + let overlays = document.body ? collectFrom(document.body.querySelectorAll(NARROW_SEL)) : [] + if (overlays.length === 0 && document.body) { + overlays = collectFrom(document.body.querySelectorAll('*')) + } + + // 优先级:明显是 dialog 的 + z-index 高 + 面积大 + overlays.sort((a, b) => { + if (a.looksLikeDialog !== b.looksLikeDialog) return a.looksLikeDialog ? -1 : 1 + if (b.z !== a.z) return b.z - a.z + return b.area - a.area + }) + + const findClose = (root) => { + const candidates = root.querySelectorAll('button, [role="button"], a, span, div, i') + let best = null + for (const c of candidates) { + if (!isVisible(c)) continue + // 只考虑可点击 / 有 cursor pointer 的元素 + const cs = getComputedStyle(c) + const looksClickable = c.tagName === 'BUTTON' || c.getAttribute('role') === 'button' || + cs.cursor === 'pointer' || + /btn|button|close|confirm|sure|know|agree/i.test(c.className || '') + if (!looksClickable) continue + const text = (c.innerText || c.textContent || '').trim() + const aria = (c.getAttribute('aria-label') || '') + ' ' + (c.getAttribute('title') || '') + const cls = c.className || '' + + // 1) 文本严格匹配(短文本,整段就是按钮文案) + if (text.length > 0 && text.length <= 12) { + for (const t of closeTexts) { + if (text === t || text.includes(t)) { + return { btn: c, reason: 'TEXT', text } + } + } + for (const g of closeGlyphs) { + if (text === g) return { btn: c, reason: 'GLYPH', text } + } + } + // 2) class / aria 匹配关闭语义 + if (/close|dismiss|confirm-btn|btn-sure/i.test(cls)) { + if (!best) best = { btn: c, reason: 'CLASS', text: text.slice(0, 30) } + } + if (/close|dismiss/i.test(aria)) { + if (!best) best = { btn: c, reason: 'ARIA', text: aria.trim().slice(0, 30) } + } + } + return best + } + + for (const ov of overlays) { + const hit = findClose(ov.el) + if (!hit) continue + const r = hit.btn.getBoundingClientRect() + if (r.width < 1 || r.height < 1) continue + // 触发真实 click(HTMLElement.click() 同时通知 Vue/React 监听) + try { + hit.btn.click() + } catch (_) { + // ignore + } + const outerHtml = (ov.el.outerHTML || '').slice(0, 400) + const sigParts = [] + if (ov.el.id) sigParts.push('#' + ov.el.id) + if (ov.el.className) sigParts.push('.' + String(ov.el.className).split(/\s+/).slice(0, 2).join('.')) + return { + dismissed: true, + reason: hit.reason, + text: hit.text, + outerHtml, + overlaySignature: sigParts.join('') || ov.el.tagName.toLowerCase() + } + } + return { dismissed: false } +} + +/** + * 在 page / frame 上扫描并关闭一个挡住操作的浮层。 + * 调用方可循环调用直到 false(最多 N 次)以处理叠加弹窗。 + * @param {import('puppeteer').Page | import('puppeteer').Frame} ctx + * @returns {Promise<{dismissed: boolean, reason?: string, text?: string, overlaySignature?: string, outerHtml?: string}>} + */ +export async function tryDismissOneOverlay (ctx) { + try { + const result = await ctx.evaluate(dismissInPageBody, CLOSE_TEXTS, CLOSE_GLYPHS) + if (result?.dismissed) { + logInfo('[dialog-dismisser] 自动关闭浮层:', result.overlaySignature, '匹配=', result.reason, '文案=', result.text) + logDebug('[dialog-dismisser] outerHTML 摘要:', result.outerHtml) + } + return result || { dismissed: false } + } catch (e) { + logDebug('[dialog-dismisser] evaluate 失败:', e?.message) + return { dismissed: false } + } +} + +/** + * 多次循环尝试关闭浮层(应对叠加 / 关一个出一个的情况)。 + * + * 每次 click 后等 gapMs 再重新扫描:若下一轮扫到的是同一个浮层(signature 相同), + * 说明 click 没有触发关闭(可能是 disabled 按钮或事件被吞),记为一次"无进展"; + * 连续 2 次无进展则提前终止,避免无限点击失效按钮。 + * + * @param {import('puppeteer').Page | import('puppeteer').Frame} ctx + * @param {{ maxRounds?: number, gapMs?: number }} [opts] + * @returns {Promise} 成功关闭的浮层数量 + */ +export async function dismissBlockingOverlays (ctx, opts = {}) { + const maxRounds = opts.maxRounds ?? 3 + const gapMs = opts.gapMs ?? 350 + let count = 0 + let staleSig = null // 上一轮被点击但可能未关掉的浮层 signature + let staleCount = 0 // 连续无进展次数 + for (let i = 0; i < maxRounds; i++) { + const r = await tryDismissOneOverlay(ctx) + if (!r.dismissed) break + await sleep(gapMs) + // 检查是否真正消失:若 signature 与上轮相同,视为 click 无效 + if (r.overlaySignature && r.overlaySignature === staleSig) { + staleCount++ + logDebug('[dialog-dismisser] 浮层', r.overlaySignature, '点击后仍存在(staleCount=', staleCount, ')') + if (staleCount >= 2) { + logInfo('[dialog-dismisser] 浮层', r.overlaySignature, '连续 2 次点击无效,终止重试') + break + } + } else { + // 新出现的浮层(或浮层已关且另一个冒出),重置计数 + staleSig = r.overlaySignature + staleCount = 0 + count++ + } + } + return count +} + +/** + * 检查 (x, y) 处的最顶层元素是否是 expectedEl 或其后代。被其他元素遮挡返回 blocked=true。 + * + * 坐标系:x/y 必须是 **viewport / client 坐标**(即 `document.elementFromPoint` 期望的坐标系, + * 与 `getBoundingClientRect()` 返回值一致)。Puppeteer 的 `ElementHandle.boundingBox()` + * 返回的也是 viewport-relative 坐标(与 `page.mouse.click` 接受的坐标系相同),所以可直接传入。 + * 若调用方手头是 document/page 坐标(含 scroll 偏移),需先减去 `window.scrollX/scrollY`。 + * + * @param {import('puppeteer').Page | import('puppeteer').Frame} ctx + * @param {import('puppeteer').ElementHandle} expectedEl + * @param {number} x viewport x 坐标 + * @param {number} y viewport y 坐标 + * @returns {Promise<{blocked: boolean, topTag?: string, topClass?: string}>} + */ +export async function checkBlockedAt (ctx, expectedEl, x, y) { + try { + const result = await ctx.evaluate((targetEl, px, py) => { + const top = document.elementFromPoint(px, py) + if (!top) return { blocked: false } + // 只有 top === target 或 top 是 target 的后代时才认为未被遮挡。 + // top 是 target 的祖先意味着 target 上方有元素拦截了点击事件(pointer-events 转发到祖先)。 + if (top === targetEl || (targetEl && targetEl.contains && targetEl.contains(top))) { + return { blocked: false } + } + return { + blocked: true, + topTag: top.tagName?.toLowerCase(), + topClass: (typeof top.className === 'string' ? top.className : '').slice(0, 60) + } + }, expectedEl, x, y) + return result || { blocked: false } + } catch (_) { + return { blocked: false } + } +} + +/** + * 安全点击:先校验目标是否被遮挡,被挡则尝试关闭浮层后重试。 + * + * 兼容 humanMouse 的 cursor.click({x, y})。当 ctx 是 Frame,elementFromPoint + * 是 frame 内部坐标系,但 boundingBox() 是 page 坐标——所以这里 ctx 应当与 expectedEl + * 来自同一上下文(Frame 元素就传 Frame,主页面元素就传 Page)。 + * + * @param {{ + * ctx: import('puppeteer').Page | import('puppeteer').Frame, + * page: import('puppeteer').Page, + * element: import('puppeteer').ElementHandle, + * cursor: { click: (p:{x:number,y:number}) => Promise }, + * maxRetries?: number, + * logPrefix?: string + * }} args + * @returns {Promise<{ clicked: boolean, dismissedCount: number, error?: string }>} + * - clicked: 是否真正发出过一次成功的点击调用(cursor.click 或 element.click 未抛错) + * - dismissedCount: 期间被启发式关掉的浮层数 + * - error: 失败原因(NO_BOUNDING_BOX_AND_CLICK_FAILED / BLOCKED_AND_DISMISS_FAILED / RETRY_EXHAUSTED 等) + */ +export async function safeClickElement (args) { + const { ctx, page, element, cursor, maxRetries = 3, logPrefix = '[safe-click]' } = args + let dismissedCount = 0 + for (let attempt = 0; attempt < maxRetries; attempt++) { + const box = await element.boundingBox().catch(() => null) + if (!box) { + logDebug(logPrefix, '元素无 boundingBox,回退到 element.click()') + let ok = true + await element.click().catch((e) => { + ok = false + logDebug(logPrefix, 'element.click() 抛错:', e?.message) + }) + return ok + ? { clicked: true, dismissedCount } + : { clicked: false, dismissedCount, error: 'NO_BOUNDING_BOX_AND_CLICK_FAILED' } + } + const cx = box.x + box.width / 2 + const cy = box.y + box.height / 2 + + // ctx 上的 elementFromPoint 用的是 ctx 自己的 viewport 坐标。 + // 当 ctx === page,box 已是主页面 viewport 坐标,可直接传给 elementFromPoint。 + // 当 ctx 是 frame,boundingBox 返回的是主页面 viewport 坐标但 frame 内 elementFromPoint + // 期待 frame 自己的坐标系——保守跳过遮挡检测(iframe 内罕见全局弹窗,主页面才是高发区)。 + const sameAsPage = ctx === page + let blocked = { blocked: false } + if (sameAsPage) { + blocked = await checkBlockedAt(ctx, element, cx, cy) + } + + if (blocked.blocked) { + logInfo(logPrefix, `点击目标被遮挡(top=${blocked.topTag}.${blocked.topClass}),尝试自动关闭浮层…`) + const n = await dismissBlockingOverlays(page) + dismissedCount += n + if (n === 0) { + logDebug(logPrefix, '未识别到可关闭的浮层,强制点击一次后返回(成功率不保证)') + let ok = true + await cursor.click({ x: cx, y: cy }).catch((e) => { + ok = false + logDebug(logPrefix, 'cursor.click 抛错:', e?.message) + }) + return ok + ? { clicked: true, dismissedCount, error: 'CLICKED_WHILE_BLOCKED' } + : { clicked: false, dismissedCount, error: 'BLOCKED_AND_DISMISS_FAILED' } + } + // 关闭后重试 + continue + } + let ok = true + await cursor.click({ x: cx, y: cy }).catch((e) => { + ok = false + logDebug(logPrefix, 'cursor.click 抛错:', e?.message) + }) + if (ok) return { clicked: true, dismissedCount } + // cursor 点击失败也用 retry 兜底 + } + // 重试用尽 + const box = await element.boundingBox().catch(() => null) + let ok = false + if (box) { + ok = true + await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 }).catch(() => { ok = false }) + } + return ok + ? { clicked: true, dismissedCount, error: 'RETRY_EXHAUSTED_BUT_FINAL_CLICK_OK' } + : { clicked: false, dismissedCount, error: 'RETRY_EXHAUSTED' } +} diff --git a/packages/boss-auto-browse-and-chat/humanMouse.mjs b/packages/boss-auto-browse-and-chat/humanMouse.mjs index 90ef438..2139643 100644 --- a/packages/boss-auto-browse-and-chat/humanMouse.mjs +++ b/packages/boss-auto-browse-and-chat/humanMouse.mjs @@ -1,111 +1,150 @@ -/** - * 拟人鼠标轨迹封装(招聘端专用) - * - * BOSS 对招聘端鼠标移动轨迹进行埋点,直接 page.click() 或 page.mouse.click(x,y) - * 的"瞬移"方式容易被识别为脚本。本模块封装 ghost-cursor,以贝塞尔曲线生成拟人 - * 移动路径,替换所有在招聘端页面上的点击操作。 - * - * 用法: - * import { createHumanCursor } from './humanMouse.mjs' - * const cursor = await createHumanCursor(page) - * await cursor.click(selector) // 先沿轨迹移动,再点击 - * await cursor.move(selector) // 仅移动,不点击 - */ - -/** - * 为给定 Puppeteer page 创建拟人鼠标 cursor。 - * 内部使用 ghost-cursor;若 ghost-cursor 不可用(如包未安装), - * 则 fallback 到普通 page.click(),并打印警告。 - * - * @param {import('puppeteer').Page} page - Puppeteer 页面实例 - * @returns {Promise<{ - * click: (selectorOrPos: string | {x: number, y: number}) => Promise, - * move: (selectorOrPos: string | {x: number, y: number}) => Promise - * }>} - */ -export async function createHumanCursor (page) { - let ghostCursorCreate - try { - const mod = await import('ghost-cursor') - // ghost-cursor 同时支持 ESM default export 和命名 export - ghostCursorCreate = mod.createCursor ?? mod.default?.createCursor - } catch { - ghostCursorCreate = null - } - - if (ghostCursorCreate) { - const cursor = ghostCursorCreate(page) - - /** - * 将 selector 字符串或 ElementHandle 解析成 {x, y} 坐标。 - * ghost-cursor 的 click/move 只接受 string selector 或 ElementHandle, - * 传 {x,y} 坐标对象会被误当 ElementHandle 调 element.remoteObject() 崩溃。 - * 统一在封装层解析成坐标,再用 moveTo({x,y}) + page.mouse.click(x,y) 执行。 - */ - const resolvePos = async (selectorOrPos) => { - if (typeof selectorOrPos === 'string') { - const el = await page.$(selectorOrPos) - if (!el) throw new Error(`[humanMouse] element not found: ${selectorOrPos}`) - const box = await el.boundingBox() - if (!box) throw new Error(`[humanMouse] element has no bounding box: ${selectorOrPos}`) - return { x: box.x + box.width / 2, y: box.y + box.height / 2 } - } - // ElementHandle(有 boundingBox 方法) - if (selectorOrPos && typeof selectorOrPos.boundingBox === 'function') { - const box = await selectorOrPos.boundingBox() - if (!box) throw new Error('[humanMouse] ElementHandle has no bounding box') - return { x: box.x + box.width / 2, y: box.y + box.height / 2 } - } - // 已是 {x, y} 坐标对象 - return selectorOrPos - } - - return { - /** - * 沿拟人轨迹移动到目标后点击。使用 moveTo({x,y}) + page.mouse.click(x,y) - * 规避 ghost-cursor 传坐标/ElementHandle 时调 element.evaluate 的崩溃问题。 - * @param {string | {x: number, y: number} | import('puppeteer').ElementHandle} selectorOrPos - */ - async click (selectorOrPos) { - const pos = await resolvePos(selectorOrPos) - await cursor.moveTo(pos) - await page.mouse.click(pos.x, pos.y) - }, - /** - * 沿拟人轨迹移动到目标(不点击) - * @param {string | {x: number, y: number} | import('puppeteer').ElementHandle} selectorOrPos - */ - async move (selectorOrPos) { - const pos = await resolvePos(selectorOrPos) - await cursor.moveTo(pos) - } - } - } - - // Fallback: ghost-cursor 未安装时退化为普通点击(打印警告) - console.warn('[humanMouse] ghost-cursor 未安装,退化为普通 page.click()。建议安装 ghost-cursor 以规避 BOSS 鼠标轨迹埋点检测。') - return { - async click (selectorOrPos) { - if (typeof selectorOrPos === 'string') { - await page.click(selectorOrPos) - } else if (selectorOrPos && typeof selectorOrPos.x === 'number') { - await page.mouse.click(selectorOrPos.x, selectorOrPos.y) - } - }, - async move (selectorOrPos) { - if (typeof selectorOrPos === 'string') { - try { - const el = await page.$(selectorOrPos) - if (el) { - const box = await el.boundingBox() - if (box) { - await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) - } - } - } catch (_) { /* ignore */ } - } else if (selectorOrPos && typeof selectorOrPos.x === 'number') { - await page.mouse.move(selectorOrPos.x, selectorOrPos.y) - } - } - } -} +/** + * 拟人鼠标轨迹封装(招聘端专用) + * + * BOSS 对招聘端鼠标移动轨迹进行埋点,直接 page.click() 或 page.mouse.click(x,y) + * 的"瞬移"方式容易被识别为脚本。本模块封装 ghost-cursor,以贝塞尔曲线生成拟人 + * 移动路径,替换所有在招聘端页面上的点击操作。 + * + * 用法: + * import { createHumanCursor, randomizeInitialCursorPosition } from './humanMouse.mjs' + * const cursor = await createHumanCursor(page) + * await randomizeInitialCursorPosition(page) + * await cursor.click(selector) // 先沿轨迹移动,再点击 + * await cursor.move(selector) // 仅移动,不点击 + */ + +// 模块级缓存:首次成功 preflight 后避免重复 import +let cachedGhostCursorCreate = null + +/** + * 预检查 ghost-cursor 是否可用,返回其 createCursor 函数。 + * 失败时抛出明确错误,避免静默退化为 page.click() 这种"以为隐身实则裸奔"的最坏情况。 + * + * @returns {Promise} ghost-cursor 的 createCursor 函数 + */ +export async function preflightGhostCursor () { + if (cachedGhostCursorCreate) return cachedGhostCursorCreate + let createCursor + try { + const mod = await import('ghost-cursor') + // ghost-cursor 同时支持 ESM default export 和命名 export + createCursor = mod.createCursor ?? mod.default?.createCursor + } catch (e) { + throw new Error( + 'GHOST_CURSOR_UNAVAILABLE: ghost-cursor failed to load — refusing to run with bot-like clicks. Reinstall dependencies (pnpm -F @geekgeekrun/boss-auto-browse-and-chat install).' + ) + } + if (typeof createCursor !== 'function') { + throw new Error( + 'GHOST_CURSOR_UNAVAILABLE: ghost-cursor failed to load — refusing to run with bot-like clicks. Reinstall dependencies (pnpm -F @geekgeekrun/boss-auto-browse-and-chat install).' + ) + } + cachedGhostCursorCreate = createCursor + return createCursor +} + +/** + * 在 box 的中心 60% 区域内随机一个落点(默认 centerBiasFraction=0.3,即中心 ±30%)。 + * 避免每次点击都落在精确几何中心,这本身就是脚本特征。 + * + * @param {{x: number, y: number, width: number, height: number}} box + * @param {number} [centerBiasFraction=0.3] + * @returns {{x: number, y: number}} + */ +function randomizePointInBox (box, centerBiasFraction = 0.3) { + const minXFrac = 0.5 - centerBiasFraction + const maxXFrac = 0.5 + centerBiasFraction + const minYFrac = 0.5 - centerBiasFraction + const maxYFrac = 0.5 + centerBiasFraction + const xFrac = minXFrac + Math.random() * (maxXFrac - minXFrac) + const yFrac = minYFrac + Math.random() * (maxYFrac - minYFrac) + return { + x: box.x + box.width * xFrac, + y: box.y + box.height * yFrac + } +} + +/** + * 为给定 Puppeteer page 创建拟人鼠标 cursor。 + * 内部强依赖 ghost-cursor;若不可用直接抛错(fail-fast),不再静默退化。 + * + * @param {import('puppeteer').Page} page - Puppeteer 页面实例 + * @returns {Promise<{ + * click: (selectorOrPos: string | {x: number, y: number}) => Promise, + * move: (selectorOrPos: string | {x: number, y: number}) => Promise + * }>} + */ +export async function createHumanCursor (page) { + const ghostCursorCreate = await preflightGhostCursor() + const cursor = ghostCursorCreate(page) + + /** + * 将 selector 字符串或 ElementHandle 解析成 {x, y} 坐标。 + * ghost-cursor 的 click/move 只接受 string selector 或 ElementHandle, + * 传 {x,y} 坐标对象会被误当 ElementHandle 调 element.remoteObject() 崩溃。 + * 统一在封装层解析成坐标,再用 moveTo({x,y}) + page.mouse.click(x,y) 执行。 + * 对 selector / ElementHandle 输入会在中心 60% 范围内随机落点; + * 对显式 {x,y} 输入保持原样(调用方已选定精确坐标)。 + */ + const resolvePos = async (selectorOrPos) => { + if (typeof selectorOrPos === 'string') { + const el = await page.$(selectorOrPos) + if (!el) throw new Error(`[humanMouse] element not found: ${selectorOrPos}`) + const box = await el.boundingBox() + if (!box) throw new Error(`[humanMouse] element has no bounding box: ${selectorOrPos}`) + return randomizePointInBox(box) + } + // ElementHandle(有 boundingBox 方法) + if (selectorOrPos && typeof selectorOrPos.boundingBox === 'function') { + const box = await selectorOrPos.boundingBox() + if (!box) throw new Error('[humanMouse] ElementHandle has no bounding box') + return randomizePointInBox(box) + } + // 已是 {x, y} 坐标对象,调用方已选定精确坐标,不再随机化 + return selectorOrPos + } + + return { + /** + * 沿拟人轨迹移动到目标后点击。使用 moveTo({x,y}) + page.mouse.click(x,y) + * 规避 ghost-cursor 传坐标/ElementHandle 时调 element.evaluate 的崩溃问题。 + * 以约 0.5 概率先做一次轻微 overshoot 移动,模拟真实用户在按钮附近犹豫/减速。 + * @param {string | {x: number, y: number} | import('puppeteer').ElementHandle} selectorOrPos + */ + async click (selectorOrPos) { + const pos = await resolvePos(selectorOrPos) + if (Math.random() < 0.5) { + const vp = page.viewport() || { width: 1280, height: 720 } + const overshoot = { + x: Math.max(1, Math.min(vp.width - 1, pos.x + (Math.random() * 60 - 30))), + y: Math.max(1, Math.min(vp.height - 1, pos.y + (Math.random() * 30 - 15))) + } + await cursor.moveTo(overshoot) + } + await cursor.moveTo(pos) + await page.mouse.click(pos.x, pos.y) + }, + /** + * 沿拟人轨迹移动到目标(不点击) + * @param {string | {x: number, y: number} | import('puppeteer').ElementHandle} selectorOrPos + */ + async move (selectorOrPos) { + const pos = await resolvePos(selectorOrPos) + await cursor.moveTo(pos) + } + } +} + +/** + * 将鼠标移动到 viewport 内一个随机位置,避免每次会话都从 (0,0) 起步这一明显特征。 + * 由集成方在合适时机(如打开页面后)显式调用,createHumanCursor 不会自动调用它。 + * + * @param {import('puppeteer').Page} page + */ +export async function randomizeInitialCursorPosition (page) { + // Move cursor to a random viewport position (avoids the (0,0) start signature) + const viewport = page.viewport() || { width: 1280, height: 720 } + const x = 200 + Math.floor(Math.random() * (viewport.width - 400)) + const y = 100 + Math.floor(Math.random() * (viewport.height - 200)) + await page.mouse.move(x, y, { steps: 5 + Math.floor(Math.random() * 10) }) +} diff --git a/packages/boss-auto-browse-and-chat/index.mjs b/packages/boss-auto-browse-and-chat/index.mjs index 53ea74f..ba51b33 100644 --- a/packages/boss-auto-browse-and-chat/index.mjs +++ b/packages/boss-auto-browse-and-chat/index.mjs @@ -16,7 +16,11 @@ import { import { setupNetworkInterceptor, setupCanvasTextHook } from './resume-extractor.mjs' import { parseCandidateList, filterCandidates, scrollAndLoadMore } from './candidate-processor.mjs' import { processCandidate, checkDailyLimit, clickNotInterested } from './chat-handler.mjs' +import { dismissBlockingOverlays } from './dialog-dismisser.mjs' import { setLevel, debug as logDebug, info as logInfo, warn as logWarn, error as logError } from './logger.mjs' +import { preflightGhostCursor, randomizeInitialCursorPosition } from './humanMouse.mjs' +import { buildRecruiterLaunchOptions } from './launch-options.mjs' +import { checkpointRiskControl } from './risk-detector.mjs' export { default as startBossChatPageProcess } from './chat-page-processor.mjs' @@ -53,7 +57,9 @@ export async function initPuppeteer () { puppeteer.use(StealthPlugin()) puppeteer.use(LaodengPlugin()) puppeteer.use(AnonymizeUaPlugin({ makeWindows: false })) - logDebug('[boss-auto-browse] initPuppeteer: 插件已注册') + // ghost-cursor preflight:fail-fast,避免后续静默退化为裸 page.click() + await preflightGhostCursor() + logDebug('[boss-auto-browse] initPuppeteer: 插件已注册(含 ghost-cursor preflight)') return { puppeteer, StealthPlugin, @@ -63,32 +69,36 @@ export async function initPuppeteer () { } /** - * 关闭登录后弹出的「治理公告」弹窗(点击「我已知晓」确认按钮)。 - * 该弹窗在每次登录后必现,不处理会导致后续自动化操作卡死超时。 + * 关闭登录后弹出的「治理公告」等任何挡住操作的浮层。 + * + * 历史上这里只针对 GOVERNANCE_NOTICE_DIALOG_CONFIRM_BTN_SELECTOR 硬编码点击, + * 现改为:先等待已知治理公告 selector 出现(提示性),再调用通用的 dismissBlockingOverlays + * 启发式扫描——这样新冒出的弹窗也能被自动关掉,不必每次手工加 selector。 * @param {import('puppeteer').Page} page */ export async function dismissGovernanceNoticeDialog (page) { - try { - const confirmBtn = await page - .waitForSelector(GOVERNANCE_NOTICE_DIALOG_CONFIRM_BTN_SELECTOR, { timeout: 10000 }) - .catch(() => null) - if (!confirmBtn) return - logInfo('[boss-auto-browse] 检测到「治理公告」弹窗,点击「我已知晓」关闭...') - try { + // 给已知的治理公告一点时间冒出来;超时也没关系——通用扫描兜底 + await page.waitForSelector(GOVERNANCE_NOTICE_DIALOG_SELECTOR, { timeout: 10000 }).catch(() => null) + const closed = await dismissBlockingOverlays(page, { maxRounds: 3 }).catch(() => 0) + if (closed > 0) { + logInfo(`[boss-auto-browse] 自动关闭了 ${closed} 个登录后浮层(含治理公告)`) + await sleep(300) + } + // 兜底:若启发式没识别到治理公告(例如关闭按钮文案变了),再尝试硬编码 selector + const stillThere = await page.$(GOVERNANCE_NOTICE_DIALOG_SELECTOR).catch(() => null) + if (stillThere) { + logWarn('[boss-auto-browse] 启发式未关闭治理公告,回退到硬编码 selector') + const confirmBtn = await page.$(GOVERNANCE_NOTICE_DIALOG_CONFIRM_BTN_SELECTOR).catch(() => null) + if (confirmBtn) { const box = await confirmBtn.boundingBox().catch(() => null) if (box) { - await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2).catch(() => {}) } else { - await confirmBtn.click() + await confirmBtn.click().catch(() => {}) } - } catch { - await confirmBtn.click().catch(() => {}) + await page.waitForSelector(GOVERNANCE_NOTICE_DIALOG_SELECTOR, { hidden: true, timeout: 5000 }).catch(() => {}) + await sleep(300) } - await page.waitForSelector(GOVERNANCE_NOTICE_DIALOG_SELECTOR, { hidden: true, timeout: 5000 }).catch(() => {}) - logInfo('[boss-auto-browse] 「治理公告」弹窗已关闭') - await sleep(300) - } catch { - // 弹窗不存在或关闭失败时静默继续 } } @@ -101,17 +111,14 @@ const localStoragePageUrl = 'https://www.zhipin.com/desktop/' */ export async function launchBrowserAndNavigateToChat () { if (!puppeteer) await initPuppeteer() - const headless = process.env.HEADLESS === '1' - const browser = await puppeteer.launch({ - headless, - ignoreHTTPSErrors: true, - protocolTimeout: 120000, - defaultViewport: { width: 1440, height: 900 - 140 } - }) + const launchOpts = await buildRecruiterLaunchOptions() + const browser = await puppeteer.launch(launchOpts) const page = (await browser.pages())[0] + await randomizeInitialCursorPosition(page).catch(() => {}) const bossCookies = readStorageFile('boss-cookies.json') const bossLocalStorage = readStorageFile('boss-local-storage.json') - if (Array.isArray(bossCookies) && bossCookies.length > 0) { + // persistProfile=true 时 profile 已持久化 cookies,跳过注入避免用过期文件覆盖有效 session + if (!launchOpts.userDataDir && Array.isArray(bossCookies) && bossCookies.length > 0) { await page.setCookie(...bossCookies) } await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {}) @@ -246,20 +253,12 @@ export default async function startBossAutoBrowse (hooksFromCaller, opts = {}) { } else { await hooks.beforeBrowserLaunch?.promise?.() - const headlessEnv = process.env.HEADLESS - const headless = headlessEnv === '1' - logDebug('[boss-auto-browse] 即将启动浏览器', { headless, HEADLESS_env: headlessEnv ?? null }) - browser = await puppeteer.launch({ - headless, - ignoreHTTPSErrors: true, - protocolTimeout: 120000, - defaultViewport: { - width: 1440, - height: 900 - 140 - } - }) + const launchOpts = await buildRecruiterLaunchOptions() + logDebug('[boss-auto-browse] 即将启动浏览器', { headless: launchOpts.headless, persistProfile: !!launchOpts.userDataDir }) + browser = await puppeteer.launch(launchOpts) page = (await browser.pages())[0] + await randomizeInitialCursorPosition(page).catch(() => {}) await hooks.afterBrowserLaunch?.promise?.() } @@ -271,7 +270,9 @@ export default async function startBossAutoBrowse (hooksFromCaller, opts = {}) { // 直接导航到推荐牛人页(注入 Cookie / localStorage 后 goto;复用浏览器时若已在推荐页可跳过 goto) // ----------------------------------------------------------------------- await hooks.beforeNavigateToRecommend?.promise?.() - if (Array.isArray(bossCookies) && bossCookies.length > 0) { + // persistProfile=true 时 profile 已持久化 cookies,跳过注入避免用过期文件覆盖有效 session + const persistProfile = (readConfigFile('boss-recruiter.json') || {})?.advanced?.persistProfile === true + if (!persistProfile && Array.isArray(bossCookies) && bossCookies.length > 0) { await page.setCookie(...bossCookies) } await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {}) @@ -572,10 +573,16 @@ export default async function startBossAutoBrowse (hooksFromCaller, opts = {}) { logInfo('[boss-auto-browse] ✓ 已向', candidate.geekName, '发送招呼(本次共', chatCount, '人)') } else { logInfo('[boss-auto-browse] ✗', candidate.geekName, '开聊失败:', chatResult.reason) - if (chatResult.reason === 'DAILY_LIMIT_REACHED' || chatResult.reason === 'RISK_CONTROL') { + if (chatResult.reason === 'DAILY_LIMIT_REACHED') { break mainLoop } + // 'RISK_CONTROL' 落到下面统一 checkpoint 处理 } + + // 每位候选人处理完都做一次 checkpoint:检测到验证则在循环内等待用户完成,避免崩出 catch + 3s 重试导致连环触发 + // 不传 expectedUrlPrefix:仅依赖 !detectRiskControl 判断完成,避免 URL query-params 导致误判超时 + const cpStatus = await checkpointRiskControl(page, { log: logWarn }) + if (cpStatus === 'timed-out') break mainLoop } // e. 滚动加载 / 翻页(在 iframe frame 内操作) diff --git a/packages/boss-auto-browse-and-chat/launch-options.mjs b/packages/boss-auto-browse-and-chat/launch-options.mjs new file mode 100644 index 0000000..c7ed14d --- /dev/null +++ b/packages/boss-auto-browse-and-chat/launch-options.mjs @@ -0,0 +1,69 @@ +/** + * boss-recruiter.json `advanced` section schema: + * { + * "advanced": { + * "persistProfile": false // opt-in: persist Chromium profile across launches (better anti-detection; + * // BUT cannot run BOSS in system Chrome simultaneously) + * } + * } + */ + +import path from 'node:path' +import fs from 'node:fs' +import crypto from 'node:crypto' +import { readConfigFile, storageFilePath } from './runtime-file-utils.mjs' + +const VIEWPORT_POOL = [ + { w: 1366, h: 768 }, + { w: 1440, h: 900 - 140 }, + { w: 1536, h: 864 }, + { w: 1600, h: 900 }, + { w: 1680, h: 1050 - 150 } +] + +const DEFAULT_VIEWPORT = { width: 1440, height: 760 } + +function pickViewportForPath(seed) { + const digest = crypto.createHash('md5').update(seed).digest() + const intVal = digest.readInt32BE(0) + const idx = Math.abs(intVal) % VIEWPORT_POOL.length + const picked = VIEWPORT_POOL[idx] + return { width: picked.w, height: picked.h } +} + +/** + * Build the puppeteer.launch() options object for the recruiter side. + * Reads boss-recruiter.json's `advanced` section for opt-in features. + * + * @param {object} [overrides] - shallow-merged onto the result (e.g. { headless: false } for force) + * @returns {Promise} + */ +export async function buildRecruiterLaunchOptions(overrides = {}) { + const cfg = readConfigFile('boss-recruiter.json') || {} + const advanced = cfg.advanced || {} + const persistProfile = advanced.persistProfile === true + + const headless = process.env.HEADLESS === '1' + + let userDataDir + let viewport + if (persistProfile) { + userDataDir = path.join(storageFilePath, 'boss-chrome-profile') + fs.mkdirSync(userDataDir, { recursive: true }) + viewport = pickViewportForPath(userDataDir) + } else { + viewport = { ...DEFAULT_VIEWPORT } + } + + const args = ['--lang=zh-CN', '--disable-blink-features=AutomationControlled'] + + const opts = { + headless, + ignoreHTTPSErrors: true, + protocolTimeout: 120000, + defaultViewport: viewport, + args: [...args] + } + if (userDataDir) opts.userDataDir = userDataDir + return { ...opts, ...overrides } +} diff --git a/packages/boss-auto-browse-and-chat/resume-extractor.mjs b/packages/boss-auto-browse-and-chat/resume-extractor.mjs index d44f01a..cd417a0 100644 --- a/packages/boss-auto-browse-and-chat/resume-extractor.mjs +++ b/packages/boss-auto-browse-and-chat/resume-extractor.mjs @@ -141,103 +141,134 @@ export function parseGeekInfoFromIntercepted (interceptedMap) { // Canvas 文字 Hook(与 laodeng 兼容)— 非 BOSS 自带,可能被反爬检测,沟通页请用 API 拦截 // --------------------------------------------------------------------------- +const CANVAS_HOOK_DEBUG = process.env.GEEKGEEKRUN_CANVAS_HOOK_DEBUG === '1' + /** - * 在页面上通过 evaluateOnNewDocument 注入 Canvas fillText hook,将绘制文字收集到主页面 window.__canvasCapturedText。 + * 在页面上通过 evaluateOnNewDocument 注入 Canvas fillText hook,将绘制文字收集到主页面随机命名的 marker 属性上。 * * 实现原理: * - evaluateOnNewDocument 会在主页面和每一个 iframe 中各执行一次。 * - 在线简历 iframe 带有 sandbox 属性且不含 allow-same-origin,主页面无法访问其 contentWindow, * 因此必须在 iframe 自身的执行上下文内直接 hook CanvasRenderingContext2D.prototype.fillText。 * - iframe 内 hook 到的文字通过 window.top.postMessage 批量发回主页面(同 origin 或跨 origin 均可用)。 - * - 主页面监听 message 事件并累积到 window.__canvasCapturedText。 + * - 主页面监听 message 事件并累积到随机命名的 window 属性。 + * + * 反检测:marker 属性名(capturedTextProp / messageKey / hookedFlag)每次调用本函数时随机生成, + * 不同 session 不同;同时通过 laodeng.registerFakeNativeSource 让 fillText 包装函数的 toString 返回原生外观。 * * @param {import('puppeteer').Page} page - Puppeteer 页面实例(必须在 page.goto 之前调用) - * @returns {Promise<{ getCapturedText: (page: import('puppeteer').Page) => Promise> }>} + * @returns {Promise<{ getCapturedText: (page: import('puppeteer').Page) => Promise>, clearCapturedText: (page: import('puppeteer').Page) => Promise, peekCapturedText: (page: import('puppeteer').Page) => Promise }>} */ export async function setupCanvasTextHook (page) { - // 转发浏览器内部 [canvasHook] 日志到 Node 侧,便于调试 - page.on('console', (msg) => { - const text = msg.text() - if (text.startsWith('[canvasHook]')) { - console.log('[canvasHook-browser]', text) - } - }) + const markerSuffix = Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4) + const capturedTextProp = '__cct_' + markerSuffix + const messageKey = '__mk_' + markerSuffix + const hookedFlag = '_h_' + markerSuffix - await page.evaluateOnNewDocument(() => { - // 此脚本在每个 frame(主页面 + 所有 iframe)中各执行一次。 - // 策略: - // 主页面 → 初始化收集数组,监听来自 iframe 的 postMessage - // iframe → 直接 hook 当前窗口的 fillText,批量 postMessage 到 window.top - - const isTopFrame = (window === window.top) - - if (isTopFrame) { - window.__canvasCapturedText = [] - window.addEventListener('message', (evt) => { - if (evt.data && evt.data.__bossCanvasHook && Array.isArray(evt.data.__bossCanvasHook)) { - if (!window.__canvasCapturedText) window.__canvasCapturedText = [] - for (const item of evt.data.__bossCanvasHook) { - window.__canvasCapturedText.push(item) - } - console.log('[canvasHook] main received ' + evt.data.__bossCanvasHook.length + ' items, total ' + window.__canvasCapturedText.length) - } - }) - console.log('[canvasHook] main: message listener set') - } - - // 在当前 window(无论是主页面还是 iframe)上 hook fillText - try { - const proto = window.CanvasRenderingContext2D?.prototype - if (!proto) { console.log('[canvasHook] CanvasRenderingContext2D.prototype not found'); return } - if (proto._bossHooked) { console.log('[canvasHook] already hooked, skip'); return } - proto._bossHooked = true - - const origFillText = proto.fillText - if (typeof origFillText !== 'function') { console.log('[canvasHook] fillText is not a function'); return } - - // 批量缓冲,用 setTimeout(0) 在一个事件循环 tick 后统一发送(WASM 会在同一个同步调用栈内连续 fillText) - const captured = [] - let flushScheduled = false - const flush = () => { - flushScheduled = false - if (captured.length === 0) return - const items = captured.splice(0) - if (isTopFrame) { - if (!window.__canvasCapturedText) window.__canvasCapturedText = [] - for (const item of items) window.__canvasCapturedText.push(item) - console.log('[canvasHook] main fillText wrote ' + items.length + ' items') - } else { - try { - window.top.postMessage({ __bossCanvasHook: items }, '*') - console.log('[canvasHook] iframe postMessage sent ' + items.length + ' items') - } catch (e) { - console.log('[canvasHook] postMessage failed: ' + e.message) - } - } - } - const scheduleFlush = () => { - if (!flushScheduled) { flushScheduled = true; setTimeout(flush, 0) } + // 转发浏览器内部 [canvasHook] 日志到 Node 侧(仅 debug 模式) + if (CANVAS_HOOK_DEBUG) { + page.on('console', (msg) => { + const text = msg.text() + if (text.startsWith('[canvasHook]')) { + console.log('[canvasHook-browser]', text) } + }) + } - Object.defineProperty(proto, 'fillText', { - value: new Proxy(origFillText, { - apply (target, thisArg, args) { - const [text, x, y] = args - if (typeof text === 'string' && text.trim()) { - captured.push({ text, x: Number(x) || 0, y: Number(y) || 0 }) - scheduleFlush() + // 注册 fillText 包装的伪原生 toString(依赖 laodeng 已被 puppeteer.use 装载) + try { + const laodengMod = await import('@geekgeekrun/puppeteer-extra-plugin-laodeng') + const registerFakeNativeSource = + laodengMod.registerFakeNativeSource ?? laodengMod.default?.registerFakeNativeSource + if (typeof registerFakeNativeSource === 'function') { + await registerFakeNativeSource( + page, + 'CanvasRenderingContext2D.prototype.fillText', + 'function fillText() { [native code] }' + ) + } + } catch (e) { + // non-fatal: hook still works, just one more detectable surface + } + + await page.evaluateOnNewDocument( + (capturedTextProp, messageKey, hookedFlag, DEBUG) => { + // 此脚本在每个 frame(主页面 + 所有 iframe)中各执行一次。 + const isTopFrame = (window === window.top) + + if (isTopFrame) { + window[capturedTextProp] = [] + window.addEventListener('message', (evt) => { + if (evt.data && evt.data[messageKey] && Array.isArray(evt.data[messageKey])) { + if (!window[capturedTextProp]) window[capturedTextProp] = [] + for (const item of evt.data[messageKey]) { + window[capturedTextProp].push(item) } - return Reflect.apply(target, thisArg, args) + if (DEBUG) console.log('[canvasHook] main received ' + evt.data[messageKey].length + ' items, total ' + window[capturedTextProp].length) } - }), - writable: true, - configurable: true - }) - console.log('[canvasHook] fillText hook installed, isTopFrame=' + isTopFrame + ' href=' + window.location.href) - } catch (e) { - console.log('[canvasHook] hook install error: ' + e.message) - } - }) + }) + if (DEBUG) console.log('[canvasHook] main: message listener set') + } + + // 在当前 window(无论是主页面还是 iframe)上 hook fillText + try { + const proto = window.CanvasRenderingContext2D?.prototype + if (!proto) { if (DEBUG) console.log('[canvasHook] CanvasRenderingContext2D.prototype not found'); return } + if (proto[hookedFlag]) { if (DEBUG) console.log('[canvasHook] already hooked, skip'); return } + proto[hookedFlag] = true + + const origFillText = proto.fillText + if (typeof origFillText !== 'function') { if (DEBUG) console.log('[canvasHook] fillText is not a function'); return } + + const captured = [] + let flushScheduled = false + const flush = () => { + flushScheduled = false + if (captured.length === 0) return + const items = captured.splice(0) + if (isTopFrame) { + if (!window[capturedTextProp]) window[capturedTextProp] = [] + for (const item of items) window[capturedTextProp].push(item) + if (DEBUG) console.log('[canvasHook] main fillText wrote ' + items.length + ' items') + } else { + try { + const payload = {} + payload[messageKey] = items + window.top.postMessage(payload, '*') + if (DEBUG) console.log('[canvasHook] iframe postMessage sent ' + items.length + ' items') + } catch (e) { + if (DEBUG) console.log('[canvasHook] postMessage failed: ' + e.message) + } + } + } + const scheduleFlush = () => { + if (!flushScheduled) { flushScheduled = true; setTimeout(flush, 0) } + } + + Object.defineProperty(proto, 'fillText', { + value: new Proxy(origFillText, { + apply (target, thisArg, args) { + const [text, x, y] = args + if (typeof text === 'string' && text.trim()) { + captured.push({ text, x: Number(x) || 0, y: Number(y) || 0 }) + scheduleFlush() + } + return Reflect.apply(target, thisArg, args) + } + }), + writable: true, + configurable: true + }) + if (DEBUG) console.log('[canvasHook] fillText hook installed, isTopFrame=' + isTopFrame + ' href=' + window.location.href) + } catch (e) { + if (DEBUG) console.log('[canvasHook] hook install error: ' + e.message) + } + }, + capturedTextProp, + messageKey, + hookedFlag, + CANVAS_HOOK_DEBUG + ) /** * 从主页面读取当前收集的 Canvas 文字并清空。 @@ -245,14 +276,13 @@ export async function setupCanvasTextHook (page) { * @returns {Promise>} */ async function getCapturedText (p) { - // 给浏览器 150ms 处理待发送的 setTimeout(0)/postMessage 队列 await p.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))) - const result = await p.evaluate(() => { - const arr = window.__canvasCapturedText || [] + const result = await p.evaluate((prop) => { + const arr = window[prop] || [] const copy = arr.map(({ text, x, y }) => ({ text, x, y })) - window.__canvasCapturedText = [] + window[prop] = [] return copy - }) + }, capturedTextProp) return result } @@ -261,10 +291,20 @@ export async function setupCanvasTextHook (page) { * @param {import('puppeteer').Page} p - 同一页面实例 */ async function clearCapturedText (p) { - await p.evaluate(() => { window.__canvasCapturedText = [] }) + await p.evaluate((prop) => { window[prop] = [] }, capturedTextProp) } - return { getCapturedText, clearCapturedText } + /** + * Peek at how many canvas text items have been captured so far, without consuming them. + * Used for "stable count" polling to detect when Canvas rendering has finished. + * @param {import('puppeteer').Page} p + * @returns {Promise} + */ + async function peekCapturedText (p) { + return p.evaluate((prop) => (window[prop] || []).length, capturedTextProp) + } + + return { getCapturedText, clearCapturedText, peekCapturedText } } // --------------------------------------------------------------------------- @@ -316,13 +356,15 @@ export function extractResumeText (capturedTextArray) { // --------------------------------------------------------------------------- /** - * 优先从拦截的 API 数据中取简历,若无则从页面 window.__canvasCapturedText 中提取(需先调用 setupCanvasTextHook)。 + * 优先从拦截的 API 数据中取简历,若无则从页面 Canvas hook 中提取(需先调用 setupCanvasTextHook)。 * * @param {import('puppeteer').Page} page - Puppeteer 页面实例 * @param {Map} interceptedData - setupNetworkInterceptor 返回的 getInterceptedData() 的结果 + * @param {{ getCapturedText?: (page: import('puppeteer').Page) => Promise> }} [opts] + * opts.getCapturedText — setupCanvasTextHook 返回的同名函数(支持随机 marker 名);不传时降级读 window.__canvasCapturedText(旧行为,仅向后兼容) * @returns {Promise<{ source: 'api' | 'canvas', data: unknown }>} source 为 'api' 时 data 为 API 响应对象;为 'canvas' 时为 extractResumeText 的结果(字符串数组) */ -export async function getResumeData (page, interceptedData) { +export async function getResumeData (page, interceptedData, opts = {}) { if (interceptedData && interceptedData.size > 0) { const firstEntry = interceptedData.entries().next() if (!firstEntry.done) { @@ -330,12 +372,21 @@ export async function getResumeData (page, interceptedData) { return { source: 'api', data: { path, ...(typeof data === 'object' && data !== null ? data : { value: data }) } } } } - const captured = await page.evaluate(() => { - const arr = window.__canvasCapturedText || [] - const copy = arr.map(({ text, x, y }) => ({ text, x, y })) - window.__canvasCapturedText = [] - return copy - }) + + // Canvas fallback: use getCapturedText closure if provided (supports randomized marker names) + // Fall back to legacy window.__canvasCapturedText for callers that don't yet pass it + const getCapturedTextFn = opts.getCapturedText + let captured + if (typeof getCapturedTextFn === 'function') { + captured = await getCapturedTextFn(page) + } else { + captured = await page.evaluate(() => { + const arr = window.__canvasCapturedText || [] + const copy = arr.map(({ text, x, y }) => ({ text, x, y })) + window.__canvasCapturedText = [] + return copy + }) + } const lines = extractResumeText(captured) return { source: 'canvas', data: lines } } diff --git a/packages/boss-auto-browse-and-chat/risk-detector.mjs b/packages/boss-auto-browse-and-chat/risk-detector.mjs new file mode 100644 index 0000000..c96311d --- /dev/null +++ b/packages/boss-auto-browse-and-chat/risk-detector.mjs @@ -0,0 +1,108 @@ +import { sleep } from '@geekgeekrun/utils/sleep.mjs' + +/** + * Detect whether the page is currently showing BOSS security verification + * (CAPTCHA / slider / 安全验证 / etc). + * + * Multi-signal: URL match + DOM elements + body text fallback. + * Element-first (more robust than text, which can false-positive on candidate + * descriptions that mention 验证). + * + * @param {import('puppeteer').Page} page + * @returns {Promise} + */ +export async function detectRiskControl(page) { + try { + const url = page.url() + if (/verify|captcha|security.?check|safe\b|\/safe\/|安全验证/.test(url)) return true + return await page.evaluate(() => { + const hasVerifyEl = !!( + document.querySelector('#nc_mask') || + document.querySelector('.verify-container') || + document.querySelector('.captcha-wrap') || + document.querySelector('.nc-container') || + document.querySelector('[class*="verify"][class*="wrap"]') || + document.querySelector('[class*="captcha"]') || + document.querySelector('.geetest_panel') || + document.querySelector('.geetest_box') || + document.querySelector('[id^="__yidun"]') || + document.querySelector('iframe[src*="captcha"]') || + document.querySelector('iframe[src*="verify"]') || + document.querySelector('.boss-popup__wrapper.dialog-verify') + ) + if (hasVerifyEl) return true + const bodyText = document.body?.innerText || '' + const hasVerifyText = + /请完成.{0,10}验证|安全验证|滑动.{0,6}滑块|人机验证|完成验证后继续|异常.{0,6}操作|操作过于频繁|请稍后再试.*继续|存在风险.*操作/.test( + bodyText + ) + return hasVerifyText + }) + } catch { + return false + } +} + +/** + * Block until user manually completes verification, OR timeout. + * Polls every 2s. Sends a desktop notification once on entry. + * + * @param {import('puppeteer').Page} page + * @param {object} [opts] + * @param {string} [opts.expectedUrlPrefix] - if provided, only consider verification done when url returns to this prefix + * @param {number} [opts.timeoutMs=300000] - default 5 min + * @param {(msg: string) => void} [opts.log] - optional logger + * @returns {Promise} true if completed, false if timed out + */ +export async function waitForRiskControlCompletion(page, opts = {}) { + const { expectedUrlPrefix, timeoutMs = 300000, log } = opts + const logFn = typeof log === 'function' ? log : () => {} + + logFn('⚠️ 检测到 BOSS 安全验证...') + + try { + const { Notification } = await import('electron') + new Notification({ + title: 'GeekGeekRun - 需要人工验证', + body: '检测到 BOSS 直聘安全验证,请在浏览器窗口中完成验证,完成后程序将自动继续。' + }).show() + } catch { + /* Notification 不可用时静默忽略 */ + } + + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + await sleep(2000) + try { + const isStillVerify = await detectRiskControl(page) + if (expectedUrlPrefix) { + const url = page.url() + if (url.startsWith(expectedUrlPrefix) && !isStillVerify) { + logFn('✅ 安全验证已完成') + return true + } + } else if (!isStillVerify) { + logFn('✅ 安全验证已完成') + return true + } + } catch { + /* 页面可能正在跳转,继续等待 */ + } + } + logFn('验证等待超时(5 分钟)') + return false +} + +/** + * Convenience: detect and, if positive, wait for completion. + * + * @param {import('puppeteer').Page} page + * @param {object} [opts] - same as waitForRiskControlCompletion + * @returns {Promise<'no-risk'|'completed'|'timed-out'>} + */ +export async function checkpointRiskControl(page, opts = {}) { + const detected = await detectRiskControl(page) + if (!detected) return 'no-risk' + const completed = await waitForRiskControlCompletion(page, opts) + return completed ? 'completed' : 'timed-out' +} diff --git a/packages/laodeng/index.js b/packages/laodeng/index.js index 48fe633..e98fe63 100644 --- a/packages/laodeng/index.js +++ b/packages/laodeng/index.js @@ -34,6 +34,21 @@ const stealthScript = () => { if (nativeSourceMap.has(this)) { return nativeSourceMap.get(this); } + // Path-based extra registrations + try { + const extras = window.__laodengExtraNativeSources; + if (extras && extras.size) { + for (const [path, src] of extras) { + const parts = path.split("."); + let obj = window; + for (let i = 0; i < parts.length; i++) { + if (obj == null) break; + obj = obj[parts[i]]; + } + if (obj === this) return src; + } + } + } catch (_) {} return nativeFunctionToString.call(this); }, }); @@ -152,6 +167,31 @@ class Plugin extends PuppeteerExtraPlugin { } } -module.exports = function (pluginConfig) { +/** + * Register a fake native source for a function in the target page. + * Must be called AFTER the laodeng plugin has been applied to the browser. + * The wrapped function should already exist (or be created shortly after) — this + * adds a deferred registration that runs on every new document. + * + * @param {import('puppeteer').Page} page + * @param {string} accessorPath - dotted path to the wrapped function in window scope, e.g. "CanvasRenderingContext2D.prototype.fillText" + * @param {string} fakeNativeSource - what `.toString()` should return, e.g. "function fillText() { [native code] }" + */ +async function registerFakeNativeSource(page, accessorPath, fakeNativeSource) { + await page.evaluateOnNewDocument( + function (path, src) { + try { + if (!window.__laodengExtraNativeSources) window.__laodengExtraNativeSources = new Map(); + window.__laodengExtraNativeSources.set(path, src); + } catch (_) {} + }, + accessorPath, + fakeNativeSource + ); +} + +const pluginFactory = function (pluginConfig) { return new Plugin(pluginConfig); }; +pluginFactory.registerFakeNativeSource = registerFakeNativeSource; +module.exports = pluginFactory; diff --git a/packages/ui/src/main/flow/BOSS_CHAT_PAGE_MAIN/index.ts b/packages/ui/src/main/flow/BOSS_CHAT_PAGE_MAIN/index.ts index 047c03b..119a273 100644 --- a/packages/ui/src/main/flow/BOSS_CHAT_PAGE_MAIN/index.ts +++ b/packages/ui/src/main/flow/BOSS_CHAT_PAGE_MAIN/index.ts @@ -140,7 +140,7 @@ const runChatPage = async () => { log('正在动态 import boss package...') type BossAutoBrowseModule = { startBossChatPageProcess: (hooks: any, options?: { - browser?: any; page?: any; getCapturedText?: any; clearCapturedText?: any; + browser?: any; page?: any; getCapturedText?: any; clearCapturedText?: any; peekCapturedText?: any; jobId?: string | null; retryCandidate?: { encryptGeekId: string; geekName: string; jobTitle: string } | null; processContext?: { currentCandidate: any } | null; @@ -207,6 +207,7 @@ const runChatPage = async () => { let page: any = null let getCapturedText: any = null let clearCapturedText: any = null + let peekCapturedText: any = null // processContext 提升到循环外,catch 块中可读取被中断的候选人 const processContext: { currentCandidate: any } = { currentCandidate: null } @@ -230,13 +231,12 @@ const runChatPage = async () => { log('启动浏览器...') await hooks.beforeBrowserLaunch?.promise?.() - const headless = process.env.HEADLESS === '1' - browser = await puppeteer.launch({ - headless, - ignoreHTTPSErrors: true, - protocolTimeout: 120000, - defaultViewport: { width: 1440, height: 900 - 140 } - }) + const { buildRecruiterLaunchOptions } = (await import( + '@geekgeekrun/boss-auto-browse-and-chat/launch-options.mjs' + )) as any + const launchOpts = await buildRecruiterLaunchOptions() + log(`使用 launch options:persistProfile=${!!launchOpts.userDataDir}`) + browser = await puppeteer.launch(launchOpts) await hooks.afterBrowserLaunch?.promise?.() @@ -248,7 +248,15 @@ const runChatPage = async () => { const canvasHooks = await setupCanvasTextHook(page) getCapturedText = canvasHooks.getCapturedText clearCapturedText = canvasHooks.clearCapturedText - if (Array.isArray(bossCookies) && bossCookies.length > 0) { + peekCapturedText = canvasHooks.peekCapturedText + + const { randomizeInitialCursorPosition } = (await import( + '@geekgeekrun/boss-auto-browse-and-chat/humanMouse.mjs' + )) as any + await randomizeInitialCursorPosition(page).catch(() => {}) + + // persistProfile=true 时 profile 已持久化 cookies,跳过注入避免用过期文件覆盖有效 session + if (!launchOpts.userDataDir && Array.isArray(bossCookies) && bossCookies.length > 0) { await page.setCookie(...bossCookies) } await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {}) @@ -287,7 +295,7 @@ const runChatPage = async () => { const jname = job.jobName ?? job.name log(`开始处理职位 ${jid}(${jname})的沟通页...`) processContext.currentCandidate = null - await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, jobId: jid, processContext }) + await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, peekCapturedText, jobId: jid, processContext }) log(`职位 ${jid} 沟通页处理完成`) } } else { @@ -296,7 +304,7 @@ const runChatPage = async () => { } else { log('未配置职位队列,开始执行 startBossChatPageProcess(处理所有未读)...') processContext.currentCandidate = null - await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, processContext }) + await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, peekCapturedText, processContext }) } log('startBossChatPageProcess 完成') @@ -318,6 +326,7 @@ const runChatPage = async () => { page = null getCapturedText = null clearCapturedText = null + peekCapturedText = null const rerunMs = cfg?.chatPage?.rerunIntervalMs ?? rerunInterval log(`下次运行将在 ${rerunMs}ms 后开始`) await sleep(rerunMs) @@ -346,7 +355,7 @@ const runChatPage = async () => { log(`🔄 正在重试被验证中断的候选人:${interruptedCandidate.geekName}...`) try { await startBossChatPageProcess(hooks, { - browser, page, getCapturedText, clearCapturedText, + browser, page, getCapturedText, clearCapturedText, peekCapturedText, retryCandidate: interruptedCandidate, processContext: { currentCandidate: null } }) @@ -369,6 +378,7 @@ const runChatPage = async () => { page = null getCapturedText = null clearCapturedText = null + peekCapturedText = null } if (err instanceof Error) { if (err.message.includes('LOGIN_STATUS_INVALID')) { diff --git a/packages/ui/src/main/flow/OPEN_SETTING_WINDOW/ipc/index.ts b/packages/ui/src/main/flow/OPEN_SETTING_WINDOW/ipc/index.ts index dac6742..28602e9 100644 --- a/packages/ui/src/main/flow/OPEN_SETTING_WINDOW/ipc/index.ts +++ b/packages/ui/src/main/flow/OPEN_SETTING_WINDOW/ipc/index.ts @@ -991,6 +991,12 @@ export default function initIpc() { ...payload.recommendPage } } + if (payload.advanced && typeof payload.advanced === 'object') { + bossRecruiterConfig.advanced = bossRecruiterConfig.advanced || {} + if (typeof payload.advanced.persistProfile === 'boolean') { + bossRecruiterConfig.advanced.persistProfile = payload.advanced.persistProfile + } + } const candidateFilterConfig = readBossConfigFile('candidate-filter.json') || {} if (hasOwn(payload, 'expectCityList')) { diff --git a/packages/ui/src/renderer/src/page/MainLayout/BossAutoBrowseAndChat/index.vue b/packages/ui/src/renderer/src/page/MainLayout/BossAutoBrowseAndChat/index.vue index 3229f92..a1913f2 100644 --- a/packages/ui/src/renderer/src/page/MainLayout/BossAutoBrowseAndChat/index.vue +++ b/packages/ui/src/renderer/src/page/MainLayout/BossAutoBrowseAndChat/index.vue @@ -90,6 +90,22 @@ + + +
高级反检测(实验性)
+
+ + + 持久化浏览器 profile(更难被识别为新设备) + +
+ 启用后 BOSS 看到的是「老设备」而非「每次都是新设备」,能显著降低人工验证触发率。
+ 副作用:bot 运行期间不能在系统 Chrome 同时登录 BOSS(会被挤掉);profile 文件夹长期会占用 1-2GB 磁盘空间。
+ 路径:~/.geekgeekrun/storage/boss-chrome-profile/ +
+
+
+
仅保存配置 @@ -167,7 +183,8 @@ const formContent = reactive({ recommendSkipViewedCandidates: false, recommendRerunIntervalMs: 3000, recommendDelayBetweenNotInterestedMs: [800, 2500] as [number, number], - recommendKeepBrowserOpenAfterRun: false + recommendKeepBrowserOpenAfterRun: false, + advancedPersistProfile: false }) onMounted(async () => { @@ -194,6 +211,9 @@ onMounted(async () => { : [800, 2500] formContent.recommendKeepBrowserOpenAfterRun = recommendPage.keepBrowserOpenAfterRun ?? false + + const advanced = recruiterConfig.advanced ?? {} + formContent.advancedPersistProfile = advanced.persistProfile ?? false } catch (err) { console.error(err) } @@ -213,6 +233,9 @@ const doSave = async () => { rerunIntervalMs: formContent.recommendRerunIntervalMs, delayBetweenNotInterestedMs: formContent.recommendDelayBetweenNotInterestedMs, keepBrowserOpenAfterRun: formContent.recommendKeepBrowserOpenAfterRun + }, + advanced: { + persistProfile: formContent.advancedPersistProfile } } await ipcRenderer.invoke('save-boss-recruiter-config', JSON.stringify(payload))