recruiter: enhance chat page processing, boss browse flow, and UI improvements

- Improve chat-page-processor with better candidate handling and filtering
- Update chat-page-resume extraction logic
- Add new constants to constant.mjs
- Enhance boss auto browse main flow with verification detection and multi-job sequence support
- Expand boss chat page main flow with HR guide features
- Update BossAutoSequence and BossChatPage Vue components
- Add plan docs: current_status and recruiter_chat_page_hr_guide

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rqi14
2026-03-20 19:07:59 +08:00
co-authored by Claude Sonnet 4.6
parent 95c1e54c66
commit 3fb7089c9e
10 changed files with 1454 additions and 546 deletions
@@ -25,6 +25,7 @@ import {
CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR,
CHAT_PAGE_ITEM_SELECTOR,
CHAT_PAGE_ITEM_UNREAD_SELECTOR,
CHAT_PAGE_ALL_FILTER_SELECTOR,
CHAT_PAGE_UNREAD_FILTER_SELECTOR,
CHAT_PAGE_NAME_SELECTOR,
CHAT_PAGE_JOB_SELECTOR,
@@ -41,21 +42,44 @@ const LOG = '[chat-page-processor]'
*/
async function switchChatPageJobId (page, jobId) {
try {
await page.click('.ui-dropmenu.chat-top-job .ui-dropmenu-label')
const cursor = await createHumanCursor(page)
// 用拟人轨迹点击下拉触发按钮
const dropdownBtn = await page.$('.ui-dropmenu.chat-top-job .ui-dropmenu-label')
if (dropdownBtn) {
const box = await dropdownBtn.boundingBox().catch(() => null)
if (box) {
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
} else {
await dropdownBtn.click()
}
} else {
await page.click('.ui-dropmenu.chat-top-job .ui-dropmenu-label')
}
await page.waitForSelector('.ui-dropmenu.chat-top-job .ui-dropmenu-list', { timeout: 5000 })
const found = await page.evaluate((jid) => {
const item = document.querySelector(`.ui-dropmenu.chat-top-job .ui-dropmenu-list li[value="${jid}"]`)
if (!item) return false
item.click()
return true
}, jobId)
await sleepWithRandomDelay(150, 300)
// 用拟人轨迹点击目标职位项
const items = await page.$$('.ui-dropmenu.chat-top-job .ui-dropmenu-list li')
let found = false
for (const item of items) {
const val = await item.evaluate(el => el.getAttribute('value')).catch(() => null)
if (val === jobId) {
const itemBox = await item.boundingBox().catch(() => null)
if (itemBox) {
await cursor.click({ x: itemBox.x + itemBox.width / 2, y: itemBox.y + itemBox.height / 2 })
} else {
await item.click()
}
found = true
break
}
}
if (!found) {
logWarn(`${LOG} 职位 ${jobId} 未在沟通页下拉列表中找到,将使用默认职位继续`)
await page.keyboard.press('Escape')
return
}
// 等待左侧会话列表刷新
await new Promise(r => setTimeout(r, 500))
await sleepWithRandomDelay(400, 700)
logInfo(`${LOG} 已切换到职位 ${jobId}`)
} catch (e) {
logWarn(`${LOG} 切换沟通页职位失败(${e.message}),将使用默认职位继续`)
@@ -277,11 +301,29 @@ async function waitForGeekInfo (peekFn, opts = {}) {
/**
* 沟通页自动化主入口。
* @param {object} hooksFromCaller - 与 startBossAutoBrowse 相同的 hooksonError, insertCandidateContactLog, createOrUpdateCandidateInfo, queryCandidateByEncryptId 等)
* @param {{ browser?: import('puppeteer').Browser, page?: import('puppeteer').Page }} [options] - 若传入则复用已有 browser/page,否则内部不启动浏览器(调用方需先导航到沟通页或由推荐页流程传入)
* @param {{
* browser?: import('puppeteer').Browser,
* page?: import('puppeteer').Page,
* getCapturedText?: Function,
* clearCapturedText?: Function,
* jobId?: string | null,
* retryCandidate?: { encryptGeekId: string, geekName: string, jobTitle: string } | null,
* processContext?: { currentCandidate: object | null } | null
* }} [options]
* - retryCandidate: 验证中断后需优先重试的候选人(此前已被点击成"已读",需在「全部」tab 找回)
* - processContext: 调用方传入的可变对象,本函数在处理每条会话前将 currentCandidate 写入,
* 供调用方在捕获错误时读取"是哪位候选人被中断"
*/
export default async function startBossChatPageProcess (hooksFromCaller, options = {}) {
const hooks = hooksFromCaller || {}
const { page: existingPage, getCapturedText, clearCapturedText, jobId = null } = options
const {
page: existingPage,
getCapturedText,
clearCapturedText,
jobId = null,
retryCandidate = null,
processContext = null
} = options
/** @type {import('puppeteer').Page} */
let page = existingPage
@@ -344,60 +386,54 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
await switchChatPageJobId(page, jobId)
}
// 切换到"未读"tab,确保虚拟滚动列表只包含未读会话(避免全部模式下未读项在视口外未渲染)
const cursor = await createHumanCursor(page)
const unreadTabActive = await page.evaluate((sel) => {
const el = document.querySelector(sel)
return el ? el.classList.contains('active') : false
}, CHAT_PAGE_UNREAD_FILTER_SELECTOR)
if (!unreadTabActive) {
logInfo(`${LOG} 切换到「未读」tab...`)
const tabEl = await page.$(CHAT_PAGE_UNREAD_FILTER_SELECTOR)
if (tabEl) {
const box = await tabEl.boundingBox()
if (box) {
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
await sleepWithRandomDelay(400, 600)
// 等列表刷新
try {
await page.waitForSelector(CHAT_PAGE_ITEM_SELECTOR, { timeout: 5000 })
logDebug(`${LOG} 「未读」tab 切换后列表已刷新`)
} catch {
logDebug(`${LOG} 「未读」tab 切换后列表为空(无未读会话)`)
}
// ────────────────────────────────────────────────────────────────────────────
// 内部辅助:切换到指定 tab(封闭 page、cursor
// ────────────────────────────────────────────────────────────────────────────
const switchToTab = async (selector, tabName) => {
const isActive = await page.evaluate(
(sel) => document.querySelector(sel)?.classList.contains('active') ?? false,
selector
)
if (isActive) {
logDebug(`${LOG} 已在「${tabName}」tab`)
return
}
logInfo(`${LOG} 切换到「${tabName}」tab...`)
const tabEl = await page.$(selector)
if (!tabEl) {
logWarn(`${LOG} 未找到「${tabName}」tab 元素(selector: ${selector}`)
return
}
const box = await tabEl.boundingBox().catch(() => null)
if (box) {
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
await sleepWithRandomDelay(400, 600)
try {
await page.waitForSelector(CHAT_PAGE_ITEM_SELECTOR, { timeout: 5000 })
logDebug(`${LOG}${tabName}」tab 切换后列表已刷新`)
} catch {
logDebug(`${LOG}${tabName}」tab 切换后列表为空(无会话)`)
}
} else {
logWarn(`${LOG} 未找到「未读」tab 元素(selector: ${CHAT_PAGE_UNREAD_FILTER_SELECTOR}`)
}
} else {
logDebug(`${LOG} 已在「未读」tab`)
}
await sleepWithRandomDelay(300)
const conversations = await parseConversationList(page)
logDebug(`${LOG} DOM 解析到 ${conversations.length} 条会话`)
// 已切到"未读"tab,列表中所有 item 均为未读会话,无需再用 badge-count 二次过滤
const unreadItems = conversations.filter((c) => c.encryptGeekId)
const toProcess = unreadItems.slice(0, maxProcessPerRun)
logInfo(`${LOG} 未读会话 ${unreadItems.length} 条,本次最多处理 ${toProcess.length}`)
if (toProcess.length > 0) {
logDebug(`${LOG} 候选人列表:${toProcess.map((c, i) => `[${i}] ${c.geekName}(${c.encryptGeekId})`).join(', ')}`)
}
await hooks.onProgress?.promise?.({ phase: 'chatPage', current: 0, max: toProcess.length }).catch(() => {})
let processed = 0
for (let i = 0; i < toProcess.length; i++) {
const item = toProcess[i]
// ────────────────────────────────────────────────────────────────────────────
// 内部核心:处理单条会话的完整逻辑(闭包,封闭所有外层状态变量)
// 返回 { processed: boolean, skipped: boolean }
// ────────────────────────────────────────────────────────────────────────────
const processOneCandidateConversation = async (item) => {
const { encryptGeekId, geekName, jobTitle } = item
logInfo(`${LOG} ── [${i + 1}/${toProcess.length}] 开始处理 ${geekName}${encryptGeekId})──`)
// 向调用方暴露当前正在处理的候选人(发生错误时可读取)
if (processContext) processContext.currentCandidate = item
const { contacted } = await checkIfAlreadyContacted(encryptGeekId, hooks)
if (contacted) {
logInfo(`${LOG} → 已在数据库中联系过,跳过`)
continue
if (processContext) processContext.currentCandidate = null
return { processed: false, skipped: true }
}
logDebug(`${LOG} → 数据库未记录,继续处理`)
@@ -408,12 +444,16 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
const resumeCloseBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null)
if (resumeCloseBtn) {
logDebug(`${LOG} → 检测到在线简历弹窗未关闭,点击关闭...`)
await page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => {})
const closeBox = await resumeCloseBtn.boundingBox().catch(() => null)
if (closeBox) {
await cursor.click({ x: closeBox.x + closeBox.width / 2, y: closeBox.y + closeBox.height / 2 })
} else {
await resumeCloseBtn.click().catch(() => {})
}
try {
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 4000 })
logDebug(`${LOG} → 在线简历弹窗已关闭`)
} catch {
// 仍未消失则强制刷新检查
const stillOpen = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null)
if (stillOpen) {
logWarn(`${LOG} → 在线简历弹窗关闭失败(4s 超时),继续尝试切换会话,但可能影响会话切换成功率`)
@@ -428,7 +468,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
const selected = await selectConversationById(page, encryptGeekId, { cursor })
if (!selected) {
logWarn(`${LOG} → 无法在 DOM 中找到该会话(可能已被标为已读或滚出虚拟滚动视口),跳过`)
continue
if (processContext) processContext.currentCandidate = null
return { processed: false, skipped: true }
}
logInfo(`${LOG} → 会话已选中,等待页面加载...`)
await sleepWithRandomDelay(600, 1200)
@@ -439,7 +480,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
if (panelName && !geekName.includes(panelName) && !panelName.includes(geekName)) {
logWarn(`${LOG} → 右侧面板姓名「${panelName}」与期望「${geekName}」不符,会话切换未生效,跳过`)
await sleepWithRandomDelay(300, 600)
continue
if (processContext) processContext.currentCandidate = null
return { processed: false, skipped: true }
}
if (panelName) {
logDebug(`${LOG} → 右侧面板验证:「${panelName}」✓`)
@@ -452,12 +494,23 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
if (intentKnowBtn) {
logInfo(`${LOG} → 检测到「意向沟通」提示弹窗,点击「我知道了」关闭...`)
try {
await page.click(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR)
const knowBox = await intentKnowBtn.boundingBox().catch(() => null)
if (knowBox) {
await cursor.click({ x: knowBox.x + knowBox.width / 2, y: knowBox.y + knowBox.height / 2 })
} else {
await intentKnowBtn.click().catch(() => {})
}
} catch {
// 备用:点关闭图标
await page.click(CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR).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(() => {})
}
}
}
// 等弹窗消失(点击后 Vue 会移除 DOM)
try {
await page.waitForSelector(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, { hidden: true, timeout: 3000 })
logDebug(`${LOG} → 「意向沟通」弹窗已关闭`)
@@ -474,7 +527,6 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
logDebug(`${LOG} → 等待 geek/info 数据(初步筛选,最长 5s)...`)
let geekInfoSnap = await waitForGeekInfo(peekInterceptedData, { timeoutMs: 5000 })
if (!geekInfoSnap) {
// geek/info 未到达,可能是 BOSS 缓存导致未重新请求;重新点击一次会话触发请求
logWarn(`${LOG} → geek/info 未到达,重试点击会话...`)
getInterceptedData()
const retrySelected = await selectConversationById(page, encryptGeekId, { cursor })
@@ -502,10 +554,10 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
logInfo(`${LOG} → 初步信息筛选不通过:${reason},跳过`)
await logContact(encryptGeekId, 'resume_screened_out', null, `preFilter:${reason}`, hooks)
await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'screened_out' }, hooks)
// 消费并清空拦截数据,准备下一个候选人
getInterceptedData()
await sleepWithRandomDelay(300, 600)
continue
if (processContext) processContext.currentCandidate = null
return { processed: false, skipped: true }
}
logInfo(`${LOG} → 初步信息筛选通过`)
} else {
@@ -547,36 +599,31 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
}
}
await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'contacted' }, hooks)
processed++
await hooks.onProgress?.promise?.({ phase: 'chatPage', current: processed, max: toProcess.length }).catch(() => {})
getInterceptedData()
await sleepWithRandomDelay(1000, 2500)
continue
await sleepWithRandomDelay(2000, 4000)
if (processContext) processContext.currentCandidate = null
return { processed: true, skipped: false }
}
// 无附件简历 → 说明对方只是打招呼,需要我方先筛选再决定是否索取
// 点击「查看在线简历」(等 iframe 出现即视为打开成功,geek/info 数据点击会话时已触发,复用之)
logInfo(`${LOG} → 对方打招呼,点击查看在线简历...`)
// 打开新简历前清空残留数据(主要清除 geek/info 等待阶段积累的旧 iframe postMessage
if (typeof clearCapturedText === 'function') {
await clearCapturedText(page)
}
// 传入 clearCapturedText,在旧弹窗关闭后、新简历点击前再清一次(关闭过程中可能有新一批旧数据到达)
const openedResume = await openOnlineResume(page, { cursor, clearCapturedText: clearCapturedText || undefined })
if (!openedResume) {
logWarn(`${LOG} → 未找到「查看在线简历」按钮或 iframe 未出现,跳过`)
await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'viewed' }, hooks)
getInterceptedData()
await sleepWithRandomDelay(500, 1000)
continue
if (processContext) processContext.currentCandidate = null
return { processed: false, skipped: true }
}
logInfo(`${LOG} → 在线简历 iframe 已出现,等待 Canvas 渲染完成...`)
let resumeText = ''
if (typeof getCapturedText === 'function') {
const { extractResumeText } = await import('./resume-extractor.mjs')
// 稳定轮询:连续两次 peek 到相同数量(且 > 0)视为渲染完成
// WASM 通常在 iframe 出现后 1~2s 内完成全部渲染
const POLL_INTERVAL_MS = 400
const STABLE_POLLS_NEEDED = 2
const CANVAS_POLL_TIMEOUT = 8000
@@ -595,10 +642,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
lastCount = currentCount
}
// 最终读取并清空(getCapturedText 内部再等 150ms 确保最后一批 postMessage 已到)
const captured = await getCapturedText(page)
const rawLines = extractResumeText(captured)
// 去除 BOSS 字体预热数据(首次 iframe 加载时 "bzl|abcdef..." 类的 ASCII 行)
const lines = filterFontTestLines(rawLines)
resumeText = lines.join('\n')
logInfo(`${LOG} → Canvas 抓取完成,共 ${captured.length} 次 fillText 调用,文本 ${resumeText.length} 字(原始行 ${rawLines.length},过滤后 ${lines.length}`)
@@ -609,8 +654,6 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
resumeText = out.text
logInfo(`${LOG} → geek/info 摘要文本 ${resumeText.length}`)
} else {
// 验证简历内容是否属于当前候选人。
// geekName 已知,直接用 includes 最可靠;extractNameFromResumeText 仅用于 warn 消息。
const detectedName = extractNameFromResumeText(resumeText)
logDebug(`${LOG} → 简历姓名识别:${detectedName || '(未识别)'}(期望:${geekName}`)
if (!resumeText.includes(geekName)) {
@@ -618,7 +661,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
logWarn(`${LOG} → 右侧面板未切换到本会话(geek/info 超时或被安全验证打断),跳过,下次运行时重试`)
getInterceptedData()
await sleepWithRandomDelay(300, 600)
continue
if (processContext) processContext.currentCandidate = null
return { processed: false, skipped: true }
}
}
} else {
@@ -635,6 +679,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
logInfo(`${LOG} → 简历文本获取成功(共 ${resumeText.length} 字)`)
}
await sleepWithRandomDelay(2000, 4500)
let pass = true
let filterReason = ''
@@ -669,19 +715,22 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
if (pass) {
logInfo(`${LOG} → 筛选通过,发送索取附件简历请求...`)
// 在线简历弹窗若仍开着,会遮挡附件简历按钮导致点击失效,必须先完全关闭(含动画)
const openResumeCloseBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null)
if (openResumeCloseBtn) {
logDebug(`${LOG} → 先关闭在线简历弹窗,避免遮挡附件简历按钮...`)
await page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => {})
const closeBox2 = await openResumeCloseBtn.boundingBox().catch(() => null)
if (closeBox2) {
await cursor.click({ x: closeBox2.x + closeBox2.width / 2, y: closeBox2.y + closeBox2.height / 2 })
} else {
await openResumeCloseBtn.click().catch(() => {})
}
try {
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 3000 })
logDebug(`${LOG} → 在线简历弹窗已关闭`)
} catch {
logWarn(`${LOG} → 在线简历弹窗关闭超时,继续尝试(可能影响附件简历按钮点击)`)
}
// 等待弹窗 CSS 动画完全结束,确保不再遮挡下方按钮
await new Promise(r => setTimeout(r, 400))
await sleepWithRandomDelay(500, 1000)
}
const { requested, error } = await requestAttachmentResume(page, { cursor })
if (requested) {
@@ -706,10 +755,58 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
},
hooks
)
processed++
await hooks.onProgress?.promise?.({ phase: 'chatPage', current: processed, max: toProcess.length }).catch(() => {})
getInterceptedData()
await sleepWithRandomDelay(1000, 2500)
await sleepWithRandomDelay(2000, 4500)
if (processContext) processContext.currentCandidate = null
return { processed: true, skipped: false }
}
// ────────────────────────────────────────────────────────────────────────────
// ── 验证恢复:若上次被验证中断,优先重试被中断的候选人 ────────────────────────
if (retryCandidate) {
logInfo(`${LOG} ── 验证恢复:重试被中断候选人 ${retryCandidate.geekName}${retryCandidate.encryptGeekId})──`)
// 候选人此前已被点击,状态变为"已读",需切换到「全部」tab 才能找到
await switchToTab(CHAT_PAGE_ALL_FILTER_SELECTOR, '全部')
await sleepWithRandomDelay(300)
const retrySel = await selectConversationById(page, retryCandidate.encryptGeekId, { cursor })
if (retrySel) {
logInfo(`${LOG} 重试候选人会话已找到,开始处理...`)
await sleepWithRandomDelay(600, 1200)
await processOneCandidateConversation(retryCandidate)
} else {
logWarn(`${LOG} 未在「全部」会话中找到重试候选人 ${retryCandidate.geekName}(可能已被处理或不可见),跳过`)
}
// 切回「未读」tab 进行正常扫描
await switchToTab(CHAT_PAGE_UNREAD_FILTER_SELECTOR, '未读')
await sleepWithRandomDelay(300)
}
// ── 正常扫描:切换到「未读」tab,处理未读会话 ───────────────────────────────
await switchToTab(CHAT_PAGE_UNREAD_FILTER_SELECTOR, '未读')
await sleepWithRandomDelay(300)
const conversations = await parseConversationList(page)
logDebug(`${LOG} DOM 解析到 ${conversations.length} 条会话`)
const unreadItems = conversations.filter((c) => c.encryptGeekId)
const toProcess = unreadItems.slice(0, maxProcessPerRun)
logInfo(`${LOG} 未读会话 ${unreadItems.length} 条,本次最多处理 ${toProcess.length}`)
if (toProcess.length > 0) {
logDebug(`${LOG} 候选人列表:${toProcess.map((c, i) => `[${i}] ${c.geekName}(${c.encryptGeekId})`).join(', ')}`)
}
await hooks.onProgress?.promise?.({ phase: 'chatPage', current: 0, max: toProcess.length }).catch(() => {})
let processed = 0
for (let i = 0; i < toProcess.length; i++) {
const item = toProcess[i]
logInfo(`${LOG} ── [${i + 1}/${toProcess.length}] 开始处理 ${item.geekName}${item.encryptGeekId})──`)
const result = await processOneCandidateConversation(item)
if (result.processed) {
processed++
await hooks.onProgress?.promise?.({ phase: 'chatPage', current: processed, max: toProcess.length }).catch(() => {})
}
}
logInfo(`${LOG} 本次共处理 ${processed} 条未读会话`)
@@ -55,15 +55,11 @@ export async function openOnlineResume (page, options = {}) {
const closeBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR)
if (closeBtn) {
console.log('[openOnlineResume] 检测到旧简历弹窗,点击关闭按钮...')
try {
// 直接用 page.click,比坐标点击更可靠(不受 ghost-cursor 偏移影响)
await page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR)
} catch (e) {
// 备用:坐标点击
const closeBox = await closeBtn.boundingBox().catch(() => null)
if (closeBox) {
await cursor.click({ x: closeBox.x + closeBox.width / 2, y: closeBox.y + closeBox.height / 2 })
}
const closeBox = await closeBtn.boundingBox().catch(() => null)
if (closeBox) {
await cursor.click({ x: closeBox.x + closeBox.width / 2, y: closeBox.y + closeBox.height / 2 })
} else {
await closeBtn.click().catch(() => {})
}
// 等关闭按钮从 DOM 消失(即弹窗完全关闭),比等 iframe 消失更可靠
try {
@@ -331,7 +327,7 @@ export async function openPreviewAndDownloadPdf (page, messageElement, options =
// 预览按钮在消息少时会紧贴 tab 栏,拟人轨迹从别处移过来会经过「已交换微信」等 tab,一点就切到空白。
// 此处直接用 Puppeteer 的 element.click():无移动轨迹,先 scrollIntoView 再点,避免误触 tab。
await previewBtn.evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' }))
await sleepWithRandomDelay(150, 300)
await sleepWithRandomDelay(300, 600)
console.log('[openPreviewAndDownloadPdf] 点击「点击预览附件简历」按钮(原生 click,避免轨迹误触 tab')
await previewBtn.click()
@@ -119,6 +119,9 @@ export const CHAT_PAGE_JOB_ITEM_SELECTOR = '.chat-top-job .ui-dropmenu-list li'
// - CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR:消息里"点击预览附件简历"(对方同意并发来 PDF 后,新消息里会出现)
// - CHAT_PAGE_DOWNLOAD_PDF_BTN_SELECTOR:简历预览弹窗里的"下载 PDF"按钮
/** 沟通页:顶部"全部"筛选 tabspan:nth-child(1) 在 .chat-message-filter-left 内) */
export const CHAT_PAGE_ALL_FILTER_SELECTOR = '.chat-message-filter-left span:nth-child(1)'
/** 沟通页:顶部"未读"筛选 tabspan:nth-child(2) 在 .chat-message-filter-left 内,active 时有 class="active" */
export const CHAT_PAGE_UNREAD_FILTER_SELECTOR = '.chat-message-filter-left span:nth-child(2)'
+32 -9
View File
@@ -141,22 +141,45 @@ async function storeStorage (page) {
*/
async function switchRecommendJobId (page, jobId) {
try {
await page.click(RECOMMEND_JOB_DROPDOWN_LABEL_SELECTOR)
const { createHumanCursor } = await import('./humanMouse.mjs')
const cursor = await createHumanCursor(page)
// 用拟人轨迹点击下拉触发按钮
const dropdownBtn = await page.$(RECOMMEND_JOB_DROPDOWN_LABEL_SELECTOR)
if (dropdownBtn) {
const box = await dropdownBtn.boundingBox().catch(() => null)
if (box) {
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
} else {
await dropdownBtn.click()
}
} else {
await page.click(RECOMMEND_JOB_DROPDOWN_LABEL_SELECTOR)
}
await page.waitForSelector(RECOMMEND_JOB_ITEM_SELECTOR, { timeout: 5000 })
const found = await page.evaluate((jid) => {
const item = document.querySelector(`#headerWrap ul.job-list li.job-item[value="${jid}"]`)
if (!item) return false
item.click()
return true
}, jobId)
await sleepWithRandomDelay(150, 300)
// 用拟人轨迹点击目标职位项
const items = await page.$$(RECOMMEND_JOB_ITEM_SELECTOR)
let found = false
for (const item of items) {
const val = await item.evaluate(el => el.getAttribute('value')).catch(() => null)
if (val === jobId) {
const itemBox = await item.boundingBox().catch(() => null)
if (itemBox) {
await cursor.click({ x: itemBox.x + itemBox.width / 2, y: itemBox.y + itemBox.height / 2 })
} else {
await item.click()
}
found = true
break
}
}
if (!found) {
logWarn(`[boss-auto-browse] 职位 ${jobId} 未在下拉列表中找到,将使用默认职位继续`)
// 关闭下拉
await page.keyboard.press('Escape')
return
}
// 等候选人列表重新加载
await new Promise(r => setTimeout(r, 500))
await sleepWithRandomDelay(400, 800)
logInfo(`[boss-auto-browse] 已切换到职位 ${jobId}`)
} catch (e) {
logWarn(`[boss-auto-browse] 切换推荐页职位失败(${e.message}),将使用默认职位继续`)
@@ -1,398 +1,463 @@
import { app, dialog } from 'electron'
import { AsyncSeriesHook, AsyncSeriesWaterfallHook } from 'tapable'
import { sleep } from '@geekgeekrun/utils/sleep.mjs'
import { AUTO_CHAT_ERROR_EXIT_CODE } from '../../../common/enums/auto-start-chat'
import attachListenerForKillSelfOnParentExited from '../../utils/attachListenerForKillSelfOnParentExited'
import minimist from 'minimist'
import SqlitePluginModule from '@geekgeekrun/sqlite-plugin'
import { connectToDaemon, sendToDaemon } from '../OPEN_SETTING_WINDOW/connect-to-daemon'
import { checkShouldExit } from '../../utils/worker'
import initPublicIpc from '../../utils/initPublicIpc'
import { forwardConsoleLogToDaemon } from '../../utils/forwardConsoleLogToDaemon'
import { getLastUsedAndAvailableBrowser } from '../DOWNLOAD_DEPENDENCIES/utils/browser-history'
import path from 'path'
const { default: SqlitePlugin } = SqlitePluginModule
process.on('SIGTERM', () => {
console.log('收到SIGTERM信号,正在退出')
process.exit(0)
})
const rerunInterval = (() => {
let v = Number(process.env.MAIN_BOSSGEEKGO_RERUN_INTERVAL)
if (isNaN(v)) {
v = 3000
}
return v
})()
const initPlugins = async (hooks) => {
const { storageFilePath } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
new SqlitePlugin(path.join(storageFilePath, 'public.db')).apply(hooks)
}
const runRecordId = minimist(process.argv.slice(2))['run-record-id'] ?? null
const log = (msg: string) => {
console.log(`[boss-worker] ${msg}`)
}
const runAutoBrowseAndChat = async () => {
app.dock?.hide()
log('runAutoBrowseAndChat 开始')
log(`正在查找可用浏览器...`)
let puppeteerExecutable = await getLastUsedAndAvailableBrowser()
if (!puppeteerExecutable) {
log('未找到可用浏览器,退出')
await dialog.showMessageBox({
type: `error`,
message: `未找到可用的浏览器`,
detail: `请重新运行本程序,按照提示安装、配置浏览器`
})
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: {
id: 'puppeteer-executable-check',
status: 'rejected'
},
runRecordId
}
})
app.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE)
return
}
log(`找到浏览器: ${puppeteerExecutable.executablePath}`)
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: {
id: 'puppeteer-executable-check',
status: 'fulfilled'
},
runRecordId
}
})
process.env.PUPPETEER_EXECUTABLE_PATH = puppeteerExecutable.executablePath
log('正在动态 import boss package...')
type BossAutoBrowseModule = {
default: (hooks: any, opts?: { returnBrowser?: boolean; jobId?: string; browser?: any; page?: any }) => Promise<void | { browser: any; page: any }>
startBossChatPageProcess: (hooks: any, options?: { browser?: any; page?: any; jobId?: string }) => Promise<void>
initPuppeteer: () => Promise<any>
launchBrowserAndNavigateToChat?: () => Promise<{ browser: any; page: any }>
bossAutoBrowseEventBus: InstanceType<typeof import('node:events').EventEmitter>
}
const {
default: startBossAutoBrowse,
startBossChatPageProcess,
initPuppeteer,
launchBrowserAndNavigateToChat,
bossAutoBrowseEventBus
} = (await import('@geekgeekrun/boss-auto-browse-and-chat/index.mjs')) as unknown as BossAutoBrowseModule
log('boss package import 完成,初始化 puppeteer...')
process.on('disconnect', () => {
app.exit()
})
await initPuppeteer()
log('puppeteer 初始化完成,初始化 hooks 和插件...')
const hooks = {
beforeBrowserLaunch: new AsyncSeriesHook(['_']),
afterBrowserLaunch: new AsyncSeriesHook(['_']),
beforeNavigateToRecommend: new AsyncSeriesHook(['_']),
onCandidateListLoaded: new AsyncSeriesHook(['_']),
onCandidateFiltered: new AsyncSeriesWaterfallHook(['candidates', 'filterResult'] as any),
beforeStartChat: new AsyncSeriesHook(['candidate']),
afterChatStarted: new AsyncSeriesHook(['candidate', 'result'] as any),
onError: new AsyncSeriesHook(['error']),
onComplete: new AsyncSeriesHook(['_']),
onProgress: new AsyncSeriesHook(['payload'] as any)
}
await initPlugins(hooks)
log('插件初始化完成,即将启动浏览器...')
hooks.beforeBrowserLaunch.tapPromise('log', async () => { log('beforeBrowserLaunch') })
hooks.afterBrowserLaunch.tapPromise('log', async () => { log('afterBrowserLaunch - 浏览器已启动') })
hooks.beforeNavigateToRecommend.tapPromise('log', async () => { log('beforeNavigateToRecommend - 正在导航到推荐页') })
bossAutoBrowseEventBus.once('LOGIN_STATUS_INVALID', () => {})
hooks.onCandidateListLoaded.tap('sendLoginStatusCheck', () => {
log('onCandidateListLoaded - 登录成功,候选人列表已加载')
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: {
id: 'login-status-check',
status: 'fulfilled'
},
runRecordId
}
})
})
// Accumulate candidate results for webhook reporting
const sessionCandidates: Array<{
basicInfo?: Record<string, unknown>
filterReport?: Record<string, unknown>
llmConclusion?: string
resumeFile?: { path?: string; filename?: string }
}> = []
hooks.afterChatStarted.tapPromise('collectCandidateForWebhook', async (candidate: unknown) => {
const c = candidate as Record<string, unknown>
const entry = {
basicInfo: c?.info as Record<string, unknown> | undefined,
filterReport: {
matched: true,
matchedRules: (c?.matchedRules as string[] | undefined) ?? [],
score: c?.score as number | undefined
},
llmConclusion: c?.llmConclusion as string | undefined,
resumeFile: c?.resumeFilePath
? { path: c.resumeFilePath as string, filename: c?.resumeFileName as string | undefined }
: undefined
}
sessionCandidates.push(entry)
// 逐条实时触发:每打招呼后立即发送一条 webhook
try {
const { readConfigFile: readBossConfigFile, storageFilePath } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const webhookConfig = readBossConfigFile('webhook.json')
if (webhookConfig?.enabled && webhookConfig?.url && webhookConfig?.sendMode === 'realtime') {
const { sendWebhook, normalizeWebhookConfig } = await import('../../features/webhook/index')
const normalized = normalizeWebhookConfig(webhookConfig)
if (normalized?.sendMode === 'realtime') {
const runId = `run-${runRecordId ?? Date.now()}`
const timestamp = new Date().toISOString()
const webhookPayload = {
runId,
timestamp,
summary: { total: 1, matched: 1, skipped: 0 },
candidates: [entry]
}
log(`webhook 实时发送 1 条候选人...`)
await sendWebhook(normalized, webhookPayload, { storageDir: storageFilePath })
log(`webhook 实时发送完成`)
}
}
} catch (realtimeErr) {
log(
`webhook 实时发送失败(不影响主流程):${realtimeErr instanceof Error ? realtimeErr.message : String(realtimeErr)}`
)
}
})
hooks.onProgress.tap('sendProgressToGui', (payload: unknown) => {
const p = payload as { phase?: string; current?: number; max?: number }
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'boss-auto-browse-progress',
workerId: 'bossAutoBrowseAndChatMain',
runRecordId,
phase: p?.phase,
current: p?.current ?? 0,
max: p?.max ?? 0
}
})
})
while (true) {
try {
const { readBossJobsConfig, getMergedJobConfig } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const jobsConfig = readBossJobsConfig()
const sequenceJobs = (jobsConfig.jobs || []).filter(
(j: any) => j.sequence?.enabled === true
)
if (sequenceJobs.length > 0) {
log(`检测到多职位队列,共 ${sequenceJobs.length} 个职位,依次执行...`)
let sharedBrowser: any = null
let sharedPage: any = null
try {
for (const job of sequenceJobs) {
const jid = job.jobId ?? job.id
const jname = job.jobName ?? job.name
log(`开始执行职位 ${jid}${jname}...`)
void getMergedJobConfig(jid)
const runRecommend = job.sequence?.runRecommend !== false
const runChat = job.sequence?.runChat !== false
if (runChat && !sharedPage) {
log(`[${jid}] 仅沟通页,先启动浏览器...`)
const boot = await launchBrowserAndNavigateToChat()
sharedBrowser = boot.browser
sharedPage = boot.page
}
if (runRecommend) {
log(`[${jid}] 执行推荐页...`)
const result = await startBossAutoBrowse(hooks, {
returnBrowser: true,
jobId: jid,
browser: sharedBrowser ?? undefined,
page: sharedPage ?? undefined
} as any)
if (result?.browser) {
sharedBrowser = result.browser
sharedPage = result.page
}
}
if (runChat && sharedBrowser && sharedPage) {
log(`[${jid}] 执行沟通页...`)
await startBossChatPageProcess(hooks, {
browser: sharedBrowser,
page: sharedPage,
jobId: jid
})
}
}
} finally {
if (sharedBrowser) {
try {
await sharedBrowser.close()
} catch (e) {
void e
}
sharedBrowser = null
sharedPage = null
}
}
} else {
log('开始执行 startBossAutoBrowse(推荐页)...')
const result = await startBossAutoBrowse(hooks, { returnBrowser: true })
if (result?.browser && result?.page) {
try {
log('推荐页完成,开始处理沟通页未读...')
await startBossChatPageProcess(hooks, { browser: result.browser, page: result.page })
} finally {
try {
await result.browser.close()
} catch (e) {
void e
}
}
}
}
log('startBossAutoBrowse + 沟通页 完成,检查 webhook 配置...')
try {
const { readConfigFile: readBossConfigFile, storageFilePath } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const webhookConfig = readBossConfigFile('webhook.json')
if (!webhookConfig?.enabled || !webhookConfig?.url) {
log('webhook 未启用或未配置 URL,跳过发送')
} else if (webhookConfig.sendMode === 'realtime') {
log('webhook 为实时模式,已在每条打招呼后发送,跳过汇总发送')
} else if (sessionCandidates.length === 0) {
log('本轮无候选人数据,跳过 webhook')
} else {
const { sendWebhook } = await import('../../features/webhook/index')
const matched = sessionCandidates.filter((c) => c.filterReport?.matched !== false).length
const skipped = sessionCandidates.length - matched
const webhookPayload = {
runId: `run-${runRecordId ?? Date.now()}`,
timestamp: new Date().toISOString(),
summary: { total: sessionCandidates.length, matched, skipped },
candidates: sessionCandidates as Parameters<typeof sendWebhook>[1]['candidates']
}
log(`正在发送 webhook,共 ${sessionCandidates.length} 条候选人数据...`)
const webhookResult = await sendWebhook(webhookConfig, webhookPayload, {
storageDir: storageFilePath
})
log(`webhook 发送完成,HTTP ${webhookResult.status}body 长度 ${webhookResult.body.length}`)
}
} catch (webhookErr) {
log(`webhook 发送失败(不影响主流程):${webhookErr instanceof Error ? webhookErr.message : String(webhookErr)}`)
}
sessionCandidates.length = 0
log('等待下次运行...')
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('LOGIN_STATUS_INVALID')) {
await dialog.showMessageBox({
type: `error`,
message: `登录状态无效`,
detail: `请重新登录BOSS直聘(招聘端)`
})
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.LOGIN_STATUS_INVALID)
break
}
if (err.message.includes('ERR_INTERNET_DISCONNECTED')) {
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ERR_INTERNET_DISCONNECTED)
break
}
if (err.message.includes('ACCESS_IS_DENIED')) {
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ACCESS_IS_DENIED)
break
}
if (
err.message.includes(`Could not find Chrome`) ||
err.message.includes(`no executable was found`)
) {
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE)
break
}
}
console.error(err)
const shouldExit = await checkShouldExit()
if (shouldExit) {
app.exit()
return
}
console.log(
`[Boss Auto Browse Main] An internal error is caught, and browser will be restarted in ${rerunInterval}ms.`
)
await sleep(rerunInterval)
}
}
}
export const waitForProcessHandShakeAndRunAutoChat = async () => {
await app.whenReady()
app.on('window-all-closed', () => {
// keep process alive while worker is running
})
initPublicIpc()
await connectToDaemon()
forwardConsoleLogToDaemon('bossAutoBrowseAndChatMain', runRecordId)
await sendToDaemon(
{
type: 'ping'
},
{
needCallback: true
}
)
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: {
id: 'worker-launch',
status: 'fulfilled'
},
runRecordId
}
})
runAutoBrowseAndChat()
}
attachListenerForKillSelfOnParentExited()
import { app, dialog } from 'electron'
import { AsyncSeriesHook, AsyncSeriesWaterfallHook } from 'tapable'
import { sleep } from '@geekgeekrun/utils/sleep.mjs'
import { AUTO_CHAT_ERROR_EXIT_CODE } from '../../../common/enums/auto-start-chat'
import attachListenerForKillSelfOnParentExited from '../../utils/attachListenerForKillSelfOnParentExited'
import minimist from 'minimist'
import SqlitePluginModule from '@geekgeekrun/sqlite-plugin'
import { connectToDaemon, sendToDaemon } from '../OPEN_SETTING_WINDOW/connect-to-daemon'
import { checkShouldExit } from '../../utils/worker'
import initPublicIpc from '../../utils/initPublicIpc'
import { forwardConsoleLogToDaemon } from '../../utils/forwardConsoleLogToDaemon'
import { getLastUsedAndAvailableBrowser } from '../DOWNLOAD_DEPENDENCIES/utils/browser-history'
import path from 'path'
const { default: SqlitePlugin } = SqlitePluginModule
process.on('SIGTERM', () => {
console.log('收到SIGTERM信号,正在退出')
process.exit(0)
})
const rerunInterval = (() => {
let v = Number(process.env.MAIN_BOSSGEEKGO_RERUN_INTERVAL)
if (isNaN(v)) {
v = 3000
}
return v
})()
const initPlugins = async (hooks) => {
const { storageFilePath } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
new SqlitePlugin(path.join(storageFilePath, 'public.db')).apply(hooks)
}
const runRecordId = minimist(process.argv.slice(2))['run-record-id'] ?? null
const log = (msg: string) => {
console.log(`[boss-worker] ${msg}`)
}
const checkForBossVerification = async (page: any): Promise<boolean> => {
try {
const url: string = page.url()
if (/verify|captcha|security.?check|safe\b|\/safe\/|安全验证/.test(url)) return true
return await page.evaluate(() => {
const hasVerifyText = /请完成.{0,10}验证|安全验证|滑动.{0,6}滑块|人机验证|完成验证后继续|异常.{0,6}操作|验证码/.test(
document.body?.innerText || ''
)
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"]')
)
return hasVerifyText || hasVerifyEl
})
} catch {
return false
}
}
const waitForBossVerificationCompletion = async (page: any, expectedUrlPrefix: string): Promise<boolean> => {
log('⚠️ 检测到 BOSS 安全验证,请在浏览器窗口中手动完成验证,完成后将自动继续...')
try {
const { Notification } = await import('electron')
new Notification({
title: 'GeekGeekRun - 需要人工验证',
body: '检测到 BOSS 直聘安全验证,请在打开的浏览器窗口中完成验证,完成后程序将自动继续。'
}).show()
} catch { /* Notification 不可用时静默忽略 */ }
const deadline = Date.now() + 5 * 60 * 1000
while (Date.now() < deadline) {
await sleep(2000)
try {
const url: string = page.url()
const isStillVerify = await checkForBossVerification(page)
if (url.startsWith(expectedUrlPrefix) && !isStillVerify) {
log('✅ 安全验证已完成,继续处理...')
return true
}
} catch { /* 页面可能正在跳转,继续等待 */ }
}
log('验证等待超时(5 分钟),将重启浏览器重试')
return false
}
const runAutoBrowseAndChat = async () => {
app.dock?.hide()
log('runAutoBrowseAndChat 开始')
log(`正在查找可用浏览器...`)
let puppeteerExecutable = await getLastUsedAndAvailableBrowser()
if (!puppeteerExecutable) {
log('未找到可用浏览器,退出')
await dialog.showMessageBox({
type: `error`,
message: `未找到可用的浏览器`,
detail: `请重新运行本程序,按照提示安装、配置浏览器`
})
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: {
id: 'puppeteer-executable-check',
status: 'rejected'
},
runRecordId
}
})
app.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE)
return
}
log(`找到浏览器: ${puppeteerExecutable.executablePath}`)
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: {
id: 'puppeteer-executable-check',
status: 'fulfilled'
},
runRecordId
}
})
process.env.PUPPETEER_EXECUTABLE_PATH = puppeteerExecutable.executablePath
log('正在动态 import boss package...')
type BossAutoBrowseModule = {
default: (hooks: any, opts?: { returnBrowser?: boolean; jobId?: string; browser?: any; page?: any }) => Promise<void | { browser: any; page: any }>
startBossChatPageProcess: (hooks: any, options?: { browser?: any; page?: any; jobId?: string }) => Promise<void>
initPuppeteer: () => Promise<any>
launchBrowserAndNavigateToChat?: () => Promise<{ browser: any; page: any }>
bossAutoBrowseEventBus: InstanceType<typeof import('node:events').EventEmitter>
}
const {
default: startBossAutoBrowse,
startBossChatPageProcess,
initPuppeteer,
launchBrowserAndNavigateToChat,
bossAutoBrowseEventBus
} = (await import('@geekgeekrun/boss-auto-browse-and-chat/index.mjs')) as unknown as BossAutoBrowseModule
log('boss package import 完成,初始化 puppeteer...')
process.on('disconnect', () => {
app.exit()
})
await initPuppeteer()
log('puppeteer 初始化完成,初始化 hooks 和插件...')
const hooks = {
beforeBrowserLaunch: new AsyncSeriesHook(['_']),
afterBrowserLaunch: new AsyncSeriesHook(['_']),
beforeNavigateToRecommend: new AsyncSeriesHook(['_']),
onCandidateListLoaded: new AsyncSeriesHook(['_']),
onCandidateFiltered: new AsyncSeriesWaterfallHook(['candidates', 'filterResult'] as any),
beforeStartChat: new AsyncSeriesHook(['candidate']),
afterChatStarted: new AsyncSeriesHook(['candidate', 'result'] as any),
onError: new AsyncSeriesHook(['error']),
onComplete: new AsyncSeriesHook(['_']),
onProgress: new AsyncSeriesHook(['payload'] as any)
}
await initPlugins(hooks)
log('插件初始化完成,即将启动浏览器...')
hooks.beforeBrowserLaunch.tapPromise('log', async () => { log('beforeBrowserLaunch') })
hooks.afterBrowserLaunch.tapPromise('log', async () => { log('afterBrowserLaunch - 浏览器已启动') })
hooks.beforeNavigateToRecommend.tapPromise('log', async () => { log('beforeNavigateToRecommend - 正在导航到推荐页') })
bossAutoBrowseEventBus.once('LOGIN_STATUS_INVALID', () => {})
hooks.onCandidateListLoaded.tap('sendLoginStatusCheck', () => {
log('onCandidateListLoaded - 登录成功,候选人列表已加载')
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: {
id: 'login-status-check',
status: 'fulfilled'
},
runRecordId
}
})
})
// Accumulate candidate results for webhook reporting
const sessionCandidates: Array<{
basicInfo?: Record<string, unknown>
filterReport?: Record<string, unknown>
llmConclusion?: string
resumeFile?: { path?: string; filename?: string }
}> = []
hooks.afterChatStarted.tapPromise('collectCandidateForWebhook', async (candidate: unknown) => {
const c = candidate as Record<string, unknown>
const entry = {
basicInfo: c?.info as Record<string, unknown> | undefined,
filterReport: {
matched: true,
matchedRules: (c?.matchedRules as string[] | undefined) ?? [],
score: c?.score as number | undefined
},
llmConclusion: c?.llmConclusion as string | undefined,
resumeFile: c?.resumeFilePath
? { path: c.resumeFilePath as string, filename: c?.resumeFileName as string | undefined }
: undefined
}
sessionCandidates.push(entry)
// 逐条实时触发:每打招呼后立即发送一条 webhook
try {
const { readConfigFile: readBossConfigFile, storageFilePath } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const webhookConfig = readBossConfigFile('webhook.json')
if (webhookConfig?.enabled && webhookConfig?.url && webhookConfig?.sendMode === 'realtime') {
const { sendWebhook, normalizeWebhookConfig } = await import('../../features/webhook/index')
const normalized = normalizeWebhookConfig(webhookConfig)
if (normalized?.sendMode === 'realtime') {
const runId = `run-${runRecordId ?? Date.now()}`
const timestamp = new Date().toISOString()
const webhookPayload = {
runId,
timestamp,
summary: { total: 1, matched: 1, skipped: 0 },
candidates: [entry]
}
log(`webhook 实时发送 1 条候选人...`)
await sendWebhook(normalized, webhookPayload, { storageDir: storageFilePath })
log(`webhook 实时发送完成`)
}
}
} catch (realtimeErr) {
log(
`webhook 实时发送失败(不影响主流程):${realtimeErr instanceof Error ? realtimeErr.message : String(realtimeErr)}`
)
}
})
hooks.onProgress.tap('sendProgressToGui', (payload: unknown) => {
const p = payload as { phase?: string; current?: number; max?: number }
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'boss-auto-browse-progress',
workerId: 'bossAutoBrowseAndChatMain',
runRecordId,
phase: p?.phase,
current: p?.current ?? 0,
max: p?.max ?? 0
}
})
})
while (true) {
try {
const { readBossJobsConfig, getMergedJobConfig } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const jobsConfig = readBossJobsConfig()
const sequenceJobs = (jobsConfig.jobs || []).filter(
(j: any) => j.sequence?.enabled === true
)
if (sequenceJobs.length > 0) {
log(`检测到多职位队列,共 ${sequenceJobs.length} 个职位,依次执行...`)
let sharedBrowser: any = null
let sharedPage: any = null
try {
for (const job of sequenceJobs) {
const jid = job.jobId ?? job.id
const jname = job.jobName ?? job.name
log(`开始执行职位 ${jid}${jname}...`)
void getMergedJobConfig(jid)
const runRecommend = job.sequence?.runRecommend !== false
const runChat = job.sequence?.runChat !== false
if (runChat && !sharedPage) {
log(`[${jid}] 仅沟通页,先启动浏览器...`)
const boot = await launchBrowserAndNavigateToChat()
sharedBrowser = boot.browser
sharedPage = boot.page
}
if (runRecommend) {
log(`[${jid}] 执行推荐页...`)
const result = await startBossAutoBrowse(hooks, {
returnBrowser: true,
jobId: jid,
browser: sharedBrowser ?? undefined,
page: sharedPage ?? undefined
} as any)
if (result?.browser) {
sharedBrowser = result.browser
sharedPage = result.page
}
}
if (runChat && sharedBrowser && sharedPage) {
log(`[${jid}] 执行沟通页...`)
await startBossChatPageProcess(hooks, {
browser: sharedBrowser,
page: sharedPage,
jobId: jid
})
}
}
} finally {
if (sharedBrowser) {
try {
await sharedBrowser.close()
} catch (e) {
void e
}
sharedBrowser = null
sharedPage = null
}
}
} else {
log('开始执行 startBossAutoBrowse(推荐页)...')
const result = await startBossAutoBrowse(hooks, { returnBrowser: true })
if (result?.browser && result?.page) {
try {
log('推荐页完成,开始处理沟通页未读...')
await startBossChatPageProcess(hooks, { browser: result.browser, page: result.page })
} finally {
try {
await result.browser.close()
} catch (e) {
void e
}
}
}
}
log('startBossAutoBrowse + 沟通页 完成,检查 webhook 配置...')
try {
const { readConfigFile: readBossConfigFile, storageFilePath } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const webhookConfig = readBossConfigFile('webhook.json')
if (!webhookConfig?.enabled || !webhookConfig?.url) {
log('webhook 未启用或未配置 URL,跳过发送')
} else if (webhookConfig.sendMode === 'realtime') {
log('webhook 为实时模式,已在每条打招呼后发送,跳过汇总发送')
} else if (sessionCandidates.length === 0) {
log('本轮无候选人数据,跳过 webhook')
} else {
const { sendWebhook } = await import('../../features/webhook/index')
const matched = sessionCandidates.filter((c) => c.filterReport?.matched !== false).length
const skipped = sessionCandidates.length - matched
const webhookPayload = {
runId: `run-${runRecordId ?? Date.now()}`,
timestamp: new Date().toISOString(),
summary: { total: sessionCandidates.length, matched, skipped },
candidates: sessionCandidates as Parameters<typeof sendWebhook>[1]['candidates']
}
log(`正在发送 webhook,共 ${sessionCandidates.length} 条候选人数据...`)
const webhookResult = await sendWebhook(webhookConfig, webhookPayload, {
storageDir: storageFilePath
})
log(`webhook 发送完成,HTTP ${webhookResult.status}body 长度 ${webhookResult.body.length}`)
}
} catch (webhookErr) {
log(`webhook 发送失败(不影响主流程):${webhookErr instanceof Error ? webhookErr.message : String(webhookErr)}`)
}
sessionCandidates.length = 0
log('等待下次运行...')
} catch (err) {
// ── 检测是否为安全验证触发的超时,若是则发送 OS 通知提醒用户 ──
// (推荐页流程浏览器由内部管理,验证后浏览器会重启;此处仅通知用户需要手动完成验证)
try {
const errMsg = err instanceof Error ? err.message : String(err)
if (/TimeoutError|timeout|waitForSelector|waitForFunction/i.test(errMsg)) {
log('检测到超时类错误,可能是 BOSS 安全验证导致。若浏览器窗口有验证提示,请手动完成,程序将在下一轮自动重启。')
try {
const { Notification } = await import('electron')
new Notification({
title: 'GeekGeekRun - 可能需要人工验证',
body: 'BOSS 直聘可能弹出了安全验证。请检查浏览器窗口,完成验证后程序将在下一轮自动重启继续。'
}).show()
} catch { /* Notification 不可用时静默忽略 */ }
}
} catch { /* 不影响主流程 */ }
if (err instanceof Error) {
if (err.message.includes('LOGIN_STATUS_INVALID')) {
await dialog.showMessageBox({
type: `error`,
message: `登录状态无效`,
detail: `请重新登录BOSS直聘(招聘端)`
})
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.LOGIN_STATUS_INVALID)
break
}
if (err.message.includes('ERR_INTERNET_DISCONNECTED')) {
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ERR_INTERNET_DISCONNECTED)
break
}
if (err.message.includes('ACCESS_IS_DENIED')) {
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ACCESS_IS_DENIED)
break
}
if (
err.message.includes(`Could not find Chrome`) ||
err.message.includes(`no executable was found`)
) {
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE)
break
}
}
console.error(err)
const shouldExit = await checkShouldExit()
if (shouldExit) {
app.exit()
return
}
console.log(
`[Boss Auto Browse Main] An internal error is caught, and browser will be restarted in ${rerunInterval}ms.`
)
await sleep(rerunInterval)
}
}
}
export const waitForProcessHandShakeAndRunAutoChat = async () => {
await app.whenReady()
app.on('window-all-closed', () => {
// keep process alive while worker is running
})
initPublicIpc()
await connectToDaemon()
forwardConsoleLogToDaemon('bossAutoBrowseAndChatMain', runRecordId)
await sendToDaemon(
{
type: 'ping'
},
{
needCallback: true
}
)
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: {
id: 'worker-launch',
status: 'fulfilled'
},
runRecordId
}
})
runAutoBrowseAndChat()
}
attachListenerForKillSelfOnParentExited()
@@ -39,6 +39,64 @@ const log = (msg: string) => {
console.log(`[boss-chat-page-worker] ${msg}`)
}
/**
* 检测当前页面是否为 BOSS 安全验证页(URL 特征 + 页面文字 + 常见验证组件选择器)。
* 没有具体截图样本,使用多重信号:命中任意一条即判定为验证页。
*/
const checkForBossVerification = async (page: any): Promise<boolean> => {
try {
const url: string = page.url()
if (/verify|captcha|security.?check|safe\b|\/safe\/|安全验证/.test(url)) return true
return await page.evaluate(() => {
const text = (document.body?.innerText || '').toLowerCase()
const hasVerifyText = /请完成.{0,10}验证|安全验证|滑动.{0,6}滑块|人机验证|完成验证后继续|异常.{0,6}操作|验证码/.test(
document.body?.innerText || ''
)
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"]')
)
return hasVerifyText || hasVerifyEl
})
} catch {
return false
}
}
/**
* 等待用户完成验证(最长 5 分钟)。
* 期间每 2s 轮询页面状态;完成后返回 true,超时返回 false。
*/
const waitForBossVerificationCompletion = async (page: any, expectedUrlPrefix: string, logFn: (msg: string) => void): Promise<boolean> => {
logFn('⚠️ 检测到 BOSS 安全验证,请在浏览器窗口中手动完成验证,完成后将自动继续...')
try {
const { Notification } = await import('electron')
new Notification({
title: 'GeekGeekRun - 需要人工验证',
body: '检测到 BOSS 直聘安全验证,请在打开的浏览器窗口中完成验证,完成后程序将自动继续。'
}).show()
} catch { /* Notification 不可用时静默忽略 */ }
const deadline = Date.now() + 5 * 60 * 1000
while (Date.now() < deadline) {
await sleep(2000)
try {
const url: string = page.url()
const isStillVerify = await checkForBossVerification(page)
if (url.startsWith(expectedUrlPrefix) && !isStillVerify) {
logFn('✅ 安全验证已完成,继续处理...')
return true
}
} catch { /* 页面可能正在跳转,继续等待 */ }
}
logFn('验证等待超时(5 分钟),将重启浏览器重试')
return false
}
const runChatPage = async () => {
app.dock?.hide()
log('runChatPage 开始')
@@ -81,7 +139,12 @@ const runChatPage = async () => {
log('正在动态 import boss package...')
type BossAutoBrowseModule = {
startBossChatPageProcess: (hooks: any, options?: { browser?: any; page?: any; getCapturedText?: any; clearCapturedText?: any }) => Promise<void>
startBossChatPageProcess: (hooks: any, options?: {
browser?: any; page?: any; getCapturedText?: any; clearCapturedText?: any;
jobId?: string | null;
retryCandidate?: { encryptGeekId: string; geekName: string; jobTitle: string } | null;
processContext?: { currentCandidate: any } | null;
}) => Promise<void>
initPuppeteer: () => Promise<{ puppeteer: any }>
}
const {
@@ -137,8 +200,15 @@ const runChatPage = async () => {
const { setDomainLocalStorage } = await import('@geekgeekrun/utils/puppeteer/local-storage.mjs') as any
const localStoragePageUrl = 'https://www.zhipin.com/desktop/'
// browser/page/canvas hooks 提升到循环外,验证完成后可复用
let browser: any = null
let page: any = null
let getCapturedText: any = null
let clearCapturedText: any = null
// processContext 提升到循环外,catch 块中可读取被中断的候选人
const processContext: { currentCandidate: any } = { currentCandidate: null }
while (true) {
let browser: any = null
try {
const { readConfigFile: readCfg } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
@@ -153,43 +223,77 @@ const runChatPage = async () => {
const runOnceAfterComplete = cfg?.chatPage?.runOnceAfterComplete === true
const keepBrowserOpenAfterRun = cfg?.chatPage?.keepBrowserOpenAfterRun === true
log('启动浏览器...')
await hooks.beforeBrowserLaunch?.promise?.()
// 仅在没有复用浏览器时才重新启动
if (!browser) {
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 headless = process.env.HEADLESS === '1'
browser = await puppeteer.launch({
headless,
ignoreHTTPSErrors: true,
protocolTimeout: 120000,
defaultViewport: { width: 1440, height: 900 - 140 }
})
await hooks.afterBrowserLaunch?.promise?.()
await hooks.afterBrowserLaunch?.promise?.()
const bossCookies = readStorageFile('boss-cookies.json')
const bossLocalStorage = readStorageFile('boss-local-storage.json')
const bossCookies = readStorageFile('boss-cookies.json')
const bossLocalStorage = readStorageFile('boss-local-storage.json')
const page = (await browser.pages())[0]
// 注入 Canvas fillText hook,必须在页面导航前注入(evaluateOnNewDocument
const { getCapturedText, clearCapturedText } = await setupCanvasTextHook(page)
if (Array.isArray(bossCookies) && bossCookies.length > 0) {
await page.setCookie(...bossCookies)
}
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
await page.goto(BOSS_CHAT_PAGE_URL, { timeout: 60 * 1000 })
await page.waitForFunction(() => document.readyState === 'complete', { timeout: 120 * 1000 })
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: { id: 'login-status-check', status: 'fulfilled' },
runRecordId
page = (await browser.pages())[0]
// 注入 Canvas fillText hook,必须在页面导航前注入(evaluateOnNewDocument
const canvasHooks = await setupCanvasTextHook(page)
getCapturedText = canvasHooks.getCapturedText
clearCapturedText = canvasHooks.clearCapturedText
if (Array.isArray(bossCookies) && bossCookies.length > 0) {
await page.setCookie(...bossCookies)
}
})
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
await page.goto(BOSS_CHAT_PAGE_URL, { timeout: 60 * 1000 })
await page.waitForFunction(() => document.readyState === 'complete', { timeout: 120 * 1000 })
log('开始执行 startBossChatPageProcess(沟通页)...')
await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText })
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: { id: 'login-status-check', status: 'fulfilled' },
runRecordId
}
})
} else {
log('复用已有浏览器实例,直接开始处理...')
}
log('读取职位队列配置...')
const { readBossJobsConfig } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
) as any
const jobsConfig = readBossJobsConfig()
const allJobs = jobsConfig?.jobs || []
if (allJobs.length > 0) {
const chatJobs = allJobs.filter(
(j: any) => j.sequence?.enabled === true && j.sequence?.runChat !== false
)
if (chatJobs.length > 0) {
log(`检测到 ${chatJobs.length} 个职位纳入沟通处理,依次执行...`)
for (const job of chatJobs) {
const jid = job.jobId ?? job.id
const jname = job.jobName ?? job.name
log(`开始处理职位 ${jid}${jname})的沟通页...`)
processContext.currentCandidate = null
await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, jobId: jid, processContext })
log(`职位 ${jid} 沟通页处理完成`)
}
} else {
log('当前没有勾选"纳入处理"的职位,跳过本轮沟通页扫描')
}
} else {
log('未配置职位队列,开始执行 startBossChatPageProcess(处理所有未读)...')
processContext.currentCandidate = null
await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, processContext })
}
log('startBossChatPageProcess 完成')
if (runOnceAfterComplete) {
@@ -207,13 +311,60 @@ const runChatPage = async () => {
try { await browser.close() } catch (e) { void e }
browser = null
page = null
getCapturedText = null
clearCapturedText = null
const rerunMs = cfg?.chatPage?.rerunIntervalMs ?? rerunInterval
log(`下次运行将在 ${rerunMs}ms 后开始`)
await sleep(rerunMs)
} catch (err) {
// ── 优先检测安全验证,命中则等待完成后复用浏览器继续,而非重启 ──
if (page) {
try {
const isVerify = await checkForBossVerification(page)
if (isVerify) {
// 保存被中断的候选人,验证完成后通过 retryCandidate 重试
const interruptedCandidate = processContext.currentCandidate ?? null
if (interruptedCandidate) {
log(`⚠️ 验证中断时正在处理候选人:${interruptedCandidate.geekName}${interruptedCandidate.encryptGeekId}),验证后将优先重试`)
}
const completed = await waitForBossVerificationCompletion(page, BOSS_CHAT_PAGE_URL, log)
if (completed) {
// 验证完成:导航回沟通页
try {
await page.goto(BOSS_CHAT_PAGE_URL, { timeout: 60 * 1000 })
await page.waitForFunction(() => document.readyState === 'complete', { timeout: 60 * 1000 })
} catch { /* 导航失败则让下一轮处理 */ }
// 若有被中断的候选人,立即单独重试(不依赖 jobId,在「全部」tab 中找回)
if (interruptedCandidate) {
log(`🔄 正在重试被验证中断的候选人:${interruptedCandidate.geekName}...`)
try {
await startBossChatPageProcess(hooks, {
browser, page, getCapturedText, clearCapturedText,
retryCandidate: interruptedCandidate,
processContext: { currentCandidate: null }
})
log(`重试候选人 ${interruptedCandidate.geekName} 完成`)
} catch (retryErr) {
log(`重试候选人时发生错误:${retryErr instanceof Error ? retryErr.message : String(retryErr)}`)
}
}
continue // 重新进入循环,进行正常扫描
}
}
} catch { /* 检测本身出错,走正常错误处理 */ }
}
// ── 正常错误处理:关闭浏览器、识别错误类型 ──
if (browser) {
try { await browser.close() } catch (e) { void e }
browser = null
page = null
getCapturedText = null
clearCapturedText = null
}
if (err instanceof Error) {
if (err.message.includes('LOGIN_STATUS_INVALID')) {
@@ -7,7 +7,7 @@
</template>
<template v-if="jobsList.length === 0">
<el-alert
title="请先在「推荐牛人-自动开聊」页面同步职位列表"
title="请先在「职位配置」页面同步职位列表"
type="info"
:closable="false"
show-icon
@@ -95,7 +95,7 @@
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, onActivated } from 'vue'
import { ElMessage } from 'element-plus'
import RunningOverlay from '@renderer/features/RunningOverlay/index.vue'
import { RUNNING_STATUS_ENUM } from '../../../../../common/enums/auto-start-chat'
@@ -120,14 +120,17 @@ interface JobSequenceItem {
const jobsList = ref<JobSequenceItem[]>([])
const isSavingQueue = ref(false)
onMounted(async () => {
const loadJobsList = async () => {
try {
const result = await ipcRenderer.invoke('fetch-boss-jobs-config')
jobsList.value = result?.jobs ?? []
} catch (err) {
console.error(err)
}
})
}
onMounted(loadJobsList)
onActivated(loadJobsList)
const handleSaveQueue = async () => {
isSavingQueue.value = true
@@ -2,6 +2,33 @@
<div class="boss-chat-page__wrap">
<div class="main__wrap">
<el-form ref="formRef" :model="formContent" label-position="top">
<el-card class="config-section">
<template #header>
<span>职位沟通队列</span>
</template>
<template v-if="jobsList.length === 0">
<el-alert
title="请先在「职位配置」页面同步职位列表"
type="info"
:closable="false"
show-icon
/>
</template>
<template v-else>
<el-table :data="jobsList" style="width: 100%">
<el-table-column prop="jobName" label="职位名称" />
<el-table-column label="纳入处理" width="100" align="center">
<template #default="{ row }">
<el-checkbox v-model="row.sequence.enabled" />
</template>
</el-table-column>
</el-table>
<div style="margin-top: 8px; font-size: 12px; color: #909399;">
勾选的职位将在处理沟通页时被依次扫描若全部不勾选则不处理任何职位
</div>
</template>
</el-card>
<el-card class="config-section">
<el-form-item mb0>
<div class="section-title">沟通页运行策略</div>
@@ -93,7 +120,7 @@
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, onActivated } from 'vue'
import { ElMessage } from 'element-plus'
import RunningOverlay from '@renderer/features/RunningOverlay/index.vue'
import { RUNNING_STATUS_ENUM } from '../../../../../common/enums/auto-start-chat'
@@ -107,6 +134,15 @@ const runRecordId = ref<number | null>(null)
const runningOverlayRef = ref<InstanceType<typeof RunningOverlay> | null>(null)
const isStopButtonLoading = ref(false)
interface JobSequenceItem {
jobId: string
jobName: string
sequence: { enabled: boolean; runRecommend: boolean; runChat: boolean }
[key: string]: unknown
}
const jobsList = ref<JobSequenceItem[]>([])
const formContent = reactive({
chatPage: {
maxProcessPerRun: 20,
@@ -116,19 +152,26 @@ const formContent = reactive({
}
})
onMounted(async () => {
const loadData = async () => {
try {
const result = await ipcRenderer.invoke('fetch-boss-recruiter-config-file-content')
const recruiterConfig = result?.config?.['boss-recruiter.json'] || {}
const [recruiterResult, jobsResult] = await Promise.all([
ipcRenderer.invoke('fetch-boss-recruiter-config-file-content'),
ipcRenderer.invoke('fetch-boss-jobs-config')
])
const recruiterConfig = recruiterResult?.config?.['boss-recruiter.json'] || {}
const chatPage = recruiterConfig.chatPage ?? {}
formContent.chatPage.maxProcessPerRun = chatPage.maxProcessPerRun ?? 20
formContent.chatPage.runOnceAfterComplete = chatPage.runOnceAfterComplete ?? false
formContent.chatPage.keepBrowserOpenAfterRun = chatPage.keepBrowserOpenAfterRun ?? false
formContent.chatPage.rerunIntervalMs = chatPage.rerunIntervalMs ?? 3000
jobsList.value = jobsResult?.jobs ?? []
} catch (err) {
console.error(err)
}
})
}
onMounted(loadData)
onActivated(loadData)
const doSave = async () => {
const payload = {
@@ -140,6 +183,9 @@ const doSave = async () => {
}
}
await ipcRenderer.invoke('save-boss-recruiter-config', JSON.stringify(payload))
if (jobsList.value.length > 0) {
await ipcRenderer.invoke('save-boss-jobs-config', JSON.stringify({ jobs: jobsList.value }))
}
}
const handleSave = async () => {