Files
geekgeekrun/packages/boss-auto-browse-and-chat/resume-extractor.mjs
T
rqi14 bf3132a74b 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)。
2026-05-10 20:45:31 +08:00

393 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 招聘端简历数据提取工具:网络请求拦截 + Canvas 文字提取
*
* 沟通页在线简历有两套不同的数据(详见 plan/chat_page_resume_flow.md):
* - 简单摘要:geek/info、historyMsg body.resume 等 API 返回的只有简单工作单位、学校等,可拦截后 parseGeekInfoFromIntercepted 得到。
* - 完整版(图片里那种):接收加密数据 → WASMRust decrypt.rsBase64+AES)解密 → 仅绘制到 #resume 的 Canvas,无明文 API;要拿完整版要么 Canvas hook(有反爬风险),要么逆向 examples/wasm_canvas_bg-1.0.2-5057.dcmp 的解密逻辑。
*/
// ---------------------------------------------------------------------------
// 网络拦截
// ---------------------------------------------------------------------------
/** 需要拦截的 URL 关键词(招聘端简历/候选人相关 API),含沟通页 geek/info */
const INTERCEPT_URL_KEYWORDS = ['resume', 'geek/info', 'geek/detail']
/**
* 从 URL 中提取用于存储的 path 部分(便于去重与查找)
* @param {string} url - 完整 URL
* @returns {string} path 或原 URL
*/
function getPathFromUrl (url) {
try {
const u = new URL(url)
return u.pathname || url
} catch {
return url
}
}
/**
* 判断 URL 是否包含任一拦截关键词
* @param {string} url
* @returns {boolean}
*/
function shouldIntercept (url) {
return INTERCEPT_URL_KEYWORDS.some(kw => url.includes(kw))
}
/**
* 在页面上设置网络响应拦截器,收集简历相关 API 的 JSON 响应。
* 与 laodeng 无冲突,仅在 Node 侧监听 response 事件。
*
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
* @returns {{ getInterceptedData: () => Map<string, unknown> }} 返回 getInterceptedData,调用后返回当前收集到的数据并清空
*/
export function setupNetworkInterceptor (page) {
const intercepted = new Map()
page.on('response', async (response) => {
const url = response.url()
if (!shouldIntercept(url)) return
const path = getPathFromUrl(url)
try {
const contentType = response.headers()['content-type'] || ''
if (!contentType.includes('application/json')) return
const data = await response.json()
intercepted.set(path, data)
} catch (_) {
// 非 JSON 或解析失败则忽略
}
})
/**
* 获取当前已拦截的数据并清空 Map,便于单次详情页使用后取数
* @returns {Map<string, unknown>} 本次拦截到的数据(path -> parsed JSON
*/
function getInterceptedData () {
const snapshot = new Map(intercepted)
intercepted.clear()
return snapshot
}
/**
* 查看当前已拦截的数据(不清空),便于在不消费数据的情况下检查是否有新数据
* @returns {Map<string, unknown>} 当前拦截到的数据快照(path -> parsed JSON
*/
function peekInterceptedData () {
return new Map(intercepted)
}
return { getInterceptedData, peekInterceptedData }
}
// ---------------------------------------------------------------------------
// 从拦截的 geek/info API 解析简历(沟通页推荐,不碰 Canvas)
// ---------------------------------------------------------------------------
/** 沟通页 geek/info API 路径特征 */
const GEEK_INFO_PATH = 'geek/info'
/**
* 从 getInterceptedData() 的 Map 中取出 geek/info 的响应并解析为结构化数据与拼接文案。
* 注意:geek/info 的 zpData.data 仅为简单摘要(工作单位、学校、经历列表等),与 #resume 上 WASM 解密后绘制的完整版简历不是同一份数据。
*
* @param {Map<string, unknown>} interceptedMap - getInterceptedData() 的返回值
* @returns {{ data: object | null, text: string }} data 为 zpData.datatext 为摘要拼接(供简单关键词/LLM 筛选)
*/
export function parseGeekInfoFromIntercepted (interceptedMap) {
if (!interceptedMap || interceptedMap.size === 0) {
return { data: null, text: '' }
}
for (const [path, raw] of interceptedMap) {
if (!path || !path.includes(GEEK_INFO_PATH)) continue
const res = typeof raw === 'object' && raw !== null && 'zpData' in raw
? raw
: null
if (!res || !res.zpData || !res.zpData.data) {
return { data: null, text: '' }
}
const d = res.zpData.data
const parts = []
if (d.name) parts.push(d.name)
if (d.ageDesc) parts.push(d.ageDesc)
if (d.workYear) parts.push(d.workYear)
if (d.edu) parts.push(d.edu)
if (d.positionStatus) parts.push(d.positionStatus)
if (d.school) parts.push(d.school)
if (d.major) parts.push(d.major)
if (d.city) parts.push(d.city)
if (d.salaryDesc || d.price) parts.push(d.salaryDesc || d.price)
if (d.positionName || d.toPosition) parts.push(d.positionName || d.toPosition)
if (Array.isArray(d.workExpList) && d.workExpList.length > 0) {
parts.push('工作经历:')
d.workExpList.forEach(w => {
parts.push([w.timeDesc, w.company, w.positionName].filter(Boolean).join(' '))
})
}
if (Array.isArray(d.eduExpList) && d.eduExpList.length > 0) {
parts.push('教育经历:')
d.eduExpList.forEach(e => {
parts.push([e.timeDesc, e.school, e.major, e.degree].filter(Boolean).join(' '))
})
}
const text = parts.join('\n')
return { data: d, text }
}
return { data: null, text: '' }
}
// ---------------------------------------------------------------------------
// Canvas 文字 Hook(与 laodeng 兼容)— 非 BOSS 自带,可能被反爬检测,沟通页请用 API 拦截
// ---------------------------------------------------------------------------
const CANVAS_HOOK_DEBUG = process.env.GEEKGEEKRUN_CANVAS_HOOK_DEBUG === '1'
/**
* 在页面上通过 evaluateOnNewDocument 注入 Canvas fillText hook,将绘制文字收集到主页面随机命名的 marker 属性上。
*
* 实现原理:
* - evaluateOnNewDocument 会在主页面和每一个 iframe 中各执行一次。
* - 在线简历 iframe 带有 sandbox 属性且不含 allow-same-origin,主页面无法访问其 contentWindow
* 因此必须在 iframe 自身的执行上下文内直接 hook CanvasRenderingContext2D.prototype.fillText。
* - iframe 内 hook 到的文字通过 window.top.postMessage 批量发回主页面(同 origin 或跨 origin 均可用)。
* - 主页面监听 message 事件并累积到随机命名的 window 属性。
*
* 反检测:marker 属性名(capturedTextProp / messageKey / hookedFlag)每次调用本函数时随机生成,
* 不同 session 不同;同时通过 laodeng.registerFakeNativeSource 让 fillText 包装函数的 toString 返回原生外观。
*
* @param {import('puppeteer').Page} page - Puppeteer 页面实例(必须在 page.goto 之前调用)
* @returns {Promise<{ getCapturedText: (page: import('puppeteer').Page) => Promise<Array<{text: string, x: number, y: number}>>, clearCapturedText: (page: import('puppeteer').Page) => Promise<void>, peekCapturedText: (page: import('puppeteer').Page) => Promise<number> }>}
*/
export async function setupCanvasTextHook (page) {
const markerSuffix = Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4)
const capturedTextProp = '__cct_' + markerSuffix
const messageKey = '__mk_' + markerSuffix
const hookedFlag = '_h_' + markerSuffix
// 转发浏览器内部 [canvasHook] 日志到 Node 侧(仅 debug 模式)
if (CANVAS_HOOK_DEBUG) {
page.on('console', (msg) => {
const text = msg.text()
if (text.startsWith('[canvasHook]')) {
console.log('[canvasHook-browser]', text)
}
})
}
// 注册 fillText 包装的伪原生 toString(依赖 laodeng 已被 puppeteer.use 装载)
try {
const laodengMod = await import('@geekgeekrun/puppeteer-extra-plugin-laodeng')
const registerFakeNativeSource =
laodengMod.registerFakeNativeSource ?? laodengMod.default?.registerFakeNativeSource
if (typeof registerFakeNativeSource === 'function') {
await registerFakeNativeSource(
page,
'CanvasRenderingContext2D.prototype.fillText',
'function fillText() { [native code] }'
)
}
} catch (e) {
// non-fatal: hook still works, just one more detectable surface
}
await page.evaluateOnNewDocument(
(capturedTextProp, messageKey, hookedFlag, DEBUG) => {
// 此脚本在每个 frame(主页面 + 所有 iframe)中各执行一次。
const isTopFrame = (window === window.top)
if (isTopFrame) {
window[capturedTextProp] = []
window.addEventListener('message', (evt) => {
if (evt.data && evt.data[messageKey] && Array.isArray(evt.data[messageKey])) {
if (!window[capturedTextProp]) window[capturedTextProp] = []
for (const item of evt.data[messageKey]) {
window[capturedTextProp].push(item)
}
if (DEBUG) console.log('[canvasHook] main received ' + evt.data[messageKey].length + ' items, total ' + window[capturedTextProp].length)
}
})
if (DEBUG) console.log('[canvasHook] main: message listener set')
}
// 在当前 window(无论是主页面还是 iframe)上 hook fillText
try {
const proto = window.CanvasRenderingContext2D?.prototype
if (!proto) { if (DEBUG) console.log('[canvasHook] CanvasRenderingContext2D.prototype not found'); return }
if (proto[hookedFlag]) { if (DEBUG) console.log('[canvasHook] already hooked, skip'); return }
proto[hookedFlag] = true
const origFillText = proto.fillText
if (typeof origFillText !== 'function') { if (DEBUG) console.log('[canvasHook] fillText is not a function'); return }
const captured = []
let flushScheduled = false
const flush = () => {
flushScheduled = false
if (captured.length === 0) return
const items = captured.splice(0)
if (isTopFrame) {
if (!window[capturedTextProp]) window[capturedTextProp] = []
for (const item of items) window[capturedTextProp].push(item)
if (DEBUG) console.log('[canvasHook] main fillText wrote ' + items.length + ' items')
} else {
try {
const payload = {}
payload[messageKey] = items
window.top.postMessage(payload, '*')
if (DEBUG) console.log('[canvasHook] iframe postMessage sent ' + items.length + ' items')
} catch (e) {
if (DEBUG) console.log('[canvasHook] postMessage failed: ' + e.message)
}
}
}
const scheduleFlush = () => {
if (!flushScheduled) { flushScheduled = true; setTimeout(flush, 0) }
}
Object.defineProperty(proto, 'fillText', {
value: new Proxy(origFillText, {
apply (target, thisArg, args) {
const [text, x, y] = args
if (typeof text === 'string' && text.trim()) {
captured.push({ text, x: Number(x) || 0, y: Number(y) || 0 })
scheduleFlush()
}
return Reflect.apply(target, thisArg, args)
}
}),
writable: true,
configurable: true
})
if (DEBUG) console.log('[canvasHook] fillText hook installed, isTopFrame=' + isTopFrame + ' href=' + window.location.href)
} catch (e) {
if (DEBUG) console.log('[canvasHook] hook install error: ' + e.message)
}
},
capturedTextProp,
messageKey,
hookedFlag,
CANVAS_HOOK_DEBUG
)
/**
* 从主页面读取当前收集的 Canvas 文字并清空。
* @param {import('puppeteer').Page} p - 同一页面实例
* @returns {Promise<Array<{text: string, x: number, y: number}>>}
*/
async function getCapturedText (p) {
await p.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)))
const result = await p.evaluate((prop) => {
const arr = window[prop] || []
const copy = arr.map(({ text, x, y }) => ({ text, x, y }))
window[prop] = []
return copy
}, capturedTextProp)
return result
}
/**
* 清空主页面收集数组(不返回数据),用于切换候选人前丢弃上一个 iframe 的残留数据。
* @param {import('puppeteer').Page} p - 同一页面实例
*/
async function clearCapturedText (p) {
await p.evaluate((prop) => { window[prop] = [] }, capturedTextProp)
}
/**
* 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 }
}
// ---------------------------------------------------------------------------
// 文字按行整理
// ---------------------------------------------------------------------------
/**
* 将 Canvas 捕获的 { text, x, y } 数组按行分组并排序,拼接成按行排列的文字数组。
* 同一行:y 坐标差 < 5px;同一行内按 x 排序后去重(相邻 x 差 ≤1px 的视为多次渲染同一字符),再拼接。
*
* BOSS 直聘在线简历会用多个叠加 Canvas(高清/普通各一层)以及 WASM 多次渲染,
* 导致同一字符在相同坐标被 fillText 多次,必须去重否则每字会重复 N 次。
*
* @param {Array<{text: string, x: number, y: number}>} capturedTextArray - Canvas 捕获结果
* @returns {string[]} 按行排列的文字数组(已去重)
*/
export function extractResumeText (capturedTextArray) {
if (!Array.isArray(capturedTextArray) || capturedTextArray.length === 0) {
return []
}
const Y_THRESHOLD = 5
// x 坐标差在此范围内视为同一位置的重复绘制(多层/多次渲染)
const X_DEDUP_THRESHOLD = 1
const rows = new Map()
for (const { text, x, y } of capturedTextArray) {
const yKey = Math.round(y / Y_THRESHOLD) * Y_THRESHOLD
if (!rows.has(yKey)) {
rows.set(yKey, [])
}
rows.get(yKey).push({ text, x })
}
const sortedY = Array.from(rows.keys()).sort((a, b) => a - b)
const lines = sortedY.map(yKey => {
const items = rows.get(yKey)
items.sort((a, b) => a.x - b.x)
// 去重:相邻项 x 差 ≤ X_DEDUP_THRESHOLD 时视为同一字符的重复绘制,保留第一个
const deduped = items.filter((item, i) =>
i === 0 || Math.abs(item.x - items[i - 1].x) > X_DEDUP_THRESHOLD
)
return deduped.map(it => it.text).join('')
})
return lines
}
// ---------------------------------------------------------------------------
// 统一获取简历数据(API 优先,Canvas 兜底)
// ---------------------------------------------------------------------------
/**
* 优先从拦截的 API 数据中取简历,若无则从页面 Canvas hook 中提取(需先调用 setupCanvasTextHook)。
*
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
* @param {Map<string, unknown>} interceptedData - setupNetworkInterceptor 返回的 getInterceptedData() 的结果
* @param {{ getCapturedText?: (page: import('puppeteer').Page) => Promise<Array<{text: string, x: number, y: number}>> }} [opts]
* opts.getCapturedText — setupCanvasTextHook 返回的同名函数(支持随机 marker 名);不传时降级读 window.__canvasCapturedText(旧行为,仅向后兼容)
* @returns {Promise<{ source: 'api' | 'canvas', data: unknown }>} source 为 'api' 时 data 为 API 响应对象;为 'canvas' 时为 extractResumeText 的结果(字符串数组)
*/
export async function getResumeData (page, interceptedData, opts = {}) {
if (interceptedData && interceptedData.size > 0) {
const firstEntry = interceptedData.entries().next()
if (!firstEntry.done) {
const [path, data] = firstEntry.value
return { source: 'api', data: { path, ...(typeof data === 'object' && data !== null ? data : { value: data }) } }
}
}
// Canvas fallback: use getCapturedText closure if provided (supports randomized marker names)
// Fall back to legacy window.__canvasCapturedText for callers that don't yet pass it
const getCapturedTextFn = opts.getCapturedText
let captured
if (typeof getCapturedTextFn === 'function') {
captured = await getCapturedTextFn(page)
} else {
captured = await page.evaluate(() => {
const arr = window.__canvasCapturedText || []
const copy = arr.map(({ text, x, y }) => ({ text, x, y }))
window.__canvasCapturedText = []
return copy
})
}
const lines = extractResumeText(captured)
return { source: 'canvas', data: lines }
}