feat(boss): 通用浮层启发式自动关闭,减少手维护 selector

新增 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
This commit is contained in:
rqi14
2026-05-10 19:48:36 +08:00
committed by GitHub
parent 3aeddd72f7
commit 076ac3754b
5 changed files with 421 additions and 64 deletions

View File

@@ -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' }
}
}
}

View File

@@ -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 已触发,从拦截数据取结构化字段)

View File

@@ -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 未关闭)
// 若存在则先等它消失;若等不到则视为卡死,直接报错,不继续执行

View File

@@ -0,0 +1,337 @@
/**
* 通用弹窗 / 遮挡层自动识别与关闭。
*
* 设计目标:减少手动维护「治理公告」「意向沟通」之类一次性弹窗 selector 的成本。
* 思路:
* 1) 启发式扫描页面顶层 fixed / 高 z-index 浮层;
* 2) 在浮层内按文本(我已知晓/我知道了/知道了/确定/好的/关闭/取消/跳过/×+
* aria-label / classclose|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
// 触发真实 clickHTMLElement.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<number>} 成功关闭的浮层数量
*/
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 是 FrameelementFromPoint
* 是 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<void> },
* 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 === pagebox 已是主页面 viewport 坐标,可直接传给 elementFromPoint。
// 当 ctx 是 frameboundingBox 返回的是主页面 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' }
}

View File

@@ -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 {
// 弹窗不存在或关闭失败时静默继续
}
}