mirror of
https://github.com/geekgeekrun/geekgeekrun.git
synced 2026-09-08 00:47:15 +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
@@ -25,6 +25,7 @@ import {
|
|||||||
CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR,
|
CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR,
|
||||||
CHAT_PAGE_ITEM_SELECTOR,
|
CHAT_PAGE_ITEM_SELECTOR,
|
||||||
CHAT_PAGE_ITEM_UNREAD_SELECTOR,
|
CHAT_PAGE_ITEM_UNREAD_SELECTOR,
|
||||||
|
CHAT_PAGE_ALL_FILTER_SELECTOR,
|
||||||
CHAT_PAGE_UNREAD_FILTER_SELECTOR,
|
CHAT_PAGE_UNREAD_FILTER_SELECTOR,
|
||||||
CHAT_PAGE_NAME_SELECTOR,
|
CHAT_PAGE_NAME_SELECTOR,
|
||||||
CHAT_PAGE_JOB_SELECTOR,
|
CHAT_PAGE_JOB_SELECTOR,
|
||||||
@@ -41,21 +42,44 @@ const LOG = '[chat-page-processor]'
|
|||||||
*/
|
*/
|
||||||
async function switchChatPageJobId (page, jobId) {
|
async function switchChatPageJobId (page, jobId) {
|
||||||
try {
|
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 })
|
await page.waitForSelector('.ui-dropmenu.chat-top-job .ui-dropmenu-list', { timeout: 5000 })
|
||||||
const found = await page.evaluate((jid) => {
|
await sleepWithRandomDelay(150, 300)
|
||||||
const item = document.querySelector(`.ui-dropmenu.chat-top-job .ui-dropmenu-list li[value="${jid}"]`)
|
// 用拟人轨迹点击目标职位项
|
||||||
if (!item) return false
|
const items = await page.$$('.ui-dropmenu.chat-top-job .ui-dropmenu-list li')
|
||||||
item.click()
|
let found = false
|
||||||
return true
|
for (const item of items) {
|
||||||
}, jobId)
|
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) {
|
if (!found) {
|
||||||
logWarn(`${LOG} 职位 ${jobId} 未在沟通页下拉列表中找到,将使用默认职位继续`)
|
logWarn(`${LOG} 职位 ${jobId} 未在沟通页下拉列表中找到,将使用默认职位继续`)
|
||||||
await page.keyboard.press('Escape')
|
await page.keyboard.press('Escape')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 等待左侧会话列表刷新
|
// 等待左侧会话列表刷新
|
||||||
await new Promise(r => setTimeout(r, 500))
|
await sleepWithRandomDelay(400, 700)
|
||||||
logInfo(`${LOG} 已切换到职位 ${jobId}`)
|
logInfo(`${LOG} 已切换到职位 ${jobId}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logWarn(`${LOG} 切换沟通页职位失败(${e.message}),将使用默认职位继续`)
|
logWarn(`${LOG} 切换沟通页职位失败(${e.message}),将使用默认职位继续`)
|
||||||
@@ -277,11 +301,29 @@ async function waitForGeekInfo (peekFn, opts = {}) {
|
|||||||
/**
|
/**
|
||||||
* 沟通页自动化主入口。
|
* 沟通页自动化主入口。
|
||||||
* @param {object} hooksFromCaller - 与 startBossAutoBrowse 相同的 hooks(onError, insertCandidateContactLog, createOrUpdateCandidateInfo, queryCandidateByEncryptId 等)
|
* @param {object} hooksFromCaller - 与 startBossAutoBrowse 相同的 hooks(onError, 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 = {}) {
|
export default async function startBossChatPageProcess (hooksFromCaller, options = {}) {
|
||||||
const hooks = hooksFromCaller || {}
|
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} */
|
/** @type {import('puppeteer').Page} */
|
||||||
let page = existingPage
|
let page = existingPage
|
||||||
@@ -344,60 +386,54 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
|||||||
await switchChatPageJobId(page, jobId)
|
await switchChatPageJobId(page, jobId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换到"未读"tab,确保虚拟滚动列表只包含未读会话(避免全部模式下未读项在视口外未渲染)
|
|
||||||
const cursor = await createHumanCursor(page)
|
const cursor = await createHumanCursor(page)
|
||||||
const unreadTabActive = await page.evaluate((sel) => {
|
|
||||||
const el = document.querySelector(sel)
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
return el ? el.classList.contains('active') : false
|
// 内部辅助:切换到指定 tab(封闭 page、cursor)
|
||||||
}, CHAT_PAGE_UNREAD_FILTER_SELECTOR)
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
if (!unreadTabActive) {
|
const switchToTab = async (selector, tabName) => {
|
||||||
logInfo(`${LOG} 切换到「未读」tab...`)
|
const isActive = await page.evaluate(
|
||||||
const tabEl = await page.$(CHAT_PAGE_UNREAD_FILTER_SELECTOR)
|
(sel) => document.querySelector(sel)?.classList.contains('active') ?? false,
|
||||||
if (tabEl) {
|
selector
|
||||||
const box = await tabEl.boundingBox()
|
)
|
||||||
if (box) {
|
if (isActive) {
|
||||||
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
|
logDebug(`${LOG} 已在「${tabName}」tab`)
|
||||||
await sleepWithRandomDelay(400, 600)
|
return
|
||||||
// 等列表刷新
|
}
|
||||||
try {
|
logInfo(`${LOG} 切换到「${tabName}」tab...`)
|
||||||
await page.waitForSelector(CHAT_PAGE_ITEM_SELECTOR, { timeout: 5000 })
|
const tabEl = await page.$(selector)
|
||||||
logDebug(`${LOG} 「未读」tab 切换后列表已刷新`)
|
if (!tabEl) {
|
||||||
} catch {
|
logWarn(`${LOG} 未找到「${tabName}」tab 元素(selector: ${selector})`)
|
||||||
logDebug(`${LOG} 「未读」tab 切换后列表为空(无未读会话)`)
|
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
|
// 返回 { processed: boolean, skipped: boolean }
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
for (let i = 0; i < toProcess.length; i++) {
|
const processOneCandidateConversation = async (item) => {
|
||||||
const item = toProcess[i]
|
|
||||||
const { encryptGeekId, geekName, jobTitle } = 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)
|
const { contacted } = await checkIfAlreadyContacted(encryptGeekId, hooks)
|
||||||
if (contacted) {
|
if (contacted) {
|
||||||
logInfo(`${LOG} → 已在数据库中联系过,跳过`)
|
logInfo(`${LOG} → 已在数据库中联系过,跳过`)
|
||||||
continue
|
if (processContext) processContext.currentCandidate = null
|
||||||
|
return { processed: false, skipped: true }
|
||||||
}
|
}
|
||||||
logDebug(`${LOG} → 数据库未记录,继续处理`)
|
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)
|
const resumeCloseBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null)
|
||||||
if (resumeCloseBtn) {
|
if (resumeCloseBtn) {
|
||||||
logDebug(`${LOG} → 检测到在线简历弹窗未关闭,点击关闭...`)
|
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 {
|
try {
|
||||||
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 4000 })
|
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 4000 })
|
||||||
logDebug(`${LOG} → 在线简历弹窗已关闭`)
|
logDebug(`${LOG} → 在线简历弹窗已关闭`)
|
||||||
} catch {
|
} catch {
|
||||||
// 仍未消失则强制刷新检查
|
|
||||||
const stillOpen = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null)
|
const stillOpen = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null)
|
||||||
if (stillOpen) {
|
if (stillOpen) {
|
||||||
logWarn(`${LOG} → 在线简历弹窗关闭失败(4s 超时),继续尝试切换会话,但可能影响会话切换成功率`)
|
logWarn(`${LOG} → 在线简历弹窗关闭失败(4s 超时),继续尝试切换会话,但可能影响会话切换成功率`)
|
||||||
@@ -428,7 +468,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
|||||||
const selected = await selectConversationById(page, encryptGeekId, { cursor })
|
const selected = await selectConversationById(page, encryptGeekId, { cursor })
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
logWarn(`${LOG} → 无法在 DOM 中找到该会话(可能已被标为已读或滚出虚拟滚动视口),跳过`)
|
logWarn(`${LOG} → 无法在 DOM 中找到该会话(可能已被标为已读或滚出虚拟滚动视口),跳过`)
|
||||||
continue
|
if (processContext) processContext.currentCandidate = null
|
||||||
|
return { processed: false, skipped: true }
|
||||||
}
|
}
|
||||||
logInfo(`${LOG} → 会话已选中,等待页面加载...`)
|
logInfo(`${LOG} → 会话已选中,等待页面加载...`)
|
||||||
await sleepWithRandomDelay(600, 1200)
|
await sleepWithRandomDelay(600, 1200)
|
||||||
@@ -439,7 +480,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
|||||||
if (panelName && !geekName.includes(panelName) && !panelName.includes(geekName)) {
|
if (panelName && !geekName.includes(panelName) && !panelName.includes(geekName)) {
|
||||||
logWarn(`${LOG} → 右侧面板姓名「${panelName}」与期望「${geekName}」不符,会话切换未生效,跳过`)
|
logWarn(`${LOG} → 右侧面板姓名「${panelName}」与期望「${geekName}」不符,会话切换未生效,跳过`)
|
||||||
await sleepWithRandomDelay(300, 600)
|
await sleepWithRandomDelay(300, 600)
|
||||||
continue
|
if (processContext) processContext.currentCandidate = null
|
||||||
|
return { processed: false, skipped: true }
|
||||||
}
|
}
|
||||||
if (panelName) {
|
if (panelName) {
|
||||||
logDebug(`${LOG} → 右侧面板验证:「${panelName}」✓`)
|
logDebug(`${LOG} → 右侧面板验证:「${panelName}」✓`)
|
||||||
@@ -452,12 +494,23 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
|||||||
if (intentKnowBtn) {
|
if (intentKnowBtn) {
|
||||||
logInfo(`${LOG} → 检测到「意向沟通」提示弹窗,点击「我知道了」关闭...`)
|
logInfo(`${LOG} → 检测到「意向沟通」提示弹窗,点击「我知道了」关闭...`)
|
||||||
try {
|
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 {
|
} catch {
|
||||||
// 备用:点关闭图标
|
const closeIconEl = await page.$(CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR).catch(() => null)
|
||||||
await page.click(CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR).catch(() => {})
|
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 {
|
try {
|
||||||
await page.waitForSelector(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, { hidden: true, timeout: 3000 })
|
await page.waitForSelector(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, { hidden: true, timeout: 3000 })
|
||||||
logDebug(`${LOG} → 「意向沟通」弹窗已关闭`)
|
logDebug(`${LOG} → 「意向沟通」弹窗已关闭`)
|
||||||
@@ -474,7 +527,6 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
|||||||
logDebug(`${LOG} → 等待 geek/info 数据(初步筛选,最长 5s)...`)
|
logDebug(`${LOG} → 等待 geek/info 数据(初步筛选,最长 5s)...`)
|
||||||
let geekInfoSnap = await waitForGeekInfo(peekInterceptedData, { timeoutMs: 5000 })
|
let geekInfoSnap = await waitForGeekInfo(peekInterceptedData, { timeoutMs: 5000 })
|
||||||
if (!geekInfoSnap) {
|
if (!geekInfoSnap) {
|
||||||
// geek/info 未到达,可能是 BOSS 缓存导致未重新请求;重新点击一次会话触发请求
|
|
||||||
logWarn(`${LOG} → geek/info 未到达,重试点击会话...`)
|
logWarn(`${LOG} → geek/info 未到达,重试点击会话...`)
|
||||||
getInterceptedData()
|
getInterceptedData()
|
||||||
const retrySelected = await selectConversationById(page, encryptGeekId, { cursor })
|
const retrySelected = await selectConversationById(page, encryptGeekId, { cursor })
|
||||||
@@ -502,10 +554,10 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
|||||||
logInfo(`${LOG} → 初步信息筛选不通过:${reason},跳过`)
|
logInfo(`${LOG} → 初步信息筛选不通过:${reason},跳过`)
|
||||||
await logContact(encryptGeekId, 'resume_screened_out', null, `preFilter:${reason}`, hooks)
|
await logContact(encryptGeekId, 'resume_screened_out', null, `preFilter:${reason}`, hooks)
|
||||||
await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'screened_out' }, hooks)
|
await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'screened_out' }, hooks)
|
||||||
// 消费并清空拦截数据,准备下一个候选人
|
|
||||||
getInterceptedData()
|
getInterceptedData()
|
||||||
await sleepWithRandomDelay(300, 600)
|
await sleepWithRandomDelay(300, 600)
|
||||||
continue
|
if (processContext) processContext.currentCandidate = null
|
||||||
|
return { processed: false, skipped: true }
|
||||||
}
|
}
|
||||||
logInfo(`${LOG} → 初步信息筛选通过`)
|
logInfo(`${LOG} → 初步信息筛选通过`)
|
||||||
} else {
|
} else {
|
||||||
@@ -547,36 +599,31 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'contacted' }, hooks)
|
await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'contacted' }, hooks)
|
||||||
processed++
|
|
||||||
await hooks.onProgress?.promise?.({ phase: 'chatPage', current: processed, max: toProcess.length }).catch(() => {})
|
|
||||||
getInterceptedData()
|
getInterceptedData()
|
||||||
await sleepWithRandomDelay(1000, 2500)
|
await sleepWithRandomDelay(2000, 4000)
|
||||||
continue
|
if (processContext) processContext.currentCandidate = null
|
||||||
|
return { processed: true, skipped: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无附件简历 → 说明对方只是打招呼,需要我方先筛选再决定是否索取
|
// 无附件简历 → 说明对方只是打招呼,需要我方先筛选再决定是否索取
|
||||||
// 点击「查看在线简历」(等 iframe 出现即视为打开成功,geek/info 数据点击会话时已触发,复用之)
|
|
||||||
logInfo(`${LOG} → 对方打招呼,点击查看在线简历...`)
|
logInfo(`${LOG} → 对方打招呼,点击查看在线简历...`)
|
||||||
// 打开新简历前清空残留数据(主要清除 geek/info 等待阶段积累的旧 iframe postMessage)
|
|
||||||
if (typeof clearCapturedText === 'function') {
|
if (typeof clearCapturedText === 'function') {
|
||||||
await clearCapturedText(page)
|
await clearCapturedText(page)
|
||||||
}
|
}
|
||||||
// 传入 clearCapturedText,在旧弹窗关闭后、新简历点击前再清一次(关闭过程中可能有新一批旧数据到达)
|
|
||||||
const openedResume = await openOnlineResume(page, { cursor, clearCapturedText: clearCapturedText || undefined })
|
const openedResume = await openOnlineResume(page, { cursor, clearCapturedText: clearCapturedText || undefined })
|
||||||
if (!openedResume) {
|
if (!openedResume) {
|
||||||
logWarn(`${LOG} → 未找到「查看在线简历」按钮或 iframe 未出现,跳过`)
|
logWarn(`${LOG} → 未找到「查看在线简历」按钮或 iframe 未出现,跳过`)
|
||||||
await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'viewed' }, hooks)
|
await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'viewed' }, hooks)
|
||||||
getInterceptedData()
|
getInterceptedData()
|
||||||
await sleepWithRandomDelay(500, 1000)
|
await sleepWithRandomDelay(500, 1000)
|
||||||
continue
|
if (processContext) processContext.currentCandidate = null
|
||||||
|
return { processed: false, skipped: true }
|
||||||
}
|
}
|
||||||
logInfo(`${LOG} → 在线简历 iframe 已出现,等待 Canvas 渲染完成...`)
|
logInfo(`${LOG} → 在线简历 iframe 已出现,等待 Canvas 渲染完成...`)
|
||||||
let resumeText = ''
|
let resumeText = ''
|
||||||
if (typeof getCapturedText === 'function') {
|
if (typeof getCapturedText === 'function') {
|
||||||
const { extractResumeText } = await import('./resume-extractor.mjs')
|
const { extractResumeText } = await import('./resume-extractor.mjs')
|
||||||
|
|
||||||
// 稳定轮询:连续两次 peek 到相同数量(且 > 0)视为渲染完成
|
|
||||||
// WASM 通常在 iframe 出现后 1~2s 内完成全部渲染
|
|
||||||
const POLL_INTERVAL_MS = 400
|
const POLL_INTERVAL_MS = 400
|
||||||
const STABLE_POLLS_NEEDED = 2
|
const STABLE_POLLS_NEEDED = 2
|
||||||
const CANVAS_POLL_TIMEOUT = 8000
|
const CANVAS_POLL_TIMEOUT = 8000
|
||||||
@@ -595,10 +642,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
|||||||
lastCount = currentCount
|
lastCount = currentCount
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最终读取并清空(getCapturedText 内部再等 150ms 确保最后一批 postMessage 已到)
|
|
||||||
const captured = await getCapturedText(page)
|
const captured = await getCapturedText(page)
|
||||||
const rawLines = extractResumeText(captured)
|
const rawLines = extractResumeText(captured)
|
||||||
// 去除 BOSS 字体预热数据(首次 iframe 加载时 "bzl|abcdef..." 类的 ASCII 行)
|
|
||||||
const lines = filterFontTestLines(rawLines)
|
const lines = filterFontTestLines(rawLines)
|
||||||
resumeText = lines.join('\n')
|
resumeText = lines.join('\n')
|
||||||
logInfo(`${LOG} → Canvas 抓取完成,共 ${captured.length} 次 fillText 调用,文本 ${resumeText.length} 字(原始行 ${rawLines.length},过滤后 ${lines.length})`)
|
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
|
resumeText = out.text
|
||||||
logInfo(`${LOG} → geek/info 摘要文本 ${resumeText.length} 字`)
|
logInfo(`${LOG} → geek/info 摘要文本 ${resumeText.length} 字`)
|
||||||
} else {
|
} else {
|
||||||
// 验证简历内容是否属于当前候选人。
|
|
||||||
// geekName 已知,直接用 includes 最可靠;extractNameFromResumeText 仅用于 warn 消息。
|
|
||||||
const detectedName = extractNameFromResumeText(resumeText)
|
const detectedName = extractNameFromResumeText(resumeText)
|
||||||
logDebug(`${LOG} → 简历姓名识别:${detectedName || '(未识别)'}(期望:${geekName})`)
|
logDebug(`${LOG} → 简历姓名识别:${detectedName || '(未识别)'}(期望:${geekName})`)
|
||||||
if (!resumeText.includes(geekName)) {
|
if (!resumeText.includes(geekName)) {
|
||||||
@@ -618,7 +661,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
|||||||
logWarn(`${LOG} → 右侧面板未切换到本会话(geek/info 超时或被安全验证打断),跳过,下次运行时重试`)
|
logWarn(`${LOG} → 右侧面板未切换到本会话(geek/info 超时或被安全验证打断),跳过,下次运行时重试`)
|
||||||
getInterceptedData()
|
getInterceptedData()
|
||||||
await sleepWithRandomDelay(300, 600)
|
await sleepWithRandomDelay(300, 600)
|
||||||
continue
|
if (processContext) processContext.currentCandidate = null
|
||||||
|
return { processed: false, skipped: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -635,6 +679,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
|||||||
logInfo(`${LOG} → 简历文本获取成功(共 ${resumeText.length} 字)`)
|
logInfo(`${LOG} → 简历文本获取成功(共 ${resumeText.length} 字)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await sleepWithRandomDelay(2000, 4500)
|
||||||
|
|
||||||
let pass = true
|
let pass = true
|
||||||
let filterReason = ''
|
let filterReason = ''
|
||||||
|
|
||||||
@@ -669,19 +715,22 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
|||||||
|
|
||||||
if (pass) {
|
if (pass) {
|
||||||
logInfo(`${LOG} → 筛选通过,发送索取附件简历请求...`)
|
logInfo(`${LOG} → 筛选通过,发送索取附件简历请求...`)
|
||||||
// 在线简历弹窗若仍开着,会遮挡附件简历按钮导致点击失效,必须先完全关闭(含动画)
|
|
||||||
const openResumeCloseBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null)
|
const openResumeCloseBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null)
|
||||||
if (openResumeCloseBtn) {
|
if (openResumeCloseBtn) {
|
||||||
logDebug(`${LOG} → 先关闭在线简历弹窗,避免遮挡附件简历按钮...`)
|
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 {
|
try {
|
||||||
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 3000 })
|
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 3000 })
|
||||||
logDebug(`${LOG} → 在线简历弹窗已关闭`)
|
logDebug(`${LOG} → 在线简历弹窗已关闭`)
|
||||||
} catch {
|
} catch {
|
||||||
logWarn(`${LOG} → 在线简历弹窗关闭超时,继续尝试(可能影响附件简历按钮点击)`)
|
logWarn(`${LOG} → 在线简历弹窗关闭超时,继续尝试(可能影响附件简历按钮点击)`)
|
||||||
}
|
}
|
||||||
// 等待弹窗 CSS 动画完全结束,确保不再遮挡下方按钮
|
await sleepWithRandomDelay(500, 1000)
|
||||||
await new Promise(r => setTimeout(r, 400))
|
|
||||||
}
|
}
|
||||||
const { requested, error } = await requestAttachmentResume(page, { cursor })
|
const { requested, error } = await requestAttachmentResume(page, { cursor })
|
||||||
if (requested) {
|
if (requested) {
|
||||||
@@ -706,10 +755,58 @@ export default async function startBossChatPageProcess (hooksFromCaller, options
|
|||||||
},
|
},
|
||||||
hooks
|
hooks
|
||||||
)
|
)
|
||||||
processed++
|
|
||||||
await hooks.onProgress?.promise?.({ phase: 'chatPage', current: processed, max: toProcess.length }).catch(() => {})
|
|
||||||
getInterceptedData()
|
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} 条未读会话`)
|
logInfo(`${LOG} 本次共处理 ${processed} 条未读会话`)
|
||||||
|
|||||||
@@ -55,15 +55,11 @@ export async function openOnlineResume (page, options = {}) {
|
|||||||
const closeBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR)
|
const closeBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR)
|
||||||
if (closeBtn) {
|
if (closeBtn) {
|
||||||
console.log('[openOnlineResume] 检测到旧简历弹窗,点击关闭按钮...')
|
console.log('[openOnlineResume] 检测到旧简历弹窗,点击关闭按钮...')
|
||||||
try {
|
const closeBox = await closeBtn.boundingBox().catch(() => null)
|
||||||
// 直接用 page.click,比坐标点击更可靠(不受 ghost-cursor 偏移影响)
|
if (closeBox) {
|
||||||
await page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR)
|
await cursor.click({ x: closeBox.x + closeBox.width / 2, y: closeBox.y + closeBox.height / 2 })
|
||||||
} catch (e) {
|
} else {
|
||||||
// 备用:坐标点击
|
await closeBtn.click().catch(() => {})
|
||||||
const closeBox = await closeBtn.boundingBox().catch(() => null)
|
|
||||||
if (closeBox) {
|
|
||||||
await cursor.click({ x: closeBox.x + closeBox.width / 2, y: closeBox.y + closeBox.height / 2 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 等关闭按钮从 DOM 消失(即弹窗完全关闭),比等 iframe 消失更可靠
|
// 等关闭按钮从 DOM 消失(即弹窗完全关闭),比等 iframe 消失更可靠
|
||||||
try {
|
try {
|
||||||
@@ -331,7 +327,7 @@ export async function openPreviewAndDownloadPdf (page, messageElement, options =
|
|||||||
// 预览按钮在消息少时会紧贴 tab 栏,拟人轨迹从别处移过来会经过「已交换微信」等 tab,一点就切到空白。
|
// 预览按钮在消息少时会紧贴 tab 栏,拟人轨迹从别处移过来会经过「已交换微信」等 tab,一点就切到空白。
|
||||||
// 此处直接用 Puppeteer 的 element.click():无移动轨迹,先 scrollIntoView 再点,避免误触 tab。
|
// 此处直接用 Puppeteer 的 element.click():无移动轨迹,先 scrollIntoView 再点,避免误触 tab。
|
||||||
await previewBtn.evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' }))
|
await previewBtn.evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' }))
|
||||||
await sleepWithRandomDelay(150, 300)
|
await sleepWithRandomDelay(300, 600)
|
||||||
console.log('[openPreviewAndDownloadPdf] 点击「点击预览附件简历」按钮(原生 click,避免轨迹误触 tab)')
|
console.log('[openPreviewAndDownloadPdf] 点击「点击预览附件简历」按钮(原生 click,避免轨迹误触 tab)')
|
||||||
await previewBtn.click()
|
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_PREVIEW_RESUME_BTN_SELECTOR:消息里"点击预览附件简历"(对方同意并发来 PDF 后,新消息里会出现)
|
||||||
// - CHAT_PAGE_DOWNLOAD_PDF_BTN_SELECTOR:简历预览弹窗里的"下载 PDF"按钮
|
// - CHAT_PAGE_DOWNLOAD_PDF_BTN_SELECTOR:简历预览弹窗里的"下载 PDF"按钮
|
||||||
|
|
||||||
|
/** 沟通页:顶部"全部"筛选 tab(span:nth-child(1) 在 .chat-message-filter-left 内) */
|
||||||
|
export const CHAT_PAGE_ALL_FILTER_SELECTOR = '.chat-message-filter-left span:nth-child(1)'
|
||||||
|
|
||||||
/** 沟通页:顶部"未读"筛选 tab(span:nth-child(2) 在 .chat-message-filter-left 内,active 时有 class="active") */
|
/** 沟通页:顶部"未读"筛选 tab(span: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)'
|
export const CHAT_PAGE_UNREAD_FILTER_SELECTOR = '.chat-message-filter-left span:nth-child(2)'
|
||||||
|
|
||||||
|
|||||||
@@ -141,22 +141,45 @@ async function storeStorage (page) {
|
|||||||
*/
|
*/
|
||||||
async function switchRecommendJobId (page, jobId) {
|
async function switchRecommendJobId (page, jobId) {
|
||||||
try {
|
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 })
|
await page.waitForSelector(RECOMMEND_JOB_ITEM_SELECTOR, { timeout: 5000 })
|
||||||
const found = await page.evaluate((jid) => {
|
await sleepWithRandomDelay(150, 300)
|
||||||
const item = document.querySelector(`#headerWrap ul.job-list li.job-item[value="${jid}"]`)
|
// 用拟人轨迹点击目标职位项
|
||||||
if (!item) return false
|
const items = await page.$$(RECOMMEND_JOB_ITEM_SELECTOR)
|
||||||
item.click()
|
let found = false
|
||||||
return true
|
for (const item of items) {
|
||||||
}, jobId)
|
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) {
|
if (!found) {
|
||||||
logWarn(`[boss-auto-browse] 职位 ${jobId} 未在下拉列表中找到,将使用默认职位继续`)
|
logWarn(`[boss-auto-browse] 职位 ${jobId} 未在下拉列表中找到,将使用默认职位继续`)
|
||||||
// 关闭下拉
|
|
||||||
await page.keyboard.press('Escape')
|
await page.keyboard.press('Escape')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 等候选人列表重新加载
|
// 等候选人列表重新加载
|
||||||
await new Promise(r => setTimeout(r, 500))
|
await sleepWithRandomDelay(400, 800)
|
||||||
logInfo(`[boss-auto-browse] 已切换到职位 ${jobId}`)
|
logInfo(`[boss-auto-browse] 已切换到职位 ${jobId}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logWarn(`[boss-auto-browse] 切换推荐页职位失败(${e.message}),将使用默认职位继续`)
|
logWarn(`[boss-auto-browse] 切换推荐页职位失败(${e.message}),将使用默认职位继续`)
|
||||||
|
|||||||
@@ -1,398 +1,463 @@
|
|||||||
import { app, dialog } from 'electron'
|
import { app, dialog } from 'electron'
|
||||||
import { AsyncSeriesHook, AsyncSeriesWaterfallHook } from 'tapable'
|
import { AsyncSeriesHook, AsyncSeriesWaterfallHook } from 'tapable'
|
||||||
import { sleep } from '@geekgeekrun/utils/sleep.mjs'
|
import { sleep } from '@geekgeekrun/utils/sleep.mjs'
|
||||||
import { AUTO_CHAT_ERROR_EXIT_CODE } from '../../../common/enums/auto-start-chat'
|
import { AUTO_CHAT_ERROR_EXIT_CODE } from '../../../common/enums/auto-start-chat'
|
||||||
import attachListenerForKillSelfOnParentExited from '../../utils/attachListenerForKillSelfOnParentExited'
|
import attachListenerForKillSelfOnParentExited from '../../utils/attachListenerForKillSelfOnParentExited'
|
||||||
import minimist from 'minimist'
|
import minimist from 'minimist'
|
||||||
import SqlitePluginModule from '@geekgeekrun/sqlite-plugin'
|
import SqlitePluginModule from '@geekgeekrun/sqlite-plugin'
|
||||||
import { connectToDaemon, sendToDaemon } from '../OPEN_SETTING_WINDOW/connect-to-daemon'
|
import { connectToDaemon, sendToDaemon } from '../OPEN_SETTING_WINDOW/connect-to-daemon'
|
||||||
import { checkShouldExit } from '../../utils/worker'
|
import { checkShouldExit } from '../../utils/worker'
|
||||||
import initPublicIpc from '../../utils/initPublicIpc'
|
import initPublicIpc from '../../utils/initPublicIpc'
|
||||||
import { forwardConsoleLogToDaemon } from '../../utils/forwardConsoleLogToDaemon'
|
import { forwardConsoleLogToDaemon } from '../../utils/forwardConsoleLogToDaemon'
|
||||||
import { getLastUsedAndAvailableBrowser } from '../DOWNLOAD_DEPENDENCIES/utils/browser-history'
|
import { getLastUsedAndAvailableBrowser } from '../DOWNLOAD_DEPENDENCIES/utils/browser-history'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
const { default: SqlitePlugin } = SqlitePluginModule
|
const { default: SqlitePlugin } = SqlitePluginModule
|
||||||
|
|
||||||
process.on('SIGTERM', () => {
|
process.on('SIGTERM', () => {
|
||||||
console.log('收到SIGTERM信号,正在退出')
|
console.log('收到SIGTERM信号,正在退出')
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
const rerunInterval = (() => {
|
const rerunInterval = (() => {
|
||||||
let v = Number(process.env.MAIN_BOSSGEEKGO_RERUN_INTERVAL)
|
let v = Number(process.env.MAIN_BOSSGEEKGO_RERUN_INTERVAL)
|
||||||
if (isNaN(v)) {
|
if (isNaN(v)) {
|
||||||
v = 3000
|
v = 3000
|
||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const initPlugins = async (hooks) => {
|
const initPlugins = async (hooks) => {
|
||||||
const { storageFilePath } = await import(
|
const { storageFilePath } = await import(
|
||||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||||
)
|
)
|
||||||
new SqlitePlugin(path.join(storageFilePath, 'public.db')).apply(hooks)
|
new SqlitePlugin(path.join(storageFilePath, 'public.db')).apply(hooks)
|
||||||
}
|
}
|
||||||
|
|
||||||
const runRecordId = minimist(process.argv.slice(2))['run-record-id'] ?? null
|
const runRecordId = minimist(process.argv.slice(2))['run-record-id'] ?? null
|
||||||
|
|
||||||
const log = (msg: string) => {
|
const log = (msg: string) => {
|
||||||
console.log(`[boss-worker] ${msg}`)
|
console.log(`[boss-worker] ${msg}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const runAutoBrowseAndChat = async () => {
|
const checkForBossVerification = async (page: any): Promise<boolean> => {
|
||||||
app.dock?.hide()
|
try {
|
||||||
log('runAutoBrowseAndChat 开始')
|
const url: string = page.url()
|
||||||
log(`正在查找可用浏览器...`)
|
if (/verify|captcha|security.?check|safe\b|\/safe\/|安全验证/.test(url)) return true
|
||||||
let puppeteerExecutable = await getLastUsedAndAvailableBrowser()
|
return await page.evaluate(() => {
|
||||||
if (!puppeteerExecutable) {
|
const hasVerifyText = /请完成.{0,10}验证|安全验证|滑动.{0,6}滑块|人机验证|完成验证后继续|异常.{0,6}操作|验证码/.test(
|
||||||
log('未找到可用浏览器,退出')
|
document.body?.innerText || ''
|
||||||
await dialog.showMessageBox({
|
)
|
||||||
type: `error`,
|
const hasVerifyEl = !!(
|
||||||
message: `未找到可用的浏览器`,
|
document.querySelector('#nc_mask') ||
|
||||||
detail: `请重新运行本程序,按照提示安装、配置浏览器`
|
document.querySelector('.verify-container') ||
|
||||||
})
|
document.querySelector('.captcha-wrap') ||
|
||||||
sendToDaemon({
|
document.querySelector('.nc-container') ||
|
||||||
type: 'worker-to-gui-message',
|
document.querySelector('[class*="verify"][class*="wrap"]') ||
|
||||||
data: {
|
document.querySelector('[class*="captcha"]')
|
||||||
type: 'prerequisite-step-by-step-checkstep-by-step-check',
|
)
|
||||||
step: {
|
return hasVerifyText || hasVerifyEl
|
||||||
id: 'puppeteer-executable-check',
|
})
|
||||||
status: 'rejected'
|
} catch {
|
||||||
},
|
return false
|
||||||
runRecordId
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
app.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE)
|
const waitForBossVerificationCompletion = async (page: any, expectedUrlPrefix: string): Promise<boolean> => {
|
||||||
return
|
log('⚠️ 检测到 BOSS 安全验证,请在浏览器窗口中手动完成验证,完成后将自动继续...')
|
||||||
}
|
try {
|
||||||
log(`找到浏览器: ${puppeteerExecutable.executablePath}`)
|
const { Notification } = await import('electron')
|
||||||
sendToDaemon({
|
new Notification({
|
||||||
type: 'worker-to-gui-message',
|
title: 'GeekGeekRun - 需要人工验证',
|
||||||
data: {
|
body: '检测到 BOSS 直聘安全验证,请在打开的浏览器窗口中完成验证,完成后程序将自动继续。'
|
||||||
type: 'prerequisite-step-by-step-checkstep-by-step-check',
|
}).show()
|
||||||
step: {
|
} catch { /* Notification 不可用时静默忽略 */ }
|
||||||
id: 'puppeteer-executable-check',
|
|
||||||
status: 'fulfilled'
|
const deadline = Date.now() + 5 * 60 * 1000
|
||||||
},
|
while (Date.now() < deadline) {
|
||||||
runRecordId
|
await sleep(2000)
|
||||||
}
|
try {
|
||||||
})
|
const url: string = page.url()
|
||||||
process.env.PUPPETEER_EXECUTABLE_PATH = puppeteerExecutable.executablePath
|
const isStillVerify = await checkForBossVerification(page)
|
||||||
|
if (url.startsWith(expectedUrlPrefix) && !isStillVerify) {
|
||||||
log('正在动态 import boss package...')
|
log('✅ 安全验证已完成,继续处理...')
|
||||||
type BossAutoBrowseModule = {
|
return true
|
||||||
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>
|
} catch { /* 页面可能正在跳转,继续等待 */ }
|
||||||
initPuppeteer: () => Promise<any>
|
}
|
||||||
launchBrowserAndNavigateToChat?: () => Promise<{ browser: any; page: any }>
|
log('验证等待超时(5 分钟),将重启浏览器重试')
|
||||||
bossAutoBrowseEventBus: InstanceType<typeof import('node:events').EventEmitter>
|
return false
|
||||||
}
|
}
|
||||||
const {
|
|
||||||
default: startBossAutoBrowse,
|
const runAutoBrowseAndChat = async () => {
|
||||||
startBossChatPageProcess,
|
app.dock?.hide()
|
||||||
initPuppeteer,
|
log('runAutoBrowseAndChat 开始')
|
||||||
launchBrowserAndNavigateToChat,
|
log(`正在查找可用浏览器...`)
|
||||||
bossAutoBrowseEventBus
|
let puppeteerExecutable = await getLastUsedAndAvailableBrowser()
|
||||||
} = (await import('@geekgeekrun/boss-auto-browse-and-chat/index.mjs')) as unknown as BossAutoBrowseModule
|
if (!puppeteerExecutable) {
|
||||||
log('boss package import 完成,初始化 puppeteer...')
|
log('未找到可用浏览器,退出')
|
||||||
|
await dialog.showMessageBox({
|
||||||
process.on('disconnect', () => {
|
type: `error`,
|
||||||
app.exit()
|
message: `未找到可用的浏览器`,
|
||||||
})
|
detail: `请重新运行本程序,按照提示安装、配置浏览器`
|
||||||
|
})
|
||||||
await initPuppeteer()
|
sendToDaemon({
|
||||||
log('puppeteer 初始化完成,初始化 hooks 和插件...')
|
type: 'worker-to-gui-message',
|
||||||
|
data: {
|
||||||
const hooks = {
|
type: 'prerequisite-step-by-step-checkstep-by-step-check',
|
||||||
beforeBrowserLaunch: new AsyncSeriesHook(['_']),
|
step: {
|
||||||
afterBrowserLaunch: new AsyncSeriesHook(['_']),
|
id: 'puppeteer-executable-check',
|
||||||
beforeNavigateToRecommend: new AsyncSeriesHook(['_']),
|
status: 'rejected'
|
||||||
onCandidateListLoaded: new AsyncSeriesHook(['_']),
|
},
|
||||||
onCandidateFiltered: new AsyncSeriesWaterfallHook(['candidates', 'filterResult'] as any),
|
runRecordId
|
||||||
beforeStartChat: new AsyncSeriesHook(['candidate']),
|
}
|
||||||
afterChatStarted: new AsyncSeriesHook(['candidate', 'result'] as any),
|
})
|
||||||
onError: new AsyncSeriesHook(['error']),
|
app.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE)
|
||||||
onComplete: new AsyncSeriesHook(['_']),
|
return
|
||||||
onProgress: new AsyncSeriesHook(['payload'] as any)
|
}
|
||||||
}
|
log(`找到浏览器: ${puppeteerExecutable.executablePath}`)
|
||||||
|
sendToDaemon({
|
||||||
await initPlugins(hooks)
|
type: 'worker-to-gui-message',
|
||||||
log('插件初始化完成,即将启动浏览器...')
|
data: {
|
||||||
|
type: 'prerequisite-step-by-step-checkstep-by-step-check',
|
||||||
hooks.beforeBrowserLaunch.tapPromise('log', async () => { log('beforeBrowserLaunch') })
|
step: {
|
||||||
hooks.afterBrowserLaunch.tapPromise('log', async () => { log('afterBrowserLaunch - 浏览器已启动') })
|
id: 'puppeteer-executable-check',
|
||||||
hooks.beforeNavigateToRecommend.tapPromise('log', async () => { log('beforeNavigateToRecommend - 正在导航到推荐页') })
|
status: 'fulfilled'
|
||||||
|
},
|
||||||
bossAutoBrowseEventBus.once('LOGIN_STATUS_INVALID', () => {})
|
runRecordId
|
||||||
|
}
|
||||||
hooks.onCandidateListLoaded.tap('sendLoginStatusCheck', () => {
|
})
|
||||||
log('onCandidateListLoaded - 登录成功,候选人列表已加载')
|
process.env.PUPPETEER_EXECUTABLE_PATH = puppeteerExecutable.executablePath
|
||||||
sendToDaemon({
|
|
||||||
type: 'worker-to-gui-message',
|
log('正在动态 import boss package...')
|
||||||
data: {
|
type BossAutoBrowseModule = {
|
||||||
type: 'prerequisite-step-by-step-checkstep-by-step-check',
|
default: (hooks: any, opts?: { returnBrowser?: boolean; jobId?: string; browser?: any; page?: any }) => Promise<void | { browser: any; page: any }>
|
||||||
step: {
|
startBossChatPageProcess: (hooks: any, options?: { browser?: any; page?: any; jobId?: string }) => Promise<void>
|
||||||
id: 'login-status-check',
|
initPuppeteer: () => Promise<any>
|
||||||
status: 'fulfilled'
|
launchBrowserAndNavigateToChat?: () => Promise<{ browser: any; page: any }>
|
||||||
},
|
bossAutoBrowseEventBus: InstanceType<typeof import('node:events').EventEmitter>
|
||||||
runRecordId
|
}
|
||||||
}
|
const {
|
||||||
})
|
default: startBossAutoBrowse,
|
||||||
})
|
startBossChatPageProcess,
|
||||||
|
initPuppeteer,
|
||||||
// Accumulate candidate results for webhook reporting
|
launchBrowserAndNavigateToChat,
|
||||||
const sessionCandidates: Array<{
|
bossAutoBrowseEventBus
|
||||||
basicInfo?: Record<string, unknown>
|
} = (await import('@geekgeekrun/boss-auto-browse-and-chat/index.mjs')) as unknown as BossAutoBrowseModule
|
||||||
filterReport?: Record<string, unknown>
|
log('boss package import 完成,初始化 puppeteer...')
|
||||||
llmConclusion?: string
|
|
||||||
resumeFile?: { path?: string; filename?: string }
|
process.on('disconnect', () => {
|
||||||
}> = []
|
app.exit()
|
||||||
|
})
|
||||||
hooks.afterChatStarted.tapPromise('collectCandidateForWebhook', async (candidate: unknown) => {
|
|
||||||
const c = candidate as Record<string, unknown>
|
await initPuppeteer()
|
||||||
const entry = {
|
log('puppeteer 初始化完成,初始化 hooks 和插件...')
|
||||||
basicInfo: c?.info as Record<string, unknown> | undefined,
|
|
||||||
filterReport: {
|
const hooks = {
|
||||||
matched: true,
|
beforeBrowserLaunch: new AsyncSeriesHook(['_']),
|
||||||
matchedRules: (c?.matchedRules as string[] | undefined) ?? [],
|
afterBrowserLaunch: new AsyncSeriesHook(['_']),
|
||||||
score: c?.score as number | undefined
|
beforeNavigateToRecommend: new AsyncSeriesHook(['_']),
|
||||||
},
|
onCandidateListLoaded: new AsyncSeriesHook(['_']),
|
||||||
llmConclusion: c?.llmConclusion as string | undefined,
|
onCandidateFiltered: new AsyncSeriesWaterfallHook(['candidates', 'filterResult'] as any),
|
||||||
resumeFile: c?.resumeFilePath
|
beforeStartChat: new AsyncSeriesHook(['candidate']),
|
||||||
? { path: c.resumeFilePath as string, filename: c?.resumeFileName as string | undefined }
|
afterChatStarted: new AsyncSeriesHook(['candidate', 'result'] as any),
|
||||||
: undefined
|
onError: new AsyncSeriesHook(['error']),
|
||||||
}
|
onComplete: new AsyncSeriesHook(['_']),
|
||||||
sessionCandidates.push(entry)
|
onProgress: new AsyncSeriesHook(['payload'] as any)
|
||||||
|
}
|
||||||
// 逐条实时触发:每打招呼后立即发送一条 webhook
|
|
||||||
try {
|
await initPlugins(hooks)
|
||||||
const { readConfigFile: readBossConfigFile, storageFilePath } = await import(
|
log('插件初始化完成,即将启动浏览器...')
|
||||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
|
||||||
)
|
hooks.beforeBrowserLaunch.tapPromise('log', async () => { log('beforeBrowserLaunch') })
|
||||||
const webhookConfig = readBossConfigFile('webhook.json')
|
hooks.afterBrowserLaunch.tapPromise('log', async () => { log('afterBrowserLaunch - 浏览器已启动') })
|
||||||
if (webhookConfig?.enabled && webhookConfig?.url && webhookConfig?.sendMode === 'realtime') {
|
hooks.beforeNavigateToRecommend.tapPromise('log', async () => { log('beforeNavigateToRecommend - 正在导航到推荐页') })
|
||||||
const { sendWebhook, normalizeWebhookConfig } = await import('../../features/webhook/index')
|
|
||||||
const normalized = normalizeWebhookConfig(webhookConfig)
|
bossAutoBrowseEventBus.once('LOGIN_STATUS_INVALID', () => {})
|
||||||
if (normalized?.sendMode === 'realtime') {
|
|
||||||
const runId = `run-${runRecordId ?? Date.now()}`
|
hooks.onCandidateListLoaded.tap('sendLoginStatusCheck', () => {
|
||||||
const timestamp = new Date().toISOString()
|
log('onCandidateListLoaded - 登录成功,候选人列表已加载')
|
||||||
const webhookPayload = {
|
sendToDaemon({
|
||||||
runId,
|
type: 'worker-to-gui-message',
|
||||||
timestamp,
|
data: {
|
||||||
summary: { total: 1, matched: 1, skipped: 0 },
|
type: 'prerequisite-step-by-step-checkstep-by-step-check',
|
||||||
candidates: [entry]
|
step: {
|
||||||
}
|
id: 'login-status-check',
|
||||||
log(`webhook 实时发送 1 条候选人...`)
|
status: 'fulfilled'
|
||||||
await sendWebhook(normalized, webhookPayload, { storageDir: storageFilePath })
|
},
|
||||||
log(`webhook 实时发送完成`)
|
runRecordId
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
} catch (realtimeErr) {
|
})
|
||||||
log(
|
|
||||||
`webhook 实时发送失败(不影响主流程):${realtimeErr instanceof Error ? realtimeErr.message : String(realtimeErr)}`
|
// Accumulate candidate results for webhook reporting
|
||||||
)
|
const sessionCandidates: Array<{
|
||||||
}
|
basicInfo?: Record<string, unknown>
|
||||||
})
|
filterReport?: Record<string, unknown>
|
||||||
|
llmConclusion?: string
|
||||||
hooks.onProgress.tap('sendProgressToGui', (payload: unknown) => {
|
resumeFile?: { path?: string; filename?: string }
|
||||||
const p = payload as { phase?: string; current?: number; max?: number }
|
}> = []
|
||||||
sendToDaemon({
|
|
||||||
type: 'worker-to-gui-message',
|
hooks.afterChatStarted.tapPromise('collectCandidateForWebhook', async (candidate: unknown) => {
|
||||||
data: {
|
const c = candidate as Record<string, unknown>
|
||||||
type: 'boss-auto-browse-progress',
|
const entry = {
|
||||||
workerId: 'bossAutoBrowseAndChatMain',
|
basicInfo: c?.info as Record<string, unknown> | undefined,
|
||||||
runRecordId,
|
filterReport: {
|
||||||
phase: p?.phase,
|
matched: true,
|
||||||
current: p?.current ?? 0,
|
matchedRules: (c?.matchedRules as string[] | undefined) ?? [],
|
||||||
max: p?.max ?? 0
|
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 }
|
||||||
while (true) {
|
: undefined
|
||||||
try {
|
}
|
||||||
const { readBossJobsConfig, getMergedJobConfig } = await import(
|
sessionCandidates.push(entry)
|
||||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
|
||||||
)
|
// 逐条实时触发:每打招呼后立即发送一条 webhook
|
||||||
const jobsConfig = readBossJobsConfig()
|
try {
|
||||||
const sequenceJobs = (jobsConfig.jobs || []).filter(
|
const { readConfigFile: readBossConfigFile, storageFilePath } = await import(
|
||||||
(j: any) => j.sequence?.enabled === true
|
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||||
)
|
)
|
||||||
|
const webhookConfig = readBossConfigFile('webhook.json')
|
||||||
if (sequenceJobs.length > 0) {
|
if (webhookConfig?.enabled && webhookConfig?.url && webhookConfig?.sendMode === 'realtime') {
|
||||||
log(`检测到多职位队列,共 ${sequenceJobs.length} 个职位,依次执行...`)
|
const { sendWebhook, normalizeWebhookConfig } = await import('../../features/webhook/index')
|
||||||
let sharedBrowser: any = null
|
const normalized = normalizeWebhookConfig(webhookConfig)
|
||||||
let sharedPage: any = null
|
if (normalized?.sendMode === 'realtime') {
|
||||||
|
const runId = `run-${runRecordId ?? Date.now()}`
|
||||||
try {
|
const timestamp = new Date().toISOString()
|
||||||
for (const job of sequenceJobs) {
|
const webhookPayload = {
|
||||||
const jid = job.jobId ?? job.id
|
runId,
|
||||||
const jname = job.jobName ?? job.name
|
timestamp,
|
||||||
log(`开始执行职位 ${jid}(${jname})...`)
|
summary: { total: 1, matched: 1, skipped: 0 },
|
||||||
void getMergedJobConfig(jid)
|
candidates: [entry]
|
||||||
|
}
|
||||||
const runRecommend = job.sequence?.runRecommend !== false
|
log(`webhook 实时发送 1 条候选人...`)
|
||||||
const runChat = job.sequence?.runChat !== false
|
await sendWebhook(normalized, webhookPayload, { storageDir: storageFilePath })
|
||||||
|
log(`webhook 实时发送完成`)
|
||||||
if (runChat && !sharedPage) {
|
}
|
||||||
log(`[${jid}] 仅沟通页,先启动浏览器...`)
|
}
|
||||||
const boot = await launchBrowserAndNavigateToChat()
|
} catch (realtimeErr) {
|
||||||
sharedBrowser = boot.browser
|
log(
|
||||||
sharedPage = boot.page
|
`webhook 实时发送失败(不影响主流程):${realtimeErr instanceof Error ? realtimeErr.message : String(realtimeErr)}`
|
||||||
}
|
)
|
||||||
|
}
|
||||||
if (runRecommend) {
|
})
|
||||||
log(`[${jid}] 执行推荐页...`)
|
|
||||||
const result = await startBossAutoBrowse(hooks, {
|
hooks.onProgress.tap('sendProgressToGui', (payload: unknown) => {
|
||||||
returnBrowser: true,
|
const p = payload as { phase?: string; current?: number; max?: number }
|
||||||
jobId: jid,
|
sendToDaemon({
|
||||||
browser: sharedBrowser ?? undefined,
|
type: 'worker-to-gui-message',
|
||||||
page: sharedPage ?? undefined
|
data: {
|
||||||
} as any)
|
type: 'boss-auto-browse-progress',
|
||||||
if (result?.browser) {
|
workerId: 'bossAutoBrowseAndChatMain',
|
||||||
sharedBrowser = result.browser
|
runRecordId,
|
||||||
sharedPage = result.page
|
phase: p?.phase,
|
||||||
}
|
current: p?.current ?? 0,
|
||||||
}
|
max: p?.max ?? 0
|
||||||
|
}
|
||||||
if (runChat && sharedBrowser && sharedPage) {
|
})
|
||||||
log(`[${jid}] 执行沟通页...`)
|
})
|
||||||
await startBossChatPageProcess(hooks, {
|
|
||||||
browser: sharedBrowser,
|
while (true) {
|
||||||
page: sharedPage,
|
try {
|
||||||
jobId: jid
|
const { readBossJobsConfig, getMergedJobConfig } = await import(
|
||||||
})
|
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||||
}
|
)
|
||||||
}
|
const jobsConfig = readBossJobsConfig()
|
||||||
} finally {
|
const sequenceJobs = (jobsConfig.jobs || []).filter(
|
||||||
if (sharedBrowser) {
|
(j: any) => j.sequence?.enabled === true
|
||||||
try {
|
)
|
||||||
await sharedBrowser.close()
|
|
||||||
} catch (e) {
|
if (sequenceJobs.length > 0) {
|
||||||
void e
|
log(`检测到多职位队列,共 ${sequenceJobs.length} 个职位,依次执行...`)
|
||||||
}
|
let sharedBrowser: any = null
|
||||||
sharedBrowser = null
|
let sharedPage: any = null
|
||||||
sharedPage = null
|
|
||||||
}
|
try {
|
||||||
}
|
for (const job of sequenceJobs) {
|
||||||
} else {
|
const jid = job.jobId ?? job.id
|
||||||
log('开始执行 startBossAutoBrowse(推荐页)...')
|
const jname = job.jobName ?? job.name
|
||||||
const result = await startBossAutoBrowse(hooks, { returnBrowser: true })
|
log(`开始执行职位 ${jid}(${jname})...`)
|
||||||
if (result?.browser && result?.page) {
|
void getMergedJobConfig(jid)
|
||||||
try {
|
|
||||||
log('推荐页完成,开始处理沟通页未读...')
|
const runRecommend = job.sequence?.runRecommend !== false
|
||||||
await startBossChatPageProcess(hooks, { browser: result.browser, page: result.page })
|
const runChat = job.sequence?.runChat !== false
|
||||||
} finally {
|
|
||||||
try {
|
if (runChat && !sharedPage) {
|
||||||
await result.browser.close()
|
log(`[${jid}] 仅沟通页,先启动浏览器...`)
|
||||||
} catch (e) {
|
const boot = await launchBrowserAndNavigateToChat()
|
||||||
void e
|
sharedBrowser = boot.browser
|
||||||
}
|
sharedPage = boot.page
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
if (runRecommend) {
|
||||||
log('startBossAutoBrowse + 沟通页 完成,检查 webhook 配置...')
|
log(`[${jid}] 执行推荐页...`)
|
||||||
try {
|
const result = await startBossAutoBrowse(hooks, {
|
||||||
const { readConfigFile: readBossConfigFile, storageFilePath } = await import(
|
returnBrowser: true,
|
||||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
jobId: jid,
|
||||||
)
|
browser: sharedBrowser ?? undefined,
|
||||||
const webhookConfig = readBossConfigFile('webhook.json')
|
page: sharedPage ?? undefined
|
||||||
if (!webhookConfig?.enabled || !webhookConfig?.url) {
|
} as any)
|
||||||
log('webhook 未启用或未配置 URL,跳过发送')
|
if (result?.browser) {
|
||||||
} else if (webhookConfig.sendMode === 'realtime') {
|
sharedBrowser = result.browser
|
||||||
log('webhook 为实时模式,已在每条打招呼后发送,跳过汇总发送')
|
sharedPage = result.page
|
||||||
} else if (sessionCandidates.length === 0) {
|
}
|
||||||
log('本轮无候选人数据,跳过 webhook')
|
}
|
||||||
} else {
|
|
||||||
const { sendWebhook } = await import('../../features/webhook/index')
|
if (runChat && sharedBrowser && sharedPage) {
|
||||||
const matched = sessionCandidates.filter((c) => c.filterReport?.matched !== false).length
|
log(`[${jid}] 执行沟通页...`)
|
||||||
const skipped = sessionCandidates.length - matched
|
await startBossChatPageProcess(hooks, {
|
||||||
const webhookPayload = {
|
browser: sharedBrowser,
|
||||||
runId: `run-${runRecordId ?? Date.now()}`,
|
page: sharedPage,
|
||||||
timestamp: new Date().toISOString(),
|
jobId: jid
|
||||||
summary: { total: sessionCandidates.length, matched, skipped },
|
})
|
||||||
candidates: sessionCandidates as Parameters<typeof sendWebhook>[1]['candidates']
|
}
|
||||||
}
|
}
|
||||||
log(`正在发送 webhook,共 ${sessionCandidates.length} 条候选人数据...`)
|
} finally {
|
||||||
const webhookResult = await sendWebhook(webhookConfig, webhookPayload, {
|
if (sharedBrowser) {
|
||||||
storageDir: storageFilePath
|
try {
|
||||||
})
|
await sharedBrowser.close()
|
||||||
log(`webhook 发送完成,HTTP ${webhookResult.status},body 长度 ${webhookResult.body.length}`)
|
} catch (e) {
|
||||||
}
|
void e
|
||||||
} catch (webhookErr) {
|
}
|
||||||
log(`webhook 发送失败(不影响主流程):${webhookErr instanceof Error ? webhookErr.message : String(webhookErr)}`)
|
sharedBrowser = null
|
||||||
}
|
sharedPage = null
|
||||||
sessionCandidates.length = 0
|
}
|
||||||
log('等待下次运行...')
|
}
|
||||||
} catch (err) {
|
} else {
|
||||||
if (err instanceof Error) {
|
log('开始执行 startBossAutoBrowse(推荐页)...')
|
||||||
if (err.message.includes('LOGIN_STATUS_INVALID')) {
|
const result = await startBossAutoBrowse(hooks, { returnBrowser: true })
|
||||||
await dialog.showMessageBox({
|
if (result?.browser && result?.page) {
|
||||||
type: `error`,
|
try {
|
||||||
message: `登录状态无效`,
|
log('推荐页完成,开始处理沟通页未读...')
|
||||||
detail: `请重新登录BOSS直聘(招聘端)`
|
await startBossChatPageProcess(hooks, { browser: result.browser, page: result.page })
|
||||||
})
|
} finally {
|
||||||
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.LOGIN_STATUS_INVALID)
|
try {
|
||||||
break
|
await result.browser.close()
|
||||||
}
|
} catch (e) {
|
||||||
if (err.message.includes('ERR_INTERNET_DISCONNECTED')) {
|
void e
|
||||||
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)
|
log('startBossAutoBrowse + 沟通页 完成,检查 webhook 配置...')
|
||||||
break
|
try {
|
||||||
}
|
const { readConfigFile: readBossConfigFile, storageFilePath } = await import(
|
||||||
if (
|
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||||
err.message.includes(`Could not find Chrome`) ||
|
)
|
||||||
err.message.includes(`no executable was found`)
|
const webhookConfig = readBossConfigFile('webhook.json')
|
||||||
) {
|
if (!webhookConfig?.enabled || !webhookConfig?.url) {
|
||||||
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE)
|
log('webhook 未启用或未配置 URL,跳过发送')
|
||||||
break
|
} else if (webhookConfig.sendMode === 'realtime') {
|
||||||
}
|
log('webhook 为实时模式,已在每条打招呼后发送,跳过汇总发送')
|
||||||
}
|
} else if (sessionCandidates.length === 0) {
|
||||||
console.error(err)
|
log('本轮无候选人数据,跳过 webhook')
|
||||||
const shouldExit = await checkShouldExit()
|
} else {
|
||||||
if (shouldExit) {
|
const { sendWebhook } = await import('../../features/webhook/index')
|
||||||
app.exit()
|
const matched = sessionCandidates.filter((c) => c.filterReport?.matched !== false).length
|
||||||
return
|
const skipped = sessionCandidates.length - matched
|
||||||
}
|
const webhookPayload = {
|
||||||
console.log(
|
runId: `run-${runRecordId ?? Date.now()}`,
|
||||||
`[Boss Auto Browse Main] An internal error is caught, and browser will be restarted in ${rerunInterval}ms.`
|
timestamp: new Date().toISOString(),
|
||||||
)
|
summary: { total: sessionCandidates.length, matched, skipped },
|
||||||
await sleep(rerunInterval)
|
candidates: sessionCandidates as Parameters<typeof sendWebhook>[1]['candidates']
|
||||||
}
|
}
|
||||||
}
|
log(`正在发送 webhook,共 ${sessionCandidates.length} 条候选人数据...`)
|
||||||
}
|
const webhookResult = await sendWebhook(webhookConfig, webhookPayload, {
|
||||||
|
storageDir: storageFilePath
|
||||||
export const waitForProcessHandShakeAndRunAutoChat = async () => {
|
})
|
||||||
await app.whenReady()
|
log(`webhook 发送完成,HTTP ${webhookResult.status},body 长度 ${webhookResult.body.length}`)
|
||||||
app.on('window-all-closed', () => {
|
}
|
||||||
// keep process alive while worker is running
|
} catch (webhookErr) {
|
||||||
})
|
log(`webhook 发送失败(不影响主流程):${webhookErr instanceof Error ? webhookErr.message : String(webhookErr)}`)
|
||||||
initPublicIpc()
|
}
|
||||||
await connectToDaemon()
|
sessionCandidates.length = 0
|
||||||
forwardConsoleLogToDaemon('bossAutoBrowseAndChatMain', runRecordId)
|
log('等待下次运行...')
|
||||||
await sendToDaemon(
|
} catch (err) {
|
||||||
{
|
// ── 检测是否为安全验证触发的超时,若是则发送 OS 通知提醒用户 ──
|
||||||
type: 'ping'
|
// (推荐页流程浏览器由内部管理,验证后浏览器会重启;此处仅通知用户需要手动完成验证)
|
||||||
},
|
try {
|
||||||
{
|
const errMsg = err instanceof Error ? err.message : String(err)
|
||||||
needCallback: true
|
if (/TimeoutError|timeout|waitForSelector|waitForFunction/i.test(errMsg)) {
|
||||||
}
|
log('检测到超时类错误,可能是 BOSS 安全验证导致。若浏览器窗口有验证提示,请手动完成,程序将在下一轮自动重启。')
|
||||||
)
|
try {
|
||||||
sendToDaemon({
|
const { Notification } = await import('electron')
|
||||||
type: 'worker-to-gui-message',
|
new Notification({
|
||||||
data: {
|
title: 'GeekGeekRun - 可能需要人工验证',
|
||||||
type: 'prerequisite-step-by-step-checkstep-by-step-check',
|
body: 'BOSS 直聘可能弹出了安全验证。请检查浏览器窗口,完成验证后程序将在下一轮自动重启继续。'
|
||||||
step: {
|
}).show()
|
||||||
id: 'worker-launch',
|
} catch { /* Notification 不可用时静默忽略 */ }
|
||||||
status: 'fulfilled'
|
}
|
||||||
},
|
} catch { /* 不影响主流程 */ }
|
||||||
runRecordId
|
|
||||||
}
|
if (err instanceof Error) {
|
||||||
})
|
if (err.message.includes('LOGIN_STATUS_INVALID')) {
|
||||||
runAutoBrowseAndChat()
|
await dialog.showMessageBox({
|
||||||
}
|
type: `error`,
|
||||||
|
message: `登录状态无效`,
|
||||||
attachListenerForKillSelfOnParentExited()
|
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}`)
|
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 () => {
|
const runChatPage = async () => {
|
||||||
app.dock?.hide()
|
app.dock?.hide()
|
||||||
log('runChatPage 开始')
|
log('runChatPage 开始')
|
||||||
@@ -81,7 +139,12 @@ const runChatPage = async () => {
|
|||||||
|
|
||||||
log('正在动态 import boss package...')
|
log('正在动态 import boss package...')
|
||||||
type BossAutoBrowseModule = {
|
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 }>
|
initPuppeteer: () => Promise<{ puppeteer: any }>
|
||||||
}
|
}
|
||||||
const {
|
const {
|
||||||
@@ -137,8 +200,15 @@ const runChatPage = async () => {
|
|||||||
const { setDomainLocalStorage } = await import('@geekgeekrun/utils/puppeteer/local-storage.mjs') as any
|
const { setDomainLocalStorage } = await import('@geekgeekrun/utils/puppeteer/local-storage.mjs') as any
|
||||||
const localStoragePageUrl = 'https://www.zhipin.com/desktop/'
|
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) {
|
while (true) {
|
||||||
let browser: any = null
|
|
||||||
try {
|
try {
|
||||||
const { readConfigFile: readCfg } = await import(
|
const { readConfigFile: readCfg } = await import(
|
||||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||||
@@ -153,43 +223,77 @@ const runChatPage = async () => {
|
|||||||
const runOnceAfterComplete = cfg?.chatPage?.runOnceAfterComplete === true
|
const runOnceAfterComplete = cfg?.chatPage?.runOnceAfterComplete === true
|
||||||
const keepBrowserOpenAfterRun = cfg?.chatPage?.keepBrowserOpenAfterRun === 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'
|
const headless = process.env.HEADLESS === '1'
|
||||||
browser = await puppeteer.launch({
|
browser = await puppeteer.launch({
|
||||||
headless,
|
headless,
|
||||||
ignoreHTTPSErrors: true,
|
ignoreHTTPSErrors: true,
|
||||||
protocolTimeout: 120000,
|
protocolTimeout: 120000,
|
||||||
defaultViewport: { width: 1440, height: 900 - 140 }
|
defaultViewport: { width: 1440, height: 900 - 140 }
|
||||||
})
|
})
|
||||||
|
|
||||||
await hooks.afterBrowserLaunch?.promise?.()
|
await hooks.afterBrowserLaunch?.promise?.()
|
||||||
|
|
||||||
const bossCookies = readStorageFile('boss-cookies.json')
|
const bossCookies = readStorageFile('boss-cookies.json')
|
||||||
const bossLocalStorage = readStorageFile('boss-local-storage.json')
|
const bossLocalStorage = readStorageFile('boss-local-storage.json')
|
||||||
|
|
||||||
const page = (await browser.pages())[0]
|
page = (await browser.pages())[0]
|
||||||
// 注入 Canvas fillText hook,必须在页面导航前注入(evaluateOnNewDocument)
|
// 注入 Canvas fillText hook,必须在页面导航前注入(evaluateOnNewDocument)
|
||||||
const { getCapturedText, clearCapturedText } = await setupCanvasTextHook(page)
|
const canvasHooks = await setupCanvasTextHook(page)
|
||||||
if (Array.isArray(bossCookies) && bossCookies.length > 0) {
|
getCapturedText = canvasHooks.getCapturedText
|
||||||
await page.setCookie(...bossCookies)
|
clearCapturedText = canvasHooks.clearCapturedText
|
||||||
}
|
if (Array.isArray(bossCookies) && bossCookies.length > 0) {
|
||||||
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
|
await page.setCookie(...bossCookies)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
})
|
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(沟通页)...')
|
sendToDaemon({
|
||||||
await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText })
|
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 完成')
|
log('startBossChatPageProcess 完成')
|
||||||
|
|
||||||
if (runOnceAfterComplete) {
|
if (runOnceAfterComplete) {
|
||||||
@@ -207,13 +311,60 @@ const runChatPage = async () => {
|
|||||||
|
|
||||||
try { await browser.close() } catch (e) { void e }
|
try { await browser.close() } catch (e) { void e }
|
||||||
browser = null
|
browser = null
|
||||||
|
page = null
|
||||||
|
getCapturedText = null
|
||||||
|
clearCapturedText = null
|
||||||
const rerunMs = cfg?.chatPage?.rerunIntervalMs ?? rerunInterval
|
const rerunMs = cfg?.chatPage?.rerunIntervalMs ?? rerunInterval
|
||||||
log(`下次运行将在 ${rerunMs}ms 后开始`)
|
log(`下次运行将在 ${rerunMs}ms 后开始`)
|
||||||
await sleep(rerunMs)
|
await sleep(rerunMs)
|
||||||
} catch (err) {
|
} 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) {
|
if (browser) {
|
||||||
try { await browser.close() } catch (e) { void e }
|
try { await browser.close() } catch (e) { void e }
|
||||||
browser = null
|
browser = null
|
||||||
|
page = null
|
||||||
|
getCapturedText = null
|
||||||
|
clearCapturedText = null
|
||||||
}
|
}
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
if (err.message.includes('LOGIN_STATUS_INVALID')) {
|
if (err.message.includes('LOGIN_STATUS_INVALID')) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-if="jobsList.length === 0">
|
<template v-if="jobsList.length === 0">
|
||||||
<el-alert
|
<el-alert
|
||||||
title="请先在「推荐牛人-自动开聊」页面同步职位列表"
|
title="请先在「职位配置」页面同步职位列表"
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onActivated } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import RunningOverlay from '@renderer/features/RunningOverlay/index.vue'
|
import RunningOverlay from '@renderer/features/RunningOverlay/index.vue'
|
||||||
import { RUNNING_STATUS_ENUM } from '../../../../../common/enums/auto-start-chat'
|
import { RUNNING_STATUS_ENUM } from '../../../../../common/enums/auto-start-chat'
|
||||||
@@ -120,14 +120,17 @@ interface JobSequenceItem {
|
|||||||
const jobsList = ref<JobSequenceItem[]>([])
|
const jobsList = ref<JobSequenceItem[]>([])
|
||||||
const isSavingQueue = ref(false)
|
const isSavingQueue = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
const loadJobsList = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await ipcRenderer.invoke('fetch-boss-jobs-config')
|
const result = await ipcRenderer.invoke('fetch-boss-jobs-config')
|
||||||
jobsList.value = result?.jobs ?? []
|
jobsList.value = result?.jobs ?? []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
onMounted(loadJobsList)
|
||||||
|
onActivated(loadJobsList)
|
||||||
|
|
||||||
const handleSaveQueue = async () => {
|
const handleSaveQueue = async () => {
|
||||||
isSavingQueue.value = true
|
isSavingQueue.value = true
|
||||||
|
|||||||
@@ -2,6 +2,33 @@
|
|||||||
<div class="boss-chat-page__wrap">
|
<div class="boss-chat-page__wrap">
|
||||||
<div class="main__wrap">
|
<div class="main__wrap">
|
||||||
<el-form ref="formRef" :model="formContent" label-position="top">
|
<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-card class="config-section">
|
||||||
<el-form-item mb0>
|
<el-form-item mb0>
|
||||||
<div class="section-title">沟通页运行策略</div>
|
<div class="section-title">沟通页运行策略</div>
|
||||||
@@ -93,7 +120,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted, onActivated } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import RunningOverlay from '@renderer/features/RunningOverlay/index.vue'
|
import RunningOverlay from '@renderer/features/RunningOverlay/index.vue'
|
||||||
import { RUNNING_STATUS_ENUM } from '../../../../../common/enums/auto-start-chat'
|
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 runningOverlayRef = ref<InstanceType<typeof RunningOverlay> | null>(null)
|
||||||
const isStopButtonLoading = ref(false)
|
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({
|
const formContent = reactive({
|
||||||
chatPage: {
|
chatPage: {
|
||||||
maxProcessPerRun: 20,
|
maxProcessPerRun: 20,
|
||||||
@@ -116,19 +152,26 @@ const formContent = reactive({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await ipcRenderer.invoke('fetch-boss-recruiter-config-file-content')
|
const [recruiterResult, jobsResult] = await Promise.all([
|
||||||
const recruiterConfig = result?.config?.['boss-recruiter.json'] || {}
|
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 ?? {}
|
const chatPage = recruiterConfig.chatPage ?? {}
|
||||||
formContent.chatPage.maxProcessPerRun = chatPage.maxProcessPerRun ?? 20
|
formContent.chatPage.maxProcessPerRun = chatPage.maxProcessPerRun ?? 20
|
||||||
formContent.chatPage.runOnceAfterComplete = chatPage.runOnceAfterComplete ?? false
|
formContent.chatPage.runOnceAfterComplete = chatPage.runOnceAfterComplete ?? false
|
||||||
formContent.chatPage.keepBrowserOpenAfterRun = chatPage.keepBrowserOpenAfterRun ?? false
|
formContent.chatPage.keepBrowserOpenAfterRun = chatPage.keepBrowserOpenAfterRun ?? false
|
||||||
formContent.chatPage.rerunIntervalMs = chatPage.rerunIntervalMs ?? 3000
|
formContent.chatPage.rerunIntervalMs = chatPage.rerunIntervalMs ?? 3000
|
||||||
|
jobsList.value = jobsResult?.jobs ?? []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
onActivated(loadData)
|
||||||
|
|
||||||
const doSave = async () => {
|
const doSave = async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -140,6 +183,9 @@ const doSave = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await ipcRenderer.invoke('save-boss-recruiter-config', JSON.stringify(payload))
|
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 () => {
|
const handleSave = async () => {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# 当前状况(2026-03-18)
|
||||||
|
|
||||||
|
本文档用于记录 **招聘端(BOSS)当前已实现能力、已知问题、以及下一步修复/测试计划**,方便在打包测试前统一对齐。
|
||||||
|
|
||||||
|
> 结论先行:**沟通页流程总体正常**;主要欠账在「推荐牛人」的状态判定与流程闭环(已读/已打招呼/已处理/去重不够精确),以及 Webhook 功能尚未做端到端验证与“功能安排”完善。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 已实现(可用能力)
|
||||||
|
|
||||||
|
### 1.1 Worker / UI 入口
|
||||||
|
|
||||||
|
- **BossAutoBrowseAndChat / BossAutoSequence**:推荐牛人 + 沟通页串联(复用同一 browser)
|
||||||
|
- **BossRecommend / BossChatPage**:两段可分别单独跑(用于调试)
|
||||||
|
- **WebhookIntegration**:Webhook 配置页(启用、URL、Headers、Payload options、测试发送/手动触发)
|
||||||
|
|
||||||
|
### 1.2 数据持久化(SQLite)
|
||||||
|
|
||||||
|
- `CandidateInfo`:候选人基础信息(用于去重与状态追踪的基础)
|
||||||
|
- `CandidateContactLog`:联系记录(用于判断“是否已联系/已处理”的依据)
|
||||||
|
- 迁移:`AddCandidateTables`
|
||||||
|
|
||||||
|
### 1.3 自动化核心(boss-auto-browse-and-chat)
|
||||||
|
|
||||||
|
- **推荐牛人页**:解析候选人列表、筛选、点击打招呼、处理弹窗、主循环翻页/滚动加载
|
||||||
|
- **沟通页**:解析会话列表(unread 优先)、打开在线简历(Canvas hook)、可请求附件简历/下载 PDF(按配置)
|
||||||
|
- **反爬/拟人**:ghost-cursor 人类轨迹、随机 delay、stealth/laodeng 等插件栈
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 已知问题(需要修复/补齐)
|
||||||
|
|
||||||
|
### 2.1 推荐牛人:状态判定不够精确(优先级最高)
|
||||||
|
|
||||||
|
你提到的核心痛点是 **“哪些已读、哪些已打招呼、哪些已经处理过”** 没有被准确识别和闭环,导致:
|
||||||
|
|
||||||
|
- **重复处理**:同一候选人被多次进入流程(浪费配额/触发风控风险)
|
||||||
|
- **漏处理**:本应处理的候选人因为状态误判被跳过
|
||||||
|
- **UI 展示与实际行为不一致**:页面上看似“已读/已沟通”,但本地状态未落库或落库不一致
|
||||||
|
|
||||||
|
#### 需要补齐的“状态模型”(建议落库维度)
|
||||||
|
|
||||||
|
- **viewed(已读/已查看详情)**:是否点击过候选人卡片进入详情弹窗
|
||||||
|
- **greeted(已打招呼)**:是否成功触发并完成“打招呼”链路(含弹窗确认)
|
||||||
|
- **processed(已处理)**:本轮流程是否已经对该候选人做完“筛选 →(可选)打招呼/不感兴趣 → 记录”闭环
|
||||||
|
- **skippedReason(跳过原因)**:如重复推荐/命中屏蔽名/学历不符/工作年限不符/薪资不符/技能不符/日限已满等
|
||||||
|
|
||||||
|
> 备注:上述状态不一定都需要新表字段;也可以通过 `CandidateContactLog` 的 action/type + timestamp 来推断。但目前推断链路不够稳,建议明确化并保证写入时机一致。
|
||||||
|
|
||||||
|
#### 可能的根因方向(便于定位修复点)
|
||||||
|
|
||||||
|
- **DOM 层状态标识不稳定**:推荐列表/卡片上的“已读/已沟通/已打招呼”可能是 class/图标变化,当前解析没有覆盖或覆盖不全
|
||||||
|
- **打招呼成功条件定义不一致**:点击按钮 ≠ 实际送达;需要用弹窗、toast、按钮状态变化、或网络请求成功作为确认信号
|
||||||
|
- **去重 key 不统一**:`geekId` / `encryptGeekId` / DOM data 属性在不同阶段使用不一致,导致写库与判断对不上
|
||||||
|
- **写库时机缺口**:只在某些节点写 `CandidateInfo/ContactLog`,导致“已读但未落库/已打招呼但未落库”的灰状态
|
||||||
|
|
||||||
|
### 2.2 Webhook:尚未端到端测试 + 功能安排不完善
|
||||||
|
|
||||||
|
当前实现已经包含配置、mock 测试与触发入口,但缺少:
|
||||||
|
|
||||||
|
- **真实 run 的联动验证**:推荐/沟通真实跑一轮后,是否能正确汇总候选人数据并发送
|
||||||
|
- **错误与重试体验**:网络失败/4xx/5xx 时的日志、重试、以及失败队列(若开启)是否可用
|
||||||
|
- **payload 与下游契合**:JSON / multipart 两种模式的边界,以及简历字段(path/base64)在不同配置下是否符合预期
|
||||||
|
- **“功能安排”**:比如 realtime/batch 两种 sendMode 与 UI/日志/存储的配套是否完善、默认值是否合理
|
||||||
|
|
||||||
|
### 2.3 沟通页:目前认为正常(仅保留回归点)
|
||||||
|
|
||||||
|
你反馈沟通页正常,这里只建议在打包测试时做最小回归:
|
||||||
|
|
||||||
|
- unread 会话能按预期被识别与逐条处理
|
||||||
|
- 在线简历 Canvas 提取正常
|
||||||
|
- 附件简历下载/跳过下载配置(`skipDownload`)行为符合预期
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 打包前的测试计划(本次目标)
|
||||||
|
|
||||||
|
### 3.1 推荐牛人(重点验证)
|
||||||
|
|
||||||
|
- **状态一致性**:同一候选人连续运行两次,不应重复“已打招呼”的人
|
||||||
|
- **去重有效**:刷新/翻页/滚动加载后,已处理候选人不会再次进入队列
|
||||||
|
- **打招呼确认**:出现弹窗/按钮变灰/提示信息时均能稳定判定成功或失败,并落库
|
||||||
|
|
||||||
|
### 3.2 Webhook(最小可行验证)
|
||||||
|
|
||||||
|
- 在本地起一个临时接收端(或用现成 request bin)接收 webhook
|
||||||
|
- 用 UI 的 **保存并测试发送** 验证接口可达
|
||||||
|
- 真实跑一轮推荐/沟通后,验证自动触发 payload(batch 模式)确实发送且内容合理
|
||||||
|
|
||||||
|
### 3.3 沟通页(回归)
|
||||||
|
|
||||||
|
- 选 3-5 个 unread 会话跑一轮,确认不崩、不重复、能落库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 下一步修复建议(按优先级)
|
||||||
|
|
||||||
|
1. **先把推荐牛人的“状态模型”落地**:明确 viewed/greeted/processed/skippedReason 的来源与写入点
|
||||||
|
2. **统一去重 key**:全链路统一使用同一种 `geekId`(或明确映射),避免 DB 与 DOM key 不一致
|
||||||
|
3. **把 Webhook 做一次真实跑通**:补齐失败重试/日志,并把 payload 与配置默认值调整到“开箱可用”
|
||||||
|
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
# 招聘端(给 HR)沟通页使用说明(含 LLM Rubric 详细教程)
|
||||||
|
|
||||||
|
> 适用范围:只使用「招聘BOSS → 沟通」相关功能(以及它依赖的「职位配置」「配置大语言模型」「招聘端调试工具」)。
|
||||||
|
>
|
||||||
|
> 你只需要按本文档把三件事配置好:**登录凭据**、**职位筛选规则**、**LLM 模型**。然后在「沟通」页启动即可。
|
||||||
|
>
|
||||||
|
> 本软件会把所有配置保存到你电脑的 `~/.geekgeekrun/` 目录下(重装软件通常不影响配置)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 你会用到的页面入口(左侧导航)
|
||||||
|
|
||||||
|
- **沟通**:只配置“怎么跑”(处理多少未读、是否单轮、两轮间隔),并启动任务
|
||||||
|
- **职位配置**:配置“筛什么人”(城市/学历/年限/薪资 + 简历全文关键词/正则/LLM Rubric)
|
||||||
|
- **配置大语言模型**:配置 LLM 服务商(baseURL、API Key、模型、用途默认模型、测试连接)
|
||||||
|
- **编辑登录凭据**:登录 BOSS 直聘(招聘者身份),保存 Cookie
|
||||||
|
- **招聘端调试工具**:Rubric 生成/评估/测试专用(强烈建议你用它把 Rubric 调顺再去正式跑)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) 第一次使用:先配置“登录凭据”
|
||||||
|
|
||||||
|
### 1.1 登录凭据是什么
|
||||||
|
|
||||||
|
软件需要你 BOSS 直聘**招聘者身份**的登录态(Cookie + localStorage)才能打开沟通页并读取职位列表。
|
||||||
|
|
||||||
|
会用到两份本地文件(一般不需要你手动打开它们):
|
||||||
|
|
||||||
|
- `~/.geekgeekrun/storage/boss-cookies.json`:招聘端 Cookie(最关键)
|
||||||
|
- `~/.geekgeekrun/storage/boss-local-storage.json`:招聘端 localStorage
|
||||||
|
|
||||||
|
### 1.2 一键登录(推荐做法)
|
||||||
|
|
||||||
|
1. 打开:**招聘BOSS → 编辑登录凭据**
|
||||||
|
2. 确认提示里写的是「招聘者身份登录」(很重要)
|
||||||
|
3. 在弹出的“BOSS 登录助手”窗口里,点击 **启动浏览器**
|
||||||
|
4. 用你常用的方式登录(短信/二维码/微信小程序等)
|
||||||
|
5. 登录完成后等待 5–10 秒:Cookie 会自动出现在输入框
|
||||||
|
6. 点击右下角 **确定** 保存
|
||||||
|
7. 看到“Cookie 保存成功”的提示即可
|
||||||
|
|
||||||
|
### 1.3 常见问题
|
||||||
|
|
||||||
|
- **我登录了但 Cookie 没自动出现**
|
||||||
|
- 在登录助手窗口里按它的说明安装/打开 `EditThisCookie` 扩展,导出 Cookie 后粘贴到输入框保存
|
||||||
|
- **我确认登录了但运行时提示未登录/403/拒绝访问**
|
||||||
|
- 先重新走一遍“编辑登录凭据”
|
||||||
|
- 确保账号确实是招聘者身份(能看到招聘端功能)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) 配置 LLM Provider + Model(boss-llm.json)
|
||||||
|
|
||||||
|
> 只要你想用「LLM Rubric 筛选」或「自动生成 Rubric」,就必须配置这一步。
|
||||||
|
|
||||||
|
配置入口:**招聘BOSS → 配置大语言模型**
|
||||||
|
配置保存到:`~/.geekgeekrun/config/boss-llm.json`
|
||||||
|
|
||||||
|
### 2.1 你需要填什么
|
||||||
|
|
||||||
|
#### A. 服务商(Provider)
|
||||||
|
|
||||||
|
- **API Base URL**:常见例子
|
||||||
|
- SiliconFlow:`https://api.siliconflow.cn/v1`
|
||||||
|
- DeepSeek 官方:`https://api.deepseek.com/v1`
|
||||||
|
- 阿里云百炼(兼容模式):`https://dashscope.aliyuncs.com/compatible-mode/v1`
|
||||||
|
- Ollama 本地:`http://localhost:11434/v1`
|
||||||
|
- **API Key**:服务商提供的 Key(通常以 `sk-` 开头)
|
||||||
|
|
||||||
|
#### B. 模型(Model)
|
||||||
|
|
||||||
|
每个模型一行(同一服务商下多个模型共享一个 API Key):
|
||||||
|
|
||||||
|
- **启用开关**:关闭后该模型不会被使用
|
||||||
|
- **模型别名(name)**:给你自己看的名字,例如“DeepSeek-R1(简历筛选)”
|
||||||
|
- **Model ID(model)**:服务商要求的模型标识,例如 `deepseek-reasoner`、`deepseek-chat` 等
|
||||||
|
- **推理模型**(可选):如果你用的是推理模型(例如 R1/Qwen3 推理系列),可以勾选启用并设置预算(常用 2048 起)
|
||||||
|
|
||||||
|
#### C. 测试连接(非常重要)
|
||||||
|
|
||||||
|
每个模型旁边点 **测试连接**:
|
||||||
|
|
||||||
|
- 显示“连接成功”才算配置完成
|
||||||
|
- 如果失败:优先检查 Base URL 是否正确(很多服务商必须带 `/v1`)、API Key 是否正确、网络是否可达
|
||||||
|
|
||||||
|
### 2.2 “各用途默认模型”应该怎么选
|
||||||
|
|
||||||
|
页面下方的“各用途默认模型”,建议至少设置:
|
||||||
|
|
||||||
|
- **简历筛选**:选择你用于 Rubric 评估的模型(最常用)
|
||||||
|
- 其他用途(招呼语生成、消息续写、默认)可以先不设置或按需选择
|
||||||
|
|
||||||
|
> 留空时:系统会“跟随第一个启用的模型”。为了可控,建议明确选一下“简历筛选”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) 职位配置(岗位设置):筛选条件都在这里
|
||||||
|
|
||||||
|
配置入口:**招聘BOSS → 职位配置**
|
||||||
|
保存到:`~/.geekgeekrun/config/boss-jobs-config.json`
|
||||||
|
|
||||||
|
### 3.1 第一步:同步职位列表
|
||||||
|
|
||||||
|
进入「职位配置」页面后,先点顶部 **同步职位列表**。
|
||||||
|
|
||||||
|
- 如果提示 `NEED_LOGIN`:说明登录凭据不对或已过期,回到“编辑登录凭据”重新登录保存 Cookie
|
||||||
|
|
||||||
|
同步完成后会显示职位列表,点职位名展开配置。
|
||||||
|
|
||||||
|
### 3.2 你要理解的关键点
|
||||||
|
|
||||||
|
- **“沟通”页不配置筛选规则**
|
||||||
|
沟通页只管“怎么跑”;筛选规则按职位配置来执行。
|
||||||
|
- **一职位一套筛选规则**
|
||||||
|
你可以给不同岗位设置不同城市/学历/薪资/简历筛选 Rubric。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) “沟通”页:只配置运行策略,然后启动
|
||||||
|
|
||||||
|
入口:**招聘BOSS → 沟通**
|
||||||
|
|
||||||
|
这页会保存到 `boss-recruiter.json` 的 `chatPage` 配置,但你不需要关心文件。
|
||||||
|
|
||||||
|
### 4.1 每个字段含义与建议值
|
||||||
|
|
||||||
|
- **每次最多处理未读会话数**
|
||||||
|
- 含义:本轮最多处理多少条“未读对话”
|
||||||
|
- 建议:先用 10~20 做小流量测试,稳定再加
|
||||||
|
|
||||||
|
- **单轮运行完成后停止(不再自动重启)**
|
||||||
|
- 含义:跑完这一轮就结束,不会过一会儿再跑
|
||||||
|
- 建议:你手动跑的时候先勾上,便于观察效果
|
||||||
|
|
||||||
|
- **单轮结束后保持浏览器打开(需同时勾选「单轮运行完成后停止」)**
|
||||||
|
- 含义:本轮跑完不关浏览器,方便你检查页面;你手动把浏览器关掉后任务才算结束
|
||||||
|
- 建议:排错时勾选
|
||||||
|
|
||||||
|
- **两轮之间的等待间隔(毫秒)**
|
||||||
|
- 含义:不勾选“单轮停止”时,每轮跑完等多久再跑下一轮
|
||||||
|
- 默认:3000(3 秒)
|
||||||
|
- 建议:一般保持默认;如果你想更稳、减少触发风控的概率,可以调大(例如 10000)
|
||||||
|
|
||||||
|
### 4.2 两个按钮怎么用
|
||||||
|
|
||||||
|
- **仅保存配置**:只保存,不启动
|
||||||
|
- **保存配置,并开始处理沟通页!**:保存 + 立刻启动任务
|
||||||
|
|
||||||
|
启动后如果要中止,在运行遮罩层里点 **结束任务**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) LLM Rubric(AI 简历筛选)详细教程
|
||||||
|
|
||||||
|
> 目标:把“复杂的 criteria”变成一套可重复、可验证、可迭代的配置。
|
||||||
|
>
|
||||||
|
> 你会做 4 件事:
|
||||||
|
> 1) 准备 JD(输入给 LLM)
|
||||||
|
> 2) 生成 Rubric(knockouts + dimensions)
|
||||||
|
> 3) 用真实简历文本测试 Rubric(通过/不通过是否符合预期)
|
||||||
|
> 4) 调整 Rubric 再测试,最后应用到职位配置
|
||||||
|
|
||||||
|
### 5.1 Rubric 的结构是什么(你需要看懂)
|
||||||
|
|
||||||
|
Rubric 是一个 JSON,对应三部分:
|
||||||
|
|
||||||
|
- **knockouts**:一票否决项(命中任意一条,直接淘汰)
|
||||||
|
- **dimensions**:评分维度列表(每个维度 1/3/5 分标准 + 权重 weight)
|
||||||
|
- **passThreshold**:通过分数线(0–100),例如 75 分以上通过
|
||||||
|
|
||||||
|
手动 Rubric JSON 的标准格式示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"knockouts": [
|
||||||
|
"必须统招本科及以上",
|
||||||
|
"必须有 3 年以上前端经验"
|
||||||
|
],
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"name": "前端工程化与质量",
|
||||||
|
"weight": 35,
|
||||||
|
"criteria": {
|
||||||
|
"1": "缺乏工程化经验:无构建/规范/CI/CD/测试实践描述",
|
||||||
|
"3": "有基础工程化实践:使用过打包工具、Lint、简单 CI,但缺少质量指标或规模化经验",
|
||||||
|
"5": "工程化成熟:能落地规范、CI/CD、监控与质量体系;有大型项目治理经验"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "业务交付与协作",
|
||||||
|
"weight": 65,
|
||||||
|
"criteria": {
|
||||||
|
"1": "主要做简单页面或边缘需求,缺少端到端交付与跨团队协作证据",
|
||||||
|
"3": "能独立交付模块:有业务拆解、排期、联调与上线经验",
|
||||||
|
"5": "能主导复杂交付:推动关键项目落地,跨团队协作强,能复盘与优化"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"passThreshold": 75
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 提醒:`weight` 建议总和为 100(更直观)。维度分数是 1/3/5,系统会按权重换算成 0–100 总分。
|
||||||
|
|
||||||
|
### 5.2 JD 怎么给(写得好,Rubric 才会准)
|
||||||
|
|
||||||
|
#### 5.2.1 最推荐的 JD 输入模板(直接粘贴给 LLM)
|
||||||
|
|
||||||
|
把你手里的 JD 改成“能评估”的结构,尽量包含:
|
||||||
|
|
||||||
|
- **岗位目标**:这个岗位最核心的产出是什么(1–3 句话)
|
||||||
|
- **必须项(硬门槛)**:学历/年限/城市/必须技能/必须行业等(这些应当转成 knockouts)
|
||||||
|
- **加分项(软偏好)**:有更好,没有也能考虑(这些适合做 dimensions 或 3/5 分差异)
|
||||||
|
- **禁止项(明确不考虑)**:例如“必须坐班/不接受远程/不接受频繁跳槽”等(也是 knockouts 候选)
|
||||||
|
- **评估方式倾向**:你希望 LLM 重点看什么证据(项目、成果指标、技术栈、论文、专利、带队等)
|
||||||
|
|
||||||
|
示例(你可以复制改):
|
||||||
|
|
||||||
|
```text
|
||||||
|
岗位:前端工程师(ToB)
|
||||||
|
|
||||||
|
岗位目标:
|
||||||
|
1) 负责核心业务模块交付,提升交付质量与稳定性
|
||||||
|
2) 推进工程化建设(规范、CI、监控、性能优化)
|
||||||
|
|
||||||
|
必须项(硬门槛):
|
||||||
|
- 本科及以上
|
||||||
|
- 3 年以上前端经验
|
||||||
|
- 熟悉 Vue 或 React 至少一种,并有线上项目经验
|
||||||
|
|
||||||
|
加分项(软偏好):
|
||||||
|
- 有工程化/组件库/低代码平台经验
|
||||||
|
- 有性能优化、监控告警、可观测性经验
|
||||||
|
- 有带新人/小组协作经验
|
||||||
|
|
||||||
|
不考虑:
|
||||||
|
- 频繁跳槽(平均在职 < 10 个月)
|
||||||
|
|
||||||
|
评估重点:
|
||||||
|
- 项目经历是否体现端到端交付(需求拆解、联调、上线、复盘)
|
||||||
|
- 是否有工程化/质量体系的证据(规范、测试、CI/CD、监控)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2.2 常见写法错误(会让 Rubric 变得很玄学)
|
||||||
|
|
||||||
|
- 只有“岗位职责”没有“硬门槛/加分项/不考虑”
|
||||||
|
- 只写“熟悉/精通/良好沟通”这种形容词,没有“证据/行为/产出”
|
||||||
|
- 把“加分项”当“必须项”写死,导致 knockouts 太多、候选人几乎全被淘汰
|
||||||
|
|
||||||
|
### 5.3 如何生成 Rubric(两种方式)
|
||||||
|
|
||||||
|
你可以在两处生成 Rubric:
|
||||||
|
|
||||||
|
- **方式 A(推荐)**:在「职位配置」页面里生成(生成后就属于该职位)
|
||||||
|
- **方式 B(更适合调试)**:在「招聘端调试工具 → LLM 筛选」里生成(更适合反复试错)
|
||||||
|
|
||||||
|
#### 5.3.1 在“职位配置”里生成(最常用)
|
||||||
|
|
||||||
|
1. 打开:**职位配置** → 展开某个职位
|
||||||
|
2. 在“简历全文筛选”里勾选:**大模型筛选(AI Rubric)**
|
||||||
|
3. 在 Step 1 的 “岗位描述(JD)” 粘贴你的 JD
|
||||||
|
4. 点 **自动生成评分标准**
|
||||||
|
5. 生成后出现:
|
||||||
|
- 一票否决项(knockouts)
|
||||||
|
- 评分维度(dimensions)
|
||||||
|
- 通过分数线(passThreshold)
|
||||||
|
6. 你可以直接微调(见 5.5),然后点该职位底部 **保存**。
|
||||||
|
|
||||||
|
> 如果提示“未检测到可用模型”:先去「配置大语言模型」添加并启用至少一个模型,并测试连接成功。
|
||||||
|
|
||||||
|
#### 5.3.2 在“招聘端调试工具”里生成(强烈建议先用它把规则跑通)
|
||||||
|
|
||||||
|
1. 打开:**招聘端调试工具 → Tab B:LLM 筛选**
|
||||||
|
2. 在“区域 1:生成 Rubric”输入 JD
|
||||||
|
3. 点 **生成 Rubric**
|
||||||
|
4. 生成后你可以:
|
||||||
|
- **复制 JSON**
|
||||||
|
- **用于评估**(把 JSON 直接带到区域 3)
|
||||||
|
- **应用到职位配置**(选目标职位 → 一键写入职位配置)
|
||||||
|
|
||||||
|
### 5.4 Rubric 怎么测试(这是最关键的一步)
|
||||||
|
|
||||||
|
测试的目标是:让“通过/不通过”的结果**符合你的直觉**,并且“理由”能解释清楚。
|
||||||
|
|
||||||
|
推荐用:**招聘端调试工具 → Tab B:LLM 筛选**
|
||||||
|
|
||||||
|
#### 5.4.1 准备一份真实简历文本(区域 2)
|
||||||
|
|
||||||
|
1. 在调试工具顶部点 **启动浏览器**
|
||||||
|
2. 浏览器打开到 BOSS 沟通页后,你在左侧会话列表**手动点击一条会话**(让右侧候选人信息显示出来)
|
||||||
|
3. 回到调试工具,点 **📄 提取当前简历文本**
|
||||||
|
4. 成功后会显示简历文本长度(多少字),并自动填到区域 3 的“简历文本”里
|
||||||
|
|
||||||
|
> 说明:它会自动打开“在线简历”,用 Canvas hook 把文字提取出来,所以能得到较完整的简历文本。
|
||||||
|
|
||||||
|
#### 5.4.2 运行 Rubric 评估(区域 3)
|
||||||
|
|
||||||
|
区域 3 有两种 Rubric 来源:
|
||||||
|
|
||||||
|
- **从职位配置读取**:适合验证“某个职位”现在的配置是否好用
|
||||||
|
- **手动填写 JSON**:适合快速试错(比如刚生成的 Rubric)
|
||||||
|
|
||||||
|
操作流程(推荐手动 JSON):
|
||||||
|
|
||||||
|
1. 在区域 1 生成 Rubric 后点 **用于评估**(会把 JSON 填入区域 3)
|
||||||
|
2. 确认区域 3 里“简历文本”有内容(来自区域 2,或你手动粘贴)
|
||||||
|
3. 点 **🤖 运行 LLM 评估**
|
||||||
|
4. 查看结果:
|
||||||
|
- ✅/❌ 是否通过
|
||||||
|
- 总分(如 78/100)
|
||||||
|
- 各维度得分(1–5 分)
|
||||||
|
- 原因(reason)
|
||||||
|
|
||||||
|
#### 5.4.3 你应该怎么判断“Rubric 好不好”
|
||||||
|
|
||||||
|
用 3 个候选人做最小验证:
|
||||||
|
|
||||||
|
- **明显不合格**:应当稳定不通过(最好被 knockouts 或低分维度卡住)
|
||||||
|
- **明显合格**:应当稳定通过(总分高,关键维度 4–5 分)
|
||||||
|
- **边界候选人**:通过与否取决于你希望的标准(用它来调 passThreshold/权重)
|
||||||
|
|
||||||
|
如果 3 个样本的结果都符合预期,再开始正式跑沟通页。
|
||||||
|
|
||||||
|
### 5.5 Rubric 怎么修改(让它更像“你的判断标准”)
|
||||||
|
|
||||||
|
你主要会改 4 类东西:
|
||||||
|
|
||||||
|
#### A. knockouts(硬门槛)
|
||||||
|
|
||||||
|
适合放“真的不能谈”的条件:
|
||||||
|
|
||||||
|
- 学历硬门槛(如必须本科)
|
||||||
|
- 年限硬门槛(如必须 3 年以上)
|
||||||
|
- 必须证书/必须行业/必须到岗方式等
|
||||||
|
|
||||||
|
不建议放:
|
||||||
|
|
||||||
|
- 软偏好(如“更偏好大厂”“最好做过组件库”)——放到维度更合理
|
||||||
|
|
||||||
|
#### B. dimensions 的 name(维度名称)
|
||||||
|
|
||||||
|
维度名称要“可评估”,不要写空话。好例子:
|
||||||
|
|
||||||
|
- “工程化与质量体系”
|
||||||
|
- “业务交付与跨团队协作”
|
||||||
|
- “算法建模与实验设计”
|
||||||
|
|
||||||
|
不好的例子:
|
||||||
|
|
||||||
|
- “综合匹配度”“整体优秀”“符合岗位需求”
|
||||||
|
|
||||||
|
#### C. criteria(1/3/5 分标准)
|
||||||
|
|
||||||
|
这是你觉得“复杂”的地方,但你只要记住一句话:
|
||||||
|
|
||||||
|
> **criteria 不是形容词,是证据。**
|
||||||
|
|
||||||
|
写法建议:
|
||||||
|
|
||||||
|
- **1 分**:缺少关键证据(没做过/没体现过/只有名词无案例)
|
||||||
|
- **3 分**:有证据但不够强(参与过、做过模块、缺少规模/指标/主导)
|
||||||
|
- **5 分**:证据很强(主导过、规模化、可量化成果、影响面大)
|
||||||
|
|
||||||
|
典型可用“证据关键词”:
|
||||||
|
|
||||||
|
- “主导/负责/推动/落地/设计”
|
||||||
|
- “从 0 到 1 / 从 1 到 N”
|
||||||
|
- “指标:耗时降低 X%、崩溃率降低 X、性能提升 X”
|
||||||
|
- “规模:DAU、QPS、组件数量、团队人数”
|
||||||
|
|
||||||
|
#### D. weight(权重)与 passThreshold(分数线)
|
||||||
|
|
||||||
|
建议调参顺序:
|
||||||
|
|
||||||
|
1. 先把维度写清楚(criteria 能拉开差异)
|
||||||
|
2. 再调权重:让你最关心的能力占更大比重
|
||||||
|
3. 最后调通过线:
|
||||||
|
- 你觉得“通过的人太多” → 提高 passThreshold
|
||||||
|
- 你觉得“好的人被误杀” → 降低 passThreshold 或放宽 knockouts
|
||||||
|
|
||||||
|
### 5.6 生成出来的 Rubric 不靠谱怎么办(最常见原因)
|
||||||
|
|
||||||
|
- **原因 1:JD 太泛**
|
||||||
|
- 解决:按 5.2 的模板补齐“必须项/加分项/不考虑/评估重点”
|
||||||
|
- **原因 2:knockouts 太多导致全淘汰**
|
||||||
|
- 解决:只保留真正的硬门槛;软偏好放维度
|
||||||
|
- **原因 3:criteria 写成“部分符合/完全符合”这种废话**
|
||||||
|
- 解决:改成“证据标准”(项目/指标/产出/职责范围)
|
||||||
|
- **原因 4:模型不稳定/经常超时**
|
||||||
|
- 解决:换一个更稳定的 provider/model;先用“测试连接”确认连通
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) 最推荐的落地流程(照着做就行)
|
||||||
|
|
||||||
|
1. **编辑登录凭据**:确保招聘者身份 Cookie 保存成功
|
||||||
|
2. **配置大语言模型**:至少 1 个可用模型,测试连接成功;用途默认模型设置“简历筛选”
|
||||||
|
3. **职位配置**:
|
||||||
|
- 同步职位列表
|
||||||
|
- 给目标职位开启“LLM Rubric”
|
||||||
|
4. **招聘端调试工具**:
|
||||||
|
- 生成 Rubric → 提取 3 份真实简历文本 → 运行评估 → 调整 Rubric → 直到结果符合预期
|
||||||
|
- 一键“应用到职位配置”
|
||||||
|
5. **沟通**:
|
||||||
|
- 设置每轮处理未读数(先 10)
|
||||||
|
- 勾选“单轮运行完成后停止”先跑一轮观察
|
||||||
|
- 点击“保存配置,并开始处理沟通页!”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) 常见错误提示对照表
|
||||||
|
|
||||||
|
- **同步职位列表失败:NEED_LOGIN**
|
||||||
|
- 去“编辑登录凭据”重新登录保存 Cookie(招聘者身份)
|
||||||
|
|
||||||
|
- **运行沟通页后提示登录无效**
|
||||||
|
- Cookie 过期了,重新登录保存即可
|
||||||
|
|
||||||
|
- **LLM 生成/评估失败:请检查 LLM 配置**
|
||||||
|
- 去“配置大语言模型”测试连接
|
||||||
|
- 检查 baseURL 是否正确(尤其是否包含 `/v1`)
|
||||||
|
- 检查该模型是否启用
|
||||||
|
|
||||||
Reference in New Issue
Block a user