mirror of
https://github.com/geekgeekrun/geekgeekrun.git
synced 2026-09-07 00:17:17 +08:00
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:
co-authored by
Claude Sonnet 4.6
parent
95c1e54c66
commit
3fb7089c9e
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user