feat(boss): 招聘端反检测增强 — 验证码 in-loop 等待 + 指纹/行为加固

## 变更汇总

**核心修复**:验证码出现时不再 crash → 关浏览器 → 3 秒重启(连环触发),改为在主循环内等待用户手动完成(最长 5 分钟),完成后无缝继续。

**行为层**(默认开启):
- ghost-cursor fail-fast(防静默降级为裸 page.click())
- 点击落点在按钮中心 60% 区域内随机化 + ~50% 概率 micro-overshoot
- 每次会话启动时初始鼠标位置随机化(三处 launch 全部覆盖)
- 每位候选人处理后做一次 checkpointRiskControl;软风控(风控/操作频繁)和硬验证码均可检测,均走 in-loop 等待

**指纹层**(默认开启):
- Launch args:--lang=zh-CN、--disable-blink-features=AutomationControlled
- Canvas hook marker 名(window.__cct_<random>)每次会话随机,[canvasHook] 日志默认静默
- fillText wrapper 通过 laodeng.registerFakeNativeSource 注册到 toString 伪装表

**指纹层**(可选,默认关):
- boss-recruiter.json advanced.persistProfile:持久化 Chromium profile,BOSS 看到「老设备」
- UI 加「高级反检测(实验性)」开关,IPC 支持保存

**关联修复**(Copilot + internal review 发现):
- RISK_CONTROL 软风控文案覆盖到 detectRiskControl
- window.__canvasCapturedText 直读路径全面迁移:新增 peekCapturedText(不消费的计数查询)、getResumeData 接收 getCapturedText closure
- persistProfile=true 时两处 launch 均跳过 setCookie

新增文件:risk-detector.mjs、launch-options.mjs
不影响应聘端(geek-auto-start-chat-with-boss)。
This commit is contained in:
rqi14
2026-05-10 20:45:31 +08:00
committed by GitHub
parent 076ac3754b
commit bf3132a74b
11 changed files with 597 additions and 246 deletions
@@ -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 optionspersistProfile=${!!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))