From 076ac3754bd1de24ea55bb48c5a4f99dbb0e8380 Mon Sep 17 00:00:00 2001 From: rqi14 Date: Sun, 10 May 2026 19:48:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(boss):=20=E9=80=9A=E7=94=A8=E6=B5=AE?= =?UTF-8?q?=E5=B1=82=E5=90=AF=E5=8F=91=E5=BC=8F=E8=87=AA=E5=8A=A8=E5=85=B3?= =?UTF-8?q?=E9=97=AD=EF=BC=8C=E5=87=8F=E5=B0=91=E6=89=8B=E7=BB=B4=E6=8A=A4?= =?UTF-8?q?=20selector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 dialog-dismisser.mjs:启发式识别 fixed/高 z-index 浮层,按文本/class/aria 匹配关闭按钮并 click。 接入治理公告、意向沟通、打招呼「知道了」、请求附件简历前等关键点;原硬编码 selector 仅作兜底。 白名单排除 resume-common-dialog / ask-for-resume-confirm / c-resume。 后续 Copilot + Codex review 修复: - isVisible opacity 改 parseFloat - 窄选择器优先扫描 + 5000 元素 cap 回退 - safeClickElement 返回 clicked:false + error 字段 - 浮层 click 后无进展检测(连续 2 次 signature 相同则终止) - 打招呼「知道了」弹窗未出现时返回 GREETING_SENT_DIALOG_NOT_APPEARED 而非误报 OK --- .../chat-handler.mjs | 73 ++-- .../chat-page-processor.mjs | 21 +- .../chat-page-resume.mjs | 13 +- .../dialog-dismisser.mjs | 337 ++++++++++++++++++ packages/boss-auto-browse-and-chat/index.mjs | 41 ++- 5 files changed, 421 insertions(+), 64 deletions(-) create mode 100644 packages/boss-auto-browse-and-chat/dialog-dismisser.mjs diff --git a/packages/boss-auto-browse-and-chat/chat-handler.mjs b/packages/boss-auto-browse-and-chat/chat-handler.mjs index fc47092..febdc6c 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, @@ -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..5d77d37 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, @@ -494,6 +494,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 +507,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 已触发,从拦截数据取结构化字段) 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/index.mjs b/packages/boss-auto-browse-and-chat/index.mjs index 53ea74f..3aef3c2 100644 --- a/packages/boss-auto-browse-and-chat/index.mjs +++ b/packages/boss-auto-browse-and-chat/index.mjs @@ -16,6 +16,7 @@ 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' export { default as startBossChatPageProcess } from './chat-page-processor.mjs' @@ -63,32 +64,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 { - // 弹窗不存在或关闭失败时静默继续 } }