Files
geekgeekrun/packages/boss-auto-browse-and-chat/chat-handler.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

498 lines
23 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.
/**
* 招聘端自动开聊与对话处理
* 提供:查看候选人详情、发起沟通、单候选人处理流程、每日限额检测
*
* 注意:凡在招聘端页面上的点击/移动,均通过 humanMouse.mjs 的拟人轨迹执行,
* 不直接调用 page.click() / page.mouse.click(),以规避 BOSS 鼠标埋点检测。
*/
import { sleep, sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs'
import { getResumeData, extractResumeText } from './resume-extractor.mjs'
import { createHumanCursor } from './humanMouse.mjs'
import { safeClickElement, dismissBlockingOverlays } from './dialog-dismisser.mjs'
import { debug as logDebug, info as logInfo, warn as logWarn } from './logger.mjs'
import {
CANDIDATE_ITEM_SELECTOR,
CHAT_START_BUTTON_SELECTOR,
GREETING_SENT_KNOW_BTN_SELECTOR,
RESUME_POPUP_CLOSE_SELECTOR,
NOT_INTERESTED_IN_ITEM_SELECTOR,
NOT_INTERESTED_REASON_POPUP_SELECTOR,
NOT_INTERESTED_REASON_ITEMS_SELECTOR,
NOT_INTERESTED_REASON_MAP,
NOT_INTERESTED_REASON_POSITION_MISMATCH,
NOT_INTERESTED_REASON_FALLBACK,
NOT_INTERESTED_REASON_POPUP_CLOSE_SELECTOR
} from './constant.mjs'
// ---------------------------------------------------------------------------
// 查看候选人详情
// ---------------------------------------------------------------------------
/**
* 点击候选人条目进入详情,等待详情面板加载,提取详情信息;若详情含 canvas 简历则通过 resume-extractor 获取文字。
* 选择器为 TODO 占位符,需登录招聘端后通过 DevTools 确认。
*
* 点击使用 humanMouse 拟人轨迹,规避 BOSS 鼠标埋点。
*
* @param {import('puppeteer').Frame} frame - iframe Frame 实例(候选人列表在此 frame 内)
* @param {object} candidateItem - 列表中的候选人项,至少含 encryptGeekId、geekName 等
* @param {{
* cursor?: object,
* mainPage?: import('puppeteer').Page,
* getInterceptedData?: () => Map<string, unknown>,
* getCapturedText?: (p: import('puppeteer').Page) => Promise<Array<{text: string, x: number, y: number}>>,
* candidateIndex?: number
* }} [options] - 可选:cursor、mainPage(主页面,用于 Canvas 提取与关闭弹窗);candidateIndex 为列表中的索引
* @returns {Promise<object>} 详情数据对象
*/
export async function viewCandidateDetail (frame, candidateItem, options = {}) {
const { mainPage, getInterceptedData, getCapturedText } = options
// 推荐牛人页无独立详情面板(CANDIDATE_DETAIL_SELECTOR 为空),不点击卡片(点击会弹出简历弹窗,遮挡列表影响后续打招呼)
const detail = {
encryptGeekId: candidateItem.encryptGeekId,
geekName: candidateItem.geekName,
education: candidateItem.education,
workExp: candidateItem.workExp,
city: candidateItem.city,
jobTitle: candidateItem.jobTitle,
salaryExpect: candidateItem.salaryExpect ?? candidateItem.salary,
skills: candidateItem.skills
}
let resumeText = null
let resumeSource = null
if (getInterceptedData) {
const intercepted = getInterceptedData()
const resumeResult = await getResumeData(frame, intercepted, { getCapturedText: options.getCapturedText })
if (resumeResult.source === 'api' && resumeResult.data) {
resumeSource = 'api'
resumeText = typeof resumeResult.data === 'string' ? resumeResult.data : JSON.stringify(resumeResult.data)
} else if (resumeResult.source === 'canvas' && Array.isArray(resumeResult.data)) {
resumeSource = 'canvas'
resumeText = resumeResult.data.join('\n')
}
} else if (getCapturedText && mainPage) {
const captured = await getCapturedText(mainPage)
const lines = extractResumeText(captured)
if (lines.length > 0) {
resumeSource = 'canvas'
resumeText = lines.join('\n')
}
}
if (resumeText) {
detail.resumeText = resumeText
detail.resumeSource = resumeSource
}
// 关闭简历弹窗(主页面上的 dialog),避免列表一直灰显且便于下一项操作
if (mainPage && RESUME_POPUP_CLOSE_SELECTOR) {
try {
await closeResumePopup(mainPage)
} catch (e) {
logWarn('[chat-handler] 关闭简历弹窗失败:', e?.message)
}
}
return detail
}
/**
* 点击简历详情弹窗的关闭按钮(主页面)
* @param {import('puppeteer').Page} page - 主页面
*/
export async function closeResumePopup (page) {
if (!page || !RESUME_POPUP_CLOSE_SELECTOR) return
const closeBtn = await page.$(RESUME_POPUP_CLOSE_SELECTOR)
if (!closeBtn) return
const cursor = await createHumanCursor(page)
const box = await closeBtn.boundingBox()
if (box) {
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
} else {
await closeBtn.click()
}
await sleepWithRandomDelay(300)
}
/**
* 在推荐页 iframe 内点击第 itemIndex 条候选人的"不感兴趣",使该条从列表移除,避免重复扫描。
* 会先悬停到该卡片再查找按钮;点击后弹出原因弹窗,按筛选原因(filterResult.reason)选对应选项以优化 BOSS 推荐。
* 后续可接入 LLM:根据 opts.filterResult.reasonDetail 或候选人信息选择更贴切的原因项。
* @param {import('puppeteer').Frame} frame - recommendFrame
* @param {number} itemIndex - 在 ul.card-list > li.card-item 中的索引
* @param {object} [cursor] - 可选拟人 cursor(须为 frame 所在 page 的 cursor,坐标在 page 坐标系)
* @param {{ logPrefix?: string, filterResult?: { reason: string, reasonDetail?: string } }} [opts] - logPrefix 日志前缀;filterResult 为本条被跳过的筛选结果,用于选对应原因项
*/
export async function clickNotInterested (frame, itemIndex, cursor, opts = {}) {
const logPrefix = opts.logPrefix || '[chat-handler]'
const filterResult = opts.filterResult
const reason = filterResult?.reason || 'unknown'
if (!NOT_INTERESTED_IN_ITEM_SELECTOR) return
const items = await frame.$$(CANDIDATE_ITEM_SELECTOR)
const item = items[itemIndex]
if (!item) {
logInfo(logPrefix, '未找到候选人条目(index=', itemIndex, '),跳过点击不感兴趣')
return
}
const page = frame.page?.() || frame
const c = cursor || await createHumanCursor(page)
// Puppeteer 24.x boundingBox() 已自动叠加 iframe 偏移,返回 page 绝对坐标,直接使用
// 第一步:将卡片滚动进视口(卡片可能在视口外,page.mouse 只对可视区域内坐标有效)
await item.scrollIntoView().catch(() => {})
await sleepWithRandomDelay(300)
// 第二步:悬停到卡片中央,触发卡片 hover 样式(使操作区出现)
const itemBox = await item.boundingBox()
logDebug(logPrefix, '卡片 boundingBox index=', itemIndex, ':', itemBox)
if (itemBox) {
logDebug(logPrefix, '悬停到卡片 index=', itemIndex)
await c.move({ x: itemBox.x + itemBox.width / 2, y: itemBox.y + itemBox.height / 2 })
await sleepWithRandomDelay(400)
}
// 第三步:找到 tooltip-wrap 区域并移动过去,触发该区域 hover("不感兴趣"在此区域内)
let wrap = await item.$(NOT_INTERESTED_IN_ITEM_SELECTOR)
let box = wrap ? await wrap.boundingBox() : null
logDebug(logPrefix, 'tooltip-wrap boundingBox index=', itemIndex, ':', box)
if (!wrap || !box) {
logInfo(logPrefix, '未找到不感兴趣按钮或不可见(index=', itemIndex, '),选择器:', NOT_INTERESTED_IN_ITEM_SELECTOR)
return
}
await c.move({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
await sleepWithRandomDelay(400)
// 第四步:再次获取 box 后点击
box = await wrap.boundingBox()
if (!box) {
logInfo(logPrefix, 'tooltip-wrap hover 后仍不可见(index=', itemIndex, '),跳过')
return
}
// 点击前在 frameiframe)和主页面同时注入 MutationObserver 监听 toast 插入
// toast 是临时元素(几秒后从 DOM 移除),必须在插入瞬间捕获;toast 可能在 iframe 内或主页面
const injectToastObserver = (ctx) => ctx.evaluate(() => {
window.__notInterestedLimitReached = false
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType === 1 && node.classList?.contains('toast')) {
if (node.innerText?.includes('已达上限')) {
window.__notInterestedLimitReached = true
}
observer.disconnect()
return
}
}
}
})
observer.observe(document.body, { childList: true, subtree: true })
setTimeout(() => observer.disconnect(), 3000)
}).catch(() => {})
await Promise.all([injectToastObserver(frame), injectToastObserver(page)])
logDebug(logPrefix, '点击不感兴趣按钮 index=', itemIndex, 'box:', box)
await c.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
await sleep(800)
// 读取 MutationObserver 结果(frame 或 page 任一检测到即算)
const limitReachedInFrame = await frame.evaluate(() => window.__notInterestedLimitReached === true).catch(() => false)
const limitReachedInPage = await page.evaluate(() => window.__notInterestedLimitReached === true).catch(() => false)
if (limitReachedInFrame || limitReachedInPage) {
logInfo(logPrefix, '当天标记不合适的牛人数已达上限,停止点击不感兴趣')
return 'NOT_INTERESTED_LIMIT_REACHED'
}
// 点击"不感兴趣"后弹出原因弹窗,按筛选原因选对应选项;弹窗在 iframe 内(与列表同属 recommendFrame
if (NOT_INTERESTED_REASON_POPUP_SELECTOR && NOT_INTERESTED_REASON_ITEMS_SELECTOR) {
const reasonPopupTimeoutMs = 5000
const runReasonPopup = async () => {
const popupInFrame = await frame.waitForSelector(NOT_INTERESTED_REASON_POPUP_SELECTOR, { timeout: 2000 }).catch(() => null)
const popupInPage = !popupInFrame && page !== frame ? await page.waitForSelector(NOT_INTERESTED_REASON_POPUP_SELECTOR, { timeout: 2000 }).catch(() => null) : null
const popup = popupInFrame || popupInPage
const ctx = popupInFrame ? frame : page
if (!popup) {
if (logPrefix) logInfo(logPrefix, '原因弹窗未出现(index=', itemIndex, '),跳过')
return
}
if (logPrefix) logDebug(logPrefix, '原因弹窗已出现,正在选择原因(', reason, ')…')
const reasonItems = await ctx.$$(NOT_INTERESTED_REASON_ITEMS_SELECTOR)
const targetText = NOT_INTERESTED_REASON_MAP[reason] || NOT_INTERESTED_REASON_FALLBACK
const preferPositionMismatch = (reason === 'skills' || reason === 'blockName') && NOT_INTERESTED_REASON_POSITION_MISMATCH
let toClick = null
let matchedText = null
for (const el of reasonItems) {
const text = await ctx.evaluate(e => e.textContent.trim(), el).catch(() => '')
if (preferPositionMismatch && text.includes(NOT_INTERESTED_REASON_POSITION_MISMATCH)) {
toClick = el
matchedText = text
break
}
if (text === targetText) {
toClick = el
matchedText = text
break
}
}
if (!toClick && targetText !== NOT_INTERESTED_REASON_FALLBACK) {
for (const el of reasonItems) {
const text = await ctx.evaluate(e => e.textContent.trim(), el).catch(() => '')
if (text === NOT_INTERESTED_REASON_FALLBACK) {
toClick = el
matchedText = text
break
}
}
}
if (toClick) {
const reasonBox = await toClick.boundingBox()
if (reasonBox) {
await c.click({ x: reasonBox.x + reasonBox.width / 2, y: reasonBox.y + reasonBox.height / 2 })
} else {
await toClick.click()
}
if (logPrefix && matchedText) {
logInfo(logPrefix, '不感兴趣原因已选:', matchedText, '(筛选原因:', reason, '')
}
} else {
// 未匹配到任何选项时点击关闭图标,避免弹窗一直挡住后续操作
if (NOT_INTERESTED_REASON_POPUP_CLOSE_SELECTOR) {
const closeBtn = await ctx.$(NOT_INTERESTED_REASON_POPUP_CLOSE_SELECTOR)
if (closeBtn) {
const closeBox = await closeBtn.boundingBox()
if (closeBox) await c.click({ x: closeBox.x + closeBox.width / 2, y: closeBox.y + closeBox.height / 2 })
else await closeBtn.click()
if (logPrefix) logDebug(logPrefix, '未匹配到原因选项,已点击关闭弹窗')
}
}
}
await sleepWithRandomDelay(300)
}
try {
await Promise.race([
runReasonPopup(),
sleep(reasonPopupTimeoutMs).then(() => { throw new Error('原因弹窗处理超时') })
])
} catch (e) {
logWarn(logPrefix, '不感兴趣原因弹窗处理失败:', e?.message)
}
}
}
// ---------------------------------------------------------------------------
// 发起沟通(打招呼 → 知道了 → 继续沟通 → 发消息)
// ---------------------------------------------------------------------------
/**
* 招聘端打招呼完整流程:
* 1. 找到并以拟人轨迹点击"打招呼"按钮(CHAT_START_BUTTON_SELECTOR,在当前候选人 item 内)
* 2. 等待"已向牛人发送招呼"弹窗出现,点击"知道了"GREETING_SENT_KNOW_BTN_SELECTOR
* 3. 等待"继续沟通"按钮出现,在当前候选人 item 内点击(CONTINUE_CHAT_BUTTON_SELECTOR
* 4. 等待全局聊天输入框出现(CHAT_INPUT_SELECTOR),输入后续消息(若配置了 greetingMessage)并回车发送
*
* 选择器为实际值(已从 HTML 示例中确认)。
* 所有点击通过 humanMouse 拟人轨迹执行,规避 BOSS 鼠标埋点。
* 当 options.candidateIndex 存在时,仅在对应 CANDIDATE_ITEM_SELECTOR 的该条内查找按钮,避免点到别的候选人。
*
* @param {import('puppeteer').Frame} frame - iframe Frame 实例(候选人列表在此 frame 内)
* @param {object} candidate - 候选人对象,至少含 encryptGeekId、geekName
* @param {string} [greetingMessage] - 打招呼后在聊天框发送的后续消息(可选)
* @param {{ cursor?: object, candidateIndex?: number, mainPage?: import('puppeteer').Page }} [options] - mainPage 为主页面,用于处理弹窗和风控检测
* @returns {Promise<{ success: boolean, reason: string }>}
*/
export async function startChatWithCandidate (frame, _candidate, _greetingMessage, options = {}) {
const cursor = options.cursor || await createHumanCursor(frame)
const candidateIndex = options.candidateIndex
// mainPage 用于处理主页面弹窗("知道了" dialog 在主页面,不在 iframe 内)
const mainPage = options.mainPage || frame
if (!CHAT_START_BUTTON_SELECTOR) {
return { success: false, reason: 'CHAT_START_BUTTON_SELECTOR_NOT_CONFIGURED' }
}
// 1. 点击"打招呼"按钮(拟人轨迹);在 iframe frame 内按 candidateIndex 找到对应 item
// Puppeteer 24.x boundingBox() 已自动叠加 iframe 偏移,返回 page 绝对坐标,直接使用
// 点击前先扫一下主页面是否有挡住操作的浮层(治理公告 / 意向沟通 等突发弹窗)
if (mainPage && mainPage !== frame) {
await dismissBlockingOverlays(mainPage, { maxRounds: 2 }).catch(() => 0)
}
try {
let startBtn
if (typeof candidateIndex === 'number' && CANDIDATE_ITEM_SELECTOR) {
const items = await frame.$$(CANDIDATE_ITEM_SELECTOR)
const item = items[candidateIndex]
if (!item) return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
startBtn = await item.$(CHAT_START_BUTTON_SELECTOR)
} else {
startBtn = await frame.waitForSelector(CHAT_START_BUTTON_SELECTOR, { timeout: 8000 })
}
if (!startBtn) return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
// 「打招呼」按钮在 iframe 内,遮挡几乎都来自主页面 —— 这里直接 cursor.click
// safeClickElement 在 frame 上下文做 elementFromPoint 不可靠,改为提前 sweep 主页面
const box = await startBtn.boundingBox()
if (box) {
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
} else {
await startBtn.click()
}
} catch {
return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
}
await sleepWithRandomDelay(800)
// 检测风控或每日限额(在主页面检测,iframe 内不会显示这些提示)
const immediateCheck = await mainPage.evaluate(() => {
const bodyText = document.body?.innerText || ''
if (/今日沟通人数已达上限|明天再来|今日.*已达上限/.test(bodyText)) {
return 'DAILY_LIMIT_REACHED'
}
if (/风控|存在风险|请稍后再试|操作过于频繁/.test(bodyText)) {
return 'RISK_CONTROL'
}
return null
})
if (immediateCheck) {
return { success: false, reason: immediateCheck }
}
// 2. 等待"已向牛人发送招呼"弹窗并点击"知道了"(弹窗在主页面,不在 iframe 内)
// 优先用 known selector;找不到则用启发式扫描兜底(应对弹窗结构变动)。
// 若弹窗始终未出现(selector 超时 + 启发式也没关掉任何浮层),视为招呼可能未发出,
// 返回 GREETING_SENT_DIALOG_NOT_APPEARED 而非乐观地返回 OK,防止误计成功次数。
if (GREETING_SENT_KNOW_BTN_SELECTOR) {
let handled = false
try {
const knowBtn = await mainPage.waitForSelector(GREETING_SENT_KNOW_BTN_SELECTOR, { timeout: 6000 })
if (knowBtn) {
const r = await safeClickElement({
ctx: mainPage, page: mainPage, element: knowBtn, cursor,
logPrefix: '[chat-handler:greet-know]'
})
// 仅当真正点击成功时认为已处理;clicked=false 时落到下方启发式兜底
handled = r.clicked === true && !r.error
if (r.error) logWarn('[chat-handler] safeClickElement 报告 error=', r.error)
}
await sleepWithRandomDelay(500)
} catch {
// selector 超时,留给启发式兜底
}
if (!handled) {
const closed = await dismissBlockingOverlays(mainPage, { maxRounds: 2 }).catch(() => 0)
if (closed > 0) {
logInfo('[chat-handler] 启发式关闭了发送招呼后的浮层')
handled = true
} else {
// 弹窗既没有通过 selector 出现,也没有被启发式识别到——
// 不能确认招呼已发,提前返回失败,避免误报成功
logWarn('[chat-handler] 招呼确认弹窗("知道了")始终未出现,招呼可能未被发送')
return { success: false, reason: 'GREETING_SENT_DIALOG_NOT_APPEARED' }
}
}
}
// 打招呼已通过点击 button.btn-greet 自动发送,无需继续沟通或发后续消息
// 最终结果判断:招呼已发成功视为 OK;在 iframe 和主页面同时检测风控/限额文字
const checkBodyText = (bodyText) => {
if (/今日沟通人数已达上限|明天再来|今日.*已达上限/.test(bodyText)) {
return { success: false, reason: 'DAILY_LIMIT_REACHED' }
}
if (/风控|存在风险|请稍后再试|操作过于频繁/.test(bodyText)) {
return { success: false, reason: 'RISK_CONTROL' }
}
return null
}
const [frameText, pageText] = await Promise.all([
frame.evaluate(() => document.body?.innerText || '').catch(() => ''),
mainPage.evaluate(() => document.body?.innerText || '').catch(() => '')
])
const result = checkBodyText(frameText) || checkBodyText(pageText) || { success: true, reason: 'OK' }
return result
}
// ---------------------------------------------------------------------------
// 单候选人完整流程(详情 + 开聊 + hooks + 延迟)
// ---------------------------------------------------------------------------
/**
* 整合查看详情与发起沟通的完整流程:beforeStartChat → 开聊 → afterChatStarted,并在开聊后随机延迟。
* 所有页面点击通过共享的 humanMouse cursor 执行,规避 BOSS 鼠标埋点。
*
* @param {import('puppeteer').Frame} frame - iframe Frame 实例(候选人列表在此 frame 内)
* @param {object} candidate - 候选人对象
* @param {object} config - 配置,含 autoChat.greetingMessage、autoChat.delayBetweenChats [min, max]
* @param {object} hooks - tapable hooksbeforeStartChat、afterChatStarted
* @param {{ getInterceptedData?: () => Map<string, unknown>, getCapturedText?: (p: import('puppeteer').Frame) => Promise<unknown[]>, candidateIndex?: number, mainPage?: import('puppeteer').Page }} [resumeOptions] - 可选,用于 viewCandidateDetail 获取简历;mainPage 为主页面(用于处理弹窗)
* @returns {Promise<{ detail?: object, chatResult: { success: boolean, reason: string } }>}
*/
export async function processCandidate (frame, candidate, config, hooks, resumeOptions = {}) {
await hooks.beforeStartChat?.promise?.(candidate)
const greetingMessage = config?.autoChat?.greetingMessage || ''
const candidateIndex = resumeOptions.candidateIndex
const mainPage = resumeOptions.mainPage
// cursor 必须用 Page 创建(ghost-cursor 内部依赖 page.evaluate 等),不能用 Frame
const pageForCursor = mainPage || (typeof frame.page === 'function' ? frame.page() : frame)
const cursor = await createHumanCursor(pageForCursor)
let detail = null
try {
detail = await viewCandidateDetail(frame, candidate, { ...resumeOptions, cursor })
} catch (err) {
return {
detail: null,
chatResult: { success: false, reason: `VIEW_DETAIL_FAILED: ${err?.message || err}` }
}
}
const chatResult = await startChatWithCandidate(frame, candidate, greetingMessage, { cursor, candidateIndex, mainPage })
await hooks.afterChatStarted?.promise?.(candidate, chatResult)
const delayRange = config?.autoChat?.delayBetweenChats
if (Array.isArray(delayRange) && delayRange.length >= 2) {
const [minMs, maxMs] = delayRange
const delay = minMs + Math.random() * (maxMs - minMs)
await sleep(delay)
} else {
await sleepWithRandomDelay(3000)
}
return { detail, chatResult }
}
// ---------------------------------------------------------------------------
// 每日开聊限额检测
// ---------------------------------------------------------------------------
/**
* 检查今日是否已达到开聊上限。通过页面提示语或 DOM 中的限额信息判断。
*
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
* @returns {Promise<{ limitReached: boolean, count?: number, max?: number }>}
*/
export async function checkDailyLimit (page) {
const result = await page.evaluate(() => {
const bodyText = document.body?.innerText || ''
const todayLimitMatch = bodyText.match(/今日已沟通\s*(\d+)\s*\/\s*(\d+)/) ||
bodyText.match(/今日沟通.*?(\d+).*?(\d+)/) ||
bodyText.match(/(\d+)\s*\/\s*(\d+)\s*次/)
if (todayLimitMatch) {
const count = parseInt(todayLimitMatch[1], 10)
const max = parseInt(todayLimitMatch[2], 10)
return { limitReached: count >= max, count, max }
}
if (/今日沟通人数已达上限|明天再来|今日.*已达上限/.test(bodyText)) {
return { limitReached: true }
}
return { limitReached: false }
})
return result
}