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 2b8838c..35f77db 100644 --- a/packages/boss-auto-browse-and-chat/chat-page-processor.mjs +++ b/packages/boss-auto-browse-and-chat/chat-page-processor.mjs @@ -25,6 +25,7 @@ import { CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR, CHAT_PAGE_ITEM_SELECTOR, CHAT_PAGE_ITEM_UNREAD_SELECTOR, + CHAT_PAGE_ALL_FILTER_SELECTOR, CHAT_PAGE_UNREAD_FILTER_SELECTOR, CHAT_PAGE_NAME_SELECTOR, CHAT_PAGE_JOB_SELECTOR, @@ -41,21 +42,44 @@ const LOG = '[chat-page-processor]' */ async function switchChatPageJobId (page, jobId) { try { - await page.click('.ui-dropmenu.chat-top-job .ui-dropmenu-label') + const cursor = await createHumanCursor(page) + // 用拟人轨迹点击下拉触发按钮 + const dropdownBtn = await page.$('.ui-dropmenu.chat-top-job .ui-dropmenu-label') + if (dropdownBtn) { + const box = await dropdownBtn.boundingBox().catch(() => null) + if (box) { + await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 }) + } else { + await dropdownBtn.click() + } + } else { + await page.click('.ui-dropmenu.chat-top-job .ui-dropmenu-label') + } await page.waitForSelector('.ui-dropmenu.chat-top-job .ui-dropmenu-list', { timeout: 5000 }) - const found = await page.evaluate((jid) => { - const item = document.querySelector(`.ui-dropmenu.chat-top-job .ui-dropmenu-list li[value="${jid}"]`) - if (!item) return false - item.click() - return true - }, jobId) + await sleepWithRandomDelay(150, 300) + // 用拟人轨迹点击目标职位项 + const items = await page.$$('.ui-dropmenu.chat-top-job .ui-dropmenu-list li') + let found = false + for (const item of items) { + const val = await item.evaluate(el => el.getAttribute('value')).catch(() => null) + if (val === jobId) { + const itemBox = await item.boundingBox().catch(() => null) + if (itemBox) { + await cursor.click({ x: itemBox.x + itemBox.width / 2, y: itemBox.y + itemBox.height / 2 }) + } else { + await item.click() + } + found = true + break + } + } if (!found) { logWarn(`${LOG} 职位 ${jobId} 未在沟通页下拉列表中找到,将使用默认职位继续`) await page.keyboard.press('Escape') return } // 等待左侧会话列表刷新 - await new Promise(r => setTimeout(r, 500)) + await sleepWithRandomDelay(400, 700) logInfo(`${LOG} 已切换到职位 ${jobId}`) } catch (e) { logWarn(`${LOG} 切换沟通页职位失败(${e.message}),将使用默认职位继续`) @@ -277,11 +301,29 @@ async function waitForGeekInfo (peekFn, opts = {}) { /** * 沟通页自动化主入口。 * @param {object} hooksFromCaller - 与 startBossAutoBrowse 相同的 hooks(onError, insertCandidateContactLog, createOrUpdateCandidateInfo, queryCandidateByEncryptId 等) - * @param {{ browser?: import('puppeteer').Browser, page?: import('puppeteer').Page }} [options] - 若传入则复用已有 browser/page,否则内部不启动浏览器(调用方需先导航到沟通页或由推荐页流程传入) + * @param {{ + * browser?: import('puppeteer').Browser, + * page?: import('puppeteer').Page, + * getCapturedText?: Function, + * clearCapturedText?: Function, + * jobId?: string | null, + * retryCandidate?: { encryptGeekId: string, geekName: string, jobTitle: string } | null, + * processContext?: { currentCandidate: object | null } | null + * }} [options] + * - retryCandidate: 验证中断后需优先重试的候选人(此前已被点击成"已读",需在「全部」tab 找回) + * - processContext: 调用方传入的可变对象,本函数在处理每条会话前将 currentCandidate 写入, + * 供调用方在捕获错误时读取"是哪位候选人被中断" */ export default async function startBossChatPageProcess (hooksFromCaller, options = {}) { const hooks = hooksFromCaller || {} - const { page: existingPage, getCapturedText, clearCapturedText, jobId = null } = options + const { + page: existingPage, + getCapturedText, + clearCapturedText, + jobId = null, + retryCandidate = null, + processContext = null + } = options /** @type {import('puppeteer').Page} */ let page = existingPage @@ -344,60 +386,54 @@ export default async function startBossChatPageProcess (hooksFromCaller, options await switchChatPageJobId(page, jobId) } - // 切换到"未读"tab,确保虚拟滚动列表只包含未读会话(避免全部模式下未读项在视口外未渲染) const cursor = await createHumanCursor(page) - const unreadTabActive = await page.evaluate((sel) => { - const el = document.querySelector(sel) - return el ? el.classList.contains('active') : false - }, CHAT_PAGE_UNREAD_FILTER_SELECTOR) - if (!unreadTabActive) { - logInfo(`${LOG} 切换到「未读」tab...`) - const tabEl = await page.$(CHAT_PAGE_UNREAD_FILTER_SELECTOR) - if (tabEl) { - const box = await tabEl.boundingBox() - if (box) { - await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 }) - await sleepWithRandomDelay(400, 600) - // 等列表刷新 - try { - await page.waitForSelector(CHAT_PAGE_ITEM_SELECTOR, { timeout: 5000 }) - logDebug(`${LOG} 「未读」tab 切换后列表已刷新`) - } catch { - logDebug(`${LOG} 「未读」tab 切换后列表为空(无未读会话)`) - } - } - } else { - logWarn(`${LOG} 未找到「未读」tab 元素(selector: ${CHAT_PAGE_UNREAD_FILTER_SELECTOR})`) + + // ──────────────────────────────────────────────────────────────────────────── + // 内部辅助:切换到指定 tab(封闭 page、cursor) + // ──────────────────────────────────────────────────────────────────────────── + const switchToTab = async (selector, tabName) => { + const isActive = await page.evaluate( + (sel) => document.querySelector(sel)?.classList.contains('active') ?? false, + selector + ) + if (isActive) { + logDebug(`${LOG} 已在「${tabName}」tab`) + return + } + logInfo(`${LOG} 切换到「${tabName}」tab...`) + const tabEl = await page.$(selector) + if (!tabEl) { + logWarn(`${LOG} 未找到「${tabName}」tab 元素(selector: ${selector})`) + return + } + const box = await tabEl.boundingBox().catch(() => null) + if (box) { + await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 }) + await sleepWithRandomDelay(400, 600) + try { + await page.waitForSelector(CHAT_PAGE_ITEM_SELECTOR, { timeout: 5000 }) + logDebug(`${LOG} 「${tabName}」tab 切换后列表已刷新`) + } catch { + logDebug(`${LOG} 「${tabName}」tab 切换后列表为空(无会话)`) + } } - } else { - logDebug(`${LOG} 已在「未读」tab`) - } - await sleepWithRandomDelay(300) - - const conversations = await parseConversationList(page) - logDebug(`${LOG} DOM 解析到 ${conversations.length} 条会话`) - - // 已切到"未读"tab,列表中所有 item 均为未读会话,无需再用 badge-count 二次过滤 - const unreadItems = conversations.filter((c) => c.encryptGeekId) - const toProcess = unreadItems.slice(0, maxProcessPerRun) - logInfo(`${LOG} 未读会话 ${unreadItems.length} 条,本次最多处理 ${toProcess.length} 条`) - if (toProcess.length > 0) { - logDebug(`${LOG} 候选人列表:${toProcess.map((c, i) => `[${i}] ${c.geekName}(${c.encryptGeekId})`).join(', ')}`) } - await hooks.onProgress?.promise?.({ phase: 'chatPage', current: 0, max: toProcess.length }).catch(() => {}) - - let processed = 0 - - for (let i = 0; i < toProcess.length; i++) { - const item = toProcess[i] + // ──────────────────────────────────────────────────────────────────────────── + // 内部核心:处理单条会话的完整逻辑(闭包,封闭所有外层状态变量) + // 返回 { processed: boolean, skipped: boolean } + // ──────────────────────────────────────────────────────────────────────────── + const processOneCandidateConversation = async (item) => { const { encryptGeekId, geekName, jobTitle } = item - logInfo(`${LOG} ── [${i + 1}/${toProcess.length}] 开始处理 ${geekName}(${encryptGeekId})──`) + + // 向调用方暴露当前正在处理的候选人(发生错误时可读取) + if (processContext) processContext.currentCandidate = item const { contacted } = await checkIfAlreadyContacted(encryptGeekId, hooks) if (contacted) { logInfo(`${LOG} → 已在数据库中联系过,跳过`) - continue + if (processContext) processContext.currentCandidate = null + return { processed: false, skipped: true } } logDebug(`${LOG} → 数据库未记录,继续处理`) @@ -408,12 +444,16 @@ export default async function startBossChatPageProcess (hooksFromCaller, options const resumeCloseBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null) if (resumeCloseBtn) { logDebug(`${LOG} → 检测到在线简历弹窗未关闭,点击关闭...`) - await page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => {}) + const closeBox = await resumeCloseBtn.boundingBox().catch(() => null) + if (closeBox) { + await cursor.click({ x: closeBox.x + closeBox.width / 2, y: closeBox.y + closeBox.height / 2 }) + } else { + await resumeCloseBtn.click().catch(() => {}) + } try { await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 4000 }) logDebug(`${LOG} → 在线简历弹窗已关闭`) } catch { - // 仍未消失则强制刷新检查 const stillOpen = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null) if (stillOpen) { logWarn(`${LOG} → 在线简历弹窗关闭失败(4s 超时),继续尝试切换会话,但可能影响会话切换成功率`) @@ -428,7 +468,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options const selected = await selectConversationById(page, encryptGeekId, { cursor }) if (!selected) { logWarn(`${LOG} → 无法在 DOM 中找到该会话(可能已被标为已读或滚出虚拟滚动视口),跳过`) - continue + if (processContext) processContext.currentCandidate = null + return { processed: false, skipped: true } } logInfo(`${LOG} → 会话已选中,等待页面加载...`) await sleepWithRandomDelay(600, 1200) @@ -439,7 +480,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options if (panelName && !geekName.includes(panelName) && !panelName.includes(geekName)) { logWarn(`${LOG} → 右侧面板姓名「${panelName}」与期望「${geekName}」不符,会话切换未生效,跳过`) await sleepWithRandomDelay(300, 600) - continue + if (processContext) processContext.currentCandidate = null + return { processed: false, skipped: true } } if (panelName) { logDebug(`${LOG} → 右侧面板验证:「${panelName}」✓`) @@ -452,12 +494,23 @@ export default async function startBossChatPageProcess (hooksFromCaller, options if (intentKnowBtn) { logInfo(`${LOG} → 检测到「意向沟通」提示弹窗,点击「我知道了」关闭...`) try { - await page.click(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR) + const knowBox = await intentKnowBtn.boundingBox().catch(() => null) + if (knowBox) { + await cursor.click({ x: knowBox.x + knowBox.width / 2, y: knowBox.y + knowBox.height / 2 }) + } else { + await intentKnowBtn.click().catch(() => {}) + } } catch { - // 备用:点关闭图标 - await page.click(CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR).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(() => {}) + } + } } - // 等弹窗消失(点击后 Vue 会移除 DOM) try { await page.waitForSelector(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, { hidden: true, timeout: 3000 }) logDebug(`${LOG} → 「意向沟通」弹窗已关闭`) @@ -474,7 +527,6 @@ export default async function startBossChatPageProcess (hooksFromCaller, options logDebug(`${LOG} → 等待 geek/info 数据(初步筛选,最长 5s)...`) let geekInfoSnap = await waitForGeekInfo(peekInterceptedData, { timeoutMs: 5000 }) if (!geekInfoSnap) { - // geek/info 未到达,可能是 BOSS 缓存导致未重新请求;重新点击一次会话触发请求 logWarn(`${LOG} → geek/info 未到达,重试点击会话...`) getInterceptedData() const retrySelected = await selectConversationById(page, encryptGeekId, { cursor }) @@ -502,10 +554,10 @@ export default async function startBossChatPageProcess (hooksFromCaller, options logInfo(`${LOG} → 初步信息筛选不通过:${reason},跳过`) await logContact(encryptGeekId, 'resume_screened_out', null, `preFilter:${reason}`, hooks) await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'screened_out' }, hooks) - // 消费并清空拦截数据,准备下一个候选人 getInterceptedData() await sleepWithRandomDelay(300, 600) - continue + if (processContext) processContext.currentCandidate = null + return { processed: false, skipped: true } } logInfo(`${LOG} → 初步信息筛选通过`) } else { @@ -547,36 +599,31 @@ export default async function startBossChatPageProcess (hooksFromCaller, options } } await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'contacted' }, hooks) - processed++ - await hooks.onProgress?.promise?.({ phase: 'chatPage', current: processed, max: toProcess.length }).catch(() => {}) getInterceptedData() - await sleepWithRandomDelay(1000, 2500) - continue + await sleepWithRandomDelay(2000, 4000) + if (processContext) processContext.currentCandidate = null + return { processed: true, skipped: false } } // 无附件简历 → 说明对方只是打招呼,需要我方先筛选再决定是否索取 - // 点击「查看在线简历」(等 iframe 出现即视为打开成功,geek/info 数据点击会话时已触发,复用之) logInfo(`${LOG} → 对方打招呼,点击查看在线简历...`) - // 打开新简历前清空残留数据(主要清除 geek/info 等待阶段积累的旧 iframe postMessage) if (typeof clearCapturedText === 'function') { await clearCapturedText(page) } - // 传入 clearCapturedText,在旧弹窗关闭后、新简历点击前再清一次(关闭过程中可能有新一批旧数据到达) const openedResume = await openOnlineResume(page, { cursor, clearCapturedText: clearCapturedText || undefined }) if (!openedResume) { logWarn(`${LOG} → 未找到「查看在线简历」按钮或 iframe 未出现,跳过`) await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'viewed' }, hooks) getInterceptedData() await sleepWithRandomDelay(500, 1000) - continue + if (processContext) processContext.currentCandidate = null + return { processed: false, skipped: true } } logInfo(`${LOG} → 在线简历 iframe 已出现,等待 Canvas 渲染完成...`) let resumeText = '' if (typeof getCapturedText === 'function') { const { extractResumeText } = await import('./resume-extractor.mjs') - // 稳定轮询:连续两次 peek 到相同数量(且 > 0)视为渲染完成 - // WASM 通常在 iframe 出现后 1~2s 内完成全部渲染 const POLL_INTERVAL_MS = 400 const STABLE_POLLS_NEEDED = 2 const CANVAS_POLL_TIMEOUT = 8000 @@ -595,10 +642,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options lastCount = currentCount } - // 最终读取并清空(getCapturedText 内部再等 150ms 确保最后一批 postMessage 已到) const captured = await getCapturedText(page) const rawLines = extractResumeText(captured) - // 去除 BOSS 字体预热数据(首次 iframe 加载时 "bzl|abcdef..." 类的 ASCII 行) const lines = filterFontTestLines(rawLines) resumeText = lines.join('\n') logInfo(`${LOG} → Canvas 抓取完成,共 ${captured.length} 次 fillText 调用,文本 ${resumeText.length} 字(原始行 ${rawLines.length},过滤后 ${lines.length})`) @@ -609,8 +654,6 @@ export default async function startBossChatPageProcess (hooksFromCaller, options resumeText = out.text logInfo(`${LOG} → geek/info 摘要文本 ${resumeText.length} 字`) } else { - // 验证简历内容是否属于当前候选人。 - // geekName 已知,直接用 includes 最可靠;extractNameFromResumeText 仅用于 warn 消息。 const detectedName = extractNameFromResumeText(resumeText) logDebug(`${LOG} → 简历姓名识别:${detectedName || '(未识别)'}(期望:${geekName})`) if (!resumeText.includes(geekName)) { @@ -618,7 +661,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options logWarn(`${LOG} → 右侧面板未切换到本会话(geek/info 超时或被安全验证打断),跳过,下次运行时重试`) getInterceptedData() await sleepWithRandomDelay(300, 600) - continue + if (processContext) processContext.currentCandidate = null + return { processed: false, skipped: true } } } } else { @@ -635,6 +679,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options logInfo(`${LOG} → 简历文本获取成功(共 ${resumeText.length} 字)`) } + await sleepWithRandomDelay(2000, 4500) + let pass = true let filterReason = '' @@ -669,19 +715,22 @@ export default async function startBossChatPageProcess (hooksFromCaller, options if (pass) { logInfo(`${LOG} → 筛选通过,发送索取附件简历请求...`) - // 在线简历弹窗若仍开着,会遮挡附件简历按钮导致点击失效,必须先完全关闭(含动画) const openResumeCloseBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null) if (openResumeCloseBtn) { logDebug(`${LOG} → 先关闭在线简历弹窗,避免遮挡附件简历按钮...`) - await page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => {}) + const closeBox2 = await openResumeCloseBtn.boundingBox().catch(() => null) + if (closeBox2) { + await cursor.click({ x: closeBox2.x + closeBox2.width / 2, y: closeBox2.y + closeBox2.height / 2 }) + } else { + await openResumeCloseBtn.click().catch(() => {}) + } try { await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 3000 }) logDebug(`${LOG} → 在线简历弹窗已关闭`) } catch { logWarn(`${LOG} → 在线简历弹窗关闭超时,继续尝试(可能影响附件简历按钮点击)`) } - // 等待弹窗 CSS 动画完全结束,确保不再遮挡下方按钮 - await new Promise(r => setTimeout(r, 400)) + await sleepWithRandomDelay(500, 1000) } const { requested, error } = await requestAttachmentResume(page, { cursor }) if (requested) { @@ -706,10 +755,58 @@ export default async function startBossChatPageProcess (hooksFromCaller, options }, hooks ) - processed++ - await hooks.onProgress?.promise?.({ phase: 'chatPage', current: processed, max: toProcess.length }).catch(() => {}) getInterceptedData() - await sleepWithRandomDelay(1000, 2500) + await sleepWithRandomDelay(2000, 4500) + if (processContext) processContext.currentCandidate = null + return { processed: true, skipped: false } + } + // ──────────────────────────────────────────────────────────────────────────── + + // ── 验证恢复:若上次被验证中断,优先重试被中断的候选人 ──────────────────────── + if (retryCandidate) { + logInfo(`${LOG} ── 验证恢复:重试被中断候选人 ${retryCandidate.geekName}(${retryCandidate.encryptGeekId})──`) + // 候选人此前已被点击,状态变为"已读",需切换到「全部」tab 才能找到 + await switchToTab(CHAT_PAGE_ALL_FILTER_SELECTOR, '全部') + await sleepWithRandomDelay(300) + const retrySel = await selectConversationById(page, retryCandidate.encryptGeekId, { cursor }) + if (retrySel) { + logInfo(`${LOG} 重试候选人会话已找到,开始处理...`) + await sleepWithRandomDelay(600, 1200) + await processOneCandidateConversation(retryCandidate) + } else { + logWarn(`${LOG} 未在「全部」会话中找到重试候选人 ${retryCandidate.geekName}(可能已被处理或不可见),跳过`) + } + // 切回「未读」tab 进行正常扫描 + await switchToTab(CHAT_PAGE_UNREAD_FILTER_SELECTOR, '未读') + await sleepWithRandomDelay(300) + } + + // ── 正常扫描:切换到「未读」tab,处理未读会话 ─────────────────────────────── + await switchToTab(CHAT_PAGE_UNREAD_FILTER_SELECTOR, '未读') + await sleepWithRandomDelay(300) + + const conversations = await parseConversationList(page) + logDebug(`${LOG} DOM 解析到 ${conversations.length} 条会话`) + + const unreadItems = conversations.filter((c) => c.encryptGeekId) + const toProcess = unreadItems.slice(0, maxProcessPerRun) + logInfo(`${LOG} 未读会话 ${unreadItems.length} 条,本次最多处理 ${toProcess.length} 条`) + if (toProcess.length > 0) { + logDebug(`${LOG} 候选人列表:${toProcess.map((c, i) => `[${i}] ${c.geekName}(${c.encryptGeekId})`).join(', ')}`) + } + + await hooks.onProgress?.promise?.({ phase: 'chatPage', current: 0, max: toProcess.length }).catch(() => {}) + + let processed = 0 + + for (let i = 0; i < toProcess.length; i++) { + const item = toProcess[i] + logInfo(`${LOG} ── [${i + 1}/${toProcess.length}] 开始处理 ${item.geekName}(${item.encryptGeekId})──`) + const result = await processOneCandidateConversation(item) + if (result.processed) { + processed++ + await hooks.onProgress?.promise?.({ phase: 'chatPage', current: processed, max: toProcess.length }).catch(() => {}) + } } logInfo(`${LOG} 本次共处理 ${processed} 条未读会话`) 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 45186bd..bf91f5b 100644 --- a/packages/boss-auto-browse-and-chat/chat-page-resume.mjs +++ b/packages/boss-auto-browse-and-chat/chat-page-resume.mjs @@ -55,15 +55,11 @@ export async function openOnlineResume (page, options = {}) { const closeBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR) if (closeBtn) { console.log('[openOnlineResume] 检测到旧简历弹窗,点击关闭按钮...') - try { - // 直接用 page.click,比坐标点击更可靠(不受 ghost-cursor 偏移影响) - await page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR) - } catch (e) { - // 备用:坐标点击 - const closeBox = await closeBtn.boundingBox().catch(() => null) - if (closeBox) { - await cursor.click({ x: closeBox.x + closeBox.width / 2, y: closeBox.y + closeBox.height / 2 }) - } + const closeBox = await closeBtn.boundingBox().catch(() => null) + if (closeBox) { + await cursor.click({ x: closeBox.x + closeBox.width / 2, y: closeBox.y + closeBox.height / 2 }) + } else { + await closeBtn.click().catch(() => {}) } // 等关闭按钮从 DOM 消失(即弹窗完全关闭),比等 iframe 消失更可靠 try { @@ -331,7 +327,7 @@ export async function openPreviewAndDownloadPdf (page, messageElement, options = // 预览按钮在消息少时会紧贴 tab 栏,拟人轨迹从别处移过来会经过「已交换微信」等 tab,一点就切到空白。 // 此处直接用 Puppeteer 的 element.click():无移动轨迹,先 scrollIntoView 再点,避免误触 tab。 await previewBtn.evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' })) - await sleepWithRandomDelay(150, 300) + await sleepWithRandomDelay(300, 600) console.log('[openPreviewAndDownloadPdf] 点击「点击预览附件简历」按钮(原生 click,避免轨迹误触 tab)') await previewBtn.click() diff --git a/packages/boss-auto-browse-and-chat/constant.mjs b/packages/boss-auto-browse-and-chat/constant.mjs index cf15eda..20e90f2 100644 --- a/packages/boss-auto-browse-and-chat/constant.mjs +++ b/packages/boss-auto-browse-and-chat/constant.mjs @@ -119,6 +119,9 @@ export const CHAT_PAGE_JOB_ITEM_SELECTOR = '.chat-top-job .ui-dropmenu-list li' // - CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR:消息里"点击预览附件简历"(对方同意并发来 PDF 后,新消息里会出现) // - CHAT_PAGE_DOWNLOAD_PDF_BTN_SELECTOR:简历预览弹窗里的"下载 PDF"按钮 +/** 沟通页:顶部"全部"筛选 tab(span:nth-child(1) 在 .chat-message-filter-left 内) */ +export const CHAT_PAGE_ALL_FILTER_SELECTOR = '.chat-message-filter-left span:nth-child(1)' + /** 沟通页:顶部"未读"筛选 tab(span:nth-child(2) 在 .chat-message-filter-left 内,active 时有 class="active") */ export const CHAT_PAGE_UNREAD_FILTER_SELECTOR = '.chat-message-filter-left span:nth-child(2)' diff --git a/packages/boss-auto-browse-and-chat/index.mjs b/packages/boss-auto-browse-and-chat/index.mjs index 1e7321c..a1c887d 100644 --- a/packages/boss-auto-browse-and-chat/index.mjs +++ b/packages/boss-auto-browse-and-chat/index.mjs @@ -141,22 +141,45 @@ async function storeStorage (page) { */ async function switchRecommendJobId (page, jobId) { try { - await page.click(RECOMMEND_JOB_DROPDOWN_LABEL_SELECTOR) + const { createHumanCursor } = await import('./humanMouse.mjs') + const cursor = await createHumanCursor(page) + // 用拟人轨迹点击下拉触发按钮 + const dropdownBtn = await page.$(RECOMMEND_JOB_DROPDOWN_LABEL_SELECTOR) + if (dropdownBtn) { + const box = await dropdownBtn.boundingBox().catch(() => null) + if (box) { + await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 }) + } else { + await dropdownBtn.click() + } + } else { + await page.click(RECOMMEND_JOB_DROPDOWN_LABEL_SELECTOR) + } await page.waitForSelector(RECOMMEND_JOB_ITEM_SELECTOR, { timeout: 5000 }) - const found = await page.evaluate((jid) => { - const item = document.querySelector(`#headerWrap ul.job-list li.job-item[value="${jid}"]`) - if (!item) return false - item.click() - return true - }, jobId) + await sleepWithRandomDelay(150, 300) + // 用拟人轨迹点击目标职位项 + const items = await page.$$(RECOMMEND_JOB_ITEM_SELECTOR) + let found = false + for (const item of items) { + const val = await item.evaluate(el => el.getAttribute('value')).catch(() => null) + if (val === jobId) { + const itemBox = await item.boundingBox().catch(() => null) + if (itemBox) { + await cursor.click({ x: itemBox.x + itemBox.width / 2, y: itemBox.y + itemBox.height / 2 }) + } else { + await item.click() + } + found = true + break + } + } if (!found) { logWarn(`[boss-auto-browse] 职位 ${jobId} 未在下拉列表中找到,将使用默认职位继续`) - // 关闭下拉 await page.keyboard.press('Escape') return } // 等候选人列表重新加载 - await new Promise(r => setTimeout(r, 500)) + await sleepWithRandomDelay(400, 800) logInfo(`[boss-auto-browse] 已切换到职位 ${jobId}`) } catch (e) { logWarn(`[boss-auto-browse] 切换推荐页职位失败(${e.message}),将使用默认职位继续`) diff --git a/packages/ui/src/main/flow/BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts b/packages/ui/src/main/flow/BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts index 0b130a6..cec9b0c 100644 --- a/packages/ui/src/main/flow/BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts +++ b/packages/ui/src/main/flow/BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts @@ -1,398 +1,463 @@ -import { app, dialog } from 'electron' -import { AsyncSeriesHook, AsyncSeriesWaterfallHook } from 'tapable' -import { sleep } from '@geekgeekrun/utils/sleep.mjs' -import { AUTO_CHAT_ERROR_EXIT_CODE } from '../../../common/enums/auto-start-chat' -import attachListenerForKillSelfOnParentExited from '../../utils/attachListenerForKillSelfOnParentExited' -import minimist from 'minimist' -import SqlitePluginModule from '@geekgeekrun/sqlite-plugin' -import { connectToDaemon, sendToDaemon } from '../OPEN_SETTING_WINDOW/connect-to-daemon' -import { checkShouldExit } from '../../utils/worker' -import initPublicIpc from '../../utils/initPublicIpc' -import { forwardConsoleLogToDaemon } from '../../utils/forwardConsoleLogToDaemon' -import { getLastUsedAndAvailableBrowser } from '../DOWNLOAD_DEPENDENCIES/utils/browser-history' -import path from 'path' -const { default: SqlitePlugin } = SqlitePluginModule - -process.on('SIGTERM', () => { - console.log('收到SIGTERM信号,正在退出') - process.exit(0) -}) - -const rerunInterval = (() => { - let v = Number(process.env.MAIN_BOSSGEEKGO_RERUN_INTERVAL) - if (isNaN(v)) { - v = 3000 - } - return v -})() - -const initPlugins = async (hooks) => { - const { storageFilePath } = await import( - '@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs' - ) - new SqlitePlugin(path.join(storageFilePath, 'public.db')).apply(hooks) -} - -const runRecordId = minimist(process.argv.slice(2))['run-record-id'] ?? null - -const log = (msg: string) => { - console.log(`[boss-worker] ${msg}`) -} - -const runAutoBrowseAndChat = async () => { - app.dock?.hide() - log('runAutoBrowseAndChat 开始') - log(`正在查找可用浏览器...`) - let puppeteerExecutable = await getLastUsedAndAvailableBrowser() - if (!puppeteerExecutable) { - log('未找到可用浏览器,退出') - await dialog.showMessageBox({ - type: `error`, - message: `未找到可用的浏览器`, - detail: `请重新运行本程序,按照提示安装、配置浏览器` - }) - sendToDaemon({ - type: 'worker-to-gui-message', - data: { - type: 'prerequisite-step-by-step-checkstep-by-step-check', - step: { - id: 'puppeteer-executable-check', - status: 'rejected' - }, - runRecordId - } - }) - app.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE) - return - } - log(`找到浏览器: ${puppeteerExecutable.executablePath}`) - sendToDaemon({ - type: 'worker-to-gui-message', - data: { - type: 'prerequisite-step-by-step-checkstep-by-step-check', - step: { - id: 'puppeteer-executable-check', - status: 'fulfilled' - }, - runRecordId - } - }) - process.env.PUPPETEER_EXECUTABLE_PATH = puppeteerExecutable.executablePath - - log('正在动态 import boss package...') - type BossAutoBrowseModule = { - default: (hooks: any, opts?: { returnBrowser?: boolean; jobId?: string; browser?: any; page?: any }) => Promise - startBossChatPageProcess: (hooks: any, options?: { browser?: any; page?: any; jobId?: string }) => Promise - initPuppeteer: () => Promise - launchBrowserAndNavigateToChat?: () => Promise<{ browser: any; page: any }> - bossAutoBrowseEventBus: InstanceType - } - const { - default: startBossAutoBrowse, - startBossChatPageProcess, - initPuppeteer, - launchBrowserAndNavigateToChat, - bossAutoBrowseEventBus - } = (await import('@geekgeekrun/boss-auto-browse-and-chat/index.mjs')) as unknown as BossAutoBrowseModule - log('boss package import 完成,初始化 puppeteer...') - - process.on('disconnect', () => { - app.exit() - }) - - await initPuppeteer() - log('puppeteer 初始化完成,初始化 hooks 和插件...') - - const hooks = { - beforeBrowserLaunch: new AsyncSeriesHook(['_']), - afterBrowserLaunch: new AsyncSeriesHook(['_']), - beforeNavigateToRecommend: new AsyncSeriesHook(['_']), - onCandidateListLoaded: new AsyncSeriesHook(['_']), - onCandidateFiltered: new AsyncSeriesWaterfallHook(['candidates', 'filterResult'] as any), - beforeStartChat: new AsyncSeriesHook(['candidate']), - afterChatStarted: new AsyncSeriesHook(['candidate', 'result'] as any), - onError: new AsyncSeriesHook(['error']), - onComplete: new AsyncSeriesHook(['_']), - onProgress: new AsyncSeriesHook(['payload'] as any) - } - - await initPlugins(hooks) - log('插件初始化完成,即将启动浏览器...') - - hooks.beforeBrowserLaunch.tapPromise('log', async () => { log('beforeBrowserLaunch') }) - hooks.afterBrowserLaunch.tapPromise('log', async () => { log('afterBrowserLaunch - 浏览器已启动') }) - hooks.beforeNavigateToRecommend.tapPromise('log', async () => { log('beforeNavigateToRecommend - 正在导航到推荐页') }) - - bossAutoBrowseEventBus.once('LOGIN_STATUS_INVALID', () => {}) - - hooks.onCandidateListLoaded.tap('sendLoginStatusCheck', () => { - log('onCandidateListLoaded - 登录成功,候选人列表已加载') - sendToDaemon({ - type: 'worker-to-gui-message', - data: { - type: 'prerequisite-step-by-step-checkstep-by-step-check', - step: { - id: 'login-status-check', - status: 'fulfilled' - }, - runRecordId - } - }) - }) - - // Accumulate candidate results for webhook reporting - const sessionCandidates: Array<{ - basicInfo?: Record - filterReport?: Record - llmConclusion?: string - resumeFile?: { path?: string; filename?: string } - }> = [] - - hooks.afterChatStarted.tapPromise('collectCandidateForWebhook', async (candidate: unknown) => { - const c = candidate as Record - const entry = { - basicInfo: c?.info as Record | undefined, - filterReport: { - matched: true, - matchedRules: (c?.matchedRules as string[] | undefined) ?? [], - score: c?.score as number | undefined - }, - llmConclusion: c?.llmConclusion as string | undefined, - resumeFile: c?.resumeFilePath - ? { path: c.resumeFilePath as string, filename: c?.resumeFileName as string | undefined } - : undefined - } - sessionCandidates.push(entry) - - // 逐条实时触发:每打招呼后立即发送一条 webhook - try { - const { readConfigFile: readBossConfigFile, storageFilePath } = await import( - '@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs' - ) - const webhookConfig = readBossConfigFile('webhook.json') - if (webhookConfig?.enabled && webhookConfig?.url && webhookConfig?.sendMode === 'realtime') { - const { sendWebhook, normalizeWebhookConfig } = await import('../../features/webhook/index') - const normalized = normalizeWebhookConfig(webhookConfig) - if (normalized?.sendMode === 'realtime') { - const runId = `run-${runRecordId ?? Date.now()}` - const timestamp = new Date().toISOString() - const webhookPayload = { - runId, - timestamp, - summary: { total: 1, matched: 1, skipped: 0 }, - candidates: [entry] - } - log(`webhook 实时发送 1 条候选人...`) - await sendWebhook(normalized, webhookPayload, { storageDir: storageFilePath }) - log(`webhook 实时发送完成`) - } - } - } catch (realtimeErr) { - log( - `webhook 实时发送失败(不影响主流程):${realtimeErr instanceof Error ? realtimeErr.message : String(realtimeErr)}` - ) - } - }) - - hooks.onProgress.tap('sendProgressToGui', (payload: unknown) => { - const p = payload as { phase?: string; current?: number; max?: number } - sendToDaemon({ - type: 'worker-to-gui-message', - data: { - type: 'boss-auto-browse-progress', - workerId: 'bossAutoBrowseAndChatMain', - runRecordId, - phase: p?.phase, - current: p?.current ?? 0, - max: p?.max ?? 0 - } - }) - }) - - while (true) { - try { - const { readBossJobsConfig, getMergedJobConfig } = await import( - '@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs' - ) - const jobsConfig = readBossJobsConfig() - const sequenceJobs = (jobsConfig.jobs || []).filter( - (j: any) => j.sequence?.enabled === true - ) - - if (sequenceJobs.length > 0) { - log(`检测到多职位队列,共 ${sequenceJobs.length} 个职位,依次执行...`) - let sharedBrowser: any = null - let sharedPage: any = null - - try { - for (const job of sequenceJobs) { - const jid = job.jobId ?? job.id - const jname = job.jobName ?? job.name - log(`开始执行职位 ${jid}(${jname})...`) - void getMergedJobConfig(jid) - - const runRecommend = job.sequence?.runRecommend !== false - const runChat = job.sequence?.runChat !== false - - if (runChat && !sharedPage) { - log(`[${jid}] 仅沟通页,先启动浏览器...`) - const boot = await launchBrowserAndNavigateToChat() - sharedBrowser = boot.browser - sharedPage = boot.page - } - - if (runRecommend) { - log(`[${jid}] 执行推荐页...`) - const result = await startBossAutoBrowse(hooks, { - returnBrowser: true, - jobId: jid, - browser: sharedBrowser ?? undefined, - page: sharedPage ?? undefined - } as any) - if (result?.browser) { - sharedBrowser = result.browser - sharedPage = result.page - } - } - - if (runChat && sharedBrowser && sharedPage) { - log(`[${jid}] 执行沟通页...`) - await startBossChatPageProcess(hooks, { - browser: sharedBrowser, - page: sharedPage, - jobId: jid - }) - } - } - } finally { - if (sharedBrowser) { - try { - await sharedBrowser.close() - } catch (e) { - void e - } - sharedBrowser = null - sharedPage = null - } - } - } else { - log('开始执行 startBossAutoBrowse(推荐页)...') - const result = await startBossAutoBrowse(hooks, { returnBrowser: true }) - if (result?.browser && result?.page) { - try { - log('推荐页完成,开始处理沟通页未读...') - await startBossChatPageProcess(hooks, { browser: result.browser, page: result.page }) - } finally { - try { - await result.browser.close() - } catch (e) { - void e - } - } - } - } - log('startBossAutoBrowse + 沟通页 完成,检查 webhook 配置...') - try { - const { readConfigFile: readBossConfigFile, storageFilePath } = await import( - '@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs' - ) - const webhookConfig = readBossConfigFile('webhook.json') - if (!webhookConfig?.enabled || !webhookConfig?.url) { - log('webhook 未启用或未配置 URL,跳过发送') - } else if (webhookConfig.sendMode === 'realtime') { - log('webhook 为实时模式,已在每条打招呼后发送,跳过汇总发送') - } else if (sessionCandidates.length === 0) { - log('本轮无候选人数据,跳过 webhook') - } else { - const { sendWebhook } = await import('../../features/webhook/index') - const matched = sessionCandidates.filter((c) => c.filterReport?.matched !== false).length - const skipped = sessionCandidates.length - matched - const webhookPayload = { - runId: `run-${runRecordId ?? Date.now()}`, - timestamp: new Date().toISOString(), - summary: { total: sessionCandidates.length, matched, skipped }, - candidates: sessionCandidates as Parameters[1]['candidates'] - } - log(`正在发送 webhook,共 ${sessionCandidates.length} 条候选人数据...`) - const webhookResult = await sendWebhook(webhookConfig, webhookPayload, { - storageDir: storageFilePath - }) - log(`webhook 发送完成,HTTP ${webhookResult.status},body 长度 ${webhookResult.body.length}`) - } - } catch (webhookErr) { - log(`webhook 发送失败(不影响主流程):${webhookErr instanceof Error ? webhookErr.message : String(webhookErr)}`) - } - sessionCandidates.length = 0 - log('等待下次运行...') - } catch (err) { - if (err instanceof Error) { - if (err.message.includes('LOGIN_STATUS_INVALID')) { - await dialog.showMessageBox({ - type: `error`, - message: `登录状态无效`, - detail: `请重新登录BOSS直聘(招聘端)` - }) - process.exit(AUTO_CHAT_ERROR_EXIT_CODE.LOGIN_STATUS_INVALID) - break - } - if (err.message.includes('ERR_INTERNET_DISCONNECTED')) { - process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ERR_INTERNET_DISCONNECTED) - break - } - if (err.message.includes('ACCESS_IS_DENIED')) { - process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ACCESS_IS_DENIED) - break - } - if ( - err.message.includes(`Could not find Chrome`) || - err.message.includes(`no executable was found`) - ) { - process.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE) - break - } - } - console.error(err) - const shouldExit = await checkShouldExit() - if (shouldExit) { - app.exit() - return - } - console.log( - `[Boss Auto Browse Main] An internal error is caught, and browser will be restarted in ${rerunInterval}ms.` - ) - await sleep(rerunInterval) - } - } -} - -export const waitForProcessHandShakeAndRunAutoChat = async () => { - await app.whenReady() - app.on('window-all-closed', () => { - // keep process alive while worker is running - }) - initPublicIpc() - await connectToDaemon() - forwardConsoleLogToDaemon('bossAutoBrowseAndChatMain', runRecordId) - await sendToDaemon( - { - type: 'ping' - }, - { - needCallback: true - } - ) - sendToDaemon({ - type: 'worker-to-gui-message', - data: { - type: 'prerequisite-step-by-step-checkstep-by-step-check', - step: { - id: 'worker-launch', - status: 'fulfilled' - }, - runRecordId - } - }) - runAutoBrowseAndChat() -} - -attachListenerForKillSelfOnParentExited() +import { app, dialog } from 'electron' +import { AsyncSeriesHook, AsyncSeriesWaterfallHook } from 'tapable' +import { sleep } from '@geekgeekrun/utils/sleep.mjs' +import { AUTO_CHAT_ERROR_EXIT_CODE } from '../../../common/enums/auto-start-chat' +import attachListenerForKillSelfOnParentExited from '../../utils/attachListenerForKillSelfOnParentExited' +import minimist from 'minimist' +import SqlitePluginModule from '@geekgeekrun/sqlite-plugin' +import { connectToDaemon, sendToDaemon } from '../OPEN_SETTING_WINDOW/connect-to-daemon' +import { checkShouldExit } from '../../utils/worker' +import initPublicIpc from '../../utils/initPublicIpc' +import { forwardConsoleLogToDaemon } from '../../utils/forwardConsoleLogToDaemon' +import { getLastUsedAndAvailableBrowser } from '../DOWNLOAD_DEPENDENCIES/utils/browser-history' +import path from 'path' +const { default: SqlitePlugin } = SqlitePluginModule + +process.on('SIGTERM', () => { + console.log('收到SIGTERM信号,正在退出') + process.exit(0) +}) + +const rerunInterval = (() => { + let v = Number(process.env.MAIN_BOSSGEEKGO_RERUN_INTERVAL) + if (isNaN(v)) { + v = 3000 + } + return v +})() + +const initPlugins = async (hooks) => { + const { storageFilePath } = await import( + '@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs' + ) + new SqlitePlugin(path.join(storageFilePath, 'public.db')).apply(hooks) +} + +const runRecordId = minimist(process.argv.slice(2))['run-record-id'] ?? null + +const log = (msg: string) => { + console.log(`[boss-worker] ${msg}`) +} + +const checkForBossVerification = async (page: any): Promise => { + try { + const url: string = page.url() + if (/verify|captcha|security.?check|safe\b|\/safe\/|安全验证/.test(url)) return true + return await page.evaluate(() => { + const hasVerifyText = /请完成.{0,10}验证|安全验证|滑动.{0,6}滑块|人机验证|完成验证后继续|异常.{0,6}操作|验证码/.test( + document.body?.innerText || '' + ) + 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"]') + ) + return hasVerifyText || hasVerifyEl + }) + } catch { + return false + } +} + +const waitForBossVerificationCompletion = async (page: any, expectedUrlPrefix: string): Promise => { + log('⚠️ 检测到 BOSS 安全验证,请在浏览器窗口中手动完成验证,完成后将自动继续...') + try { + const { Notification } = await import('electron') + new Notification({ + title: 'GeekGeekRun - 需要人工验证', + body: '检测到 BOSS 直聘安全验证,请在打开的浏览器窗口中完成验证,完成后程序将自动继续。' + }).show() + } catch { /* Notification 不可用时静默忽略 */ } + + const deadline = Date.now() + 5 * 60 * 1000 + while (Date.now() < deadline) { + await sleep(2000) + try { + const url: string = page.url() + const isStillVerify = await checkForBossVerification(page) + if (url.startsWith(expectedUrlPrefix) && !isStillVerify) { + log('✅ 安全验证已完成,继续处理...') + return true + } + } catch { /* 页面可能正在跳转,继续等待 */ } + } + log('验证等待超时(5 分钟),将重启浏览器重试') + return false +} + +const runAutoBrowseAndChat = async () => { + app.dock?.hide() + log('runAutoBrowseAndChat 开始') + log(`正在查找可用浏览器...`) + let puppeteerExecutable = await getLastUsedAndAvailableBrowser() + if (!puppeteerExecutable) { + log('未找到可用浏览器,退出') + await dialog.showMessageBox({ + type: `error`, + message: `未找到可用的浏览器`, + detail: `请重新运行本程序,按照提示安装、配置浏览器` + }) + sendToDaemon({ + type: 'worker-to-gui-message', + data: { + type: 'prerequisite-step-by-step-checkstep-by-step-check', + step: { + id: 'puppeteer-executable-check', + status: 'rejected' + }, + runRecordId + } + }) + app.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE) + return + } + log(`找到浏览器: ${puppeteerExecutable.executablePath}`) + sendToDaemon({ + type: 'worker-to-gui-message', + data: { + type: 'prerequisite-step-by-step-checkstep-by-step-check', + step: { + id: 'puppeteer-executable-check', + status: 'fulfilled' + }, + runRecordId + } + }) + process.env.PUPPETEER_EXECUTABLE_PATH = puppeteerExecutable.executablePath + + log('正在动态 import boss package...') + type BossAutoBrowseModule = { + default: (hooks: any, opts?: { returnBrowser?: boolean; jobId?: string; browser?: any; page?: any }) => Promise + startBossChatPageProcess: (hooks: any, options?: { browser?: any; page?: any; jobId?: string }) => Promise + initPuppeteer: () => Promise + launchBrowserAndNavigateToChat?: () => Promise<{ browser: any; page: any }> + bossAutoBrowseEventBus: InstanceType + } + const { + default: startBossAutoBrowse, + startBossChatPageProcess, + initPuppeteer, + launchBrowserAndNavigateToChat, + bossAutoBrowseEventBus + } = (await import('@geekgeekrun/boss-auto-browse-and-chat/index.mjs')) as unknown as BossAutoBrowseModule + log('boss package import 完成,初始化 puppeteer...') + + process.on('disconnect', () => { + app.exit() + }) + + await initPuppeteer() + log('puppeteer 初始化完成,初始化 hooks 和插件...') + + const hooks = { + beforeBrowserLaunch: new AsyncSeriesHook(['_']), + afterBrowserLaunch: new AsyncSeriesHook(['_']), + beforeNavigateToRecommend: new AsyncSeriesHook(['_']), + onCandidateListLoaded: new AsyncSeriesHook(['_']), + onCandidateFiltered: new AsyncSeriesWaterfallHook(['candidates', 'filterResult'] as any), + beforeStartChat: new AsyncSeriesHook(['candidate']), + afterChatStarted: new AsyncSeriesHook(['candidate', 'result'] as any), + onError: new AsyncSeriesHook(['error']), + onComplete: new AsyncSeriesHook(['_']), + onProgress: new AsyncSeriesHook(['payload'] as any) + } + + await initPlugins(hooks) + log('插件初始化完成,即将启动浏览器...') + + hooks.beforeBrowserLaunch.tapPromise('log', async () => { log('beforeBrowserLaunch') }) + hooks.afterBrowserLaunch.tapPromise('log', async () => { log('afterBrowserLaunch - 浏览器已启动') }) + hooks.beforeNavigateToRecommend.tapPromise('log', async () => { log('beforeNavigateToRecommend - 正在导航到推荐页') }) + + bossAutoBrowseEventBus.once('LOGIN_STATUS_INVALID', () => {}) + + hooks.onCandidateListLoaded.tap('sendLoginStatusCheck', () => { + log('onCandidateListLoaded - 登录成功,候选人列表已加载') + sendToDaemon({ + type: 'worker-to-gui-message', + data: { + type: 'prerequisite-step-by-step-checkstep-by-step-check', + step: { + id: 'login-status-check', + status: 'fulfilled' + }, + runRecordId + } + }) + }) + + // Accumulate candidate results for webhook reporting + const sessionCandidates: Array<{ + basicInfo?: Record + filterReport?: Record + llmConclusion?: string + resumeFile?: { path?: string; filename?: string } + }> = [] + + hooks.afterChatStarted.tapPromise('collectCandidateForWebhook', async (candidate: unknown) => { + const c = candidate as Record + const entry = { + basicInfo: c?.info as Record | undefined, + filterReport: { + matched: true, + matchedRules: (c?.matchedRules as string[] | undefined) ?? [], + score: c?.score as number | undefined + }, + llmConclusion: c?.llmConclusion as string | undefined, + resumeFile: c?.resumeFilePath + ? { path: c.resumeFilePath as string, filename: c?.resumeFileName as string | undefined } + : undefined + } + sessionCandidates.push(entry) + + // 逐条实时触发:每打招呼后立即发送一条 webhook + try { + const { readConfigFile: readBossConfigFile, storageFilePath } = await import( + '@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs' + ) + const webhookConfig = readBossConfigFile('webhook.json') + if (webhookConfig?.enabled && webhookConfig?.url && webhookConfig?.sendMode === 'realtime') { + const { sendWebhook, normalizeWebhookConfig } = await import('../../features/webhook/index') + const normalized = normalizeWebhookConfig(webhookConfig) + if (normalized?.sendMode === 'realtime') { + const runId = `run-${runRecordId ?? Date.now()}` + const timestamp = new Date().toISOString() + const webhookPayload = { + runId, + timestamp, + summary: { total: 1, matched: 1, skipped: 0 }, + candidates: [entry] + } + log(`webhook 实时发送 1 条候选人...`) + await sendWebhook(normalized, webhookPayload, { storageDir: storageFilePath }) + log(`webhook 实时发送完成`) + } + } + } catch (realtimeErr) { + log( + `webhook 实时发送失败(不影响主流程):${realtimeErr instanceof Error ? realtimeErr.message : String(realtimeErr)}` + ) + } + }) + + hooks.onProgress.tap('sendProgressToGui', (payload: unknown) => { + const p = payload as { phase?: string; current?: number; max?: number } + sendToDaemon({ + type: 'worker-to-gui-message', + data: { + type: 'boss-auto-browse-progress', + workerId: 'bossAutoBrowseAndChatMain', + runRecordId, + phase: p?.phase, + current: p?.current ?? 0, + max: p?.max ?? 0 + } + }) + }) + + while (true) { + try { + const { readBossJobsConfig, getMergedJobConfig } = await import( + '@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs' + ) + const jobsConfig = readBossJobsConfig() + const sequenceJobs = (jobsConfig.jobs || []).filter( + (j: any) => j.sequence?.enabled === true + ) + + if (sequenceJobs.length > 0) { + log(`检测到多职位队列,共 ${sequenceJobs.length} 个职位,依次执行...`) + let sharedBrowser: any = null + let sharedPage: any = null + + try { + for (const job of sequenceJobs) { + const jid = job.jobId ?? job.id + const jname = job.jobName ?? job.name + log(`开始执行职位 ${jid}(${jname})...`) + void getMergedJobConfig(jid) + + const runRecommend = job.sequence?.runRecommend !== false + const runChat = job.sequence?.runChat !== false + + if (runChat && !sharedPage) { + log(`[${jid}] 仅沟通页,先启动浏览器...`) + const boot = await launchBrowserAndNavigateToChat() + sharedBrowser = boot.browser + sharedPage = boot.page + } + + if (runRecommend) { + log(`[${jid}] 执行推荐页...`) + const result = await startBossAutoBrowse(hooks, { + returnBrowser: true, + jobId: jid, + browser: sharedBrowser ?? undefined, + page: sharedPage ?? undefined + } as any) + if (result?.browser) { + sharedBrowser = result.browser + sharedPage = result.page + } + } + + if (runChat && sharedBrowser && sharedPage) { + log(`[${jid}] 执行沟通页...`) + await startBossChatPageProcess(hooks, { + browser: sharedBrowser, + page: sharedPage, + jobId: jid + }) + } + } + } finally { + if (sharedBrowser) { + try { + await sharedBrowser.close() + } catch (e) { + void e + } + sharedBrowser = null + sharedPage = null + } + } + } else { + log('开始执行 startBossAutoBrowse(推荐页)...') + const result = await startBossAutoBrowse(hooks, { returnBrowser: true }) + if (result?.browser && result?.page) { + try { + log('推荐页完成,开始处理沟通页未读...') + await startBossChatPageProcess(hooks, { browser: result.browser, page: result.page }) + } finally { + try { + await result.browser.close() + } catch (e) { + void e + } + } + } + } + log('startBossAutoBrowse + 沟通页 完成,检查 webhook 配置...') + try { + const { readConfigFile: readBossConfigFile, storageFilePath } = await import( + '@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs' + ) + const webhookConfig = readBossConfigFile('webhook.json') + if (!webhookConfig?.enabled || !webhookConfig?.url) { + log('webhook 未启用或未配置 URL,跳过发送') + } else if (webhookConfig.sendMode === 'realtime') { + log('webhook 为实时模式,已在每条打招呼后发送,跳过汇总发送') + } else if (sessionCandidates.length === 0) { + log('本轮无候选人数据,跳过 webhook') + } else { + const { sendWebhook } = await import('../../features/webhook/index') + const matched = sessionCandidates.filter((c) => c.filterReport?.matched !== false).length + const skipped = sessionCandidates.length - matched + const webhookPayload = { + runId: `run-${runRecordId ?? Date.now()}`, + timestamp: new Date().toISOString(), + summary: { total: sessionCandidates.length, matched, skipped }, + candidates: sessionCandidates as Parameters[1]['candidates'] + } + log(`正在发送 webhook,共 ${sessionCandidates.length} 条候选人数据...`) + const webhookResult = await sendWebhook(webhookConfig, webhookPayload, { + storageDir: storageFilePath + }) + log(`webhook 发送完成,HTTP ${webhookResult.status},body 长度 ${webhookResult.body.length}`) + } + } catch (webhookErr) { + log(`webhook 发送失败(不影响主流程):${webhookErr instanceof Error ? webhookErr.message : String(webhookErr)}`) + } + sessionCandidates.length = 0 + log('等待下次运行...') + } catch (err) { + // ── 检测是否为安全验证触发的超时,若是则发送 OS 通知提醒用户 ── + // (推荐页流程浏览器由内部管理,验证后浏览器会重启;此处仅通知用户需要手动完成验证) + try { + const errMsg = err instanceof Error ? err.message : String(err) + if (/TimeoutError|timeout|waitForSelector|waitForFunction/i.test(errMsg)) { + log('检测到超时类错误,可能是 BOSS 安全验证导致。若浏览器窗口有验证提示,请手动完成,程序将在下一轮自动重启。') + try { + const { Notification } = await import('electron') + new Notification({ + title: 'GeekGeekRun - 可能需要人工验证', + body: 'BOSS 直聘可能弹出了安全验证。请检查浏览器窗口,完成验证后程序将在下一轮自动重启继续。' + }).show() + } catch { /* Notification 不可用时静默忽略 */ } + } + } catch { /* 不影响主流程 */ } + + if (err instanceof Error) { + if (err.message.includes('LOGIN_STATUS_INVALID')) { + await dialog.showMessageBox({ + type: `error`, + message: `登录状态无效`, + detail: `请重新登录BOSS直聘(招聘端)` + }) + process.exit(AUTO_CHAT_ERROR_EXIT_CODE.LOGIN_STATUS_INVALID) + break + } + if (err.message.includes('ERR_INTERNET_DISCONNECTED')) { + process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ERR_INTERNET_DISCONNECTED) + break + } + if (err.message.includes('ACCESS_IS_DENIED')) { + process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ACCESS_IS_DENIED) + break + } + if ( + err.message.includes(`Could not find Chrome`) || + err.message.includes(`no executable was found`) + ) { + process.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE) + break + } + } + console.error(err) + const shouldExit = await checkShouldExit() + if (shouldExit) { + app.exit() + return + } + console.log( + `[Boss Auto Browse Main] An internal error is caught, and browser will be restarted in ${rerunInterval}ms.` + ) + await sleep(rerunInterval) + } + } +} + +export const waitForProcessHandShakeAndRunAutoChat = async () => { + await app.whenReady() + app.on('window-all-closed', () => { + // keep process alive while worker is running + }) + initPublicIpc() + await connectToDaemon() + forwardConsoleLogToDaemon('bossAutoBrowseAndChatMain', runRecordId) + await sendToDaemon( + { + type: 'ping' + }, + { + needCallback: true + } + ) + sendToDaemon({ + type: 'worker-to-gui-message', + data: { + type: 'prerequisite-step-by-step-checkstep-by-step-check', + step: { + id: 'worker-launch', + status: 'fulfilled' + }, + runRecordId + } + }) + runAutoBrowseAndChat() +} + +attachListenerForKillSelfOnParentExited() 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 f700797..aec5875 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 @@ -39,6 +39,64 @@ const log = (msg: string) => { console.log(`[boss-chat-page-worker] ${msg}`) } +/** + * 检测当前页面是否为 BOSS 安全验证页(URL 特征 + 页面文字 + 常见验证组件选择器)。 + * 没有具体截图样本,使用多重信号:命中任意一条即判定为验证页。 + */ +const checkForBossVerification = async (page: any): Promise => { + try { + const url: string = page.url() + if (/verify|captcha|security.?check|safe\b|\/safe\/|安全验证/.test(url)) return true + return await page.evaluate(() => { + const text = (document.body?.innerText || '').toLowerCase() + const hasVerifyText = /请完成.{0,10}验证|安全验证|滑动.{0,6}滑块|人机验证|完成验证后继续|异常.{0,6}操作|验证码/.test( + document.body?.innerText || '' + ) + 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"]') + ) + return hasVerifyText || hasVerifyEl + }) + } catch { + return false + } +} + +/** + * 等待用户完成验证(最长 5 分钟)。 + * 期间每 2s 轮询页面状态;完成后返回 true,超时返回 false。 + */ +const waitForBossVerificationCompletion = async (page: any, expectedUrlPrefix: string, logFn: (msg: string) => void): Promise => { + logFn('⚠️ 检测到 BOSS 安全验证,请在浏览器窗口中手动完成验证,完成后将自动继续...') + try { + const { Notification } = await import('electron') + new Notification({ + title: 'GeekGeekRun - 需要人工验证', + body: '检测到 BOSS 直聘安全验证,请在打开的浏览器窗口中完成验证,完成后程序将自动继续。' + }).show() + } catch { /* Notification 不可用时静默忽略 */ } + + const deadline = Date.now() + 5 * 60 * 1000 + while (Date.now() < deadline) { + await sleep(2000) + try { + const url: string = page.url() + const isStillVerify = await checkForBossVerification(page) + if (url.startsWith(expectedUrlPrefix) && !isStillVerify) { + logFn('✅ 安全验证已完成,继续处理...') + return true + } + } catch { /* 页面可能正在跳转,继续等待 */ } + } + logFn('验证等待超时(5 分钟),将重启浏览器重试') + return false +} + const runChatPage = async () => { app.dock?.hide() log('runChatPage 开始') @@ -81,7 +139,12 @@ const runChatPage = async () => { log('正在动态 import boss package...') type BossAutoBrowseModule = { - startBossChatPageProcess: (hooks: any, options?: { browser?: any; page?: any; getCapturedText?: any; clearCapturedText?: any }) => Promise + startBossChatPageProcess: (hooks: any, options?: { + browser?: any; page?: any; getCapturedText?: any; clearCapturedText?: any; + jobId?: string | null; + retryCandidate?: { encryptGeekId: string; geekName: string; jobTitle: string } | null; + processContext?: { currentCandidate: any } | null; + }) => Promise initPuppeteer: () => Promise<{ puppeteer: any }> } const { @@ -137,8 +200,15 @@ const runChatPage = async () => { const { setDomainLocalStorage } = await import('@geekgeekrun/utils/puppeteer/local-storage.mjs') as any const localStoragePageUrl = 'https://www.zhipin.com/desktop/' + // browser/page/canvas hooks 提升到循环外,验证完成后可复用 + let browser: any = null + let page: any = null + let getCapturedText: any = null + let clearCapturedText: any = null + // processContext 提升到循环外,catch 块中可读取被中断的候选人 + const processContext: { currentCandidate: any } = { currentCandidate: null } + while (true) { - let browser: any = null try { const { readConfigFile: readCfg } = await import( '@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs' @@ -153,43 +223,77 @@ const runChatPage = async () => { const runOnceAfterComplete = cfg?.chatPage?.runOnceAfterComplete === true const keepBrowserOpenAfterRun = cfg?.chatPage?.keepBrowserOpenAfterRun === true - log('启动浏览器...') - await hooks.beforeBrowserLaunch?.promise?.() + // 仅在没有复用浏览器时才重新启动 + if (!browser) { + 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 headless = process.env.HEADLESS === '1' + browser = await puppeteer.launch({ + headless, + ignoreHTTPSErrors: true, + protocolTimeout: 120000, + defaultViewport: { width: 1440, height: 900 - 140 } + }) - await hooks.afterBrowserLaunch?.promise?.() + await hooks.afterBrowserLaunch?.promise?.() - const bossCookies = readStorageFile('boss-cookies.json') - const bossLocalStorage = readStorageFile('boss-local-storage.json') + const bossCookies = readStorageFile('boss-cookies.json') + const bossLocalStorage = readStorageFile('boss-local-storage.json') - const page = (await browser.pages())[0] - // 注入 Canvas fillText hook,必须在页面导航前注入(evaluateOnNewDocument) - const { getCapturedText, clearCapturedText } = await setupCanvasTextHook(page) - if (Array.isArray(bossCookies) && bossCookies.length > 0) { - await page.setCookie(...bossCookies) - } - await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {}) - await page.goto(BOSS_CHAT_PAGE_URL, { timeout: 60 * 1000 }) - await page.waitForFunction(() => document.readyState === 'complete', { timeout: 120 * 1000 }) - - sendToDaemon({ - type: 'worker-to-gui-message', - data: { - type: 'prerequisite-step-by-step-checkstep-by-step-check', - step: { id: 'login-status-check', status: 'fulfilled' }, - runRecordId + page = (await browser.pages())[0] + // 注入 Canvas fillText hook,必须在页面导航前注入(evaluateOnNewDocument) + const canvasHooks = await setupCanvasTextHook(page) + getCapturedText = canvasHooks.getCapturedText + clearCapturedText = canvasHooks.clearCapturedText + if (Array.isArray(bossCookies) && bossCookies.length > 0) { + await page.setCookie(...bossCookies) } - }) + await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {}) + await page.goto(BOSS_CHAT_PAGE_URL, { timeout: 60 * 1000 }) + await page.waitForFunction(() => document.readyState === 'complete', { timeout: 120 * 1000 }) - log('开始执行 startBossChatPageProcess(沟通页)...') - await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText }) + sendToDaemon({ + type: 'worker-to-gui-message', + data: { + type: 'prerequisite-step-by-step-checkstep-by-step-check', + step: { id: 'login-status-check', status: 'fulfilled' }, + runRecordId + } + }) + } else { + log('复用已有浏览器实例,直接开始处理...') + } + + log('读取职位队列配置...') + const { readBossJobsConfig } = await import( + '@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs' + ) as any + const jobsConfig = readBossJobsConfig() + const allJobs = jobsConfig?.jobs || [] + + if (allJobs.length > 0) { + const chatJobs = allJobs.filter( + (j: any) => j.sequence?.enabled === true && j.sequence?.runChat !== false + ) + if (chatJobs.length > 0) { + log(`检测到 ${chatJobs.length} 个职位纳入沟通处理,依次执行...`) + for (const job of chatJobs) { + const jid = job.jobId ?? job.id + const jname = job.jobName ?? job.name + log(`开始处理职位 ${jid}(${jname})的沟通页...`) + processContext.currentCandidate = null + await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, jobId: jid, processContext }) + log(`职位 ${jid} 沟通页处理完成`) + } + } else { + log('当前没有勾选"纳入处理"的职位,跳过本轮沟通页扫描') + } + } else { + log('未配置职位队列,开始执行 startBossChatPageProcess(处理所有未读)...') + processContext.currentCandidate = null + await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, processContext }) + } log('startBossChatPageProcess 完成') if (runOnceAfterComplete) { @@ -207,13 +311,60 @@ const runChatPage = async () => { try { await browser.close() } catch (e) { void e } browser = null + page = null + getCapturedText = null + clearCapturedText = null const rerunMs = cfg?.chatPage?.rerunIntervalMs ?? rerunInterval log(`下次运行将在 ${rerunMs}ms 后开始`) await sleep(rerunMs) } catch (err) { + // ── 优先检测安全验证,命中则等待完成后复用浏览器继续,而非重启 ── + if (page) { + try { + const isVerify = await checkForBossVerification(page) + if (isVerify) { + // 保存被中断的候选人,验证完成后通过 retryCandidate 重试 + const interruptedCandidate = processContext.currentCandidate ?? null + if (interruptedCandidate) { + log(`⚠️ 验证中断时正在处理候选人:${interruptedCandidate.geekName}(${interruptedCandidate.encryptGeekId}),验证后将优先重试`) + } + + const completed = await waitForBossVerificationCompletion(page, BOSS_CHAT_PAGE_URL, log) + if (completed) { + // 验证完成:导航回沟通页 + try { + await page.goto(BOSS_CHAT_PAGE_URL, { timeout: 60 * 1000 }) + await page.waitForFunction(() => document.readyState === 'complete', { timeout: 60 * 1000 }) + } catch { /* 导航失败则让下一轮处理 */ } + + // 若有被中断的候选人,立即单独重试(不依赖 jobId,在「全部」tab 中找回) + if (interruptedCandidate) { + log(`🔄 正在重试被验证中断的候选人:${interruptedCandidate.geekName}...`) + try { + await startBossChatPageProcess(hooks, { + browser, page, getCapturedText, clearCapturedText, + retryCandidate: interruptedCandidate, + processContext: { currentCandidate: null } + }) + log(`重试候选人 ${interruptedCandidate.geekName} 完成`) + } catch (retryErr) { + log(`重试候选人时发生错误:${retryErr instanceof Error ? retryErr.message : String(retryErr)}`) + } + } + + continue // 重新进入循环,进行正常扫描 + } + } + } catch { /* 检测本身出错,走正常错误处理 */ } + } + + // ── 正常错误处理:关闭浏览器、识别错误类型 ── if (browser) { try { await browser.close() } catch (e) { void e } browser = null + page = null + getCapturedText = null + clearCapturedText = null } if (err instanceof Error) { if (err.message.includes('LOGIN_STATUS_INVALID')) { diff --git a/packages/ui/src/renderer/src/page/MainLayout/BossAutoSequence/index.vue b/packages/ui/src/renderer/src/page/MainLayout/BossAutoSequence/index.vue index f173cbe..e240a21 100644 --- a/packages/ui/src/renderer/src/page/MainLayout/BossAutoSequence/index.vue +++ b/packages/ui/src/renderer/src/page/MainLayout/BossAutoSequence/index.vue @@ -7,7 +7,7 @@