mirror of
https://github.com/geekgeekrun/geekgeekrun.git
synced 2026-09-05 15:38:46 +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 { sleep, sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs'
|
||||||
import { getResumeData, extractResumeText } from './resume-extractor.mjs'
|
import { getResumeData, extractResumeText } from './resume-extractor.mjs'
|
||||||
import { createHumanCursor } from './humanMouse.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 { debug as logDebug, info as logInfo, warn as logWarn } from './logger.mjs'
|
||||||
import {
|
import {
|
||||||
CANDIDATE_ITEM_SELECTOR,
|
CANDIDATE_ITEM_SELECTOR,
|
||||||
@@ -65,7 +66,7 @@ export async function viewCandidateDetail (frame, candidateItem, options = {}) {
|
|||||||
|
|
||||||
if (getInterceptedData) {
|
if (getInterceptedData) {
|
||||||
const intercepted = 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) {
|
if (resumeResult.source === 'api' && resumeResult.data) {
|
||||||
resumeSource = 'api'
|
resumeSource = 'api'
|
||||||
resumeText = typeof resumeResult.data === 'string' ? resumeResult.data : JSON.stringify(resumeResult.data)
|
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
|
// 1. 点击"打招呼"按钮(拟人轨迹);在 iframe frame 内按 candidateIndex 找到对应 item
|
||||||
// Puppeteer 24.x boundingBox() 已自动叠加 iframe 偏移,返回 page 绝对坐标,直接使用
|
// Puppeteer 24.x boundingBox() 已自动叠加 iframe 偏移,返回 page 绝对坐标,直接使用
|
||||||
|
// 点击前先扫一下主页面是否有挡住操作的浮层(治理公告 / 意向沟通 等突发弹窗)
|
||||||
|
if (mainPage && mainPage !== frame) {
|
||||||
|
await dismissBlockingOverlays(mainPage, { maxRounds: 2 }).catch(() => 0)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
|
let startBtn
|
||||||
if (typeof candidateIndex === 'number' && CANDIDATE_ITEM_SELECTOR) {
|
if (typeof candidateIndex === 'number' && CANDIDATE_ITEM_SELECTOR) {
|
||||||
const items = await frame.$$(CANDIDATE_ITEM_SELECTOR)
|
const items = await frame.$$(CANDIDATE_ITEM_SELECTOR)
|
||||||
const item = items[candidateIndex]
|
const item = items[candidateIndex]
|
||||||
if (!item) {
|
if (!item) return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
|
||||||
return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
|
startBtn = await item.$(CHAT_START_BUTTON_SELECTOR)
|
||||||
}
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const startBtn = await frame.waitForSelector(CHAT_START_BUTTON_SELECTOR, { timeout: 8000 })
|
startBtn = await frame.waitForSelector(CHAT_START_BUTTON_SELECTOR, { timeout: 8000 })
|
||||||
if (!startBtn) {
|
}
|
||||||
return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
|
if (!startBtn) return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
|
||||||
}
|
// 「打招呼」按钮在 iframe 内,遮挡几乎都来自主页面 —— 这里直接 cursor.click,
|
||||||
const box = await startBtn.boundingBox()
|
// safeClickElement 在 frame 上下文做 elementFromPoint 不可靠,改为提前 sweep 主页面
|
||||||
if (box) {
|
const box = await startBtn.boundingBox()
|
||||||
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
|
if (box) {
|
||||||
} else {
|
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
|
||||||
await startBtn.click()
|
} else {
|
||||||
}
|
await startBtn.click()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
|
return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
|
||||||
@@ -365,21 +360,37 @@ export async function startChatWithCandidate (frame, _candidate, _greetingMessag
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 等待"已向牛人发送招呼"弹窗并点击"知道了"(弹窗在主页面,不在 iframe 内)
|
// 2. 等待"已向牛人发送招呼"弹窗并点击"知道了"(弹窗在主页面,不在 iframe 内)
|
||||||
|
// 优先用 known selector;找不到则用启发式扫描兜底(应对弹窗结构变动)。
|
||||||
|
// 若弹窗始终未出现(selector 超时 + 启发式也没关掉任何浮层),视为招呼可能未发出,
|
||||||
|
// 返回 GREETING_SENT_DIALOG_NOT_APPEARED 而非乐观地返回 OK,防止误计成功次数。
|
||||||
if (GREETING_SENT_KNOW_BTN_SELECTOR) {
|
if (GREETING_SENT_KNOW_BTN_SELECTOR) {
|
||||||
|
let handled = false
|
||||||
try {
|
try {
|
||||||
const knowBtn = await mainPage.waitForSelector(GREETING_SENT_KNOW_BTN_SELECTOR, { timeout: 6000 })
|
const knowBtn = await mainPage.waitForSelector(GREETING_SENT_KNOW_BTN_SELECTOR, { timeout: 6000 })
|
||||||
if (knowBtn) {
|
if (knowBtn) {
|
||||||
const box = await knowBtn.boundingBox()
|
const r = await safeClickElement({
|
||||||
if (box) {
|
ctx: mainPage, page: mainPage, element: knowBtn, cursor,
|
||||||
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
|
logPrefix: '[chat-handler:greet-know]'
|
||||||
} else {
|
})
|
||||||
await knowBtn.click()
|
// 仅当真正点击成功时认为已处理;clicked=false 时落到下方启发式兜底
|
||||||
}
|
handled = r.clicked === true && !r.error
|
||||||
|
if (r.error) logWarn('[chat-handler] safeClickElement 报告 error=', r.error)
|
||||||
}
|
}
|
||||||
await sleepWithRandomDelay(500)
|
await sleepWithRandomDelay(500)
|
||||||
} catch {
|
} catch {
|
||||||
// 弹窗未出现不是致命错误,继续
|
// selector 超时,留给启发式兜底
|
||||||
logWarn('[chat-handler] "知道了"弹窗未出现,继续尝试后续步骤')
|
}
|
||||||
|
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 { readConfigFile, getMergedJobConfig } from './runtime-file-utils.mjs'
|
||||||
import { setupNetworkInterceptor, parseGeekInfoFromIntercepted } from './resume-extractor.mjs'
|
import { setupNetworkInterceptor, parseGeekInfoFromIntercepted } from './resume-extractor.mjs'
|
||||||
import { createHumanCursor } from './humanMouse.mjs'
|
import { createHumanCursor } from './humanMouse.mjs'
|
||||||
|
import { dismissBlockingOverlays } from './dialog-dismisser.mjs'
|
||||||
import {
|
import {
|
||||||
openOnlineResume,
|
openOnlineResume,
|
||||||
getOnlineResumeText,
|
getOnlineResumeText,
|
||||||
@@ -22,7 +23,6 @@ import {
|
|||||||
BOSS_CHAT_PAGE_URL,
|
BOSS_CHAT_PAGE_URL,
|
||||||
CHAT_PAGE_ACTIVE_NAME_SELECTOR,
|
CHAT_PAGE_ACTIVE_NAME_SELECTOR,
|
||||||
CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR,
|
CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR,
|
||||||
CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR,
|
|
||||||
CHAT_PAGE_ITEM_SELECTOR,
|
CHAT_PAGE_ITEM_SELECTOR,
|
||||||
CHAT_PAGE_ITEM_UNREAD_SELECTOR,
|
CHAT_PAGE_ITEM_UNREAD_SELECTOR,
|
||||||
CHAT_PAGE_ALL_FILTER_SELECTOR,
|
CHAT_PAGE_ALL_FILTER_SELECTOR,
|
||||||
@@ -323,6 +323,7 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
|||||||
page: existingPage,
|
page: existingPage,
|
||||||
getCapturedText,
|
getCapturedText,
|
||||||
clearCapturedText,
|
clearCapturedText,
|
||||||
|
peekCapturedText,
|
||||||
jobId = null,
|
jobId = null,
|
||||||
retryCandidate = null,
|
retryCandidate = null,
|
||||||
processContext = null
|
processContext = null
|
||||||
@@ -494,6 +495,7 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 关闭「意向沟通」提示弹窗(BOSS 每次新浏览器会话打开某些会话时会弹出,遮挡右侧操作按钮)
|
// 关闭「意向沟通」提示弹窗(BOSS 每次新浏览器会话打开某些会话时会弹出,遮挡右侧操作按钮)
|
||||||
|
// 优先用已知 selector 快速路径;失败则启发式扫描兜底(应对 BOSS 弹窗结构变动)
|
||||||
{
|
{
|
||||||
const intentKnowBtn = await page.$(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR).catch(() => null)
|
const intentKnowBtn = await page.$(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR).catch(() => null)
|
||||||
if (intentKnowBtn) {
|
if (intentKnowBtn) {
|
||||||
@@ -506,24 +508,22 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
|||||||
await intentKnowBtn.click().catch(() => {})
|
await intentKnowBtn.click().catch(() => {})
|
||||||
}
|
}
|
||||||
} 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 {
|
try {
|
||||||
await page.waitForSelector(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, { hidden: true, timeout: 3000 })
|
await page.waitForSelector(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, { hidden: true, timeout: 3000 })
|
||||||
logDebug(`${LOG} → 「意向沟通」弹窗已关闭`)
|
logDebug(`${LOG} → 「意向沟通」弹窗已关闭`)
|
||||||
} catch {
|
} catch {
|
||||||
logWarn(`${LOG} → 「意向沟通」弹窗 3s 内未消失,继续执行(可能影响按钮点击)`)
|
logWarn(`${LOG} → 「意向沟通」弹窗 3s 内未消失,启发式兜底...`)
|
||||||
}
|
}
|
||||||
await sleepWithRandomDelay(200, 400)
|
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 已触发,从拦截数据取结构化字段)
|
// 阶段一:初步信息筛选(点击会话后 geek/info 已触发,从拦截数据取结构化字段)
|
||||||
@@ -637,7 +637,9 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
|||||||
let stableCount = 0
|
let stableCount = 0
|
||||||
while (Date.now() < canvasDeadline) {
|
while (Date.now() < canvasDeadline) {
|
||||||
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS))
|
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) {
|
if (currentCount > 0 && currentCount === lastCount) {
|
||||||
stableCount++
|
stableCount++
|
||||||
if (stableCount >= STABLE_POLLS_NEEDED) break
|
if (stableCount >= STABLE_POLLS_NEEDED) break
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import { extractResumeText, parseGeekInfoFromIntercepted } from './resume-extractor.mjs'
|
import { extractResumeText, parseGeekInfoFromIntercepted } from './resume-extractor.mjs'
|
||||||
import { sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs'
|
import { sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs'
|
||||||
import { createHumanCursor } from './humanMouse.mjs'
|
import { createHumanCursor } from './humanMouse.mjs'
|
||||||
|
import { dismissBlockingOverlays } from './dialog-dismisser.mjs'
|
||||||
import {
|
import {
|
||||||
CHAT_PAGE_ONLINE_RESUME_SELECTOR,
|
CHAT_PAGE_ONLINE_RESUME_SELECTOR,
|
||||||
CHAT_PAGE_ONLINE_RESUME_IFRAME_SELECTOR,
|
CHAT_PAGE_ONLINE_RESUME_IFRAME_SELECTOR,
|
||||||
@@ -26,8 +27,7 @@ import {
|
|||||||
CHAT_PAGE_ATTACH_RESUME_DIALOG_CLOSE_SELECTOR,
|
CHAT_PAGE_ATTACH_RESUME_DIALOG_CLOSE_SELECTOR,
|
||||||
CHAT_PAGE_TAB_ALL_SELECTOR,
|
CHAT_PAGE_TAB_ALL_SELECTOR,
|
||||||
CHAT_PAGE_ITEM_SELECTOR,
|
CHAT_PAGE_ITEM_SELECTOR,
|
||||||
CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR,
|
CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR
|
||||||
CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR
|
|
||||||
} from './constant.mjs'
|
} from './constant.mjs'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -162,6 +162,7 @@ export async function requestAttachmentResume (page, options = {}) {
|
|||||||
const cursor = options.cursor ?? await createHumanCursor(page)
|
const cursor = options.cursor ?? await createHumanCursor(page)
|
||||||
|
|
||||||
// 请求前先检测并关闭 tutorial/意向沟通弹窗,避免遮挡附件简历按钮或误点
|
// 请求前先检测并关闭 tutorial/意向沟通弹窗,避免遮挡附件简历按钮或误点
|
||||||
|
// 已知 selector 优先 → 启发式扫描兜底(应对未知新弹窗)
|
||||||
const intentKnowBtn = await page.$(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR).catch(() => null)
|
const intentKnowBtn = await page.$(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR).catch(() => null)
|
||||||
if (intentKnowBtn) {
|
if (intentKnowBtn) {
|
||||||
console.log('[requestAttachmentResume] 检测到意向沟通/tutorial 弹窗,先关闭...')
|
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 })
|
await page.waitForSelector(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, { hidden: true, timeout: 3000 })
|
||||||
console.log('[requestAttachmentResume] tutorial 弹窗已关闭')
|
console.log('[requestAttachmentResume] tutorial 弹窗已关闭')
|
||||||
} catch {
|
} catch {
|
||||||
const closeIcon = await page.$(CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR).catch(() => null)
|
// 留给启发式兜底
|
||||||
if (closeIcon) await closeIcon.click().catch(() => {})
|
|
||||||
}
|
}
|
||||||
await sleepWithRandomDelay(300, 600)
|
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 未关闭)
|
// 检查是否有残留的确认弹窗(上一个候选人流程遗留,v-if 未关闭)
|
||||||
// 若存在则先等它消失;若等不到则视为卡死,直接报错,不继续执行
|
// 若存在则先等它消失;若等不到则视为卡死,直接报错,不继续执行
|
||||||
|
|||||||
@@ -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)
|
* BOSS 对招聘端鼠标移动轨迹进行埋点,直接 page.click() 或 page.mouse.click(x,y)
|
||||||
* 的"瞬移"方式容易被识别为脚本。本模块封装 ghost-cursor,以贝塞尔曲线生成拟人
|
* 的"瞬移"方式容易被识别为脚本。本模块封装 ghost-cursor,以贝塞尔曲线生成拟人
|
||||||
* 移动路径,替换所有在招聘端页面上的点击操作。
|
* 移动路径,替换所有在招聘端页面上的点击操作。
|
||||||
*
|
*
|
||||||
* 用法:
|
* 用法:
|
||||||
* import { createHumanCursor } from './humanMouse.mjs'
|
* import { createHumanCursor, randomizeInitialCursorPosition } from './humanMouse.mjs'
|
||||||
* const cursor = await createHumanCursor(page)
|
* const cursor = await createHumanCursor(page)
|
||||||
* await cursor.click(selector) // 先沿轨迹移动,再点击
|
* await randomizeInitialCursorPosition(page)
|
||||||
* await cursor.move(selector) // 仅移动,不点击
|
* await cursor.click(selector) // 先沿轨迹移动,再点击
|
||||||
*/
|
* await cursor.move(selector) // 仅移动,不点击
|
||||||
|
*/
|
||||||
/**
|
|
||||||
* 为给定 Puppeteer page 创建拟人鼠标 cursor。
|
// 模块级缓存:首次成功 preflight 后避免重复 import
|
||||||
* 内部使用 ghost-cursor;若 ghost-cursor 不可用(如包未安装),
|
let cachedGhostCursorCreate = null
|
||||||
* 则 fallback 到普通 page.click(),并打印警告。
|
|
||||||
*
|
/**
|
||||||
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
|
* 预检查 ghost-cursor 是否可用,返回其 createCursor 函数。
|
||||||
* @returns {Promise<{
|
* 失败时抛出明确错误,避免静默退化为 page.click() 这种"以为隐身实则裸奔"的最坏情况。
|
||||||
* click: (selectorOrPos: string | {x: number, y: number}) => Promise<void>,
|
*
|
||||||
* move: (selectorOrPos: string | {x: number, y: number}) => Promise<void>
|
* @returns {Promise<Function>} ghost-cursor 的 createCursor 函数
|
||||||
* }>}
|
*/
|
||||||
*/
|
export async function preflightGhostCursor () {
|
||||||
export async function createHumanCursor (page) {
|
if (cachedGhostCursorCreate) return cachedGhostCursorCreate
|
||||||
let ghostCursorCreate
|
let createCursor
|
||||||
try {
|
try {
|
||||||
const mod = await import('ghost-cursor')
|
const mod = await import('ghost-cursor')
|
||||||
// ghost-cursor 同时支持 ESM default export 和命名 export
|
// ghost-cursor 同时支持 ESM default export 和命名 export
|
||||||
ghostCursorCreate = mod.createCursor ?? mod.default?.createCursor
|
createCursor = mod.createCursor ?? mod.default?.createCursor
|
||||||
} catch {
|
} catch (e) {
|
||||||
ghostCursorCreate = null
|
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 (ghostCursorCreate) {
|
}
|
||||||
const cursor = ghostCursorCreate(page)
|
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).'
|
||||||
* 将 selector 字符串或 ElementHandle 解析成 {x, y} 坐标。
|
)
|
||||||
* ghost-cursor 的 click/move 只接受 string selector 或 ElementHandle,
|
}
|
||||||
* 传 {x,y} 坐标对象会被误当 ElementHandle 调 element.remoteObject() 崩溃。
|
cachedGhostCursorCreate = createCursor
|
||||||
* 统一在封装层解析成坐标,再用 moveTo({x,y}) + page.mouse.click(x,y) 执行。
|
return createCursor
|
||||||
*/
|
}
|
||||||
const resolvePos = async (selectorOrPos) => {
|
|
||||||
if (typeof selectorOrPos === 'string') {
|
/**
|
||||||
const el = await page.$(selectorOrPos)
|
* 在 box 的中心 60% 区域内随机一个落点(默认 centerBiasFraction=0.3,即中心 ±30%)。
|
||||||
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}`)
|
* @param {{x: number, y: number, width: number, height: number}} box
|
||||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
* @param {number} [centerBiasFraction=0.3]
|
||||||
}
|
* @returns {{x: number, y: number}}
|
||||||
// ElementHandle(有 boundingBox 方法)
|
*/
|
||||||
if (selectorOrPos && typeof selectorOrPos.boundingBox === 'function') {
|
function randomizePointInBox (box, centerBiasFraction = 0.3) {
|
||||||
const box = await selectorOrPos.boundingBox()
|
const minXFrac = 0.5 - centerBiasFraction
|
||||||
if (!box) throw new Error('[humanMouse] ElementHandle has no bounding box')
|
const maxXFrac = 0.5 + centerBiasFraction
|
||||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
const minYFrac = 0.5 - centerBiasFraction
|
||||||
}
|
const maxYFrac = 0.5 + centerBiasFraction
|
||||||
// 已是 {x, y} 坐标对象
|
const xFrac = minXFrac + Math.random() * (maxXFrac - minXFrac)
|
||||||
return selectorOrPos
|
const yFrac = minYFrac + Math.random() * (maxYFrac - minYFrac)
|
||||||
}
|
return {
|
||||||
|
x: box.x + box.width * xFrac,
|
||||||
return {
|
y: box.y + box.height * yFrac
|
||||||
/**
|
}
|
||||||
* 沿拟人轨迹移动到目标后点击。使用 moveTo({x,y}) + page.mouse.click(x,y)
|
}
|
||||||
* 规避 ghost-cursor 传坐标/ElementHandle 时调 element.evaluate 的崩溃问题。
|
|
||||||
* @param {string | {x: number, y: number} | import('puppeteer').ElementHandle} selectorOrPos
|
/**
|
||||||
*/
|
* 为给定 Puppeteer page 创建拟人鼠标 cursor。
|
||||||
async click (selectorOrPos) {
|
* 内部强依赖 ghost-cursor;若不可用直接抛错(fail-fast),不再静默退化。
|
||||||
const pos = await resolvePos(selectorOrPos)
|
*
|
||||||
await cursor.moveTo(pos)
|
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
|
||||||
await page.mouse.click(pos.x, pos.y)
|
* @returns {Promise<{
|
||||||
},
|
* click: (selectorOrPos: string | {x: number, y: number}) => Promise<void>,
|
||||||
/**
|
* move: (selectorOrPos: string | {x: number, y: number}) => Promise<void>
|
||||||
* 沿拟人轨迹移动到目标(不点击)
|
* }>}
|
||||||
* @param {string | {x: number, y: number} | import('puppeteer').ElementHandle} selectorOrPos
|
*/
|
||||||
*/
|
export async function createHumanCursor (page) {
|
||||||
async move (selectorOrPos) {
|
const ghostCursorCreate = await preflightGhostCursor()
|
||||||
const pos = await resolvePos(selectorOrPos)
|
const cursor = ghostCursorCreate(page)
|
||||||
await cursor.moveTo(pos)
|
|
||||||
}
|
/**
|
||||||
}
|
* 将 selector 字符串或 ElementHandle 解析成 {x, y} 坐标。
|
||||||
}
|
* ghost-cursor 的 click/move 只接受 string selector 或 ElementHandle,
|
||||||
|
* 传 {x,y} 坐标对象会被误当 ElementHandle 调 element.remoteObject() 崩溃。
|
||||||
// Fallback: ghost-cursor 未安装时退化为普通点击(打印警告)
|
* 统一在封装层解析成坐标,再用 moveTo({x,y}) + page.mouse.click(x,y) 执行。
|
||||||
console.warn('[humanMouse] ghost-cursor 未安装,退化为普通 page.click()。建议安装 ghost-cursor 以规避 BOSS 鼠标轨迹埋点检测。')
|
* 对 selector / ElementHandle 输入会在中心 60% 范围内随机落点;
|
||||||
return {
|
* 对显式 {x,y} 输入保持原样(调用方已选定精确坐标)。
|
||||||
async click (selectorOrPos) {
|
*/
|
||||||
if (typeof selectorOrPos === 'string') {
|
const resolvePos = async (selectorOrPos) => {
|
||||||
await page.click(selectorOrPos)
|
if (typeof selectorOrPos === 'string') {
|
||||||
} else if (selectorOrPos && typeof selectorOrPos.x === 'number') {
|
const el = await page.$(selectorOrPos)
|
||||||
await page.mouse.click(selectorOrPos.x, selectorOrPos.y)
|
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}`)
|
||||||
async move (selectorOrPos) {
|
return randomizePointInBox(box)
|
||||||
if (typeof selectorOrPos === 'string') {
|
}
|
||||||
try {
|
// ElementHandle(有 boundingBox 方法)
|
||||||
const el = await page.$(selectorOrPos)
|
if (selectorOrPos && typeof selectorOrPos.boundingBox === 'function') {
|
||||||
if (el) {
|
const box = await selectorOrPos.boundingBox()
|
||||||
const box = await el.boundingBox()
|
if (!box) throw new Error('[humanMouse] ElementHandle has no bounding box')
|
||||||
if (box) {
|
return randomizePointInBox(box)
|
||||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
}
|
||||||
}
|
// 已是 {x, y} 坐标对象,调用方已选定精确坐标,不再随机化
|
||||||
}
|
return selectorOrPos
|
||||||
} catch (_) { /* ignore */ }
|
}
|
||||||
} else if (selectorOrPos && typeof selectorOrPos.x === 'number') {
|
|
||||||
await page.mouse.move(selectorOrPos.x, selectorOrPos.y)
|
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 { setupNetworkInterceptor, setupCanvasTextHook } from './resume-extractor.mjs'
|
||||||
import { parseCandidateList, filterCandidates, scrollAndLoadMore } from './candidate-processor.mjs'
|
import { parseCandidateList, filterCandidates, scrollAndLoadMore } from './candidate-processor.mjs'
|
||||||
import { processCandidate, checkDailyLimit, clickNotInterested } from './chat-handler.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 { 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'
|
export { default as startBossChatPageProcess } from './chat-page-processor.mjs'
|
||||||
|
|
||||||
@@ -53,7 +57,9 @@ export async function initPuppeteer () {
|
|||||||
puppeteer.use(StealthPlugin())
|
puppeteer.use(StealthPlugin())
|
||||||
puppeteer.use(LaodengPlugin())
|
puppeteer.use(LaodengPlugin())
|
||||||
puppeteer.use(AnonymizeUaPlugin({ makeWindows: false }))
|
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 {
|
return {
|
||||||
puppeteer,
|
puppeteer,
|
||||||
StealthPlugin,
|
StealthPlugin,
|
||||||
@@ -63,32 +69,36 @@ export async function initPuppeteer () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关闭登录后弹出的「治理公告」弹窗(点击「我已知晓」确认按钮)。
|
* 关闭登录后弹出的「治理公告」等任何挡住操作的浮层。
|
||||||
* 该弹窗在每次登录后必现,不处理会导致后续自动化操作卡死超时。
|
*
|
||||||
|
* 历史上这里只针对 GOVERNANCE_NOTICE_DIALOG_CONFIRM_BTN_SELECTOR 硬编码点击,
|
||||||
|
* 现改为:先等待已知治理公告 selector 出现(提示性),再调用通用的 dismissBlockingOverlays
|
||||||
|
* 启发式扫描——这样新冒出的弹窗也能被自动关掉,不必每次手工加 selector。
|
||||||
* @param {import('puppeteer').Page} page
|
* @param {import('puppeteer').Page} page
|
||||||
*/
|
*/
|
||||||
export async function dismissGovernanceNoticeDialog (page) {
|
export async function dismissGovernanceNoticeDialog (page) {
|
||||||
try {
|
// 给已知的治理公告一点时间冒出来;超时也没关系——通用扫描兜底
|
||||||
const confirmBtn = await page
|
await page.waitForSelector(GOVERNANCE_NOTICE_DIALOG_SELECTOR, { timeout: 10000 }).catch(() => null)
|
||||||
.waitForSelector(GOVERNANCE_NOTICE_DIALOG_CONFIRM_BTN_SELECTOR, { timeout: 10000 })
|
const closed = await dismissBlockingOverlays(page, { maxRounds: 3 }).catch(() => 0)
|
||||||
.catch(() => null)
|
if (closed > 0) {
|
||||||
if (!confirmBtn) return
|
logInfo(`[boss-auto-browse] 自动关闭了 ${closed} 个登录后浮层(含治理公告)`)
|
||||||
logInfo('[boss-auto-browse] 检测到「治理公告」弹窗,点击「我已知晓」关闭...')
|
await sleep(300)
|
||||||
try {
|
}
|
||||||
|
// 兜底:若启发式没识别到治理公告(例如关闭按钮文案变了),再尝试硬编码 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)
|
const box = await confirmBtn.boundingBox().catch(() => null)
|
||||||
if (box) {
|
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 {
|
} else {
|
||||||
await confirmBtn.click()
|
await confirmBtn.click().catch(() => {})
|
||||||
}
|
}
|
||||||
} catch {
|
await page.waitForSelector(GOVERNANCE_NOTICE_DIALOG_SELECTOR, { hidden: true, timeout: 5000 }).catch(() => {})
|
||||||
await confirmBtn.click().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 () {
|
export async function launchBrowserAndNavigateToChat () {
|
||||||
if (!puppeteer) await initPuppeteer()
|
if (!puppeteer) await initPuppeteer()
|
||||||
const headless = process.env.HEADLESS === '1'
|
const launchOpts = await buildRecruiterLaunchOptions()
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch(launchOpts)
|
||||||
headless,
|
|
||||||
ignoreHTTPSErrors: true,
|
|
||||||
protocolTimeout: 120000,
|
|
||||||
defaultViewport: { width: 1440, height: 900 - 140 }
|
|
||||||
})
|
|
||||||
const page = (await browser.pages())[0]
|
const page = (await browser.pages())[0]
|
||||||
|
await randomizeInitialCursorPosition(page).catch(() => {})
|
||||||
const bossCookies = readStorageFile('boss-cookies.json')
|
const bossCookies = readStorageFile('boss-cookies.json')
|
||||||
const bossLocalStorage = readStorageFile('boss-local-storage.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 page.setCookie(...bossCookies)
|
||||||
}
|
}
|
||||||
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
|
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
|
||||||
@@ -246,20 +253,12 @@ export default async function startBossAutoBrowse (hooksFromCaller, opts = {}) {
|
|||||||
} else {
|
} else {
|
||||||
await hooks.beforeBrowserLaunch?.promise?.()
|
await hooks.beforeBrowserLaunch?.promise?.()
|
||||||
|
|
||||||
const headlessEnv = process.env.HEADLESS
|
const launchOpts = await buildRecruiterLaunchOptions()
|
||||||
const headless = headlessEnv === '1'
|
logDebug('[boss-auto-browse] 即将启动浏览器', { headless: launchOpts.headless, persistProfile: !!launchOpts.userDataDir })
|
||||||
logDebug('[boss-auto-browse] 即将启动浏览器', { headless, HEADLESS_env: headlessEnv ?? null })
|
browser = await puppeteer.launch(launchOpts)
|
||||||
browser = await puppeteer.launch({
|
|
||||||
headless,
|
|
||||||
ignoreHTTPSErrors: true,
|
|
||||||
protocolTimeout: 120000,
|
|
||||||
defaultViewport: {
|
|
||||||
width: 1440,
|
|
||||||
height: 900 - 140
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
page = (await browser.pages())[0]
|
page = (await browser.pages())[0]
|
||||||
|
await randomizeInitialCursorPosition(page).catch(() => {})
|
||||||
|
|
||||||
await hooks.afterBrowserLaunch?.promise?.()
|
await hooks.afterBrowserLaunch?.promise?.()
|
||||||
}
|
}
|
||||||
@@ -271,7 +270,9 @@ export default async function startBossAutoBrowse (hooksFromCaller, opts = {}) {
|
|||||||
// 直接导航到推荐牛人页(注入 Cookie / localStorage 后 goto;复用浏览器时若已在推荐页可跳过 goto)
|
// 直接导航到推荐牛人页(注入 Cookie / localStorage 后 goto;复用浏览器时若已在推荐页可跳过 goto)
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
await hooks.beforeNavigateToRecommend?.promise?.()
|
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 page.setCookie(...bossCookies)
|
||||||
}
|
}
|
||||||
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
|
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
|
||||||
@@ -572,10 +573,16 @@ export default async function startBossAutoBrowse (hooksFromCaller, opts = {}) {
|
|||||||
logInfo('[boss-auto-browse] ✓ 已向', candidate.geekName, '发送招呼(本次共', chatCount, '人)')
|
logInfo('[boss-auto-browse] ✓ 已向', candidate.geekName, '发送招呼(本次共', chatCount, '人)')
|
||||||
} else {
|
} else {
|
||||||
logInfo('[boss-auto-browse] ✗', candidate.geekName, '开聊失败:', chatResult.reason)
|
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
|
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 内操作)
|
// e. 滚动加载 / 翻页(在 iframe frame 内操作)
|
||||||
|
|||||||
@@ -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 拦截
|
// 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 中各执行一次。
|
* - evaluateOnNewDocument 会在主页面和每一个 iframe 中各执行一次。
|
||||||
* - 在线简历 iframe 带有 sandbox 属性且不含 allow-same-origin,主页面无法访问其 contentWindow,
|
* - 在线简历 iframe 带有 sandbox 属性且不含 allow-same-origin,主页面无法访问其 contentWindow,
|
||||||
* 因此必须在 iframe 自身的执行上下文内直接 hook CanvasRenderingContext2D.prototype.fillText。
|
* 因此必须在 iframe 自身的执行上下文内直接 hook CanvasRenderingContext2D.prototype.fillText。
|
||||||
* - iframe 内 hook 到的文字通过 window.top.postMessage 批量发回主页面(同 origin 或跨 origin 均可用)。
|
* - 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 之前调用)
|
* @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) {
|
export async function setupCanvasTextHook (page) {
|
||||||
// 转发浏览器内部 [canvasHook] 日志到 Node 侧,便于调试
|
const markerSuffix = Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4)
|
||||||
page.on('console', (msg) => {
|
const capturedTextProp = '__cct_' + markerSuffix
|
||||||
const text = msg.text()
|
const messageKey = '__mk_' + markerSuffix
|
||||||
if (text.startsWith('[canvasHook]')) {
|
const hookedFlag = '_h_' + markerSuffix
|
||||||
console.log('[canvasHook-browser]', text)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.evaluateOnNewDocument(() => {
|
// 转发浏览器内部 [canvasHook] 日志到 Node 侧(仅 debug 模式)
|
||||||
// 此脚本在每个 frame(主页面 + 所有 iframe)中各执行一次。
|
if (CANVAS_HOOK_DEBUG) {
|
||||||
// 策略:
|
page.on('console', (msg) => {
|
||||||
// 主页面 → 初始化收集数组,监听来自 iframe 的 postMessage
|
const text = msg.text()
|
||||||
// iframe → 直接 hook 当前窗口的 fillText,批量 postMessage 到 window.top
|
if (text.startsWith('[canvasHook]')) {
|
||||||
|
console.log('[canvasHook-browser]', text)
|
||||||
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) }
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
Object.defineProperty(proto, 'fillText', {
|
// 注册 fillText 包装的伪原生 toString(依赖 laodeng 已被 puppeteer.use 装载)
|
||||||
value: new Proxy(origFillText, {
|
try {
|
||||||
apply (target, thisArg, args) {
|
const laodengMod = await import('@geekgeekrun/puppeteer-extra-plugin-laodeng')
|
||||||
const [text, x, y] = args
|
const registerFakeNativeSource =
|
||||||
if (typeof text === 'string' && text.trim()) {
|
laodengMod.registerFakeNativeSource ?? laodengMod.default?.registerFakeNativeSource
|
||||||
captured.push({ text, x: Number(x) || 0, y: Number(y) || 0 })
|
if (typeof registerFakeNativeSource === 'function') {
|
||||||
scheduleFlush()
|
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,
|
if (DEBUG) console.log('[canvasHook] main: message listener set')
|
||||||
configurable: true
|
}
|
||||||
})
|
|
||||||
console.log('[canvasHook] fillText hook installed, isTopFrame=' + isTopFrame + ' href=' + window.location.href)
|
// 在当前 window(无论是主页面还是 iframe)上 hook fillText
|
||||||
} catch (e) {
|
try {
|
||||||
console.log('[canvasHook] hook install error: ' + e.message)
|
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 文字并清空。
|
* 从主页面读取当前收集的 Canvas 文字并清空。
|
||||||
@@ -245,14 +276,13 @@ export async function setupCanvasTextHook (page) {
|
|||||||
* @returns {Promise<Array<{text: string, x: number, y: number}>>}
|
* @returns {Promise<Array<{text: string, x: number, y: number}>>}
|
||||||
*/
|
*/
|
||||||
async function getCapturedText (p) {
|
async function getCapturedText (p) {
|
||||||
// 给浏览器 150ms 处理待发送的 setTimeout(0)/postMessage 队列
|
|
||||||
await p.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)))
|
await p.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)))
|
||||||
const result = await p.evaluate(() => {
|
const result = await p.evaluate((prop) => {
|
||||||
const arr = window.__canvasCapturedText || []
|
const arr = window[prop] || []
|
||||||
const copy = arr.map(({ text, x, y }) => ({ text, x, y }))
|
const copy = arr.map(({ text, x, y }) => ({ text, x, y }))
|
||||||
window.__canvasCapturedText = []
|
window[prop] = []
|
||||||
return copy
|
return copy
|
||||||
})
|
}, capturedTextProp)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,10 +291,20 @@ export async function setupCanvasTextHook (page) {
|
|||||||
* @param {import('puppeteer').Page} p - 同一页面实例
|
* @param {import('puppeteer').Page} p - 同一页面实例
|
||||||
*/
|
*/
|
||||||
async function clearCapturedText (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 {import('puppeteer').Page} page - Puppeteer 页面实例
|
||||||
* @param {Map<string, unknown>} interceptedData - setupNetworkInterceptor 返回的 getInterceptedData() 的结果
|
* @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 的结果(字符串数组)
|
* @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) {
|
if (interceptedData && interceptedData.size > 0) {
|
||||||
const firstEntry = interceptedData.entries().next()
|
const firstEntry = interceptedData.entries().next()
|
||||||
if (!firstEntry.done) {
|
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 }) } }
|
return { source: 'api', data: { path, ...(typeof data === 'object' && data !== null ? data : { value: data }) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const captured = await page.evaluate(() => {
|
|
||||||
const arr = window.__canvasCapturedText || []
|
// Canvas fallback: use getCapturedText closure if provided (supports randomized marker names)
|
||||||
const copy = arr.map(({ text, x, y }) => ({ text, x, y }))
|
// Fall back to legacy window.__canvasCapturedText for callers that don't yet pass it
|
||||||
window.__canvasCapturedText = []
|
const getCapturedTextFn = opts.getCapturedText
|
||||||
return copy
|
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)
|
const lines = extractResumeText(captured)
|
||||||
return { source: 'canvas', data: lines }
|
return { source: 'canvas', data: lines }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
if (nativeSourceMap.has(this)) {
|
||||||
return nativeSourceMap.get(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);
|
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);
|
return new Plugin(pluginConfig);
|
||||||
};
|
};
|
||||||
|
pluginFactory.registerFakeNativeSource = registerFakeNativeSource;
|
||||||
|
module.exports = pluginFactory;
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ const runChatPage = async () => {
|
|||||||
log('正在动态 import boss package...')
|
log('正在动态 import boss package...')
|
||||||
type BossAutoBrowseModule = {
|
type BossAutoBrowseModule = {
|
||||||
startBossChatPageProcess: (hooks: any, options?: {
|
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;
|
jobId?: string | null;
|
||||||
retryCandidate?: { encryptGeekId: string; geekName: string; jobTitle: string } | null;
|
retryCandidate?: { encryptGeekId: string; geekName: string; jobTitle: string } | null;
|
||||||
processContext?: { currentCandidate: any } | null;
|
processContext?: { currentCandidate: any } | null;
|
||||||
@@ -207,6 +207,7 @@ const runChatPage = async () => {
|
|||||||
let page: any = null
|
let page: any = null
|
||||||
let getCapturedText: any = null
|
let getCapturedText: any = null
|
||||||
let clearCapturedText: any = null
|
let clearCapturedText: any = null
|
||||||
|
let peekCapturedText: any = null
|
||||||
// processContext 提升到循环外,catch 块中可读取被中断的候选人
|
// processContext 提升到循环外,catch 块中可读取被中断的候选人
|
||||||
const processContext: { currentCandidate: any } = { currentCandidate: null }
|
const processContext: { currentCandidate: any } = { currentCandidate: null }
|
||||||
|
|
||||||
@@ -230,13 +231,12 @@ const runChatPage = async () => {
|
|||||||
log('启动浏览器...')
|
log('启动浏览器...')
|
||||||
await hooks.beforeBrowserLaunch?.promise?.()
|
await hooks.beforeBrowserLaunch?.promise?.()
|
||||||
|
|
||||||
const headless = process.env.HEADLESS === '1'
|
const { buildRecruiterLaunchOptions } = (await import(
|
||||||
browser = await puppeteer.launch({
|
'@geekgeekrun/boss-auto-browse-and-chat/launch-options.mjs'
|
||||||
headless,
|
)) as any
|
||||||
ignoreHTTPSErrors: true,
|
const launchOpts = await buildRecruiterLaunchOptions()
|
||||||
protocolTimeout: 120000,
|
log(`使用 launch options:persistProfile=${!!launchOpts.userDataDir}`)
|
||||||
defaultViewport: { width: 1440, height: 900 - 140 }
|
browser = await puppeteer.launch(launchOpts)
|
||||||
})
|
|
||||||
|
|
||||||
await hooks.afterBrowserLaunch?.promise?.()
|
await hooks.afterBrowserLaunch?.promise?.()
|
||||||
|
|
||||||
@@ -248,7 +248,15 @@ const runChatPage = async () => {
|
|||||||
const canvasHooks = await setupCanvasTextHook(page)
|
const canvasHooks = await setupCanvasTextHook(page)
|
||||||
getCapturedText = canvasHooks.getCapturedText
|
getCapturedText = canvasHooks.getCapturedText
|
||||||
clearCapturedText = canvasHooks.clearCapturedText
|
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 page.setCookie(...bossCookies)
|
||||||
}
|
}
|
||||||
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
|
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
|
||||||
@@ -287,7 +295,7 @@ const runChatPage = async () => {
|
|||||||
const jname = job.jobName ?? job.name
|
const jname = job.jobName ?? job.name
|
||||||
log(`开始处理职位 ${jid}(${jname})的沟通页...`)
|
log(`开始处理职位 ${jid}(${jname})的沟通页...`)
|
||||||
processContext.currentCandidate = null
|
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} 沟通页处理完成`)
|
log(`职位 ${jid} 沟通页处理完成`)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -296,7 +304,7 @@ const runChatPage = async () => {
|
|||||||
} else {
|
} else {
|
||||||
log('未配置职位队列,开始执行 startBossChatPageProcess(处理所有未读)...')
|
log('未配置职位队列,开始执行 startBossChatPageProcess(处理所有未读)...')
|
||||||
processContext.currentCandidate = null
|
processContext.currentCandidate = null
|
||||||
await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, processContext })
|
await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, peekCapturedText, processContext })
|
||||||
}
|
}
|
||||||
log('startBossChatPageProcess 完成')
|
log('startBossChatPageProcess 完成')
|
||||||
|
|
||||||
@@ -318,6 +326,7 @@ const runChatPage = async () => {
|
|||||||
page = null
|
page = null
|
||||||
getCapturedText = null
|
getCapturedText = null
|
||||||
clearCapturedText = null
|
clearCapturedText = null
|
||||||
|
peekCapturedText = null
|
||||||
const rerunMs = cfg?.chatPage?.rerunIntervalMs ?? rerunInterval
|
const rerunMs = cfg?.chatPage?.rerunIntervalMs ?? rerunInterval
|
||||||
log(`下次运行将在 ${rerunMs}ms 后开始`)
|
log(`下次运行将在 ${rerunMs}ms 后开始`)
|
||||||
await sleep(rerunMs)
|
await sleep(rerunMs)
|
||||||
@@ -346,7 +355,7 @@ const runChatPage = async () => {
|
|||||||
log(`🔄 正在重试被验证中断的候选人:${interruptedCandidate.geekName}...`)
|
log(`🔄 正在重试被验证中断的候选人:${interruptedCandidate.geekName}...`)
|
||||||
try {
|
try {
|
||||||
await startBossChatPageProcess(hooks, {
|
await startBossChatPageProcess(hooks, {
|
||||||
browser, page, getCapturedText, clearCapturedText,
|
browser, page, getCapturedText, clearCapturedText, peekCapturedText,
|
||||||
retryCandidate: interruptedCandidate,
|
retryCandidate: interruptedCandidate,
|
||||||
processContext: { currentCandidate: null }
|
processContext: { currentCandidate: null }
|
||||||
})
|
})
|
||||||
@@ -369,6 +378,7 @@ const runChatPage = async () => {
|
|||||||
page = null
|
page = null
|
||||||
getCapturedText = null
|
getCapturedText = null
|
||||||
clearCapturedText = null
|
clearCapturedText = null
|
||||||
|
peekCapturedText = null
|
||||||
}
|
}
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
if (err.message.includes('LOGIN_STATUS_INVALID')) {
|
if (err.message.includes('LOGIN_STATUS_INVALID')) {
|
||||||
|
|||||||
@@ -991,6 +991,12 @@ export default function initIpc() {
|
|||||||
...payload.recommendPage
|
...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') || {}
|
const candidateFilterConfig = readBossConfigFile('candidate-filter.json') || {}
|
||||||
if (hasOwn(payload, 'expectCityList')) {
|
if (hasOwn(payload, 'expectCityList')) {
|
||||||
|
|||||||
@@ -90,6 +90,22 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-card>
|
</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">
|
<div class="action-bar">
|
||||||
<el-button :loading="isSaving" @click="handleSave">仅保存配置</el-button>
|
<el-button :loading="isSaving" @click="handleSave">仅保存配置</el-button>
|
||||||
<el-button type="primary" :loading="isSaving" @click="handleSubmit">
|
<el-button type="primary" :loading="isSaving" @click="handleSubmit">
|
||||||
@@ -167,7 +183,8 @@ const formContent = reactive({
|
|||||||
recommendSkipViewedCandidates: false,
|
recommendSkipViewedCandidates: false,
|
||||||
recommendRerunIntervalMs: 3000,
|
recommendRerunIntervalMs: 3000,
|
||||||
recommendDelayBetweenNotInterestedMs: [800, 2500] as [number, number],
|
recommendDelayBetweenNotInterestedMs: [800, 2500] as [number, number],
|
||||||
recommendKeepBrowserOpenAfterRun: false
|
recommendKeepBrowserOpenAfterRun: false,
|
||||||
|
advancedPersistProfile: false
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -194,6 +211,9 @@ onMounted(async () => {
|
|||||||
: [800, 2500]
|
: [800, 2500]
|
||||||
formContent.recommendKeepBrowserOpenAfterRun =
|
formContent.recommendKeepBrowserOpenAfterRun =
|
||||||
recommendPage.keepBrowserOpenAfterRun ?? false
|
recommendPage.keepBrowserOpenAfterRun ?? false
|
||||||
|
|
||||||
|
const advanced = recruiterConfig.advanced ?? {}
|
||||||
|
formContent.advancedPersistProfile = advanced.persistProfile ?? false
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
@@ -213,6 +233,9 @@ const doSave = async () => {
|
|||||||
rerunIntervalMs: formContent.recommendRerunIntervalMs,
|
rerunIntervalMs: formContent.recommendRerunIntervalMs,
|
||||||
delayBetweenNotInterestedMs: formContent.recommendDelayBetweenNotInterestedMs,
|
delayBetweenNotInterestedMs: formContent.recommendDelayBetweenNotInterestedMs,
|
||||||
keepBrowserOpenAfterRun: formContent.recommendKeepBrowserOpenAfterRun
|
keepBrowserOpenAfterRun: formContent.recommendKeepBrowserOpenAfterRun
|
||||||
|
},
|
||||||
|
advanced: {
|
||||||
|
persistProfile: formContent.advancedPersistProfile
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await ipcRenderer.invoke('save-boss-recruiter-config', JSON.stringify(payload))
|
await ipcRenderer.invoke('save-boss-recruiter-config', JSON.stringify(payload))
|
||||||
|
|||||||
Reference in New Issue
Block a user