mirror of
https://github.com/geekgeekrun/geekgeekrun.git
synced 2026-05-25 18:20:14 +08:00
Merge origin/master (反检测增强) into master
Brings in 反检测 commits (bf3132a,076ac37) from origin: - 验证码 in-loop 等待 + 指纹/行为加固 - 通用浮层启发式自动关闭 Conflict resolution: laodeng/index.js — kept registerNativeSource helper from upstream merge and combined with origin's enhanced toString hijack that supports __laodengExtraNativeSources path-based lookups. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
import { sleep, sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs'
|
||||
import { getResumeData, extractResumeText } from './resume-extractor.mjs'
|
||||
import { createHumanCursor } from './humanMouse.mjs'
|
||||
import { safeClickElement, dismissBlockingOverlays } from './dialog-dismisser.mjs'
|
||||
import { debug as logDebug, info as logInfo, warn as logWarn } from './logger.mjs'
|
||||
import {
|
||||
CANDIDATE_ITEM_SELECTOR,
|
||||
@@ -65,7 +66,7 @@ export async function viewCandidateDetail (frame, candidateItem, options = {}) {
|
||||
|
||||
if (getInterceptedData) {
|
||||
const intercepted = getInterceptedData()
|
||||
const resumeResult = await getResumeData(frame, intercepted)
|
||||
const resumeResult = await getResumeData(frame, intercepted, { getCapturedText: options.getCapturedText })
|
||||
if (resumeResult.source === 'api' && resumeResult.data) {
|
||||
resumeSource = 'api'
|
||||
resumeText = typeof resumeResult.data === 'string' ? resumeResult.data : JSON.stringify(resumeResult.data)
|
||||
@@ -314,34 +315,28 @@ export async function startChatWithCandidate (frame, _candidate, _greetingMessag
|
||||
|
||||
// 1. 点击"打招呼"按钮(拟人轨迹);在 iframe frame 内按 candidateIndex 找到对应 item
|
||||
// Puppeteer 24.x boundingBox() 已自动叠加 iframe 偏移,返回 page 绝对坐标,直接使用
|
||||
// 点击前先扫一下主页面是否有挡住操作的浮层(治理公告 / 意向沟通 等突发弹窗)
|
||||
if (mainPage && mainPage !== frame) {
|
||||
await dismissBlockingOverlays(mainPage, { maxRounds: 2 }).catch(() => 0)
|
||||
}
|
||||
try {
|
||||
let startBtn
|
||||
if (typeof candidateIndex === 'number' && CANDIDATE_ITEM_SELECTOR) {
|
||||
const items = await frame.$$(CANDIDATE_ITEM_SELECTOR)
|
||||
const item = items[candidateIndex]
|
||||
if (!item) {
|
||||
return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
|
||||
}
|
||||
const startBtn = await item.$(CHAT_START_BUTTON_SELECTOR)
|
||||
if (!startBtn) {
|
||||
return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
|
||||
}
|
||||
const box = await startBtn.boundingBox()
|
||||
if (box) {
|
||||
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
|
||||
} else {
|
||||
await startBtn.click()
|
||||
}
|
||||
if (!item) return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
|
||||
startBtn = await item.$(CHAT_START_BUTTON_SELECTOR)
|
||||
} else {
|
||||
const startBtn = await frame.waitForSelector(CHAT_START_BUTTON_SELECTOR, { timeout: 8000 })
|
||||
if (!startBtn) {
|
||||
return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
|
||||
}
|
||||
const box = await startBtn.boundingBox()
|
||||
if (box) {
|
||||
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
|
||||
} else {
|
||||
await startBtn.click()
|
||||
}
|
||||
startBtn = await frame.waitForSelector(CHAT_START_BUTTON_SELECTOR, { timeout: 8000 })
|
||||
}
|
||||
if (!startBtn) return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
|
||||
// 「打招呼」按钮在 iframe 内,遮挡几乎都来自主页面 —— 这里直接 cursor.click,
|
||||
// safeClickElement 在 frame 上下文做 elementFromPoint 不可靠,改为提前 sweep 主页面
|
||||
const box = await startBtn.boundingBox()
|
||||
if (box) {
|
||||
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
|
||||
} else {
|
||||
await startBtn.click()
|
||||
}
|
||||
} catch {
|
||||
return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
|
||||
@@ -365,21 +360,37 @@ export async function startChatWithCandidate (frame, _candidate, _greetingMessag
|
||||
}
|
||||
|
||||
// 2. 等待"已向牛人发送招呼"弹窗并点击"知道了"(弹窗在主页面,不在 iframe 内)
|
||||
// 优先用 known selector;找不到则用启发式扫描兜底(应对弹窗结构变动)。
|
||||
// 若弹窗始终未出现(selector 超时 + 启发式也没关掉任何浮层),视为招呼可能未发出,
|
||||
// 返回 GREETING_SENT_DIALOG_NOT_APPEARED 而非乐观地返回 OK,防止误计成功次数。
|
||||
if (GREETING_SENT_KNOW_BTN_SELECTOR) {
|
||||
let handled = false
|
||||
try {
|
||||
const knowBtn = await mainPage.waitForSelector(GREETING_SENT_KNOW_BTN_SELECTOR, { timeout: 6000 })
|
||||
if (knowBtn) {
|
||||
const box = await knowBtn.boundingBox()
|
||||
if (box) {
|
||||
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
|
||||
} else {
|
||||
await knowBtn.click()
|
||||
}
|
||||
const r = await safeClickElement({
|
||||
ctx: mainPage, page: mainPage, element: knowBtn, cursor,
|
||||
logPrefix: '[chat-handler:greet-know]'
|
||||
})
|
||||
// 仅当真正点击成功时认为已处理;clicked=false 时落到下方启发式兜底
|
||||
handled = r.clicked === true && !r.error
|
||||
if (r.error) logWarn('[chat-handler] safeClickElement 报告 error=', r.error)
|
||||
}
|
||||
await sleepWithRandomDelay(500)
|
||||
} catch {
|
||||
// 弹窗未出现不是致命错误,继续
|
||||
logWarn('[chat-handler] "知道了"弹窗未出现,继续尝试后续步骤')
|
||||
// selector 超时,留给启发式兜底
|
||||
}
|
||||
if (!handled) {
|
||||
const closed = await dismissBlockingOverlays(mainPage, { maxRounds: 2 }).catch(() => 0)
|
||||
if (closed > 0) {
|
||||
logInfo('[chat-handler] 启发式关闭了发送招呼后的浮层')
|
||||
handled = true
|
||||
} else {
|
||||
// 弹窗既没有通过 selector 出现,也没有被启发式识别到——
|
||||
// 不能确认招呼已发,提前返回失败,避免误报成功
|
||||
logWarn('[chat-handler] 招呼确认弹窗("知道了")始终未出现,招呼可能未被发送')
|
||||
return { success: false, reason: 'GREETING_SENT_DIALOG_NOT_APPEARED' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs'
|
||||
import { readConfigFile, getMergedJobConfig } from './runtime-file-utils.mjs'
|
||||
import { setupNetworkInterceptor, parseGeekInfoFromIntercepted } from './resume-extractor.mjs'
|
||||
import { createHumanCursor } from './humanMouse.mjs'
|
||||
import { dismissBlockingOverlays } from './dialog-dismisser.mjs'
|
||||
import {
|
||||
openOnlineResume,
|
||||
getOnlineResumeText,
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
BOSS_CHAT_PAGE_URL,
|
||||
CHAT_PAGE_ACTIVE_NAME_SELECTOR,
|
||||
CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR,
|
||||
CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR,
|
||||
CHAT_PAGE_ITEM_SELECTOR,
|
||||
CHAT_PAGE_ITEM_UNREAD_SELECTOR,
|
||||
CHAT_PAGE_ALL_FILTER_SELECTOR,
|
||||
@@ -323,6 +323,7 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
||||
page: existingPage,
|
||||
getCapturedText,
|
||||
clearCapturedText,
|
||||
peekCapturedText,
|
||||
jobId = null,
|
||||
retryCandidate = null,
|
||||
processContext = null
|
||||
@@ -494,6 +495,7 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
||||
}
|
||||
|
||||
// 关闭「意向沟通」提示弹窗(BOSS 每次新浏览器会话打开某些会话时会弹出,遮挡右侧操作按钮)
|
||||
// 优先用已知 selector 快速路径;失败则启发式扫描兜底(应对 BOSS 弹窗结构变动)
|
||||
{
|
||||
const intentKnowBtn = await page.$(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR).catch(() => null)
|
||||
if (intentKnowBtn) {
|
||||
@@ -506,24 +508,22 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
||||
await intentKnowBtn.click().catch(() => {})
|
||||
}
|
||||
} catch {
|
||||
const closeIconEl = await page.$(CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR).catch(() => null)
|
||||
if (closeIconEl) {
|
||||
const closeIconBox = await closeIconEl.boundingBox().catch(() => null)
|
||||
if (closeIconBox) {
|
||||
await cursor.click({ x: closeIconBox.x + closeIconBox.width / 2, y: closeIconBox.y + closeIconBox.height / 2 })
|
||||
} else {
|
||||
await closeIconEl.click().catch(() => {})
|
||||
}
|
||||
}
|
||||
// 留给启发式兜底
|
||||
}
|
||||
try {
|
||||
await page.waitForSelector(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, { hidden: true, timeout: 3000 })
|
||||
logDebug(`${LOG} → 「意向沟通」弹窗已关闭`)
|
||||
} catch {
|
||||
logWarn(`${LOG} → 「意向沟通」弹窗 3s 内未消失,继续执行(可能影响按钮点击)`)
|
||||
logWarn(`${LOG} → 「意向沟通」弹窗 3s 内未消失,启发式兜底...`)
|
||||
}
|
||||
await sleepWithRandomDelay(200, 400)
|
||||
}
|
||||
// 启发式兜底:扫一遍主页面剩余浮层(治理公告补刀 / 未知新弹窗)
|
||||
const closed = await dismissBlockingOverlays(page, { maxRounds: 2 }).catch(() => 0)
|
||||
if (closed > 0) {
|
||||
logInfo(`${LOG} → 启发式额外关闭了 ${closed} 个浮层`)
|
||||
await sleepWithRandomDelay(200, 400)
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段一:初步信息筛选(点击会话后 geek/info 已触发,从拦截数据取结构化字段)
|
||||
@@ -637,7 +637,9 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
||||
let stableCount = 0
|
||||
while (Date.now() < canvasDeadline) {
|
||||
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS))
|
||||
const currentCount = await page.evaluate(() => (window.__canvasCapturedText || []).length)
|
||||
const currentCount = typeof peekCapturedText === 'function'
|
||||
? await peekCapturedText(page)
|
||||
: await page.evaluate(() => (window.__canvasCapturedText || []).length)
|
||||
if (currentCount > 0 && currentCount === lastCount) {
|
||||
stableCount++
|
||||
if (stableCount >= STABLE_POLLS_NEEDED) break
|
||||
|
||||
@@ -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 未关闭)
|
||||
// 若存在则先等它消失;若等不到则视为卡死,直接报错,不继续执行
|
||||
|
||||
337
packages/boss-auto-browse-and-chat/dialog-dismisser.mjs
Normal file
337
packages/boss-auto-browse-and-chat/dialog-dismisser.mjs
Normal file
@@ -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<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 是 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<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 === 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' }
|
||||
}
|
||||
@@ -1,111 +1,150 @@
|
||||
/**
|
||||
* 拟人鼠标轨迹封装(招聘端专用)
|
||||
*
|
||||
* BOSS 对招聘端鼠标移动轨迹进行埋点,直接 page.click() 或 page.mouse.click(x,y)
|
||||
* 的"瞬移"方式容易被识别为脚本。本模块封装 ghost-cursor,以贝塞尔曲线生成拟人
|
||||
* 移动路径,替换所有在招聘端页面上的点击操作。
|
||||
*
|
||||
* 用法:
|
||||
* import { createHumanCursor } from './humanMouse.mjs'
|
||||
* const cursor = await createHumanCursor(page)
|
||||
* await cursor.click(selector) // 先沿轨迹移动,再点击
|
||||
* await cursor.move(selector) // 仅移动,不点击
|
||||
*/
|
||||
|
||||
/**
|
||||
* 为给定 Puppeteer page 创建拟人鼠标 cursor。
|
||||
* 内部使用 ghost-cursor;若 ghost-cursor 不可用(如包未安装),
|
||||
* 则 fallback 到普通 page.click(),并打印警告。
|
||||
*
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
|
||||
* @returns {Promise<{
|
||||
* click: (selectorOrPos: string | {x: number, y: number}) => Promise<void>,
|
||||
* move: (selectorOrPos: string | {x: number, y: number}) => Promise<void>
|
||||
* }>}
|
||||
*/
|
||||
export async function createHumanCursor (page) {
|
||||
let ghostCursorCreate
|
||||
try {
|
||||
const mod = await import('ghost-cursor')
|
||||
// ghost-cursor 同时支持 ESM default export 和命名 export
|
||||
ghostCursorCreate = mod.createCursor ?? mod.default?.createCursor
|
||||
} catch {
|
||||
ghostCursorCreate = null
|
||||
}
|
||||
|
||||
if (ghostCursorCreate) {
|
||||
const cursor = ghostCursorCreate(page)
|
||||
|
||||
/**
|
||||
* 将 selector 字符串或 ElementHandle 解析成 {x, y} 坐标。
|
||||
* ghost-cursor 的 click/move 只接受 string selector 或 ElementHandle,
|
||||
* 传 {x,y} 坐标对象会被误当 ElementHandle 调 element.remoteObject() 崩溃。
|
||||
* 统一在封装层解析成坐标,再用 moveTo({x,y}) + page.mouse.click(x,y) 执行。
|
||||
*/
|
||||
const resolvePos = async (selectorOrPos) => {
|
||||
if (typeof selectorOrPos === 'string') {
|
||||
const el = await page.$(selectorOrPos)
|
||||
if (!el) throw new Error(`[humanMouse] element not found: ${selectorOrPos}`)
|
||||
const box = await el.boundingBox()
|
||||
if (!box) throw new Error(`[humanMouse] element has no bounding box: ${selectorOrPos}`)
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||
}
|
||||
// ElementHandle(有 boundingBox 方法)
|
||||
if (selectorOrPos && typeof selectorOrPos.boundingBox === 'function') {
|
||||
const box = await selectorOrPos.boundingBox()
|
||||
if (!box) throw new Error('[humanMouse] ElementHandle has no bounding box')
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||
}
|
||||
// 已是 {x, y} 坐标对象
|
||||
return selectorOrPos
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* 沿拟人轨迹移动到目标后点击。使用 moveTo({x,y}) + page.mouse.click(x,y)
|
||||
* 规避 ghost-cursor 传坐标/ElementHandle 时调 element.evaluate 的崩溃问题。
|
||||
* @param {string | {x: number, y: number} | import('puppeteer').ElementHandle} selectorOrPos
|
||||
*/
|
||||
async click (selectorOrPos) {
|
||||
const pos = await resolvePos(selectorOrPos)
|
||||
await cursor.moveTo(pos)
|
||||
await page.mouse.click(pos.x, pos.y)
|
||||
},
|
||||
/**
|
||||
* 沿拟人轨迹移动到目标(不点击)
|
||||
* @param {string | {x: number, y: number} | import('puppeteer').ElementHandle} selectorOrPos
|
||||
*/
|
||||
async move (selectorOrPos) {
|
||||
const pos = await resolvePos(selectorOrPos)
|
||||
await cursor.moveTo(pos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: ghost-cursor 未安装时退化为普通点击(打印警告)
|
||||
console.warn('[humanMouse] ghost-cursor 未安装,退化为普通 page.click()。建议安装 ghost-cursor 以规避 BOSS 鼠标轨迹埋点检测。')
|
||||
return {
|
||||
async click (selectorOrPos) {
|
||||
if (typeof selectorOrPos === 'string') {
|
||||
await page.click(selectorOrPos)
|
||||
} else if (selectorOrPos && typeof selectorOrPos.x === 'number') {
|
||||
await page.mouse.click(selectorOrPos.x, selectorOrPos.y)
|
||||
}
|
||||
},
|
||||
async move (selectorOrPos) {
|
||||
if (typeof selectorOrPos === 'string') {
|
||||
try {
|
||||
const el = await page.$(selectorOrPos)
|
||||
if (el) {
|
||||
const box = await el.boundingBox()
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||
}
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
} else if (selectorOrPos && typeof selectorOrPos.x === 'number') {
|
||||
await page.mouse.move(selectorOrPos.x, selectorOrPos.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 拟人鼠标轨迹封装(招聘端专用)
|
||||
*
|
||||
* BOSS 对招聘端鼠标移动轨迹进行埋点,直接 page.click() 或 page.mouse.click(x,y)
|
||||
* 的"瞬移"方式容易被识别为脚本。本模块封装 ghost-cursor,以贝塞尔曲线生成拟人
|
||||
* 移动路径,替换所有在招聘端页面上的点击操作。
|
||||
*
|
||||
* 用法:
|
||||
* import { createHumanCursor, randomizeInitialCursorPosition } from './humanMouse.mjs'
|
||||
* const cursor = await createHumanCursor(page)
|
||||
* await randomizeInitialCursorPosition(page)
|
||||
* await cursor.click(selector) // 先沿轨迹移动,再点击
|
||||
* await cursor.move(selector) // 仅移动,不点击
|
||||
*/
|
||||
|
||||
// 模块级缓存:首次成功 preflight 后避免重复 import
|
||||
let cachedGhostCursorCreate = null
|
||||
|
||||
/**
|
||||
* 预检查 ghost-cursor 是否可用,返回其 createCursor 函数。
|
||||
* 失败时抛出明确错误,避免静默退化为 page.click() 这种"以为隐身实则裸奔"的最坏情况。
|
||||
*
|
||||
* @returns {Promise<Function>} ghost-cursor 的 createCursor 函数
|
||||
*/
|
||||
export async function preflightGhostCursor () {
|
||||
if (cachedGhostCursorCreate) return cachedGhostCursorCreate
|
||||
let createCursor
|
||||
try {
|
||||
const mod = await import('ghost-cursor')
|
||||
// ghost-cursor 同时支持 ESM default export 和命名 export
|
||||
createCursor = mod.createCursor ?? mod.default?.createCursor
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
'GHOST_CURSOR_UNAVAILABLE: ghost-cursor failed to load — refusing to run with bot-like clicks. Reinstall dependencies (pnpm -F @geekgeekrun/boss-auto-browse-and-chat install).'
|
||||
)
|
||||
}
|
||||
if (typeof createCursor !== 'function') {
|
||||
throw new Error(
|
||||
'GHOST_CURSOR_UNAVAILABLE: ghost-cursor failed to load — refusing to run with bot-like clicks. Reinstall dependencies (pnpm -F @geekgeekrun/boss-auto-browse-and-chat install).'
|
||||
)
|
||||
}
|
||||
cachedGhostCursorCreate = createCursor
|
||||
return createCursor
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 box 的中心 60% 区域内随机一个落点(默认 centerBiasFraction=0.3,即中心 ±30%)。
|
||||
* 避免每次点击都落在精确几何中心,这本身就是脚本特征。
|
||||
*
|
||||
* @param {{x: number, y: number, width: number, height: number}} box
|
||||
* @param {number} [centerBiasFraction=0.3]
|
||||
* @returns {{x: number, y: number}}
|
||||
*/
|
||||
function randomizePointInBox (box, centerBiasFraction = 0.3) {
|
||||
const minXFrac = 0.5 - centerBiasFraction
|
||||
const maxXFrac = 0.5 + centerBiasFraction
|
||||
const minYFrac = 0.5 - centerBiasFraction
|
||||
const maxYFrac = 0.5 + centerBiasFraction
|
||||
const xFrac = minXFrac + Math.random() * (maxXFrac - minXFrac)
|
||||
const yFrac = minYFrac + Math.random() * (maxYFrac - minYFrac)
|
||||
return {
|
||||
x: box.x + box.width * xFrac,
|
||||
y: box.y + box.height * yFrac
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为给定 Puppeteer page 创建拟人鼠标 cursor。
|
||||
* 内部强依赖 ghost-cursor;若不可用直接抛错(fail-fast),不再静默退化。
|
||||
*
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
|
||||
* @returns {Promise<{
|
||||
* click: (selectorOrPos: string | {x: number, y: number}) => Promise<void>,
|
||||
* move: (selectorOrPos: string | {x: number, y: number}) => Promise<void>
|
||||
* }>}
|
||||
*/
|
||||
export async function createHumanCursor (page) {
|
||||
const ghostCursorCreate = await preflightGhostCursor()
|
||||
const cursor = ghostCursorCreate(page)
|
||||
|
||||
/**
|
||||
* 将 selector 字符串或 ElementHandle 解析成 {x, y} 坐标。
|
||||
* ghost-cursor 的 click/move 只接受 string selector 或 ElementHandle,
|
||||
* 传 {x,y} 坐标对象会被误当 ElementHandle 调 element.remoteObject() 崩溃。
|
||||
* 统一在封装层解析成坐标,再用 moveTo({x,y}) + page.mouse.click(x,y) 执行。
|
||||
* 对 selector / ElementHandle 输入会在中心 60% 范围内随机落点;
|
||||
* 对显式 {x,y} 输入保持原样(调用方已选定精确坐标)。
|
||||
*/
|
||||
const resolvePos = async (selectorOrPos) => {
|
||||
if (typeof selectorOrPos === 'string') {
|
||||
const el = await page.$(selectorOrPos)
|
||||
if (!el) throw new Error(`[humanMouse] element not found: ${selectorOrPos}`)
|
||||
const box = await el.boundingBox()
|
||||
if (!box) throw new Error(`[humanMouse] element has no bounding box: ${selectorOrPos}`)
|
||||
return randomizePointInBox(box)
|
||||
}
|
||||
// ElementHandle(有 boundingBox 方法)
|
||||
if (selectorOrPos && typeof selectorOrPos.boundingBox === 'function') {
|
||||
const box = await selectorOrPos.boundingBox()
|
||||
if (!box) throw new Error('[humanMouse] ElementHandle has no bounding box')
|
||||
return randomizePointInBox(box)
|
||||
}
|
||||
// 已是 {x, y} 坐标对象,调用方已选定精确坐标,不再随机化
|
||||
return selectorOrPos
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* 沿拟人轨迹移动到目标后点击。使用 moveTo({x,y}) + page.mouse.click(x,y)
|
||||
* 规避 ghost-cursor 传坐标/ElementHandle 时调 element.evaluate 的崩溃问题。
|
||||
* 以约 0.5 概率先做一次轻微 overshoot 移动,模拟真实用户在按钮附近犹豫/减速。
|
||||
* @param {string | {x: number, y: number} | import('puppeteer').ElementHandle} selectorOrPos
|
||||
*/
|
||||
async click (selectorOrPos) {
|
||||
const pos = await resolvePos(selectorOrPos)
|
||||
if (Math.random() < 0.5) {
|
||||
const vp = page.viewport() || { width: 1280, height: 720 }
|
||||
const overshoot = {
|
||||
x: Math.max(1, Math.min(vp.width - 1, pos.x + (Math.random() * 60 - 30))),
|
||||
y: Math.max(1, Math.min(vp.height - 1, pos.y + (Math.random() * 30 - 15)))
|
||||
}
|
||||
await cursor.moveTo(overshoot)
|
||||
}
|
||||
await cursor.moveTo(pos)
|
||||
await page.mouse.click(pos.x, pos.y)
|
||||
},
|
||||
/**
|
||||
* 沿拟人轨迹移动到目标(不点击)
|
||||
* @param {string | {x: number, y: number} | import('puppeteer').ElementHandle} selectorOrPos
|
||||
*/
|
||||
async move (selectorOrPos) {
|
||||
const pos = await resolvePos(selectorOrPos)
|
||||
await cursor.moveTo(pos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将鼠标移动到 viewport 内一个随机位置,避免每次会话都从 (0,0) 起步这一明显特征。
|
||||
* 由集成方在合适时机(如打开页面后)显式调用,createHumanCursor 不会自动调用它。
|
||||
*
|
||||
* @param {import('puppeteer').Page} page
|
||||
*/
|
||||
export async function randomizeInitialCursorPosition (page) {
|
||||
// Move cursor to a random viewport position (avoids the (0,0) start signature)
|
||||
const viewport = page.viewport() || { width: 1280, height: 720 }
|
||||
const x = 200 + Math.floor(Math.random() * (viewport.width - 400))
|
||||
const y = 100 + Math.floor(Math.random() * (viewport.height - 200))
|
||||
await page.mouse.move(x, y, { steps: 5 + Math.floor(Math.random() * 10) })
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
import { setupNetworkInterceptor, setupCanvasTextHook } from './resume-extractor.mjs'
|
||||
import { parseCandidateList, filterCandidates, scrollAndLoadMore } from './candidate-processor.mjs'
|
||||
import { processCandidate, checkDailyLimit, clickNotInterested } from './chat-handler.mjs'
|
||||
import { dismissBlockingOverlays } from './dialog-dismisser.mjs'
|
||||
import { setLevel, debug as logDebug, info as logInfo, warn as logWarn, error as logError } from './logger.mjs'
|
||||
import { preflightGhostCursor, randomizeInitialCursorPosition } from './humanMouse.mjs'
|
||||
import { buildRecruiterLaunchOptions } from './launch-options.mjs'
|
||||
import { checkpointRiskControl } from './risk-detector.mjs'
|
||||
|
||||
export { default as startBossChatPageProcess } from './chat-page-processor.mjs'
|
||||
|
||||
@@ -53,7 +57,9 @@ export async function initPuppeteer () {
|
||||
puppeteer.use(StealthPlugin())
|
||||
puppeteer.use(LaodengPlugin())
|
||||
puppeteer.use(AnonymizeUaPlugin({ makeWindows: false }))
|
||||
logDebug('[boss-auto-browse] initPuppeteer: 插件已注册')
|
||||
// ghost-cursor preflight:fail-fast,避免后续静默退化为裸 page.click()
|
||||
await preflightGhostCursor()
|
||||
logDebug('[boss-auto-browse] initPuppeteer: 插件已注册(含 ghost-cursor preflight)')
|
||||
return {
|
||||
puppeteer,
|
||||
StealthPlugin,
|
||||
@@ -63,32 +69,36 @@ export async function initPuppeteer () {
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭登录后弹出的「治理公告」弹窗(点击「我已知晓」确认按钮)。
|
||||
* 该弹窗在每次登录后必现,不处理会导致后续自动化操作卡死超时。
|
||||
* 关闭登录后弹出的「治理公告」等任何挡住操作的浮层。
|
||||
*
|
||||
* 历史上这里只针对 GOVERNANCE_NOTICE_DIALOG_CONFIRM_BTN_SELECTOR 硬编码点击,
|
||||
* 现改为:先等待已知治理公告 selector 出现(提示性),再调用通用的 dismissBlockingOverlays
|
||||
* 启发式扫描——这样新冒出的弹窗也能被自动关掉,不必每次手工加 selector。
|
||||
* @param {import('puppeteer').Page} page
|
||||
*/
|
||||
export async function dismissGovernanceNoticeDialog (page) {
|
||||
try {
|
||||
const confirmBtn = await page
|
||||
.waitForSelector(GOVERNANCE_NOTICE_DIALOG_CONFIRM_BTN_SELECTOR, { timeout: 10000 })
|
||||
.catch(() => null)
|
||||
if (!confirmBtn) return
|
||||
logInfo('[boss-auto-browse] 检测到「治理公告」弹窗,点击「我已知晓」关闭...')
|
||||
try {
|
||||
// 给已知的治理公告一点时间冒出来;超时也没关系——通用扫描兜底
|
||||
await page.waitForSelector(GOVERNANCE_NOTICE_DIALOG_SELECTOR, { timeout: 10000 }).catch(() => null)
|
||||
const closed = await dismissBlockingOverlays(page, { maxRounds: 3 }).catch(() => 0)
|
||||
if (closed > 0) {
|
||||
logInfo(`[boss-auto-browse] 自动关闭了 ${closed} 个登录后浮层(含治理公告)`)
|
||||
await sleep(300)
|
||||
}
|
||||
// 兜底:若启发式没识别到治理公告(例如关闭按钮文案变了),再尝试硬编码 selector
|
||||
const stillThere = await page.$(GOVERNANCE_NOTICE_DIALOG_SELECTOR).catch(() => null)
|
||||
if (stillThere) {
|
||||
logWarn('[boss-auto-browse] 启发式未关闭治理公告,回退到硬编码 selector')
|
||||
const confirmBtn = await page.$(GOVERNANCE_NOTICE_DIALOG_CONFIRM_BTN_SELECTOR).catch(() => null)
|
||||
if (confirmBtn) {
|
||||
const box = await confirmBtn.boundingBox().catch(() => null)
|
||||
if (box) {
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2)
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2).catch(() => {})
|
||||
} else {
|
||||
await confirmBtn.click()
|
||||
await confirmBtn.click().catch(() => {})
|
||||
}
|
||||
} catch {
|
||||
await confirmBtn.click().catch(() => {})
|
||||
await page.waitForSelector(GOVERNANCE_NOTICE_DIALOG_SELECTOR, { hidden: true, timeout: 5000 }).catch(() => {})
|
||||
await sleep(300)
|
||||
}
|
||||
await page.waitForSelector(GOVERNANCE_NOTICE_DIALOG_SELECTOR, { hidden: true, timeout: 5000 }).catch(() => {})
|
||||
logInfo('[boss-auto-browse] 「治理公告」弹窗已关闭')
|
||||
await sleep(300)
|
||||
} catch {
|
||||
// 弹窗不存在或关闭失败时静默继续
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,17 +111,14 @@ const localStoragePageUrl = 'https://www.zhipin.com/desktop/'
|
||||
*/
|
||||
export async function launchBrowserAndNavigateToChat () {
|
||||
if (!puppeteer) await initPuppeteer()
|
||||
const headless = process.env.HEADLESS === '1'
|
||||
const browser = await puppeteer.launch({
|
||||
headless,
|
||||
ignoreHTTPSErrors: true,
|
||||
protocolTimeout: 120000,
|
||||
defaultViewport: { width: 1440, height: 900 - 140 }
|
||||
})
|
||||
const launchOpts = await buildRecruiterLaunchOptions()
|
||||
const browser = await puppeteer.launch(launchOpts)
|
||||
const page = (await browser.pages())[0]
|
||||
await randomizeInitialCursorPosition(page).catch(() => {})
|
||||
const bossCookies = readStorageFile('boss-cookies.json')
|
||||
const bossLocalStorage = readStorageFile('boss-local-storage.json')
|
||||
if (Array.isArray(bossCookies) && bossCookies.length > 0) {
|
||||
// persistProfile=true 时 profile 已持久化 cookies,跳过注入避免用过期文件覆盖有效 session
|
||||
if (!launchOpts.userDataDir && Array.isArray(bossCookies) && bossCookies.length > 0) {
|
||||
await page.setCookie(...bossCookies)
|
||||
}
|
||||
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
|
||||
@@ -246,20 +253,12 @@ export default async function startBossAutoBrowse (hooksFromCaller, opts = {}) {
|
||||
} else {
|
||||
await hooks.beforeBrowserLaunch?.promise?.()
|
||||
|
||||
const headlessEnv = process.env.HEADLESS
|
||||
const headless = headlessEnv === '1'
|
||||
logDebug('[boss-auto-browse] 即将启动浏览器', { headless, HEADLESS_env: headlessEnv ?? null })
|
||||
browser = await puppeteer.launch({
|
||||
headless,
|
||||
ignoreHTTPSErrors: true,
|
||||
protocolTimeout: 120000,
|
||||
defaultViewport: {
|
||||
width: 1440,
|
||||
height: 900 - 140
|
||||
}
|
||||
})
|
||||
const launchOpts = await buildRecruiterLaunchOptions()
|
||||
logDebug('[boss-auto-browse] 即将启动浏览器', { headless: launchOpts.headless, persistProfile: !!launchOpts.userDataDir })
|
||||
browser = await puppeteer.launch(launchOpts)
|
||||
|
||||
page = (await browser.pages())[0]
|
||||
await randomizeInitialCursorPosition(page).catch(() => {})
|
||||
|
||||
await hooks.afterBrowserLaunch?.promise?.()
|
||||
}
|
||||
@@ -271,7 +270,9 @@ export default async function startBossAutoBrowse (hooksFromCaller, opts = {}) {
|
||||
// 直接导航到推荐牛人页(注入 Cookie / localStorage 后 goto;复用浏览器时若已在推荐页可跳过 goto)
|
||||
// -----------------------------------------------------------------------
|
||||
await hooks.beforeNavigateToRecommend?.promise?.()
|
||||
if (Array.isArray(bossCookies) && bossCookies.length > 0) {
|
||||
// persistProfile=true 时 profile 已持久化 cookies,跳过注入避免用过期文件覆盖有效 session
|
||||
const persistProfile = (readConfigFile('boss-recruiter.json') || {})?.advanced?.persistProfile === true
|
||||
if (!persistProfile && Array.isArray(bossCookies) && bossCookies.length > 0) {
|
||||
await page.setCookie(...bossCookies)
|
||||
}
|
||||
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
|
||||
@@ -572,10 +573,16 @@ export default async function startBossAutoBrowse (hooksFromCaller, opts = {}) {
|
||||
logInfo('[boss-auto-browse] ✓ 已向', candidate.geekName, '发送招呼(本次共', chatCount, '人)')
|
||||
} else {
|
||||
logInfo('[boss-auto-browse] ✗', candidate.geekName, '开聊失败:', chatResult.reason)
|
||||
if (chatResult.reason === 'DAILY_LIMIT_REACHED' || chatResult.reason === 'RISK_CONTROL') {
|
||||
if (chatResult.reason === 'DAILY_LIMIT_REACHED') {
|
||||
break mainLoop
|
||||
}
|
||||
// 'RISK_CONTROL' 落到下面统一 checkpoint 处理
|
||||
}
|
||||
|
||||
// 每位候选人处理完都做一次 checkpoint:检测到验证则在循环内等待用户完成,避免崩出 catch + 3s 重试导致连环触发
|
||||
// 不传 expectedUrlPrefix:仅依赖 !detectRiskControl 判断完成,避免 URL query-params 导致误判超时
|
||||
const cpStatus = await checkpointRiskControl(page, { log: logWarn })
|
||||
if (cpStatus === 'timed-out') break mainLoop
|
||||
}
|
||||
|
||||
// e. 滚动加载 / 翻页(在 iframe frame 内操作)
|
||||
|
||||
69
packages/boss-auto-browse-and-chat/launch-options.mjs
Normal file
69
packages/boss-auto-browse-and-chat/launch-options.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* boss-recruiter.json `advanced` section schema:
|
||||
* {
|
||||
* "advanced": {
|
||||
* "persistProfile": false // opt-in: persist Chromium profile across launches (better anti-detection;
|
||||
* // BUT cannot run BOSS in system Chrome simultaneously)
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import crypto from 'node:crypto'
|
||||
import { readConfigFile, storageFilePath } from './runtime-file-utils.mjs'
|
||||
|
||||
const VIEWPORT_POOL = [
|
||||
{ w: 1366, h: 768 },
|
||||
{ w: 1440, h: 900 - 140 },
|
||||
{ w: 1536, h: 864 },
|
||||
{ w: 1600, h: 900 },
|
||||
{ w: 1680, h: 1050 - 150 }
|
||||
]
|
||||
|
||||
const DEFAULT_VIEWPORT = { width: 1440, height: 760 }
|
||||
|
||||
function pickViewportForPath(seed) {
|
||||
const digest = crypto.createHash('md5').update(seed).digest()
|
||||
const intVal = digest.readInt32BE(0)
|
||||
const idx = Math.abs(intVal) % VIEWPORT_POOL.length
|
||||
const picked = VIEWPORT_POOL[idx]
|
||||
return { width: picked.w, height: picked.h }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the puppeteer.launch() options object for the recruiter side.
|
||||
* Reads boss-recruiter.json's `advanced` section for opt-in features.
|
||||
*
|
||||
* @param {object} [overrides] - shallow-merged onto the result (e.g. { headless: false } for force)
|
||||
* @returns {Promise<import('puppeteer').LaunchOptions>}
|
||||
*/
|
||||
export async function buildRecruiterLaunchOptions(overrides = {}) {
|
||||
const cfg = readConfigFile('boss-recruiter.json') || {}
|
||||
const advanced = cfg.advanced || {}
|
||||
const persistProfile = advanced.persistProfile === true
|
||||
|
||||
const headless = process.env.HEADLESS === '1'
|
||||
|
||||
let userDataDir
|
||||
let viewport
|
||||
if (persistProfile) {
|
||||
userDataDir = path.join(storageFilePath, 'boss-chrome-profile')
|
||||
fs.mkdirSync(userDataDir, { recursive: true })
|
||||
viewport = pickViewportForPath(userDataDir)
|
||||
} else {
|
||||
viewport = { ...DEFAULT_VIEWPORT }
|
||||
}
|
||||
|
||||
const args = ['--lang=zh-CN', '--disable-blink-features=AutomationControlled']
|
||||
|
||||
const opts = {
|
||||
headless,
|
||||
ignoreHTTPSErrors: true,
|
||||
protocolTimeout: 120000,
|
||||
defaultViewport: viewport,
|
||||
args: [...args]
|
||||
}
|
||||
if (userDataDir) opts.userDataDir = userDataDir
|
||||
return { ...opts, ...overrides }
|
||||
}
|
||||
@@ -141,103 +141,134 @@ export function parseGeekInfoFromIntercepted (interceptedMap) {
|
||||
// Canvas 文字 Hook(与 laodeng 兼容)— 非 BOSS 自带,可能被反爬检测,沟通页请用 API 拦截
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CANVAS_HOOK_DEBUG = process.env.GEEKGEEKRUN_CANVAS_HOOK_DEBUG === '1'
|
||||
|
||||
/**
|
||||
* 在页面上通过 evaluateOnNewDocument 注入 Canvas fillText hook,将绘制文字收集到主页面 window.__canvasCapturedText。
|
||||
* 在页面上通过 evaluateOnNewDocument 注入 Canvas fillText hook,将绘制文字收集到主页面随机命名的 marker 属性上。
|
||||
*
|
||||
* 实现原理:
|
||||
* - evaluateOnNewDocument 会在主页面和每一个 iframe 中各执行一次。
|
||||
* - 在线简历 iframe 带有 sandbox 属性且不含 allow-same-origin,主页面无法访问其 contentWindow,
|
||||
* 因此必须在 iframe 自身的执行上下文内直接 hook CanvasRenderingContext2D.prototype.fillText。
|
||||
* - iframe 内 hook 到的文字通过 window.top.postMessage 批量发回主页面(同 origin 或跨 origin 均可用)。
|
||||
* - 主页面监听 message 事件并累积到 window.__canvasCapturedText。
|
||||
* - 主页面监听 message 事件并累积到随机命名的 window 属性。
|
||||
*
|
||||
* 反检测:marker 属性名(capturedTextProp / messageKey / hookedFlag)每次调用本函数时随机生成,
|
||||
* 不同 session 不同;同时通过 laodeng.registerFakeNativeSource 让 fillText 包装函数的 toString 返回原生外观。
|
||||
*
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面实例(必须在 page.goto 之前调用)
|
||||
* @returns {Promise<{ getCapturedText: (page: import('puppeteer').Page) => Promise<Array<{text: string, x: number, y: number}>> }>}
|
||||
* @returns {Promise<{ getCapturedText: (page: import('puppeteer').Page) => Promise<Array<{text: string, x: number, y: number}>>, clearCapturedText: (page: import('puppeteer').Page) => Promise<void>, peekCapturedText: (page: import('puppeteer').Page) => Promise<number> }>}
|
||||
*/
|
||||
export async function setupCanvasTextHook (page) {
|
||||
// 转发浏览器内部 [canvasHook] 日志到 Node 侧,便于调试
|
||||
page.on('console', (msg) => {
|
||||
const text = msg.text()
|
||||
if (text.startsWith('[canvasHook]')) {
|
||||
console.log('[canvasHook-browser]', text)
|
||||
}
|
||||
})
|
||||
const markerSuffix = Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4)
|
||||
const capturedTextProp = '__cct_' + markerSuffix
|
||||
const messageKey = '__mk_' + markerSuffix
|
||||
const hookedFlag = '_h_' + markerSuffix
|
||||
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
// 此脚本在每个 frame(主页面 + 所有 iframe)中各执行一次。
|
||||
// 策略:
|
||||
// 主页面 → 初始化收集数组,监听来自 iframe 的 postMessage
|
||||
// iframe → 直接 hook 当前窗口的 fillText,批量 postMessage 到 window.top
|
||||
|
||||
const isTopFrame = (window === window.top)
|
||||
|
||||
if (isTopFrame) {
|
||||
window.__canvasCapturedText = []
|
||||
window.addEventListener('message', (evt) => {
|
||||
if (evt.data && evt.data.__bossCanvasHook && Array.isArray(evt.data.__bossCanvasHook)) {
|
||||
if (!window.__canvasCapturedText) window.__canvasCapturedText = []
|
||||
for (const item of evt.data.__bossCanvasHook) {
|
||||
window.__canvasCapturedText.push(item)
|
||||
}
|
||||
console.log('[canvasHook] main received ' + evt.data.__bossCanvasHook.length + ' items, total ' + window.__canvasCapturedText.length)
|
||||
}
|
||||
})
|
||||
console.log('[canvasHook] main: message listener set')
|
||||
}
|
||||
|
||||
// 在当前 window(无论是主页面还是 iframe)上 hook fillText
|
||||
try {
|
||||
const proto = window.CanvasRenderingContext2D?.prototype
|
||||
if (!proto) { console.log('[canvasHook] CanvasRenderingContext2D.prototype not found'); return }
|
||||
if (proto._bossHooked) { console.log('[canvasHook] already hooked, skip'); return }
|
||||
proto._bossHooked = true
|
||||
|
||||
const origFillText = proto.fillText
|
||||
if (typeof origFillText !== 'function') { console.log('[canvasHook] fillText is not a function'); return }
|
||||
|
||||
// 批量缓冲,用 setTimeout(0) 在一个事件循环 tick 后统一发送(WASM 会在同一个同步调用栈内连续 fillText)
|
||||
const captured = []
|
||||
let flushScheduled = false
|
||||
const flush = () => {
|
||||
flushScheduled = false
|
||||
if (captured.length === 0) return
|
||||
const items = captured.splice(0)
|
||||
if (isTopFrame) {
|
||||
if (!window.__canvasCapturedText) window.__canvasCapturedText = []
|
||||
for (const item of items) window.__canvasCapturedText.push(item)
|
||||
console.log('[canvasHook] main fillText wrote ' + items.length + ' items')
|
||||
} else {
|
||||
try {
|
||||
window.top.postMessage({ __bossCanvasHook: items }, '*')
|
||||
console.log('[canvasHook] iframe postMessage sent ' + items.length + ' items')
|
||||
} catch (e) {
|
||||
console.log('[canvasHook] postMessage failed: ' + e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
const scheduleFlush = () => {
|
||||
if (!flushScheduled) { flushScheduled = true; setTimeout(flush, 0) }
|
||||
// 转发浏览器内部 [canvasHook] 日志到 Node 侧(仅 debug 模式)
|
||||
if (CANVAS_HOOK_DEBUG) {
|
||||
page.on('console', (msg) => {
|
||||
const text = msg.text()
|
||||
if (text.startsWith('[canvasHook]')) {
|
||||
console.log('[canvasHook-browser]', text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Object.defineProperty(proto, 'fillText', {
|
||||
value: new Proxy(origFillText, {
|
||||
apply (target, thisArg, args) {
|
||||
const [text, x, y] = args
|
||||
if (typeof text === 'string' && text.trim()) {
|
||||
captured.push({ text, x: Number(x) || 0, y: Number(y) || 0 })
|
||||
scheduleFlush()
|
||||
// 注册 fillText 包装的伪原生 toString(依赖 laodeng 已被 puppeteer.use 装载)
|
||||
try {
|
||||
const laodengMod = await import('@geekgeekrun/puppeteer-extra-plugin-laodeng')
|
||||
const registerFakeNativeSource =
|
||||
laodengMod.registerFakeNativeSource ?? laodengMod.default?.registerFakeNativeSource
|
||||
if (typeof registerFakeNativeSource === 'function') {
|
||||
await registerFakeNativeSource(
|
||||
page,
|
||||
'CanvasRenderingContext2D.prototype.fillText',
|
||||
'function fillText() { [native code] }'
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
// non-fatal: hook still works, just one more detectable surface
|
||||
}
|
||||
|
||||
await page.evaluateOnNewDocument(
|
||||
(capturedTextProp, messageKey, hookedFlag, DEBUG) => {
|
||||
// 此脚本在每个 frame(主页面 + 所有 iframe)中各执行一次。
|
||||
const isTopFrame = (window === window.top)
|
||||
|
||||
if (isTopFrame) {
|
||||
window[capturedTextProp] = []
|
||||
window.addEventListener('message', (evt) => {
|
||||
if (evt.data && evt.data[messageKey] && Array.isArray(evt.data[messageKey])) {
|
||||
if (!window[capturedTextProp]) window[capturedTextProp] = []
|
||||
for (const item of evt.data[messageKey]) {
|
||||
window[capturedTextProp].push(item)
|
||||
}
|
||||
return Reflect.apply(target, thisArg, args)
|
||||
if (DEBUG) console.log('[canvasHook] main received ' + evt.data[messageKey].length + ' items, total ' + window[capturedTextProp].length)
|
||||
}
|
||||
}),
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
console.log('[canvasHook] fillText hook installed, isTopFrame=' + isTopFrame + ' href=' + window.location.href)
|
||||
} catch (e) {
|
||||
console.log('[canvasHook] hook install error: ' + e.message)
|
||||
}
|
||||
})
|
||||
})
|
||||
if (DEBUG) console.log('[canvasHook] main: message listener set')
|
||||
}
|
||||
|
||||
// 在当前 window(无论是主页面还是 iframe)上 hook fillText
|
||||
try {
|
||||
const proto = window.CanvasRenderingContext2D?.prototype
|
||||
if (!proto) { if (DEBUG) console.log('[canvasHook] CanvasRenderingContext2D.prototype not found'); return }
|
||||
if (proto[hookedFlag]) { if (DEBUG) console.log('[canvasHook] already hooked, skip'); return }
|
||||
proto[hookedFlag] = true
|
||||
|
||||
const origFillText = proto.fillText
|
||||
if (typeof origFillText !== 'function') { if (DEBUG) console.log('[canvasHook] fillText is not a function'); return }
|
||||
|
||||
const captured = []
|
||||
let flushScheduled = false
|
||||
const flush = () => {
|
||||
flushScheduled = false
|
||||
if (captured.length === 0) return
|
||||
const items = captured.splice(0)
|
||||
if (isTopFrame) {
|
||||
if (!window[capturedTextProp]) window[capturedTextProp] = []
|
||||
for (const item of items) window[capturedTextProp].push(item)
|
||||
if (DEBUG) console.log('[canvasHook] main fillText wrote ' + items.length + ' items')
|
||||
} else {
|
||||
try {
|
||||
const payload = {}
|
||||
payload[messageKey] = items
|
||||
window.top.postMessage(payload, '*')
|
||||
if (DEBUG) console.log('[canvasHook] iframe postMessage sent ' + items.length + ' items')
|
||||
} catch (e) {
|
||||
if (DEBUG) console.log('[canvasHook] postMessage failed: ' + e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
const scheduleFlush = () => {
|
||||
if (!flushScheduled) { flushScheduled = true; setTimeout(flush, 0) }
|
||||
}
|
||||
|
||||
Object.defineProperty(proto, 'fillText', {
|
||||
value: new Proxy(origFillText, {
|
||||
apply (target, thisArg, args) {
|
||||
const [text, x, y] = args
|
||||
if (typeof text === 'string' && text.trim()) {
|
||||
captured.push({ text, x: Number(x) || 0, y: Number(y) || 0 })
|
||||
scheduleFlush()
|
||||
}
|
||||
return Reflect.apply(target, thisArg, args)
|
||||
}
|
||||
}),
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
if (DEBUG) console.log('[canvasHook] fillText hook installed, isTopFrame=' + isTopFrame + ' href=' + window.location.href)
|
||||
} catch (e) {
|
||||
if (DEBUG) console.log('[canvasHook] hook install error: ' + e.message)
|
||||
}
|
||||
},
|
||||
capturedTextProp,
|
||||
messageKey,
|
||||
hookedFlag,
|
||||
CANVAS_HOOK_DEBUG
|
||||
)
|
||||
|
||||
/**
|
||||
* 从主页面读取当前收集的 Canvas 文字并清空。
|
||||
@@ -245,14 +276,13 @@ export async function setupCanvasTextHook (page) {
|
||||
* @returns {Promise<Array<{text: string, x: number, y: number}>>}
|
||||
*/
|
||||
async function getCapturedText (p) {
|
||||
// 给浏览器 150ms 处理待发送的 setTimeout(0)/postMessage 队列
|
||||
await p.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)))
|
||||
const result = await p.evaluate(() => {
|
||||
const arr = window.__canvasCapturedText || []
|
||||
const result = await p.evaluate((prop) => {
|
||||
const arr = window[prop] || []
|
||||
const copy = arr.map(({ text, x, y }) => ({ text, x, y }))
|
||||
window.__canvasCapturedText = []
|
||||
window[prop] = []
|
||||
return copy
|
||||
})
|
||||
}, capturedTextProp)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -261,10 +291,20 @@ export async function setupCanvasTextHook (page) {
|
||||
* @param {import('puppeteer').Page} p - 同一页面实例
|
||||
*/
|
||||
async function clearCapturedText (p) {
|
||||
await p.evaluate(() => { window.__canvasCapturedText = [] })
|
||||
await p.evaluate((prop) => { window[prop] = [] }, capturedTextProp)
|
||||
}
|
||||
|
||||
return { getCapturedText, clearCapturedText }
|
||||
/**
|
||||
* Peek at how many canvas text items have been captured so far, without consuming them.
|
||||
* Used for "stable count" polling to detect when Canvas rendering has finished.
|
||||
* @param {import('puppeteer').Page} p
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async function peekCapturedText (p) {
|
||||
return p.evaluate((prop) => (window[prop] || []).length, capturedTextProp)
|
||||
}
|
||||
|
||||
return { getCapturedText, clearCapturedText, peekCapturedText }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -316,13 +356,15 @@ export function extractResumeText (capturedTextArray) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 优先从拦截的 API 数据中取简历,若无则从页面 window.__canvasCapturedText 中提取(需先调用 setupCanvasTextHook)。
|
||||
* 优先从拦截的 API 数据中取简历,若无则从页面 Canvas hook 中提取(需先调用 setupCanvasTextHook)。
|
||||
*
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
|
||||
* @param {Map<string, unknown>} interceptedData - setupNetworkInterceptor 返回的 getInterceptedData() 的结果
|
||||
* @param {{ getCapturedText?: (page: import('puppeteer').Page) => Promise<Array<{text: string, x: number, y: number}>> }} [opts]
|
||||
* opts.getCapturedText — setupCanvasTextHook 返回的同名函数(支持随机 marker 名);不传时降级读 window.__canvasCapturedText(旧行为,仅向后兼容)
|
||||
* @returns {Promise<{ source: 'api' | 'canvas', data: unknown }>} source 为 'api' 时 data 为 API 响应对象;为 'canvas' 时为 extractResumeText 的结果(字符串数组)
|
||||
*/
|
||||
export async function getResumeData (page, interceptedData) {
|
||||
export async function getResumeData (page, interceptedData, opts = {}) {
|
||||
if (interceptedData && interceptedData.size > 0) {
|
||||
const firstEntry = interceptedData.entries().next()
|
||||
if (!firstEntry.done) {
|
||||
@@ -330,12 +372,21 @@ export async function getResumeData (page, interceptedData) {
|
||||
return { source: 'api', data: { path, ...(typeof data === 'object' && data !== null ? data : { value: data }) } }
|
||||
}
|
||||
}
|
||||
const captured = await page.evaluate(() => {
|
||||
const arr = window.__canvasCapturedText || []
|
||||
const copy = arr.map(({ text, x, y }) => ({ text, x, y }))
|
||||
window.__canvasCapturedText = []
|
||||
return copy
|
||||
})
|
||||
|
||||
// Canvas fallback: use getCapturedText closure if provided (supports randomized marker names)
|
||||
// Fall back to legacy window.__canvasCapturedText for callers that don't yet pass it
|
||||
const getCapturedTextFn = opts.getCapturedText
|
||||
let captured
|
||||
if (typeof getCapturedTextFn === 'function') {
|
||||
captured = await getCapturedTextFn(page)
|
||||
} else {
|
||||
captured = await page.evaluate(() => {
|
||||
const arr = window.__canvasCapturedText || []
|
||||
const copy = arr.map(({ text, x, y }) => ({ text, x, y }))
|
||||
window.__canvasCapturedText = []
|
||||
return copy
|
||||
})
|
||||
}
|
||||
const lines = extractResumeText(captured)
|
||||
return { source: 'canvas', data: lines }
|
||||
}
|
||||
|
||||
108
packages/boss-auto-browse-and-chat/risk-detector.mjs
Normal file
108
packages/boss-auto-browse-and-chat/risk-detector.mjs
Normal file
@@ -0,0 +1,108 @@
|
||||
import { sleep } from '@geekgeekrun/utils/sleep.mjs'
|
||||
|
||||
/**
|
||||
* Detect whether the page is currently showing BOSS security verification
|
||||
* (CAPTCHA / slider / 安全验证 / etc).
|
||||
*
|
||||
* Multi-signal: URL match + DOM elements + body text fallback.
|
||||
* Element-first (more robust than text, which can false-positive on candidate
|
||||
* descriptions that mention 验证).
|
||||
*
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function detectRiskControl(page) {
|
||||
try {
|
||||
const url = page.url()
|
||||
if (/verify|captcha|security.?check|safe\b|\/safe\/|安全验证/.test(url)) return true
|
||||
return await page.evaluate(() => {
|
||||
const hasVerifyEl = !!(
|
||||
document.querySelector('#nc_mask') ||
|
||||
document.querySelector('.verify-container') ||
|
||||
document.querySelector('.captcha-wrap') ||
|
||||
document.querySelector('.nc-container') ||
|
||||
document.querySelector('[class*="verify"][class*="wrap"]') ||
|
||||
document.querySelector('[class*="captcha"]') ||
|
||||
document.querySelector('.geetest_panel') ||
|
||||
document.querySelector('.geetest_box') ||
|
||||
document.querySelector('[id^="__yidun"]') ||
|
||||
document.querySelector('iframe[src*="captcha"]') ||
|
||||
document.querySelector('iframe[src*="verify"]') ||
|
||||
document.querySelector('.boss-popup__wrapper.dialog-verify')
|
||||
)
|
||||
if (hasVerifyEl) return true
|
||||
const bodyText = document.body?.innerText || ''
|
||||
const hasVerifyText =
|
||||
/请完成.{0,10}验证|安全验证|滑动.{0,6}滑块|人机验证|完成验证后继续|异常.{0,6}操作|操作过于频繁|请稍后再试.*继续|存在风险.*操作/.test(
|
||||
bodyText
|
||||
)
|
||||
return hasVerifyText
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block until user manually completes verification, OR timeout.
|
||||
* Polls every 2s. Sends a desktop notification once on entry.
|
||||
*
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.expectedUrlPrefix] - if provided, only consider verification done when url returns to this prefix
|
||||
* @param {number} [opts.timeoutMs=300000] - default 5 min
|
||||
* @param {(msg: string) => void} [opts.log] - optional logger
|
||||
* @returns {Promise<boolean>} true if completed, false if timed out
|
||||
*/
|
||||
export async function waitForRiskControlCompletion(page, opts = {}) {
|
||||
const { expectedUrlPrefix, timeoutMs = 300000, log } = opts
|
||||
const logFn = typeof log === 'function' ? log : () => {}
|
||||
|
||||
logFn('⚠️ 检测到 BOSS 安全验证...')
|
||||
|
||||
try {
|
||||
const { Notification } = await import('electron')
|
||||
new Notification({
|
||||
title: 'GeekGeekRun - 需要人工验证',
|
||||
body: '检测到 BOSS 直聘安全验证,请在浏览器窗口中完成验证,完成后程序将自动继续。'
|
||||
}).show()
|
||||
} catch {
|
||||
/* Notification 不可用时静默忽略 */
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(2000)
|
||||
try {
|
||||
const isStillVerify = await detectRiskControl(page)
|
||||
if (expectedUrlPrefix) {
|
||||
const url = page.url()
|
||||
if (url.startsWith(expectedUrlPrefix) && !isStillVerify) {
|
||||
logFn('✅ 安全验证已完成')
|
||||
return true
|
||||
}
|
||||
} else if (!isStillVerify) {
|
||||
logFn('✅ 安全验证已完成')
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
/* 页面可能正在跳转,继续等待 */
|
||||
}
|
||||
}
|
||||
logFn('验证等待超时(5 分钟)')
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: detect and, if positive, wait for completion.
|
||||
*
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {object} [opts] - same as waitForRiskControlCompletion
|
||||
* @returns {Promise<'no-risk'|'completed'|'timed-out'>}
|
||||
*/
|
||||
export async function checkpointRiskControl(page, opts = {}) {
|
||||
const detected = await detectRiskControl(page)
|
||||
if (!detected) return 'no-risk'
|
||||
const completed = await waitForRiskControlCompletion(page, opts)
|
||||
return completed ? 'completed' : 'timed-out'
|
||||
}
|
||||
@@ -34,6 +34,21 @@ const stealthScript = () => {
|
||||
if (nativeSourceMap.has(this)) {
|
||||
return nativeSourceMap.get(this);
|
||||
}
|
||||
// Path-based extra registrations
|
||||
try {
|
||||
const extras = window.__laodengExtraNativeSources;
|
||||
if (extras && extras.size) {
|
||||
for (const [path, src] of extras) {
|
||||
const parts = path.split(".");
|
||||
let obj = window;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (obj == null) break;
|
||||
obj = obj[parts[i]];
|
||||
}
|
||||
if (obj === this) return src;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return nativeFunctionToString.call(this);
|
||||
},
|
||||
});
|
||||
@@ -152,6 +167,31 @@ class Plugin extends PuppeteerExtraPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = function (pluginConfig) {
|
||||
/**
|
||||
* Register a fake native source for a function in the target page.
|
||||
* Must be called AFTER the laodeng plugin has been applied to the browser.
|
||||
* The wrapped function should already exist (or be created shortly after) — this
|
||||
* adds a deferred registration that runs on every new document.
|
||||
*
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {string} accessorPath - dotted path to the wrapped function in window scope, e.g. "CanvasRenderingContext2D.prototype.fillText"
|
||||
* @param {string} fakeNativeSource - what `.toString()` should return, e.g. "function fillText() { [native code] }"
|
||||
*/
|
||||
async function registerFakeNativeSource(page, accessorPath, fakeNativeSource) {
|
||||
await page.evaluateOnNewDocument(
|
||||
function (path, src) {
|
||||
try {
|
||||
if (!window.__laodengExtraNativeSources) window.__laodengExtraNativeSources = new Map();
|
||||
window.__laodengExtraNativeSources.set(path, src);
|
||||
} catch (_) {}
|
||||
},
|
||||
accessorPath,
|
||||
fakeNativeSource
|
||||
);
|
||||
}
|
||||
|
||||
const pluginFactory = function (pluginConfig) {
|
||||
return new Plugin(pluginConfig);
|
||||
};
|
||||
pluginFactory.registerFakeNativeSource = registerFakeNativeSource;
|
||||
module.exports = pluginFactory;
|
||||
|
||||
@@ -140,7 +140,7 @@ const runChatPage = async () => {
|
||||
log('正在动态 import boss package...')
|
||||
type BossAutoBrowseModule = {
|
||||
startBossChatPageProcess: (hooks: any, options?: {
|
||||
browser?: any; page?: any; getCapturedText?: any; clearCapturedText?: any;
|
||||
browser?: any; page?: any; getCapturedText?: any; clearCapturedText?: any; peekCapturedText?: any;
|
||||
jobId?: string | null;
|
||||
retryCandidate?: { encryptGeekId: string; geekName: string; jobTitle: string } | null;
|
||||
processContext?: { currentCandidate: any } | null;
|
||||
@@ -207,6 +207,7 @@ const runChatPage = async () => {
|
||||
let page: any = null
|
||||
let getCapturedText: any = null
|
||||
let clearCapturedText: any = null
|
||||
let peekCapturedText: any = null
|
||||
// processContext 提升到循环外,catch 块中可读取被中断的候选人
|
||||
const processContext: { currentCandidate: any } = { currentCandidate: null }
|
||||
|
||||
@@ -230,13 +231,12 @@ const runChatPage = async () => {
|
||||
log('启动浏览器...')
|
||||
await hooks.beforeBrowserLaunch?.promise?.()
|
||||
|
||||
const headless = process.env.HEADLESS === '1'
|
||||
browser = await puppeteer.launch({
|
||||
headless,
|
||||
ignoreHTTPSErrors: true,
|
||||
protocolTimeout: 120000,
|
||||
defaultViewport: { width: 1440, height: 900 - 140 }
|
||||
})
|
||||
const { buildRecruiterLaunchOptions } = (await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/launch-options.mjs'
|
||||
)) as any
|
||||
const launchOpts = await buildRecruiterLaunchOptions()
|
||||
log(`使用 launch options:persistProfile=${!!launchOpts.userDataDir}`)
|
||||
browser = await puppeteer.launch(launchOpts)
|
||||
|
||||
await hooks.afterBrowserLaunch?.promise?.()
|
||||
|
||||
@@ -248,7 +248,15 @@ const runChatPage = async () => {
|
||||
const canvasHooks = await setupCanvasTextHook(page)
|
||||
getCapturedText = canvasHooks.getCapturedText
|
||||
clearCapturedText = canvasHooks.clearCapturedText
|
||||
if (Array.isArray(bossCookies) && bossCookies.length > 0) {
|
||||
peekCapturedText = canvasHooks.peekCapturedText
|
||||
|
||||
const { randomizeInitialCursorPosition } = (await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/humanMouse.mjs'
|
||||
)) as any
|
||||
await randomizeInitialCursorPosition(page).catch(() => {})
|
||||
|
||||
// persistProfile=true 时 profile 已持久化 cookies,跳过注入避免用过期文件覆盖有效 session
|
||||
if (!launchOpts.userDataDir && Array.isArray(bossCookies) && bossCookies.length > 0) {
|
||||
await page.setCookie(...bossCookies)
|
||||
}
|
||||
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
|
||||
@@ -287,7 +295,7 @@ const runChatPage = async () => {
|
||||
const jname = job.jobName ?? job.name
|
||||
log(`开始处理职位 ${jid}(${jname})的沟通页...`)
|
||||
processContext.currentCandidate = null
|
||||
await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, jobId: jid, processContext })
|
||||
await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, peekCapturedText, jobId: jid, processContext })
|
||||
log(`职位 ${jid} 沟通页处理完成`)
|
||||
}
|
||||
} else {
|
||||
@@ -296,7 +304,7 @@ const runChatPage = async () => {
|
||||
} else {
|
||||
log('未配置职位队列,开始执行 startBossChatPageProcess(处理所有未读)...')
|
||||
processContext.currentCandidate = null
|
||||
await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, processContext })
|
||||
await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, peekCapturedText, processContext })
|
||||
}
|
||||
log('startBossChatPageProcess 完成')
|
||||
|
||||
@@ -318,6 +326,7 @@ const runChatPage = async () => {
|
||||
page = null
|
||||
getCapturedText = null
|
||||
clearCapturedText = null
|
||||
peekCapturedText = null
|
||||
const rerunMs = cfg?.chatPage?.rerunIntervalMs ?? rerunInterval
|
||||
log(`下次运行将在 ${rerunMs}ms 后开始`)
|
||||
await sleep(rerunMs)
|
||||
@@ -346,7 +355,7 @@ const runChatPage = async () => {
|
||||
log(`🔄 正在重试被验证中断的候选人:${interruptedCandidate.geekName}...`)
|
||||
try {
|
||||
await startBossChatPageProcess(hooks, {
|
||||
browser, page, getCapturedText, clearCapturedText,
|
||||
browser, page, getCapturedText, clearCapturedText, peekCapturedText,
|
||||
retryCandidate: interruptedCandidate,
|
||||
processContext: { currentCandidate: null }
|
||||
})
|
||||
@@ -369,6 +378,7 @@ const runChatPage = async () => {
|
||||
page = null
|
||||
getCapturedText = null
|
||||
clearCapturedText = null
|
||||
peekCapturedText = null
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes('LOGIN_STATUS_INVALID')) {
|
||||
|
||||
@@ -991,6 +991,12 @@ export default function initIpc() {
|
||||
...payload.recommendPage
|
||||
}
|
||||
}
|
||||
if (payload.advanced && typeof payload.advanced === 'object') {
|
||||
bossRecruiterConfig.advanced = bossRecruiterConfig.advanced || {}
|
||||
if (typeof payload.advanced.persistProfile === 'boolean') {
|
||||
bossRecruiterConfig.advanced.persistProfile = payload.advanced.persistProfile
|
||||
}
|
||||
}
|
||||
|
||||
const candidateFilterConfig = readBossConfigFile('candidate-filter.json') || {}
|
||||
if (hasOwn(payload, 'expectCityList')) {
|
||||
|
||||
@@ -90,6 +90,22 @@
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<el-card class="config-section">
|
||||
<el-form-item mb0>
|
||||
<div class="section-title">高级反检测(实验性)</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="formContent.advancedPersistProfile">
|
||||
持久化浏览器 profile(更难被识别为新设备)
|
||||
</el-checkbox>
|
||||
<div class="form-tip">
|
||||
启用后 BOSS 看到的是「老设备」而非「每次都是新设备」,能显著降低人工验证触发率。<br>
|
||||
副作用:bot 运行期间不能在系统 Chrome 同时登录 BOSS(会被挤掉);profile 文件夹长期会占用 1-2GB 磁盘空间。<br>
|
||||
路径:<code>~/.geekgeekrun/storage/boss-chrome-profile/</code>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<div class="action-bar">
|
||||
<el-button :loading="isSaving" @click="handleSave">仅保存配置</el-button>
|
||||
<el-button type="primary" :loading="isSaving" @click="handleSubmit">
|
||||
@@ -167,7 +183,8 @@ const formContent = reactive({
|
||||
recommendSkipViewedCandidates: false,
|
||||
recommendRerunIntervalMs: 3000,
|
||||
recommendDelayBetweenNotInterestedMs: [800, 2500] as [number, number],
|
||||
recommendKeepBrowserOpenAfterRun: false
|
||||
recommendKeepBrowserOpenAfterRun: false,
|
||||
advancedPersistProfile: false
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -194,6 +211,9 @@ onMounted(async () => {
|
||||
: [800, 2500]
|
||||
formContent.recommendKeepBrowserOpenAfterRun =
|
||||
recommendPage.keepBrowserOpenAfterRun ?? false
|
||||
|
||||
const advanced = recruiterConfig.advanced ?? {}
|
||||
formContent.advancedPersistProfile = advanced.persistProfile ?? false
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
@@ -213,6 +233,9 @@ const doSave = async () => {
|
||||
rerunIntervalMs: formContent.recommendRerunIntervalMs,
|
||||
delayBetweenNotInterestedMs: formContent.recommendDelayBetweenNotInterestedMs,
|
||||
keepBrowserOpenAfterRun: formContent.recommendKeepBrowserOpenAfterRun
|
||||
},
|
||||
advanced: {
|
||||
persistProfile: formContent.advancedPersistProfile
|
||||
}
|
||||
}
|
||||
await ipcRenderer.invoke('save-boss-recruiter-config', JSON.stringify(payload))
|
||||
|
||||
Reference in New Issue
Block a user