recruiter: add boss auto browse/chat flows, webhook, and candidate tables

- Add recruiter-side automation core and run-core entry
- Extend sqlite-plugin with candidate info + contact logs
- Add UI routes/pages, IPC handlers, progress + log panel
- Document current status and plans under plan/

Made-with: Cursor
This commit is contained in:
rqi14
2026-03-18 17:37:24 +08:00
parent 4048e3b323
commit 95c1e54c66
73 changed files with 15053 additions and 89 deletions

28
.gitignore vendored
View File

@@ -1,4 +1,26 @@
node_modules
database.sqlite
.env.local
**/node_modules/
# build outputs
dist/
out/
packages/**/dist/
packages/**/out/
packages/**/.vite/
packages/**/.turbo/
packages/**/.cache/
# OS / logs
.DS_Store
*.log
*.log.*
# local examples (often large/volatile)
examples/
# runtime data / secrets
database.sqlite
*.sqlite
*.sqlite3
*.db
*.db-journal
.env.local

116
CLAUDE.md Normal file
View File

@@ -0,0 +1,116 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Package Manager
**Always use `proto run pnpm -- <args>`** instead of bare `pnpm`. The project requires pnpm `>=8.15.9 <9.0.0`; the system pnpm (10.x) is incompatible and will cause errors.
```bash
proto run pnpm -- install
proto run pnpm -- -F geekgeekrun-ui dev
```
## Key Commands
All UI development happens in `packages/ui`. The `dev`/`build`/`start` scripts automatically rebuild `sqlite-plugin` first.
```bash
# Electron app (main entry point for users)
proto run pnpm -- -F geekgeekrun-ui dev # development mode
proto run pnpm -- -F geekgeekrun-ui build # production build
proto run pnpm -- -F geekgeekrun-ui build:win # Windows installer
# Lint & format (run from packages/ui)
proto run pnpm -- -F geekgeekrun-ui lint # eslint --fix
proto run pnpm -- -F geekgeekrun-ui format # prettier --write
# Type checking (run from packages/ui)
proto run pnpm -- -F geekgeekrun-ui typecheck # both node + web
# SQLite plugin (must build before UI if changed)
proto run pnpm -- -F @geekgeekrun/sqlite-plugin build
proto run pnpm -- -F @geekgeekrun/sqlite-plugin dev # watch mode
```
## Architecture
This is a **pnpm monorepo** (`packages/*`) — a desktop automation tool for BOSS Zhipin (job platform) built on Electron + Puppeteer.
### Two Sides
**Job-seeker side** (older, more complete):
- `packages/geek-auto-start-chat-with-boss` — core automation: LLM-based resume matching, auto-chat
- `packages/run-core-of-geek-auto-start-chat-with-boss` — headless daemon entry point
**Recruiter side** (newer, under active development):
- `packages/boss-auto-browse-and-chat` — core automation: browse candidates, filter, send greetings, extract resumes via Canvas hook
- `packages/run-core-of-boss-auto-browse` — headless daemon entry point
### Electron App (`packages/ui`)
The app uses **mode-based process routing**: every worker subprocess is actually the same Electron binary launched with a `--mode` flag. `src/main/index.ts` switches on `runMode`:
- No `--mode` (default) → opens the settings GUI window (`OPEN_SETTING_WINDOW`)
- `bossRecommendMain` / `bossChatPageMain` / `bossAutoBrowseAndChatMain` → recruiter workers
- `geekAutoStartWithBossMain` → job-seeker worker
- `launchDaemon` → background daemon process (manages worker subprocesses via `@geekgeekrun/pm`)
The GUI renderer is **Vue 3 + Pinia + Vue Router** served by electron-vite. IPC handlers live in `src/main/flow/OPEN_SETTING_WINDOW/ipc/index.ts`.
### Plugin/Hook System
All automation cores (both sides) use **tapable** (`AsyncSeriesHook`, `AsyncSeriesWaterfallHook`) for extensibility. The sqlite-plugin and webhook features attach to these hooks:
```
Worker flow file
→ constructs hooks object
→ new SqlitePlugin(dbPath).apply(hooks)
→ calls core function (startBossAutoBrowse / startBossChatPageProcess / startGeekAutoChat)
```
### Shared Packages
- `packages/sqlite-plugin` — TypeORM + better-sqlite3, compiled TypeScript (`dist/`). Entities: `CandidateInfo`, `CandidateContactLog`, `ChatStartupLog`. **Must be built before UI.**
- `packages/utils` — ESM utilities: sleep, OpenAI/GPT requests, Puppeteer helpers
- `packages/pm` — Electron multi-process daemon/worker management
- `packages/laodeng` / `packages/puppeteer-extra-plugin-laodeng` — anti-bot-detection Puppeteer plugin
### Storage Layout
```
~/.geekgeekrun/
config/
boss-recruiter.json # recruiter automation config
candidate-filter.json # candidate filter criteria
webhook.json # webhook integration config
storage/
boss-cookies.json # persisted BOSS Zhipin cookies
boss-local-storage.json # persisted localStorage
public.db # SQLite database
```
### Recruiter Automation Stack (boss-auto-browse-and-chat)
Key files and their roles:
- `index.mjs``startBossAutoBrowse()`: browser launch, login, main loop
- `candidate-processor.mjs` — DOM parsing (`#recommend-list > div > ul > li`), candidate filtering
- `chat-handler.mjs` — clicking 打招呼 (`button.btn-greet`), handling popup (`button.btn-sure-v2`), processCandidate
- `resume-extractor.mjs` — network intercept + iframe Canvas fillText hook (MutationObserver pattern, see `plan/cv_canvas_solution.md`)
- `constant.mjs` — all CSS selectors and URLs; **update here first when BOSS site HTML changes**
Anti-detection: stealth + laodeng + anonymize-ua plugins; all clicks via `ghost-cursor` (`createHumanCursor`); random delays via `sleepWithRandomDelay`.
## Code Style
Enforced by eslint + prettier in `packages/ui`:
- **No semicolons**, **single quotes**, `printWidth: 100`, no trailing commas
- Vue 3 `<script setup>` SFC style
- `.mjs` files (automation core) are plain ESM, no TypeScript, no build step
## Plan Documents
`plan/` contains architecture decision documents intended for AI-assisted development:
- `recruiter_architecture.md` — high-level overview of the recruiter side
- `recommend_page_flow.md` — detailed DOM structure, selectors, and flow for the recommend page
- `cv_canvas_solution.md` — how BOSS Zhipin's WASM+Canvas resume protection works and how it's bypassed

View File

@@ -0,0 +1,411 @@
/**
* 候选人列表解析与筛选逻辑
* 选择器值目前为 TODO 占位符,需在招聘端账号登录后通过 DevTools 确认真实值后再生效。
*/
import {
CANDIDATE_LIST_SELECTOR,
CANDIDATE_ITEM_SELECTOR,
CANDIDATE_NAME_SELECTOR,
NEXT_PAGE_BUTTON_SELECTOR
} from './constant.mjs'
import { sleep } from '@geekgeekrun/utils/sleep.mjs'
import { createHumanCursor } from './humanMouse.mjs'
import { debug as logDebug, info as logInfo, warn as logWarn } from './logger.mjs'
/**
* 从工作经验描述中解析年数(取区间最大值或单值)
* @param {string} workExpDesc - 如 "1-3年"、"3-5年"、"经验不限"、"26年应届生"
* @returns {number|null} 年数,无法解析时返回 null应届生/经验不限视为 0
*/
function parseWorkExpYears (workExpDesc) {
if (!workExpDesc || typeof workExpDesc !== 'string') return null
// 应届生、经验不限视为 0 年(优先于数字匹配,避免 "26年应届生" 被解析成 26
if (/应届生|经验不限|不限/i.test(workExpDesc)) return 0
const match = workExpDesc.match(/(\d+)\s*[-~]\s*(\d+)\s*年?/) || workExpDesc.match(/(\d+)\s*年?/)
if (match) {
if (match[2] !== undefined) return Math.max(parseInt(match[1], 10), parseInt(match[2], 10))
return parseInt(match[1], 10)
}
return null
}
/**
* 从薪资描述中解析区间(单位:千/月 或 万/月,取可比较数值)
* @param {string} salaryDesc - 如 "3-5K"、"10-15K"、"20-30K"
* @returns {{ low: number, high: number }|null}
*/
function parseSalaryRange (salaryDesc) {
if (!salaryDesc || typeof salaryDesc !== 'string') return null
const normalized = salaryDesc.replace(/\s/g, '')
const match = normalized.match(/(\d+(?:\.\d+)?)\s*[-~]\s*(\d+(?:\.\d+)?)\s*[Kk万w]?/i) ||
normalized.match(/(\d+(?:\.\d+)?)\s*[Kk万w]?/i)
if (match) {
let low = parseFloat(match[1])
let high = match[2] !== undefined ? parseFloat(match[2]) : low
if (/万|w/i.test(normalized)) {
low *= 10
high *= 10
}
return { low, high }
}
return null
}
/**
* 在 page 上解析候选人列表:优先从 Vue 组件数据获取,失败则从 DOM 解析
* @param { import('puppeteer').Page } page
* @returns { Promise<Array<{ encryptGeekId: string, geekName: string, education?: string, workExp?: string, city?: string, jobTitle?: string, salary?: string, skills?: string, [key: string]: unknown }>> }
*/
export async function parseCandidateList (page) {
const listSelector = CANDIDATE_LIST_SELECTOR
const itemSelector = CANDIDATE_ITEM_SELECTOR
const nameSelector = CANDIDATE_NAME_SELECTOR
logDebug('[candidate-processor] parseCandidateList 开始', { listSelector, itemSelector })
if (!listSelector) {
logWarn('[candidate-processor] CANDIDATE_LIST_SELECTOR 未配置TODO请登录招聘端后从 DevTools 确认选择器。')
return []
}
try {
// 方式一:通过 Vue __vue__ / __vue_app__ 获取列表数据
const vueData = await page.evaluate((selector) => {
const el = document.querySelector(selector)
if (!el) return null
// Vue 2
const v2 = el.__vue__
if (v2) {
const list = v2.candidateList ?? v2.geekList ?? v2.recommendList ?? v2.list ?? v2.$data?.candidateList ?? v2.$data?.geekList ?? v2.$data?.recommendList ?? v2.$data?.list
if (Array.isArray(list) && list.length) return list
}
// Vue 3
const v3 = el.__vue_app__
if (v3?.config?.globalProperties) return null
const v3Data = el.__vueParentComponent?.ctx ?? el.__vnode?.ctx
if (v3Data) {
const list = v3Data.candidateList ?? v3Data.geekList ?? v3Data.recommendList ?? v3Data.list
if (Array.isArray(list) && list.length) return list
}
return null
}, listSelector)
if (vueData && Array.isArray(vueData)) {
logDebug('[candidate-processor] 从 Vue 数据解析到', vueData.length, '人')
return vueData.map((item) => ({
encryptGeekId: item.encryptGeekId ?? item.encryptUserId ?? item.geekId ?? item.id ?? '',
geekName: item.geekName ?? item.name ?? item.userName ?? '',
education: item.educationLevel ?? item.education ?? item.degree,
workExp: item.workExpYears ?? item.workExp ?? item.experience,
city: item.city ?? item.cityName,
jobTitle: item.jobTitle ?? item.expectPosition ?? item.position,
salary: item.salaryExpect ?? item.salary ?? item.expectSalary,
skills: item.skills ?? item.skillList ?? (Array.isArray(item.skillTags) ? item.skillTags.join(',') : ''),
...item
}))
}
// 方式二DOM 解析兜底(依赖 CANDIDATE_ITEM_SELECTOR 等,当前为 TODO 时可能为空)
if (!itemSelector) {
logDebug('[candidate-processor] 无 itemSelector跳过 DOM 解析')
return []
}
logDebug('[candidate-processor] 尝试从 DOM 解析...')
const domList = await page.evaluate((opts) => {
const items = document.querySelectorAll(opts.itemSelector)
const result = []
items.forEach((node) => {
// encryptGeekId 在 div.card-inner[data-geek] 上
const cardInner = node.querySelector('div.card-inner')
const cardWrap = node.querySelector('div.candidate-card-wrap')
const encryptGeekId = cardInner?.getAttribute('data-geek') ?? cardInner?.getAttribute('data-geekid') ?? ''
const hasViewed = cardWrap?.classList?.contains('has-viewed') ?? false
const nameEl = opts.nameSelector ? node.querySelector(opts.nameSelector) : null
const name = nameEl?.textContent?.trim() ?? ''
// 薪资div.salary-wrap > span
const salary = node.querySelector('div.salary-wrap span')?.textContent?.trim() ?? null
// 基本信息区年龄、工作年限、学历div.base-info span不含 i 分隔符)
const baseInfoSpans = Array.from(node.querySelectorAll('div.base-info span')).map(el => el.textContent.trim()).filter(Boolean)
// baseInfoSpans[0]=年龄, [1]=工作年限, [2]=学历, [3]=求职状态(顺序不固定,但经验含"年",学历含特定文字)
const workExp = baseInfoSpans.find(s => /|经验不限/.test(s)) ?? null
const education = baseInfoSpans.find(s => /本科|硕士|博士|大专|专科|MBA|中专|高中|初中/.test(s)) ?? null
// 期望城市与职位div.expect-wrap span.content 内的 join-text-wrap > span
const expectWrap = node.querySelector('div.expect-wrap span.content div.join-text-wrap')
const expectSpans = expectWrap ? Array.from(expectWrap.querySelectorAll('span')).map(el => el.textContent.trim()).filter(Boolean) : []
const city = expectSpans[0] ?? null
const jobTitle = expectSpans[1] ?? null
result.push({
encryptGeekId,
geekName: name,
education,
workExp,
city,
jobTitle,
salary,
skills: null,
_hasViewed: hasViewed,
_fromDom: true
})
})
return result
}, { itemSelector, nameSelector })
logDebug('[candidate-processor] DOM 解析到', domList.length, '人')
return domList
} catch (err) {
logWarn('[candidate-processor] parseCandidateList 失败:', err.message)
return []
}
}
/**
* 将配置的薪资区间规范为"千/月"单位。若用户填的是元(如 8000则按 1000 折成 K。
* @param {[number, number]} range - [min, max],单位可能是 K 或 元
* @returns {[number, number]} 统一为 K
*/
function normalizeSalaryRangeToK (range) {
if (!Array.isArray(range) || range.length < 2) return [0, 0]
let [min, max] = range
if (min >= 100) min = min / 1000
if (max >= 100) max = max / 1000
return [min, max]
}
/** @typedef {'city'|'education'|'workExp'|'salary'|'skills'|'blockName'|'viewed'} FilterResultReason */
/**
* 按 candidate-filter 配置筛选候选人
* @param {Array<{ encryptGeekId?: string, geekName?: string, education?: string, workExp?: string, city?: string, jobTitle?: string, salary?: string, skills?: string, _hasViewed?: boolean }>} candidates
* @param {{
* expectCityList?: string[],
* expectEducationRegExpStr?: string,
* expectEducationList?: string[],
* expectWorkExpRange?: [number, number],
* expectSalaryRange?: [number, number],
* expectSalaryWhenNegotiable?: 'exclude'|'include',
* expectSkillKeywords?: string[],
* blockCandidateNameRegExpStr?: string,
* skipViewedCandidates?: boolean
* }} filterConfig
* expectSalaryWhenNegotiable: 候选人薪资为"面议"或无法解析时:'exclude'=不通过,'include'=通过
* @returns {{ matched: Array<{ candidate: object, filterResult: { matched: true } }>, skipped: Array<{ candidate: object, filterResult: { matched: false, reason: FilterResultReason } }> }}
*/
export function filterCandidates (candidates, filterConfig) {
const {
expectCityList = [],
expectEducationRegExpStr = '',
expectEducationList = [],
expectWorkExpRange = [0, 99],
expectSalaryRange = [0, 0],
expectSalaryWhenNegotiable = 'exclude',
expectSkillKeywords = [],
blockCandidateNameRegExpStr = '',
skipViewedCandidates = false
} = filterConfig || {}
const blockNameReg = blockCandidateNameRegExpStr
? new RegExp(blockCandidateNameRegExpStr, 'i')
: null
const matched = []
const skipped = []
for (const candidate of candidates) {
const name = (candidate.geekName ?? '').trim()
if (skipViewedCandidates && candidate._hasViewed) {
skipped.push({
candidate,
filterResult: { matched: false, reason: 'viewed', reasonDetail: '已读候选人,已跳过' }
})
continue
}
if (blockNameReg && name && blockNameReg.test(name)) {
skipped.push({
candidate,
filterResult: { matched: false, reason: 'blockName', reasonDetail: `姓名"${name}"命中屏蔽正则 ${blockCandidateNameRegExpStr}` }
})
continue
}
if (Array.isArray(expectCityList) && expectCityList.length) {
const city = (candidate.city ?? '').trim()
const cityMatched = expectCityList.some((c) => city.includes(c))
if (!cityMatched) {
skipped.push({
candidate,
filterResult: {
matched: false,
reason: 'city',
reasonDetail: `期望城市 ${expectCityList.join('、')},候选人 ${city || '未填'}`
}
})
continue
}
}
const educationRegExpStr = expectEducationRegExpStr ||
(Array.isArray(expectEducationList) && expectEducationList.length ? expectEducationList.join('|') : '')
if (educationRegExpStr) {
const education = (candidate.education ?? '').trim()
if (!education || !new RegExp(educationRegExpStr).test(education)) {
skipped.push({
candidate,
filterResult: {
matched: false,
reason: 'education',
reasonDetail: `期望学历匹配 /${educationRegExpStr}/,候选人 ${education || '未填'}`
}
})
continue
}
}
const workExpYears = parseWorkExpYears(candidate.workExp ?? '')
if (workExpYears !== null && (workExpYears < expectWorkExpRange[0] || workExpYears > expectWorkExpRange[1])) {
const [minY, maxY] = expectWorkExpRange
skipped.push({
candidate,
filterResult: {
matched: false,
reason: 'workExp',
reasonDetail: `期望工作年限 ${minY}-${maxY} 年,候选人 ${candidate.workExp ?? '未知'}(解析为 ${workExpYears} 年)`
}
})
continue
}
const [salaryMinRaw, salaryMaxRaw] = expectSalaryRange
const [salaryMin, salaryMax] = normalizeSalaryRangeToK([salaryMinRaw, salaryMaxRaw])
if (salaryMin > 0 || salaryMax > 0) {
const salaryData = parseSalaryRange(candidate.salary ?? '')
const salaryStr = candidate.salary ?? '未填'
if (salaryData) {
const inRange = (salaryData.low <= salaryMax || salaryMax === 0) && (salaryData.high >= salaryMin || salaryMin === 0)
if (!inRange) {
skipped.push({
candidate,
filterResult: {
matched: false,
reason: 'salary',
reasonDetail: `期望薪资 ${salaryMin}-${salaryMax}K候选人 ${salaryStr}(约 ${salaryData.low}-${salaryData.high}K`
}
})
continue
}
} else {
if (expectSalaryWhenNegotiable === 'include') {
// 面议或无法解析时视为通过薪资条件
} else {
skipped.push({
candidate,
filterResult: {
matched: false,
reason: 'salary',
reasonDetail: `期望薪资 ${salaryMin}-${salaryMax}K候选人 ${salaryStr}(面议/无法解析,当前策略:不通过)`
}
})
continue
}
}
}
if (Array.isArray(expectSkillKeywords) && expectSkillKeywords.length) {
const skills = (candidate.skills ?? '').toLowerCase()
const hasMatch = expectSkillKeywords.some((kw) => skills.includes((kw ?? '').toLowerCase()))
if (!hasMatch) {
skipped.push({
candidate,
filterResult: {
matched: false,
reason: 'skills',
reasonDetail: `期望技能关键词 ${expectSkillKeywords.join('、')},候选人技能/优势中未匹配`
}
})
continue
}
}
matched.push({
candidate,
filterResult: { matched: true }
})
}
return { matched, skipped }
}
/**
* 滚动页面以加载更多候选人;检测是否已到底部(如“没有更多”提示)。
* 使用拟人滚轮page.mouse.wheel() 小步、随机延迟,替代一次性 window.scrollBy以规避 BOSS 滚动埋点。
*
* @param { import('puppeteer').Page } page
* @returns { Promise<boolean> } true 表示还有更多数据可加载false 表示已到底
*/
export async function scrollAndLoadMore (page) {
const listSelector = CANDIDATE_LIST_SELECTOR
const maxScrolls = 3
const stepsPerScroll = 4
const baseDelta = 25
const deltaRandom = 15
try {
for (let i = 0; i < maxScrolls; i++) {
for (let s = 0; s < stepsPerScroll; s++) {
const deltaY = baseDelta + Math.floor(deltaRandom * Math.random())
await page.mouse.wheel({ deltaY })
await sleep(80 + Math.floor(80 * Math.random()))
}
}
const hasMore = await page.evaluate((selector) => {
const hasMoreText = /没有更多|已经到底|加载完毕|暂无更多/i.test(document.body?.innerText ?? '')
if (hasMoreText) return false
if (selector) {
const el = document.querySelector(selector)
const vueHasMore = el?.__vue__?.hasMore ?? el?.__vue__?.$data?.hasMore
if (vueHasMore === false) return false
}
return true
}, listSelector)
return hasMore
} catch (err) {
logWarn('[candidate-processor] scrollAndLoadMore 失败:', err.message)
return false
}
}
/**
* 翻页(若招聘端为分页而非无限滚动)。
* 使用拟人轨迹点击"下一页"按钮,规避 BOSS 鼠标埋点。
*
* @param { import('puppeteer').Page } page
* @param {{ cursor?: object }} [options] - 可选拟人光标,不传则内部创建
* @returns { Promise<boolean> } 是否成功翻到下一页
*/
export async function navigateToNextPage (page, options = {}) {
try {
const hasNext = await page.evaluate((sel) => {
const nextBtn = document.querySelector(sel)
return !!(nextBtn && !nextBtn.classList?.contains?.('disabled'))
}, NEXT_PAGE_BUTTON_SELECTOR)
if (!hasNext) return false
const cursor = options.cursor ?? await createHumanCursor(page)
await cursor.click(NEXT_PAGE_BUTTON_SELECTOR)
await sleep(1500)
return true
} catch (err) {
logWarn('[candidate-processor] navigateToNextPage 失败:', err.message)
return false
}
}

View File

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

View File

@@ -0,0 +1,720 @@
/**
* 沟通页自动化:处理未读会话 — 对方发来附件简历则下载,对方打招呼则看在线简历后关键词/LLM 筛选,通过则请求附件简历。
* 所有页面点击均通过 createHumanCursor使用 sleepWithRandomDelay 做随机延迟。
*/
import { sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs'
import { readConfigFile, getMergedJobConfig } from './runtime-file-utils.mjs'
import { setupNetworkInterceptor, parseGeekInfoFromIntercepted } from './resume-extractor.mjs'
import { createHumanCursor } from './humanMouse.mjs'
import {
openOnlineResume,
getOnlineResumeText,
requestAttachmentResume,
openPreviewAndDownloadPdf,
hasIncomingAttachResumeRequest,
acceptIncomingAttachResume
} from './chat-page-resume.mjs'
import { filterCandidates } from './candidate-processor.mjs'
import { checkIfAlreadyContacted, saveCandidateInfo, logContact } from './data-manager.mjs'
import { setLevel, debug as logDebug, info as logInfo, warn as logWarn } from './logger.mjs'
import {
BOSS_CHAT_PAGE_URL,
CHAT_PAGE_ACTIVE_NAME_SELECTOR,
CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR,
CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR,
CHAT_PAGE_ITEM_SELECTOR,
CHAT_PAGE_ITEM_UNREAD_SELECTOR,
CHAT_PAGE_UNREAD_FILTER_SELECTOR,
CHAT_PAGE_NAME_SELECTOR,
CHAT_PAGE_JOB_SELECTOR,
CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR,
CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR
} from './constant.mjs'
const LOG = '[chat-page-processor]'
/**
* 在沟通页切换到指定职位。
* @param {import('puppeteer').Page} page
* @param {string} jobId
*/
async function switchChatPageJobId (page, jobId) {
try {
await page.click('.ui-dropmenu.chat-top-job .ui-dropmenu-label')
await page.waitForSelector('.ui-dropmenu.chat-top-job .ui-dropmenu-list', { timeout: 5000 })
const found = await page.evaluate((jid) => {
const item = document.querySelector(`.ui-dropmenu.chat-top-job .ui-dropmenu-list li[value="${jid}"]`)
if (!item) return false
item.click()
return true
}, jobId)
if (!found) {
logWarn(`${LOG} 职位 ${jobId} 未在沟通页下拉列表中找到,将使用默认职位继续`)
await page.keyboard.press('Escape')
return
}
// 等待左侧会话列表刷新
await new Promise(r => setTimeout(r, 500))
logInfo(`${LOG} 已切换到职位 ${jobId}`)
} catch (e) {
logWarn(`${LOG} 切换沟通页职位失败(${e.message}),将使用默认职位继续`)
}
}
/**
* 从完整简历文本中提取候选人姓名。
* BOSS 在线简历开头格式:[活跃状态] [姓名] [年龄/简历...]
* 活跃状态和姓名可能在不同行y坐标不同所以必须对全文按任意空白分割
* 而不能逐行匹配。第 0 个 token = 活跃状态,第 1 个 token = 姓名。
* @param {string} text - 完整简历文本(含 \n
* @returns {string|null} 识别到的姓名,或 null
*/
function extractNameFromResumeText (text) {
const tokens = text.trim().split(/\s+/).filter(t => t.length > 0)
if (tokens.length < 1) return null
// 情况一tokens[0] 是活跃状态(含"活跃"tokens[1] 是姓名
if (tokens.length >= 2 && /活跃/.test(tokens[0])) {
return tokens[1]
}
// 情况二:无活跃状态前缀,第一个 token 本身是姓名2-4个汉字无数字
if (/^[\u4e00-\u9fff]{2,4}$/.test(tokens[0])) {
return tokens[0]
}
return null
}
/**
* 去除 canvas 简历文本开头的字体渲染预热数据。
* BOSS 在 iframe 首次加载时会把所有 ASCII 可打印字符逐一 fillText 做字形预热("bzl|abcde..."
* 这些数据在 extractResumeText 排序后会出现在真正简历内容之前。
* 定位到首个含 "活跃" 的行,之前的行全部丢弃。
* @param {string[]} lines - extractResumeText 返回的行数组
* @returns {string[]} 去除预热数据后的行数组
*/
function filterFontTestLines (lines) {
const idx = lines.findIndex(line => line.includes('活跃'))
if (idx > 0) return lines.slice(idx)
// 兜底:丢弃不含任何汉字的行
return lines.filter(line => /[\u4e00-\u9fff]/.test(line))
}
/**
* 使用 LLM 根据规则筛选简历。
* 兼容 llm.json 为数组或 { configList } 格式,以及 providerCompleteApiUrl/providerApiSecret 字段名。
* @param {string} resumeText - 简历全文
* @param {string} llmRule - 筛选规则描述
* @returns {Promise<{ pass: boolean, reason: string }>} 出错时默认 pass: true
*/
export async function screenCandidateWithLlm (resumeText, llmRule) {
const defaultResult = { pass: true, reason: 'LLM 调用失败,默认通过' }
try {
const { getEnabledLlmClient } = await import('./llm-rubric.mjs')
const client = getEnabledLlmClient()
if (!client) return defaultResult
const { completes } = await import('@geekgeekrun/utils/gpt-request.mjs')
const systemContent = `你是一个招聘筛选助手。根据以下筛选规则,判断候选人简历是否符合要求。
筛选规则:${llmRule || '无'}
请仅以JSON格式回复不要包含其他内容。格式{"pass": true或false, "reason": "判断理由"}`
const completion = await completes(
{
baseURL: client.baseURL,
apiKey: client.apiKey,
model: client.model,
max_tokens: 200
},
[
{ role: 'system', content: systemContent },
{ role: 'user', content: (resumeText || '(无简历内容)').slice(0, 3500) }
]
)
const raw = completion?.choices?.[0]?.message?.content?.trim()
if (!raw) return defaultResult
const jsonStr = raw.replace(/^[\s\S]*?(\{[\s\S]*\})[\s\S]*$/, '$1')
const parsed = JSON.parse(jsonStr)
const pass = !!parsed.pass
const reason = typeof parsed.reason === 'string' ? parsed.reason : ''
return { pass, reason }
} catch (err) {
logWarn(`${LOG} screenCandidateWithLlm 失败:`, err.message)
return defaultResult
}
}
/**
* 解析沟通页左侧会话列表(仅当前 DOM 可见的项)。
* @param {import('puppeteer').Page} page
* @returns {Promise<Array<{ encryptGeekId: string, geekName: string, jobTitle: string, unread: boolean, hasAttachmentResumeInChat: boolean }>>}
*/
async function parseConversationList (page) {
const list = await page.evaluate(
({ itemSel, nameSel, jobSel, unreadSel }) => {
const items = document.querySelectorAll(itemSel)
const result = []
items.forEach((node) => {
const nameEl = node.querySelector(nameSel)
const jobEl = node.querySelector(jobSel)
const geekName = nameEl?.textContent?.trim() ?? ''
const jobTitle = jobEl?.textContent?.trim() ?? ''
// encryptGeekId is in data-id="<geekId>-0" on the .geek-item element itself
const dataId = node.getAttribute('data-id') ?? ''
const encryptGeekId = dataId.replace(/-\d+$/, '')
// unread: span.badge-count is present only when there are unread messages
const unread = !!node.querySelector(unreadSel)
result.push({
encryptGeekId,
geekName,
jobTitle,
unread,
hasAttachmentResumeInChat: false
})
})
return result
},
{
itemSel: CHAT_PAGE_ITEM_SELECTOR,
nameSel: CHAT_PAGE_NAME_SELECTOR,
jobSel: CHAT_PAGE_JOB_SELECTOR,
unreadSel: CHAT_PAGE_ITEM_UNREAD_SELECTOR
}
)
return list
}
/**
* 点击左侧某条会话(通过 encryptGeekId使右侧显示该会话。使用拟人点击。
* 若 data-id 找不到(虚拟滚动已卸载),等待短暂后重试一次。
* @param {import('puppeteer').Page} page
* @param {string} encryptGeekId - 候选人 ID对应 .geek-item[data-id="<id>-0"]
* @param {{ cursor?: object }} [options]
* @returns {Promise<boolean>}
*/
async function selectConversationById (page, encryptGeekId, options = {}) {
const cursor = options.cursor ?? await createHumanCursor(page)
const selector = `${CHAT_PAGE_ITEM_SELECTOR}[data-id="${encryptGeekId}-0"]`
let el = await page.$(selector)
if (!el) {
// 虚拟滚动可能已将该 item 滚出视口并卸载,尝试滚动列表顶部后重查
logDebug(`${LOG} → data-id 未找到,尝试滚回列表顶部后重查...`)
await page.evaluate((listSel) => {
const listEl = document.querySelector(listSel)
if (listEl) listEl.scrollTop = 0
}, CHAT_PAGE_ITEM_SELECTOR.split(' ')[0])
await new Promise(r => setTimeout(r, 300))
el = await page.$(selector)
}
if (!el) return false
// 检查元素是否在会话列表容器的可见区域内;若超出则滚动容器让其进入可见区
// 注意:元素有 boundingBox 但 Y 坐标可能超出容器裁剪区,点击会打到容器外的其他元素
const scrolled = await page.evaluate((itemSel, targetId) => {
const container = document.querySelector('.user-list.b-scroll-stable')
if (!container) return false
const item = document.querySelector(`${itemSel}[data-id="${targetId}-0"]`)
if (!item) return false
const containerRect = container.getBoundingClientRect()
const itemRect = item.getBoundingClientRect()
const isVisible = itemRect.top >= containerRect.top && itemRect.bottom <= containerRect.bottom
if (!isVisible) {
item.scrollIntoView({ block: 'nearest' })
return true
}
return false
}, CHAT_PAGE_ITEM_SELECTOR, encryptGeekId)
if (scrolled) {
logDebug(`${LOG} → 元素超出列表可见区,已滚动至可见`)
await new Promise(r => setTimeout(r, 300))
// 重新获取元素引用scrollIntoView 后 DOM 引用仍有效,但坐标已变)
el = await page.$(selector)
if (!el) return false
}
const box = await el.boundingBox()
if (!box) {
logWarn(`${LOG} → 滚动后仍无法获取坐标,跳过`)
return false
}
logDebug(`${LOG} → 找到会话元素,坐标 (${Math.round(box.x + box.width / 2)}, ${Math.round(box.y + box.height / 2)}),执行拟人点击`)
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
return true
}
/**
* 当前右侧会话中,是否存在含"点击预览附件简历"按钮的消息(对方已发来附件)。
* @param {import('puppeteer').Page} page
* @returns {Promise<boolean>}
*/
async function hasAttachmentResumeInCurrentChat (page) {
const btn = await page.$(CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR).catch(() => null)
return !!btn
}
/**
* 等待 geek/info 数据出现在拦截 Map 中(点击会话后异步到达)
* @param {() => Map<string, unknown>} peekFn - 不清空的 peek 函数
* @param {{ timeoutMs?: number, pollMs?: number }} [opts]
* @returns {Promise<Map<string, unknown> | null>} 超时返回 null
*/
async function waitForGeekInfo (peekFn, opts = {}) {
const timeoutMs = opts.timeoutMs ?? 5000
const pollMs = opts.pollMs ?? 200
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const snap = peekFn()
for (const path of snap.keys()) {
if (path.includes('geek/info')) return snap
}
await new Promise(r => setTimeout(r, pollMs))
}
return null
}
/**
* 沟通页自动化主入口。
* @param {object} hooksFromCaller - 与 startBossAutoBrowse 相同的 hooksonError, insertCandidateContactLog, createOrUpdateCandidateInfo, queryCandidateByEncryptId 等)
* @param {{ browser?: import('puppeteer').Browser, page?: import('puppeteer').Page }} [options] - 若传入则复用已有 browser/page否则内部不启动浏览器调用方需先导航到沟通页或由推荐页流程传入
*/
export default async function startBossChatPageProcess (hooksFromCaller, options = {}) {
const hooks = hooksFromCaller || {}
const { page: existingPage, getCapturedText, clearCapturedText, jobId = null } = options
/** @type {import('puppeteer').Page} */
let page = existingPage
if (!page) {
logInfo(`${LOG} 未传入 page跳过沟通页处理。`)
return
}
const baseConfig = readConfigFile('boss-recruiter.json') || {}
const config = jobId ? getMergedJobConfig(jobId) : { ...baseConfig, chatPage: baseConfig.chatPage }
setLevel(config.logLevel || 'info')
const chatPageConfig = config.chatPage || {}
if (chatPageConfig.enabled === false) {
logInfo(`${LOG} 沟通页处理已关闭,跳过。`)
return
}
const maxProcessPerRun = chatPageConfig.maxProcessPerRun ?? 20
const preFilterConf = chatPageConfig.preFilter || {}
const filterConf = chatPageConfig.filter || {}
// 当 BOSS 直聘已配置「接收附件简历自动发到邮箱」时,可将此开关设为 true
// 系统将仅发出索取请求,不再打开预览弹窗下载 PDFwebhook resumeFile 字段在此模式下为空)。
const skipAttachmentResumeDownload = chatPageConfig.attachmentResume?.skipDownload === true
const mode = filterConf.mode || 'keywords'
const keywordList = Array.isArray(filterConf.keywordList) ? filterConf.keywordList : []
const llmRule = typeof filterConf.llmRule === 'string' ? filterConf.llmRule : ''
const llmConfig = filterConf.llmConfig || null
const hasPreFilter = Object.keys(preFilterConf).some((k) => {
const v = preFilterConf[k]
return Array.isArray(v) ? v.length > 0 : !!v
})
logDebug(`${LOG} 配置maxProcessPerRun=${maxProcessPerRun} mode=${mode} hasPreFilter=${hasPreFilter}`)
try {
const onChatPage = page.url().startsWith(BOSS_CHAT_PAGE_URL) || page.url().includes('/web/chat/')
if (!onChatPage) {
logInfo(`${LOG} 当前不在沟通页,正在导航...`)
await page.goto(BOSS_CHAT_PAGE_URL, { timeout: 60 * 1000 })
await page.waitForFunction(() => document.readyState === 'complete', { timeout: 120 * 1000 })
logDebug(`${LOG} 导航完成,当前 URL: ${page.url()}`)
} else {
logDebug(`${LOG} 已在沟通页: ${page.url()}`)
}
const { getInterceptedData, peekInterceptedData } = setupNetworkInterceptor(page)
logDebug(`${LOG} 网络拦截器已设置,等待会话列表渲染...`)
// 等待虚拟滚动列表渲染出至少一条会话 itemreadyState='complete' 不够Vue 组件需额外时间)
try {
await page.waitForSelector(CHAT_PAGE_ITEM_SELECTOR, { timeout: 15000 })
logDebug(`${LOG} 会话列表元素已出现`)
} catch {
logWarn(`${LOG} 等待会话列表超时15s列表可能为空`)
}
// 切换职位(若指定了 jobId 且非全部职位标志)
if (jobId && jobId !== '-1') {
await switchChatPageJobId(page, jobId)
}
// 切换到"未读"tab确保虚拟滚动列表只包含未读会话避免全部模式下未读项在视口外未渲染
const cursor = await createHumanCursor(page)
const unreadTabActive = await page.evaluate((sel) => {
const el = document.querySelector(sel)
return el ? el.classList.contains('active') : false
}, CHAT_PAGE_UNREAD_FILTER_SELECTOR)
if (!unreadTabActive) {
logInfo(`${LOG} 切换到「未读」tab...`)
const tabEl = await page.$(CHAT_PAGE_UNREAD_FILTER_SELECTOR)
if (tabEl) {
const box = await tabEl.boundingBox()
if (box) {
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
await sleepWithRandomDelay(400, 600)
// 等列表刷新
try {
await page.waitForSelector(CHAT_PAGE_ITEM_SELECTOR, { timeout: 5000 })
logDebug(`${LOG} 「未读」tab 切换后列表已刷新`)
} catch {
logDebug(`${LOG} 「未读」tab 切换后列表为空(无未读会话)`)
}
}
} else {
logWarn(`${LOG} 未找到「未读」tab 元素selector: ${CHAT_PAGE_UNREAD_FILTER_SELECTOR}`)
}
} else {
logDebug(`${LOG} 已在「未读」tab`)
}
await sleepWithRandomDelay(300)
const conversations = await parseConversationList(page)
logDebug(`${LOG} DOM 解析到 ${conversations.length} 条会话`)
// 已切到"未读"tab列表中所有 item 均为未读会话,无需再用 badge-count 二次过滤
const unreadItems = conversations.filter((c) => c.encryptGeekId)
const toProcess = unreadItems.slice(0, maxProcessPerRun)
logInfo(`${LOG} 未读会话 ${unreadItems.length} 条,本次最多处理 ${toProcess.length}`)
if (toProcess.length > 0) {
logDebug(`${LOG} 候选人列表:${toProcess.map((c, i) => `[${i}] ${c.geekName}(${c.encryptGeekId})`).join(', ')}`)
}
await hooks.onProgress?.promise?.({ phase: 'chatPage', current: 0, max: toProcess.length }).catch(() => {})
let processed = 0
for (let i = 0; i < toProcess.length; i++) {
const item = toProcess[i]
const { encryptGeekId, geekName, jobTitle } = item
logInfo(`${LOG} ── [${i + 1}/${toProcess.length}] 开始处理 ${geekName}${encryptGeekId})──`)
const { contacted } = await checkIfAlreadyContacted(encryptGeekId, hooks)
if (contacted) {
logInfo(`${LOG} → 已在数据库中联系过,跳过`)
continue
}
logDebug(`${LOG} → 数据库未记录,继续处理`)
// 切换会话前必须确保在线简历弹窗已关闭。
// 弹窗遮挡会导致下方会话列表的点击被拦截,使会话无法切换(右侧面板仍显示上一个人),
// 进而导致打开的在线简历是上一个候选人的数据。
{
const resumeCloseBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null)
if (resumeCloseBtn) {
logDebug(`${LOG} → 检测到在线简历弹窗未关闭,点击关闭...`)
await page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => {})
try {
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 4000 })
logDebug(`${LOG} → 在线简历弹窗已关闭`)
} catch {
// 仍未消失则强制刷新检查
const stillOpen = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null)
if (stillOpen) {
logWarn(`${LOG} → 在线简历弹窗关闭失败4s 超时),继续尝试切换会话,但可能影响会话切换成功率`)
}
}
}
}
// 点击前先清空拦截数据,确保拿到的是本次点击触发的 geek/infoBOSS 有缓存时可能不重新请求)
getInterceptedData()
logDebug(`${LOG} → 点击会话...`)
const selected = await selectConversationById(page, encryptGeekId, { cursor })
if (!selected) {
logWarn(`${LOG} → 无法在 DOM 中找到该会话(可能已被标为已读或滚出虚拟滚动视口),跳过`)
continue
}
logInfo(`${LOG} → 会话已选中,等待页面加载...`)
await sleepWithRandomDelay(600, 1200)
// 验证右侧面板已切换到目标候选人(防止会话点击未生效、面板仍停留在上一人)
{
const panelName = await page.$eval(CHAT_PAGE_ACTIVE_NAME_SELECTOR, el => el.textContent?.trim() ?? '').catch(() => '')
if (panelName && !geekName.includes(panelName) && !panelName.includes(geekName)) {
logWarn(`${LOG} → 右侧面板姓名「${panelName}」与期望「${geekName}」不符,会话切换未生效,跳过`)
await sleepWithRandomDelay(300, 600)
continue
}
if (panelName) {
logDebug(`${LOG} → 右侧面板验证:「${panelName}」✓`)
}
}
// 关闭「意向沟通」提示弹窗BOSS 每次新浏览器会话打开某些会话时会弹出,遮挡右侧操作按钮)
{
const intentKnowBtn = await page.$(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR).catch(() => null)
if (intentKnowBtn) {
logInfo(`${LOG} → 检测到「意向沟通」提示弹窗,点击「我知道了」关闭...`)
try {
await page.click(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR)
} catch {
// 备用:点关闭图标
await page.click(CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR).catch(() => {})
}
// 等弹窗消失(点击后 Vue 会移除 DOM
try {
await page.waitForSelector(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, { hidden: true, timeout: 3000 })
logDebug(`${LOG} → 「意向沟通」弹窗已关闭`)
} catch {
logWarn(`${LOG} → 「意向沟通」弹窗 3s 内未消失,继续执行(可能影响按钮点击)`)
}
await sleepWithRandomDelay(200, 400)
}
}
// 阶段一:初步信息筛选(点击会话后 geek/info 已触发,从拦截数据取结构化字段)
// 注意:使用 peekInterceptedData不清空而非 getInterceptedData清空避免数据被消费后简历筛选阶段拿不到
if (hasPreFilter) {
logDebug(`${LOG} → 等待 geek/info 数据(初步筛选,最长 5s...`)
let geekInfoSnap = await waitForGeekInfo(peekInterceptedData, { timeoutMs: 5000 })
if (!geekInfoSnap) {
// geek/info 未到达,可能是 BOSS 缓存导致未重新请求;重新点击一次会话触发请求
logWarn(`${LOG} → geek/info 未到达,重试点击会话...`)
getInterceptedData()
const retrySelected = await selectConversationById(page, encryptGeekId, { cursor })
if (retrySelected) {
await sleepWithRandomDelay(400, 800)
geekInfoSnap = await waitForGeekInfo(peekInterceptedData, { timeoutMs: 5000 })
}
}
if (geekInfoSnap) {
logDebug(`${LOG} → geek/info 已到达,解析中...`)
const { data: geekInfoData } = parseGeekInfoFromIntercepted(geekInfoSnap)
if (geekInfoData) {
logInfo(`${LOG} → geek/info 摘要:学历=${geekInfoData.edu} 工龄=${geekInfoData.workYear} 城市=${geekInfoData.city} 薪资=${geekInfoData.salaryDesc ?? geekInfoData.price}`)
const candidateForFilter = {
encryptGeekId,
geekName,
education: geekInfoData.edu ?? null,
workExp: geekInfoData.workYear ?? null,
city: geekInfoData.city ?? null,
salary: geekInfoData.salaryDesc ?? geekInfoData.price ?? null
}
const { skipped } = filterCandidates([candidateForFilter], preFilterConf)
if (skipped.length > 0) {
const reason = skipped[0].filterResult.reasonDetail || skipped[0].filterResult.reason
logInfo(`${LOG} → 初步信息筛选不通过:${reason},跳过`)
await logContact(encryptGeekId, 'resume_screened_out', null, `preFilter:${reason}`, hooks)
await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'screened_out' }, hooks)
// 消费并清空拦截数据,准备下一个候选人
getInterceptedData()
await sleepWithRandomDelay(300, 600)
continue
}
logInfo(`${LOG} → 初步信息筛选通过`)
} else {
logDebug(`${LOG} → geek/info 响应无结构化数据zpData.data 为空),跳过初步筛选`)
}
} else {
logWarn(`${LOG} → 等待 geek/info 超时(重试后仍未到达),跳过初步筛选`)
}
}
// 检查候选人是否主动发来了附件简历请求("同意/拒绝"提示),若有则自动同意
const hasIncoming = await hasIncomingAttachResumeRequest(page)
if (hasIncoming) {
logInfo(`${LOG} → 检测到对方主动发送附件简历请求,自动点击"同意"...`)
const accepted = await acceptIncomingAttachResume(page, { cursor })
if (accepted) {
logInfo(`${LOG} → 已同意对方发送附件简历`)
await logContact(encryptGeekId, 'attachment_resume_accepted_incoming', null, 'success', hooks)
} else {
logWarn(`${LOG} → 点击"同意"失败(按钮未找到或不可见)`)
}
}
// 先检查:对方是否已发来附件简历消息(我方此前请求已被对方同意,或上方同意后出现预览按钮)
const hasAttachment = await hasAttachmentResumeInCurrentChat(page)
logInfo(`${LOG} → 附件简历检查:${hasAttachment ? '已有(对方已发来附件)' : '无'}`)
if (hasAttachment) {
if (skipAttachmentResumeDownload) {
logInfo(`${LOG} → 已有附件简历,但 skipDownload=true已配置自动发邮箱跳过 PDF 下载`)
} else {
logInfo(`${LOG} → 下载附件简历...`)
const { clickedDownload } = await openPreviewAndDownloadPdf(page, null, { cursor })
if (clickedDownload) {
logInfo(`${LOG} → 附件简历下载成功`)
await logContact(encryptGeekId, 'attachment_resume_downloaded', null, 'success', hooks)
} else {
logWarn(`${LOG} → 附件简历下载失败(未找到下载按钮)`)
}
}
await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'contacted' }, hooks)
processed++
await hooks.onProgress?.promise?.({ phase: 'chatPage', current: processed, max: toProcess.length }).catch(() => {})
getInterceptedData()
await sleepWithRandomDelay(1000, 2500)
continue
}
// 无附件简历 → 说明对方只是打招呼,需要我方先筛选再决定是否索取
// 点击「查看在线简历」(等 iframe 出现即视为打开成功geek/info 数据点击会话时已触发,复用之)
logInfo(`${LOG} → 对方打招呼,点击查看在线简历...`)
// 打开新简历前清空残留数据(主要清除 geek/info 等待阶段积累的旧 iframe postMessage
if (typeof clearCapturedText === 'function') {
await clearCapturedText(page)
}
// 传入 clearCapturedText在旧弹窗关闭后、新简历点击前再清一次关闭过程中可能有新一批旧数据到达
const openedResume = await openOnlineResume(page, { cursor, clearCapturedText: clearCapturedText || undefined })
if (!openedResume) {
logWarn(`${LOG} → 未找到「查看在线简历」按钮或 iframe 未出现,跳过`)
await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'viewed' }, hooks)
getInterceptedData()
await sleepWithRandomDelay(500, 1000)
continue
}
logInfo(`${LOG} → 在线简历 iframe 已出现,等待 Canvas 渲染完成...`)
let resumeText = ''
if (typeof getCapturedText === 'function') {
const { extractResumeText } = await import('./resume-extractor.mjs')
// 稳定轮询:连续两次 peek 到相同数量(且 > 0视为渲染完成
// WASM 通常在 iframe 出现后 1~2s 内完成全部渲染
const POLL_INTERVAL_MS = 400
const STABLE_POLLS_NEEDED = 2
const CANVAS_POLL_TIMEOUT = 8000
const canvasDeadline = Date.now() + CANVAS_POLL_TIMEOUT
let lastCount = -1
let stableCount = 0
while (Date.now() < canvasDeadline) {
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS))
const currentCount = await page.evaluate(() => (window.__canvasCapturedText || []).length)
if (currentCount > 0 && currentCount === lastCount) {
stableCount++
if (stableCount >= STABLE_POLLS_NEEDED) break
} else {
stableCount = currentCount > 0 ? 1 : 0
}
lastCount = currentCount
}
// 最终读取并清空getCapturedText 内部再等 150ms 确保最后一批 postMessage 已到)
const captured = await getCapturedText(page)
const rawLines = extractResumeText(captured)
// 去除 BOSS 字体预热数据(首次 iframe 加载时 "bzl|abcdef..." 类的 ASCII 行)
const lines = filterFontTestLines(rawLines)
resumeText = lines.join('\n')
logInfo(`${LOG} → Canvas 抓取完成,共 ${captured.length} 次 fillText 调用,文本 ${resumeText.length} 字(原始行 ${rawLines.length},过滤后 ${lines.length}`)
if (captured.length === 0) {
logWarn(`${LOG} → Canvas 为空(等待 ${CANVAS_POLL_TIMEOUT}ms 超时),降级使用 geek/info 摘要...`)
const out = await getOnlineResumeText(page, { getInterceptedData })
resumeText = out.text
logInfo(`${LOG} → geek/info 摘要文本 ${resumeText.length}`)
} else {
// 验证简历内容是否属于当前候选人。
// geekName 已知,直接用 includes 最可靠extractNameFromResumeText 仅用于 warn 消息。
const detectedName = extractNameFromResumeText(resumeText)
logDebug(`${LOG} → 简历姓名识别:${detectedName || '(未识别)'}(期望:${geekName}`)
if (!resumeText.includes(geekName)) {
logWarn(`${LOG} → [简历不匹配] 期望: ${geekName},简历检测到: ${detectedName || '(未识别)'}`)
logWarn(`${LOG} → 右侧面板未切换到本会话geek/info 超时或被安全验证打断),跳过,下次运行时重试`)
getInterceptedData()
await sleepWithRandomDelay(300, 600)
continue
}
}
} else {
logWarn(`${LOG} → 无 Canvas hook从 geek/info 摘要提取...`)
const out = await getOnlineResumeText(page, { getInterceptedData })
resumeText = out.text
logInfo(`${LOG} → geek/info 摘要文本 ${resumeText.length}`)
}
if (!resumeText) {
logWarn(`${LOG} → 简历文本为空geek/info 未到达或 zpData.data 无内容)`)
} else {
logDebug(`${LOG} → 简历文本前200字${resumeText.slice(0, 200).replace(/\n/g, ' ')}`)
logInfo(`${LOG} → 简历文本获取成功(共 ${resumeText.length} 字)`)
}
let pass = true
let filterReason = ''
if (mode === 'keywords') {
const normalized = (resumeText || '').toLowerCase()
const hasKeyword = keywordList.length === 0 || keywordList.some((kw) => normalized.includes((kw || '').toLowerCase()))
pass = hasKeyword
filterReason = hasKeyword ? '' : `关键词未匹配(列表:${keywordList.join('、')}`
logInfo(`${LOG} → 关键词筛选:${pass ? '通过' : filterReason}`)
} else if (mode === 'llm') {
logInfo(`${LOG} → LLM 筛选中...`)
let result
if (llmConfig?.rubric) {
const { evaluateResumeByRubric } = await import('./llm-rubric.mjs')
result = await evaluateResumeByRubric(resumeText, {
knockouts: llmConfig.rubric?.knockouts,
dimensions: llmConfig.rubric?.dimensions,
passThreshold: llmConfig.passThreshold ?? 75
})
pass = result.isPassed
filterReason = result.reason || ''
logInfo(`${LOG} → LLM Rubric 筛选:${pass ? '通过' : '不通过'},得分:${result.totalScore},原因:${filterReason}`)
} else {
result = await screenCandidateWithLlm(resumeText, llmRule)
pass = result.pass
filterReason = result.reason || ''
logInfo(`${LOG} → LLM 筛选:${pass ? '通过' : '不通过'},原因:${filterReason}`)
}
} else {
logDebug(`${LOG} → 无筛选模式mode=${mode}),默认通过`)
}
if (pass) {
logInfo(`${LOG} → 筛选通过,发送索取附件简历请求...`)
// 在线简历弹窗若仍开着,会遮挡附件简历按钮导致点击失效,必须先完全关闭(含动画)
const openResumeCloseBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null)
if (openResumeCloseBtn) {
logDebug(`${LOG} → 先关闭在线简历弹窗,避免遮挡附件简历按钮...`)
await page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => {})
try {
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 3000 })
logDebug(`${LOG} → 在线简历弹窗已关闭`)
} catch {
logWarn(`${LOG} → 在线简历弹窗关闭超时,继续尝试(可能影响附件简历按钮点击)`)
}
// 等待弹窗 CSS 动画完全结束,确保不再遮挡下方按钮
await new Promise(r => setTimeout(r, 400))
}
const { requested, error } = await requestAttachmentResume(page, { cursor })
if (requested) {
logInfo(`${LOG} → 附件简历索取请求已发送`)
await logContact(encryptGeekId, 'attachment_resume_requested', null, 'success', hooks)
} else {
logWarn(`${LOG} → 附件简历索取失败:${error}`)
await logContact(encryptGeekId, 'attachment_resume_requested', null, error || 'failed', hooks)
}
} else {
logInfo(`${LOG} → 筛选不通过(${filterReason}),跳过`)
await logContact(encryptGeekId, 'resume_screened_out', null, filterReason || 'screened_out', hooks)
}
await saveCandidateInfo(
{
encryptGeekId,
geekName,
jobTitle,
status: pass ? 'contacted' : 'screened_out',
rawData: { resumeText: (resumeText || '').slice(0, 2000) }
},
hooks
)
processed++
await hooks.onProgress?.promise?.({ phase: 'chatPage', current: processed, max: toProcess.length }).catch(() => {})
getInterceptedData()
await sleepWithRandomDelay(1000, 2500)
}
logInfo(`${LOG} 本次共处理 ${processed} 条未读会话`)
} catch (err) {
await hooks.onError?.promise?.(err)
throw err
}
}

View File

@@ -0,0 +1,393 @@
/**
* 沟通页:先看在线简历 → 提取文字供关键词/LLM 筛选 → 再请求附件简历
*
* 在线简历有两套数据(详见 plan/chat_page_resume_flow.md
* - 简单摘要geek/info、historyMsg body.resume仅工作单位/学校等,可拦截 API 得到。
* - 完整版(图片里那种):加密 → WASM 解密 → 仅画到 #resume Canvas无明文 API要完整版需 Canvas hook 或逆向 WASM。
*
* 看在线简历无需对方同意;下载 PDF 需先请求附件简历,等对方同意后在新消息里点预览→下载。
*
* 所有在招聘端页面上的点击均通过 humanMouse 拟人轨迹执行,规避 BOSS 鼠标埋点。
*/
import { extractResumeText, parseGeekInfoFromIntercepted } from './resume-extractor.mjs'
import { sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs'
import { createHumanCursor } from './humanMouse.mjs'
import {
CHAT_PAGE_ONLINE_RESUME_SELECTOR,
CHAT_PAGE_ONLINE_RESUME_IFRAME_SELECTOR,
CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR,
CHAT_PAGE_ATTACH_RESUME_BTN_SELECTOR,
CHAT_PAGE_ASK_RESUME_CONFIRM_BTN_SELECTOR,
CHAT_PAGE_MESSAGE_ITEM_SELECTOR,
CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR,
CHAT_PAGE_ACCEPT_ATTACH_RESUME_BTN_SELECTOR,
CHAT_PAGE_DOWNLOAD_PDF_BTN_SELECTOR,
CHAT_PAGE_ATTACH_RESUME_DIALOG_CLOSE_SELECTOR,
CHAT_PAGE_TAB_ALL_SELECTOR,
CHAT_PAGE_ITEM_SELECTOR,
CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR,
CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR
} from './constant.mjs'
/**
* 点击"查看在线简历",等待简历内容区域(#resume出现。
* 调用前需已选中某条会话(右侧已为该候选人)。
* 使用拟人轨迹点击,规避 BOSS 鼠标埋点。
*
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
* @param {{ timeout?: number, cursor?: object }} [options] - timeout 毫秒cursor 可选,拟人光标(不传则内部创建)
* @returns {Promise<boolean>} 是否成功打开并看到 #resume
*/
export async function openOnlineResume (page, options = {}) {
const timeout = options.timeout ?? 10000
const cursor = options.cursor ?? await createHumanCursor(page)
// clearCapturedText 可选传入:关闭旧弹窗后调用,清空旧 iframe 在关闭过程中产生的残留 postMessage
const clearCapturedText = typeof options.clearCapturedText === 'function' ? options.clearCapturedText : null
const btn = await page.$(CHAT_PAGE_ONLINE_RESUME_SELECTOR)
if (!btn) {
console.log('[openOnlineResume] 未找到在线简历按钮 (selector:', CHAT_PAGE_ONLINE_RESUME_SELECTOR, ')')
return false
}
// 若在线简历弹窗已打开,先关闭它(弹窗不会随切换候选人自动关闭)
const closeBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR)
if (closeBtn) {
console.log('[openOnlineResume] 检测到旧简历弹窗,点击关闭按钮...')
try {
// 直接用 page.click比坐标点击更可靠不受 ghost-cursor 偏移影响)
await page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR)
} catch (e) {
// 备用:坐标点击
const closeBox = await closeBtn.boundingBox().catch(() => null)
if (closeBox) {
await cursor.click({ x: closeBox.x + closeBox.width / 2, y: closeBox.y + closeBox.height / 2 })
}
}
// 等关闭按钮从 DOM 消失(即弹窗完全关闭),比等 iframe 消失更可靠
try {
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 4000 })
console.log('[openOnlineResume] 旧简历弹窗已关闭')
} catch {
console.log('[openOnlineResume] 关闭按钮 4s 内未消失,继续执行')
}
// 关闭期间旧 iframe 可能还会发来残留 postMessage在点开新简历前清掉
if (clearCapturedText) {
await clearCapturedText(page)
console.log('[openOnlineResume] 旧弹窗关闭后残留 Canvas 数据已清空')
}
}
// 用坐标点击,更可靠
const box = await btn.boundingBox()
if (!box) {
console.log('[openOnlineResume] 在线简历按钮无法获取坐标')
return false
}
console.log('[openOnlineResume] 点击在线简历按钮,坐标:', Math.round(box.x + box.width / 2), Math.round(box.y + box.height / 2))
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
try {
// #resume 在 iframe 内部,主页面 waitForSelector 找不到;改为等待 iframe 本身出现
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_IFRAME_SELECTOR, { timeout })
console.log('[openOnlineResume] 在线简历 iframe 已出现')
return true
} catch {
console.log('[openOnlineResume] 等待 iframe 超时 (', timeout, 'ms )')
return false
}
}
/**
* 从已拦截的网络数据中取沟通页在线简历(推荐)。
* 在 openOnlineResume 之前调用 setupNetworkInterceptor(page),点开在线简历后页面会请求 geek/info
* 调用 getInterceptedData() 并传入本函数即可得到与 #resume 上简历内容一致的结构化数据与全文,无需 Canvas hook。
*
* @param {() => Map<string, unknown>} getInterceptedData - setupNetworkInterceptor 返回的 getInterceptedData
* @returns {{ text: string, lines: string[], data: object | null }} data 为 zpData.datatext 为拼接全文lines 按行数组
*/
export function getOnlineResumeDataFromApi (getInterceptedData) {
const map = getInterceptedData()
const { data, text } = parseGeekInfoFromIntercepted(map)
const lines = text ? text.split('\n').filter(Boolean) : []
return { text, lines, data }
}
/**
* 获取在线简历文字,供关键词或 LLM 筛选。
* 优先用 API传 getInterceptedData 时从拦截的 geek/info 解析,与页面 #resume 内容一致且不触发反爬。
* 仅当未传 getInterceptedData 时才用 getCapturedText需事先 setupCanvasTextHook可能被反爬检测不推荐在沟通页使用
*
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
* @param {{ getInterceptedData?: () => Map<string, unknown>, getCapturedText?: (p: import('puppeteer').Page) => Promise<Array<{text: string, x: number, y: number}>> }} [extractors] - 优先 getInterceptedDataAPI否则 getCapturedTextCanvas
* @param {{ waitForSelector?: string, paintDelayMs?: number }} [options] - 仅 Canvas 路径使用
* @returns {Promise<{ text: string, lines: string[], data?: object | null }>}
*/
export async function getOnlineResumeText (page, extractors = {}, options = {}) {
const { getInterceptedData, getCapturedText } = extractors
if (typeof getInterceptedData === 'function') {
const out = getOnlineResumeDataFromApi(getInterceptedData)
return { text: out.text, lines: out.lines, data: out.data }
}
if (typeof getCapturedText === 'function') {
const contentSelector = options.waitForSelector ?? CHAT_PAGE_ONLINE_RESUME_CONTENT_SELECTOR
const paintDelayMs = options.paintDelayMs ?? 800
try {
await page.waitForSelector(contentSelector, { timeout: 5000 })
} catch {
return { text: '', lines: [], data: null }
}
await new Promise(r => setTimeout(r, paintDelayMs))
const captured = await getCapturedText(page)
const lines = extractResumeText(captured)
const text = lines.join('\n')
return { text, lines, data: null }
}
return { text: '', lines: [], data: null }
}
/**
* 点击"附件简历"并在确认弹窗中点击确认("确定向牛人索取简历吗")。
* 调用前需已选中某条会话且已决定向该候选人请求附件。
* 注意:请求后 PDF 不会立刻到手,需等对方同意,对方同意后会在聊天里发来新消息(异步),
* 再用 waitForAttachmentResumeMessage 等该条消息出现后,点"点击预览附件简历"→"下载 PDF"。
* 使用拟人轨迹点击。
*
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
* @param {{ confirmTimeout?: number, cursor?: object }} [options] - confirmTimeout 默认 5000cursor 可选
* @returns {Promise<{ requested: boolean, error?: string }>}
*/
export async function requestAttachmentResume (page, options = {}) {
const confirmTimeout = options.confirmTimeout ?? 8000
const cursor = options.cursor ?? await createHumanCursor(page)
// 请求前先检测并关闭 tutorial/意向沟通弹窗,避免遮挡附件简历按钮或误点
const intentKnowBtn = await page.$(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR).catch(() => null)
if (intentKnowBtn) {
console.log('[requestAttachmentResume] 检测到意向沟通/tutorial 弹窗,先关闭...')
try {
await intentKnowBtn.click()
await page.waitForSelector(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, { hidden: true, timeout: 3000 })
console.log('[requestAttachmentResume] tutorial 弹窗已关闭')
} catch {
const closeIcon = await page.$(CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR).catch(() => null)
if (closeIcon) await closeIcon.click().catch(() => {})
}
await sleepWithRandomDelay(300, 600)
}
// 检查是否有残留的确认弹窗上一个候选人流程遗留v-if 未关闭)
// 若存在则先等它消失;若等不到则视为卡死,直接报错,不继续执行
const staleConfirm = await page.$(CHAT_PAGE_ASK_RESUME_CONFIRM_BTN_SELECTOR).catch(() => null)
if (staleConfirm) {
console.log('[requestAttachmentResume] 检测到残留的确认弹窗(上一次遗留),等待其消失(最长 3s...')
try {
await page.waitForSelector(CHAT_PAGE_ASK_RESUME_CONFIRM_BTN_SELECTOR, { hidden: true, timeout: 3000 })
console.log('[requestAttachmentResume] 残留弹窗已消失,继续')
} catch {
console.log('[requestAttachmentResume] 残留弹窗 3s 内未消失,跳过本次请求以避免误操作')
return { requested: false, error: 'STALE_CONFIRM_DIALOG' }
}
}
// 找到附件简历按钮并用坐标点击
const attachBtn = await page.$(CHAT_PAGE_ATTACH_RESUME_BTN_SELECTOR)
if (!attachBtn) {
console.log('[requestAttachmentResume] 未找到附件简历按钮 (selector:', CHAT_PAGE_ATTACH_RESUME_BTN_SELECTOR, ')')
return { requested: false, error: 'ATTACH_RESUME_BUTTON_NOT_FOUND' }
}
const attachBox = await attachBtn.boundingBox()
if (!attachBox) {
console.log('[requestAttachmentResume] 附件简历按钮 boundingBox 为空(不在视口或不可见)')
return { requested: false, error: 'ATTACH_RESUME_BUTTON_NOT_VISIBLE' }
}
console.log('[requestAttachmentResume] 点击附件简历按钮,坐标:', Math.round(attachBox.x + attachBox.width / 2), Math.round(attachBox.y + attachBox.height / 2))
await cursor.click({ x: attachBox.x + attachBox.width / 2, y: attachBox.y + attachBox.height / 2 })
// 等 Vue 响应点击事件并插入确认弹窗 DOMv-if
await sleepWithRandomDelay(400, 800)
// 等待确认弹窗出现v-if 插入 DOM 后即可见)
try {
console.log('[requestAttachmentResume] 等待确认弹窗出现visible:true最长', confirmTimeout, 'ms...')
await page.waitForSelector(CHAT_PAGE_ASK_RESUME_CONFIRM_BTN_SELECTOR, { visible: true, timeout: confirmTimeout })
} catch (e) {
console.log('[requestAttachmentResume] 确认弹窗未出现(超时或选择器不匹配):', e?.message)
return { requested: false, error: 'CONFIRM_DIALOG_TIMEOUT' }
}
// 再次获取确认按钮元素做坐标点击
const confirmBtn = await page.$(CHAT_PAGE_ASK_RESUME_CONFIRM_BTN_SELECTOR)
if (!confirmBtn) {
console.log('[requestAttachmentResume] 确认按钮元素消失waitForSelector 后立即消失)')
return { requested: false, error: 'CONFIRM_BTN_DISAPPEARED' }
}
const confirmBox = await confirmBtn.boundingBox()
if (!confirmBox) {
console.log('[requestAttachmentResume] 确认按钮 boundingBox 为空')
return { requested: false, error: 'CONFIRM_BTN_NOT_VISIBLE' }
}
console.log('[requestAttachmentResume] 点击确认按钮,坐标:', Math.round(confirmBox.x + confirmBox.width / 2), Math.round(confirmBox.y + confirmBox.height / 2))
await cursor.click({ x: confirmBox.x + confirmBox.width / 2, y: confirmBox.y + confirmBox.height / 2 })
// 等 Vue 响应点击并移除弹窗 DOM
await sleepWithRandomDelay(400, 800)
// 等确认弹窗消失,确认点击已被 Vue 响应v-if 变 false 移除 DOM
try {
await page.waitForSelector(CHAT_PAGE_ASK_RESUME_CONFIRM_BTN_SELECTOR, { hidden: true, timeout: 3000 })
console.log('[requestAttachmentResume] 确认弹窗已消失,请求成功')
return { requested: true }
} catch {
console.log('[requestAttachmentResume] 确认弹窗 3s 内未消失(点击未生效或 Vue 未响应),请求视为失败')
return { requested: false, error: 'CONFIRM_DIALOG_NOT_CLOSED' }
}
}
/**
* 检查当前会话是否有候选人主动发来的附件简历请求("对方想发送附件简历给您,您是否同意")。
* @param {import('puppeteer').Page} page
* @returns {Promise<boolean>}
*/
export async function hasIncomingAttachResumeRequest (page) {
const btn = await page.$(CHAT_PAGE_ACCEPT_ATTACH_RESUME_BTN_SELECTOR).catch(() => null)
return !!btn
}
/**
* 点击"同意"按钮,接受候选人主动发来的附件简历请求。
* @param {import('puppeteer').Page} page
* @param {{ cursor?: object }} [options]
* @returns {Promise<boolean>} 是否成功点击
*/
export async function acceptIncomingAttachResume (page, options = {}) {
const cursor = options.cursor ?? await createHumanCursor(page)
const acceptBtn = await page.$(CHAT_PAGE_ACCEPT_ATTACH_RESUME_BTN_SELECTOR)
if (!acceptBtn) {
console.log('[acceptIncomingAttachResume] 未找到"同意"按钮')
return false
}
const box = await acceptBtn.boundingBox()
if (!box) {
console.log('[acceptIncomingAttachResume] "同意"按钮 boundingBox 为空')
return false
}
console.log('[acceptIncomingAttachResume] 点击"同意"按钮,坐标:', Math.round(box.x + box.width / 2), Math.round(box.y + box.height / 2))
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
return true
}
/**
* 等待聊天中出现"带附件简历"的新消息(对方同意请求后发来的那条,含"点击预览附件简历")。
* 请求附件简历是异步的requestAttachmentResume 只是发出请求,需轮询直到新消息里出现预览按钮。
*
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
* @param {{ timeout?: number, pollIntervalMs?: number }} [options] - timeout 总超时(默认 120000pollIntervalMs 轮询间隔(默认 2000
* @returns {Promise<{ found: boolean, element?: import('puppeteer').ElementHandle }>} 若 found 为 trueelement 为包含"点击预览附件简历"的那条消息的容器(可在此容器内点预览、再点下载)
*/
export async function waitForAttachmentResumeMessage (page, options = {}) {
const timeout = options.timeout ?? 120000
const pollIntervalMs = options.pollIntervalMs ?? 2000
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
const msgItems = await page.$$(CHAT_PAGE_MESSAGE_ITEM_SELECTOR)
for (const el of msgItems) {
const hasPreview = await el.$(CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR).then(b => !!b).catch(() => false)
if (hasPreview) {
return { found: true, element: el }
}
}
await new Promise(r => setTimeout(r, pollIntervalMs))
}
return { found: false }
}
/**
* 在已拿到"带附件简历"的消息容器后,点击"点击预览附件简历"并等待预览弹窗出现,再点击"下载 PDF"。
* 若需指定下载目录,可在调用前用 page._client.send('Page.setDownloadBehavior', ...) 等设置。
* 使用拟人轨迹点击(预览按钮在消息容器内,用坐标点击;下载按钮用选择器)。
*
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
* @param {import('puppeteer').ElementHandle} [messageElement] - 包含预览按钮的那条消息容器;若不传则在当前对话里找第一条带预览按钮的消息
* @param {{ previewTimeout?: number, downloadTimeout?: number, cursor?: object }} [options] - cursor 可选
* @returns {Promise<{ clickedPreview: boolean, clickedDownload: boolean }>}
*/
export async function openPreviewAndDownloadPdf (page, messageElement, options = {}) {
const previewTimeout = options.previewTimeout ?? 10000
const cursor = options.cursor ?? await createHumanCursor(page)
let el = messageElement
if (!el) {
const { found, element } = await waitForAttachmentResumeMessage(page, { timeout: 5000 })
if (!found || !element) return { clickedPreview: false, clickedDownload: false }
el = element
}
const previewBtn = await el.$(CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR)
if (!previewBtn) return { clickedPreview: false, clickedDownload: false }
// 预览按钮在消息少时会紧贴 tab 栏,拟人轨迹从别处移过来会经过「已交换微信」等 tab一点就切到空白。
// 此处直接用 Puppeteer 的 element.click():无移动轨迹,先 scrollIntoView 再点,避免误触 tab。
await previewBtn.evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' }))
await sleepWithRandomDelay(150, 300)
console.log('[openPreviewAndDownloadPdf] 点击「点击预览附件简历」按钮(原生 click避免轨迹误触 tab')
await previewBtn.click()
// 等待简历预览弹窗内的下载按钮出现PDF 加载可能较慢,默认 10s
let downloadBtn
try {
await page.waitForSelector(CHAT_PAGE_DOWNLOAD_PDF_BTN_SELECTOR, { visible: true, timeout: previewTimeout })
downloadBtn = await page.$(CHAT_PAGE_DOWNLOAD_PDF_BTN_SELECTOR)
} catch {
console.log('[openPreviewAndDownloadPdf] 等待下载按钮超时 (', previewTimeout, 'ms),预览弹窗未出现')
return { clickedPreview: true, clickedDownload: false }
}
if (!downloadBtn) return { clickedPreview: true, clickedDownload: false }
const downloadBox = await downloadBtn.boundingBox()
if (!downloadBox) {
console.log('[openPreviewAndDownloadPdf] 下载按钮 boundingBox 为空')
return { clickedPreview: true, clickedDownload: false }
}
console.log('[openPreviewAndDownloadPdf] 点击下载按钮,坐标:', Math.round(downloadBox.x + downloadBox.width / 2), Math.round(downloadBox.y + downloadBox.height / 2))
await cursor.click({ x: downloadBox.x + downloadBox.width / 2, y: downloadBox.y + downloadBox.height / 2 })
// 等待下载开始(给浏览器一点时间触发下载)
await sleepWithRandomDelay(600, 1000)
// 关闭简历预览弹窗:优先用 Escape避免点击关闭时误触下方「已交换微信」等 tab 导致列表切到空分组(暂无牛人)
const dialogVisible = await page.$(CHAT_PAGE_ATTACH_RESUME_DIALOG_CLOSE_SELECTOR).then(() => true).catch(() => false)
if (dialogVisible) {
console.log('[openPreviewAndDownloadPdf] 关闭附件简历预览弹窗(优先 Escape...')
await page.keyboard.press('Escape')
await sleepWithRandomDelay(200, 400)
const stillVisible = await page.$(CHAT_PAGE_ATTACH_RESUME_DIALOG_CLOSE_SELECTOR).then(() => true).catch(() => false)
if (stillVisible) {
const dialogs = await page.$$('.resume-common-dialog').catch(() => [])
for (const dialog of dialogs) {
const visible = await dialog.boundingBox().catch(() => null)
if (!visible) continue
const closeBtn = await dialog.$('.boss-popup__close').catch(() => null)
if (closeBtn) {
await closeBtn.click().catch(() => {})
break
}
}
}
await page.waitForSelector(CHAT_PAGE_ATTACH_RESUME_DIALOG_CLOSE_SELECTOR, { hidden: true, timeout: 3000 }).catch(() => {})
}
// 若列表被清空(误触到「已交换微信」等 tab切回「全部」恢复会话列表
const listCount = await page.$$(CHAT_PAGE_ITEM_SELECTOR).then(arr => arr.length).catch(() => 0)
if (listCount === 0) {
const tabAll = await page.$(CHAT_PAGE_TAB_ALL_SELECTOR).catch(() => null)
if (tabAll) {
console.log('[openPreviewAndDownloadPdf] 检测到会话列表为空切回「全部」tab 恢复列表')
await tabAll.click().catch(() => {})
await sleepWithRandomDelay(300, 600)
}
}
return { clickedPreview: true, clickedDownload: true }
}

View File

@@ -0,0 +1,216 @@
// 招聘端 CSS 选择器常量(参考 packages/geek-auto-start-chat-with-boss/constant.mjs 风格)
// 结构参考examples/BOSS直聘-推荐牛人.html、推荐牛人-候选人详情页、推荐牛人-打招呼、推荐牛人-继续沟通
// 沟通页参考examples/BOSS直聘-沟通-聊天框.html、BOSS直聘-沟通-附件简历.html
// =============================================================================
// 一、推荐牛人页(/web/boss/recommend— 主动打招呼流程
// =============================================================================
// 流程:点"打招呼"→ 招呼自动发送 → 弹窗"已向牛人发送招呼"→ 点"知道了"→ 点该条"继续沟通"→ 在 #boss-chat-global-input 输入并回车发送(无发送按钮)
//
// 1. CANDIDATE_LIST_SELECTOR列表容器
// 2. CANDIDATE_ITEM_SELECTOR单条候选人li
// 3. CANDIDATE_NAME_SELECTOR在 item 内姓名
// 4. CANDIDATE_DETAIL_SELECTOR无独立详情面板留空
// 5. CHAT_START_BUTTON_SELECTOR打招呼按钮在 item 内)
// 6. 弹窗"知道了"、继续沟通、聊天输入框见下方
/** 1. 候选人列表容器(在 iframe[name="recommendFrame"] 内) */
export const CANDIDATE_LIST_SELECTOR = 'ul.card-list'
/** 2. 单个候选人条目(取多个用 querySelectorAll在 iframe 内) */
export const CANDIDATE_ITEM_SELECTOR = 'ul.card-list > li.card-item'
/** 3. 候选人姓名(在单条 item 内item.querySelector(CANDIDATE_NAME_SELECTOR) */
export const CANDIDATE_NAME_SELECTOR = 'span.name'
/** 4. 详情面板(推荐牛人页无独立详情面板,留空) */
export const CANDIDATE_DETAIL_SELECTOR = ''
/** 5. 打招呼按钮(在单条 item 的 div.operate-side 内) */
export const CHAT_START_BUTTON_SELECTOR = 'button.btn-greet'
/** "已向牛人发送招呼"弹窗内的"知道了"按钮(弹窗在主页面,不在 iframe 内) */
export const GREETING_SENT_KNOW_BTN_SELECTOR = 'div.dialog-wrap button.btn-sure-v2'
/** 继续沟通按钮(在单条 item 内;点完"知道了"后再点此项) */
export const CONTINUE_CHAT_BUTTON_SELECTOR = 'div.operate-side div.button-chat'
/** 聊天输入框(点"继续沟通"后出现,无发送按钮,用回车发送) */
export const CHAT_INPUT_SELECTOR = '#boss-chat-global-input'
/** 列表/分页:下一页按钮(多种样式兼容) */
export const NEXT_PAGE_BUTTON_SELECTOR = '.options-pages a.next, .pagination .next, [ka*="next"], [class*="next-page"]'
/** 推荐页:单条候选人卡片内的"不感兴趣"区域(点击后弹出原因选择,需再选原因才关闭) */
export const NOT_INTERESTED_IN_ITEM_SELECTOR = 'div.tooltip-wrap.suitable'
/** 推荐页iframe 内):点击"不感兴趣"后弹出的原因选择弹窗(选择不喜欢的原因,为您优化推荐) */
export const NOT_INTERESTED_REASON_POPUP_SELECTOR = 'div.card-reason-f1.show'
/** 推荐页原因弹窗内所有可选项span.first-reason-item按筛选原因选对应一项以优化推荐 */
export const NOT_INTERESTED_REASON_ITEMS_SELECTOR = 'div.card-reason-f1.show span.first-reason-item'
/** 筛选原因 → 弹窗选项文案(精确匹配)。与 candidate-processor 的 reason 一致,便于 BOSS 优化推荐;后续可接 LLM 根据 reasonDetail 选更贴切项 */
export const NOT_INTERESTED_REASON_MAP = {
city: '牛人距离远',
education: '不考虑本科',
salary: '期望薪资偏高',
workExp: '工作经历和制剂研发无关',
viewed: '重复推荐',
skills: '其他原因',
blockName: '其他原因'
}
/** 弹窗中用于"与职位不符"的选项匹配:若选项文案包含此字符串则视为职位/技能不符skills、blockName 可优先选此项) */
export const NOT_INTERESTED_REASON_POSITION_MISMATCH = '与职位不符'
/** 无匹配或未知原因时使用的弹窗选项 */
export const NOT_INTERESTED_REASON_FALLBACK = '其他原因'
/** 原因弹窗的关闭图标(未匹配到原因时点击以关闭弹窗,避免卡住后续操作) */
export const NOT_INTERESTED_REASON_POPUP_CLOSE_SELECTOR = 'div.card-reason-f1.show div.close-icon'
/** 推荐页:简历详情弹窗的关闭按钮(主页面,非 iframe 内) */
export const RESUME_POPUP_CLOSE_SELECTOR = 'div.boss-popup__close'
/** @deprecated 招呼为自动发送,无需弹窗输入框;若需在弹窗内编辑招呼语可再用 */
export const GREETING_DIALOG_SELECTOR = 'body > div.dialog-wrap.dialog-chat-greeting.v-transfer-dom > div.dialog-container'
/** @deprecated 招呼自动发送,留空;发后续消息用 CHAT_INPUT_SELECTOR + Enter */
export const GREETING_INPUT_SELECTOR = ''
// 招聘端 URL 常量
/** 推荐牛人页(招聘端入口可能是 /web/boss/recommend 或 /web/chat/recommend需与当前站点一致 */
export const BOSS_RECOMMEND_PAGE_URL = 'https://www.zhipin.com/web/chat/recommend'
/** 沟通页(默认入口,登录后可能落在此页,需点击"推荐牛人"切到推荐页) */
export const BOSS_CHAT_INDEX_URL = 'https://www.zhipin.com/web/chat/index'
/** 沟通页 URL 别名(/web/boss/chat */
export const BOSS_CHAT_PAGE_URL = 'https://www.zhipin.com/web/chat/index'
/** 侧栏"推荐牛人"入口(在沟通页时点击可切到推荐牛人页) */
export const RECOMMEND_MENU_BUTTON_SELECTOR = '#wrap > div.side-wrap.side-wrap-v2 > div > dl.menu-recommend > dt > a'
/** 推荐页:顶部职位下拉触发按钮(主页面 #headerWrap 内,点击后展开职位列表) */
export const RECOMMEND_JOB_DROPDOWN_LABEL_SELECTOR = '#headerWrap .ui-dropmenu-label'
/** 推荐页:职位下拉列表容器 */
export const RECOMMEND_JOB_LIST_SELECTOR = '#headerWrap ul.job-list'
/** 推荐页职位下拉内单条职位项li.job-itemvalue 为 jobId文本为职位名 */
export const RECOMMEND_JOB_ITEM_SELECTOR = '#headerWrap ul.job-list li.job-item'
/** 沟通页:顶部职位筛选下拉触发按钮(点击展开职位列表) */
export const CHAT_PAGE_JOB_DROPDOWN_SELECTOR = '.chat-top-job .ui-dropmenu-label'
/** 沟通页:职位下拉展开后的列表项(过滤 value="-1" 的"全部职位" */
export const CHAT_PAGE_JOB_ITEM_SELECTOR = '.chat-top-job .ui-dropmenu-list li'
// =============================================================================
// 二、沟通页(/web/chat/index— 会话列表、要简历、预览附件、下载 PDF
// =============================================================================
// 流程简述:
// - 看在线简历:无需对方同意,点"查看在线简历"即可,内容在 #resumeCanvas用 resume-extractor 提字 → 关键词/LLM 筛选。
// - 下载 PDF需先"请求附件简历"→ 等对方同意(异步)→ 对方同意后 PDF 会作为新消息发到聊天里 → 再点该消息里的"点击预览附件简历"→ 在弹窗中点"下载 PDF"。
// 沟通页每条是什么:
// - CHAT_PAGE_USER_LIST_SELECTOR左侧整块会话列表的容器装所有"金琳枝 研究员"这类一行一行的 div
// - CHAT_PAGE_ITEM_SELECTOR左侧"每一个会话"那一行(在列表容器内取多个)
// - CHAT_PAGE_NAME_SELECTOR / CHAT_PAGE_JOB_SELECTOR该行里的姓名、职位
// - CHAT_PAGE_ONLINE_RESUME_SELECTOR右侧"查看在线简历"的图标/链接(无需对方同意)
// - CHAT_PAGE_ATTACH_RESUME_BTN_SELECTOR右侧"附件简历"按钮,点它会出"确定向牛人索取简历吗"
// - CHAT_PAGE_ASK_RESUME_CONFIRM_BTN_SELECTOR索取简历确认弹窗里的"确认"按钮
// - CHAT_PAGE_MESSAGE_ITEM_SELECTOR右侧聊天区域里"每一条消息"的容器
// - CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR消息里"点击预览附件简历"(对方同意并发来 PDF 后,新消息里会出现)
// - CHAT_PAGE_DOWNLOAD_PDF_BTN_SELECTOR简历预览弹窗里的"下载 PDF"按钮
/** 沟通页:顶部"未读"筛选 tabspan:nth-child(2) 在 .chat-message-filter-left 内active 时有 class="active" */
export const CHAT_PAGE_UNREAD_FILTER_SELECTOR = '.chat-message-filter-left span:nth-child(2)'
/** 左侧会话列表容器 */
export const CHAT_PAGE_USER_LIST_SELECTOR = '.user-container'
/** 左侧单个会话 item.geek-itemid=_<geekId>-0data-id=<geekId>-0 */
export const CHAT_PAGE_ITEM_SELECTOR = '.user-container .geek-item'
/** 沟通页会话项内:候选人姓名 */
export const CHAT_PAGE_NAME_SELECTOR = 'span.geek-name'
/** 沟通页会话项内:职位 */
export const CHAT_PAGE_JOB_SELECTOR = 'span.source-job'
/** 沟通页左侧会话项:未读消息数角标(缺席则无未读) */
export const CHAT_PAGE_ITEM_UNREAD_SELECTOR = 'span.badge-count'
/** 沟通页右侧面板:当前会话候选人姓名(用于验证会话是否切换成功) */
export const CHAT_PAGE_ACTIVE_NAME_SELECTOR = '.name-contet .name-box'
/** 沟通页右侧查看在线简历按钮a.resume-btn-online无 hrefVue 点击事件) */
export const CHAT_PAGE_ONLINE_RESUME_SELECTOR = 'a.resume-btn-online'
/**
* 沟通页:在线简历点开后,简历内容的容器选择器(#resume
* 完整版简历:加密数据 → WASM 解密decrypt.rs→ 仅在此容器内 Canvas 绘制,与 geek/info 的简单摘要不是同一份数据;详见 plan/chat_page_resume_flow.md。
*/
export const CHAT_PAGE_ONLINE_RESUME_CONTENT_SELECTOR = '#resume'
/**
* 沟通页:在线简历 iframe 选择器(点击"查看在线简历"后动态插入,#resume Canvas 在此 iframe 内部)。
* 用于等待在线简历已打开,不能用 #resume在 iframe 内部,主页面 waitForSelector 找不到)。
*/
export const CHAT_PAGE_ONLINE_RESUME_IFRAME_SELECTOR = 'iframe[src*="c-resume"]'
/** 沟通页:在线简历弹窗的关闭按钮(切换候选人时需先关闭旧弹窗再打开新的) */
export const CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR = '.resume-common-dialog .boss-popup__close'
/** 沟通页右侧:附件简历按钮(点击后出现"确定向牛人索取简历吗"disabled 时也可点击触发确认弹窗) */
export const CHAT_PAGE_ATTACH_RESUME_BTN_SELECTOR = 'div.resume-btn-content .resume-btn-file'
/**
* 沟通页:索取简历确认弹窗内的确认按钮。
* 弹窗由 Vue v-if 控制(未点击附件简历时不在 DOM 中,点击后才插入)。
* HTML: div.ask-for-resume-confirm > div.content > button.boss-btn-primary
*/
export const CHAT_PAGE_ASK_RESUME_CONFIRM_BTN_SELECTOR = 'div.ask-for-resume-confirm > div.content > button.boss-btn-primary'
/** 沟通页:每条聊天消息的容器(.message-item在 .chat-message-list 下) */
export const CHAT_PAGE_MESSAGE_ITEM_SELECTOR = '.chat-message-list .message-item'
/**
* 沟通页:消息里的"点击预览附件简历"按钮。
* 用 :only-child 限定:预览按钮是 message-card-buttons 内的唯一子元素,
* 而"拒绝/同意"场景有两个 span.card-btn不会被误匹配。
*/
export const CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR = 'div.message-card-buttons > span.card-btn:only-child'
/**
* 沟通页候选人主动发来附件简历时BOSS 显示"对方想发送附件简历给您,您是否同意"
* 其中"同意"按钮有 d-c 属性Vue click handler"拒绝"没有。
* 用 :not(.disabled) 过滤已点击过的状态。
* HTML: <span d-c="61031" class="card-btn">同意</span>
*/
export const CHAT_PAGE_ACCEPT_ATTACH_RESUME_BTN_SELECTOR = 'div.message-card-buttons > span[d-c].card-btn:not(.disabled)'
/**
* 沟通页:简历预览弹窗内下载 PDF 按钮(.popover 容器,点击后触发下载)。
* 三个按钮顺序为:全屏、打印、下载(#icon-attacthment-download
* 用 :nth-child(3) 定位下载按钮的 .popover 容器(比定位 SVG use 更易点击)。
* HTML: .resume-common-dialog .attachment-resume-btns > .popover:nth-child(3)
*/
export const CHAT_PAGE_DOWNLOAD_PDF_BTN_SELECTOR = '.resume-common-dialog .attachment-resume-btns > .popover:nth-child(3)'
/** 沟通页:附件简历预览弹窗的关闭按钮(.resume-common-dialog > .boss-popup__close */
export const CHAT_PAGE_ATTACH_RESUME_DIALOG_CLOSE_SELECTOR = '.resume-common-dialog .boss-popup__close'
/**
* 沟通页:切换到新会话时 BOSS 弹出的「意向沟通」提示弹窗("您可以在这里直接对牛人发起「意向沟通」")。
* 浏览器每次重新启动BOSS 将其当作新用户会弹出此提示,遮挡右侧面板的操作按钮(附件简历等)。
* 弹窗位于 .op-btn.rightbar-item 内HTML: div.dialog-container > div.dialog-body > div.content > div.button > span"我知道了"
* 用 .op-btn.rightbar-item 缩小范围,避免与其他 dialog-container 冲突。
*/
export const CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR = '.op-btn.rightbar-item div.dialog-container div.button span'
/** 沟通页:「意向沟通」弹窗的关闭图标(备用,点任一即可) */
export const CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR = '.op-btn.rightbar-item div.dialog-container div.iboss-close.close'
/**
* 沟通页:左侧会话列表上方的分类 tab全部 / 沟通中 / 已获取简历 / 已交换微信 等)。
* 若误触到「已交换微信」等空分组会显示「暂无牛人」。用此选择器切回「全部」恢复列表。
* HTML: div.chat-label-item[title="全部"],选中态有 class selected。
*/
export const CHAT_PAGE_TAB_ALL_SELECTOR = '.chat-label-item[title="全部"]'

View File

@@ -0,0 +1,181 @@
/**
* data-manager.mjs
*
* 数据统计与去重模块:
* - 查询候选人是否已联系过(去重)
* - 保存/更新候选人信息到数据库
* - 插入联系记录日志
* - 生成本次运行统计汇总
* - 从候选人列表中去除已联系过的人
*
* 数据库调用约定:
* 本模块通过 tapable hooks由 sqlite-plugin 注册)异步写入数据库。
* 调用方需在 hooks 上通过 SqlitePlugin.apply(hooks) 注册对应的 tap。
* 直接查询checkIfAlreadyContacted则通过 hooks.queryCandidateByEncryptId.promise() 完成,
* 该 hook 为 AsyncSeriesWaterfallHook约定第一个返回非 null 的 tap 的返回值作为结果。
*/
/**
* 查询数据库中是否已有该候选人的联系记录。
*
* 通过 `hooks.queryCandidateByEncryptId` hook 获取结果。
* 如果 hook 未注册或查无记录,视为未联系过。
*
* @param {string} encryptGeekId - 候选人加密 ID
* @param {{ queryCandidateByEncryptId?: import('tapable').AsyncSeriesWaterfallHook<[string]> }} hooks
* 包含 queryCandidateByEncryptId hook 的 hooks 对象
* @returns {Promise<{ contacted: boolean, lastContactTime: Date|null, contactCount: number }>}
*/
export async function checkIfAlreadyContacted (encryptGeekId, hooks) {
try {
const result = await hooks.queryCandidateByEncryptId?.promise?.(encryptGeekId)
if (!result) {
return { contacted: false, lastContactTime: null, contactCount: 0 }
}
const lastContactTime = result.lastContactTime ? new Date(result.lastContactTime) : null
// CandidateInfo 实体上没有 contactCount 字段;以 lastContactTime 是否存在作为是否联系过的依据
return {
contacted: !!lastContactTime,
lastContactTime,
contactCount: lastContactTime ? 1 : 0
}
} catch (err) {
console.warn('[data-manager] checkIfAlreadyContacted 查询失败,默认视为未联系过:', err.message)
return { contacted: false, lastContactTime: null, contactCount: 0 }
}
}
/**
* 保存或更新候选人信息到数据库。
*
* 通过 `hooks.createOrUpdateCandidateInfo` hook 写入hook 由 sqlite-plugin 注册。
*
* @param {{
* encryptGeekId: string,
* geekName: string,
* educationLevel?: string|null,
* workExpYears?: string|null,
* city?: string|null,
* jobTitle?: string|null,
* salaryExpect?: string|null,
* skills?: string|string[]|null,
* status?: string,
* rawData?: object|string|null
* }} candidate - 候选人信息对象
* @param {{ createOrUpdateCandidateInfo?: import('tapable').AsyncSeriesHook<[object]> }} hooks
* @returns {Promise<void>}
*/
export async function saveCandidateInfo (candidate, hooks) {
try {
await hooks.createOrUpdateCandidateInfo?.promise?.({
encryptGeekId: candidate.encryptGeekId,
geekName: candidate.geekName,
educationLevel: candidate.educationLevel ?? null,
workExpYears: candidate.workExpYears ?? null,
city: candidate.city ?? null,
jobTitle: candidate.jobTitle ?? null,
salaryExpect: candidate.salaryExpect ?? null,
skills: Array.isArray(candidate.skills)
? candidate.skills.join(',')
: (candidate.skills ?? null),
status: candidate.status ?? 'new',
rawData: candidate.rawData
? (typeof candidate.rawData === 'string'
? candidate.rawData
: JSON.stringify(candidate.rawData))
: null
})
} catch (err) {
console.warn(`[data-manager] saveCandidateInfo 失败(${candidate.geekName}:`, err.message)
}
}
/**
* 插入一条候选人联系记录日志。
*
* 通过 `hooks.insertCandidateContactLog` hook 写入hook 由 sqlite-plugin 注册。
*
* @param {string} encryptGeekId - 候选人加密 ID
* @param {string} contactType - 联系类型,如 'chat_started'、'chat_failed'、'viewed' 等
* @param {string|null} message - 发送的消息内容(可为 null
* @param {string|null} result - 结果描述,如 'success'、'DAILY_LIMIT_REACHED' 等(可为 null
* @param {{ insertCandidateContactLog?: import('tapable').AsyncSeriesHook<[object]> }} hooks
* @returns {Promise<void>}
*/
export async function logContact (encryptGeekId, contactType, message, result, hooks) {
const now = new Date()
try {
await hooks.insertCandidateContactLog?.promise?.({
encryptGeekId,
contactType,
message: message ?? null,
result: result ?? null,
contactTime: now
})
} catch (err) {
console.warn(`[data-manager] logContact 插入失败(${encryptGeekId}:`, err.message)
}
}
/**
* 根据本次运行的所有处理结果生成统计汇总。
*
* @param {Array<{
* candidate?: { encryptGeekId?: string, geekName?: string },
* chatResult?: { success: boolean, reason?: string },
* skipped?: boolean,
* skipReason?: string
* }>} results - 本次运行中每个候选人的处理结果
* @returns {{
* totalBrowsed: number,
* totalMatched: number,
* totalSkipped: number,
* totalChatStarted: number,
* totalChatFailed: number,
* skipReasons: Record<string, number>
* }}
*/
export function generateRunSummary (results) {
const summary = {
totalBrowsed: results.length,
totalMatched: 0,
totalSkipped: 0,
totalChatStarted: 0,
totalChatFailed: 0,
skipReasons: {}
}
for (const item of results) {
if (item.skipped) {
summary.totalSkipped++
const reason = item.skipReason || 'unknown'
summary.skipReasons[reason] = (summary.skipReasons[reason] || 0) + 1
} else {
summary.totalMatched++
if (item.chatResult?.success) {
summary.totalChatStarted++
} else if (item.chatResult && !item.chatResult.success) {
summary.totalChatFailed++
const reason = item.chatResult.reason || 'unknown'
const key = `chat_failed:${reason}`
summary.skipReasons[key] = (summary.skipReasons[key] || 0) + 1
}
}
}
return summary
}
/**
* 从候选人列表中排除已联系过的人。
*
* @param {Array<{ encryptGeekId: string }>} candidates - 待去重的候选人列表
* @param {Set<string>} contactedSet - 已联系过的 encryptGeekId 集合
* @returns {Array<{ encryptGeekId: string }>} 去重后的候选人列表
*/
export function deduplicateCandidates (candidates, contactedSet) {
if (!contactedSet || contactedSet.size === 0) {
return candidates
}
return candidates.filter(c => !contactedSet.has(c.encryptGeekId))
}

View File

@@ -0,0 +1,34 @@
{
"logLevel": "info",
"targetJobId": "",
"recommendPage": {
"clickNotInterestedForFiltered": true,
"skipViewedCandidates": false,
"runOnceAfterComplete": false,
"rerunIntervalMs": 3000,
"delayBetweenNotInterestedMs": [800, 2500],
"keepBrowserOpenAfterRun": false
},
"autoChat": {
"enabled": true,
"greetingMessage": "您好,您的简历与我们的岗位非常匹配,方便了解一下吗?",
"maxChatPerRun": 50,
"delayBetweenChats": [3000, 8000]
},
"scoring": {
"enabled": false,
"minScoreToChat": 0
},
"chatPage": {
"enabled": true,
"maxProcessPerRun": 20,
"filter": {
"mode": "keywords",
"keywordList": [],
"llmRule": ""
},
"attachmentResume": {
"skipDownload": false
}
}
}

View File

@@ -0,0 +1,10 @@
{
"expectCityList": [],
"expectEducationRegExpStr": "",
"expectWorkExpRange": [0, 99],
"expectSalaryRange": [0, 0],
"expectSalaryWhenNegotiable": "exclude",
"expectSkillKeywords": [],
"blockCandidateNameRegExpStr": "",
"skipViewedCandidates": false
}

View File

@@ -0,0 +1,111 @@
/**
* 拟人鼠标轨迹封装(招聘端专用)
*
* BOSS 对招聘端鼠标移动轨迹进行埋点,直接 page.click() 或 page.mouse.click(x,y)
* 的"瞬移"方式容易被识别为脚本。本模块封装 ghost-cursor以贝塞尔曲线生成拟人
* 移动路径,替换所有在招聘端页面上的点击操作。
*
* 用法:
* import { createHumanCursor } from './humanMouse.mjs'
* const cursor = await createHumanCursor(page)
* await cursor.click(selector) // 先沿轨迹移动,再点击
* await cursor.move(selector) // 仅移动,不点击
*/
/**
* 为给定 Puppeteer page 创建拟人鼠标 cursor。
* 内部使用 ghost-cursor若 ghost-cursor 不可用(如包未安装),
* 则 fallback 到普通 page.click(),并打印警告。
*
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
* @returns {Promise<{
* click: (selectorOrPos: string | {x: number, y: number}) => Promise<void>,
* move: (selectorOrPos: string | {x: number, y: number}) => Promise<void>
* }>}
*/
export async function createHumanCursor (page) {
let ghostCursorCreate
try {
const mod = await import('ghost-cursor')
// ghost-cursor 同时支持 ESM default export 和命名 export
ghostCursorCreate = mod.createCursor ?? mod.default?.createCursor
} catch {
ghostCursorCreate = null
}
if (ghostCursorCreate) {
const cursor = ghostCursorCreate(page)
/**
* 将 selector 字符串或 ElementHandle 解析成 {x, y} 坐标。
* ghost-cursor 的 click/move 只接受 string selector 或 ElementHandle
* 传 {x,y} 坐标对象会被误当 ElementHandle 调 element.remoteObject() 崩溃。
* 统一在封装层解析成坐标,再用 moveTo({x,y}) + page.mouse.click(x,y) 执行。
*/
const resolvePos = async (selectorOrPos) => {
if (typeof selectorOrPos === 'string') {
const el = await page.$(selectorOrPos)
if (!el) throw new Error(`[humanMouse] element not found: ${selectorOrPos}`)
const box = await el.boundingBox()
if (!box) throw new Error(`[humanMouse] element has no bounding box: ${selectorOrPos}`)
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
}
// ElementHandle有 boundingBox 方法)
if (selectorOrPos && typeof selectorOrPos.boundingBox === 'function') {
const box = await selectorOrPos.boundingBox()
if (!box) throw new Error('[humanMouse] ElementHandle has no bounding box')
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
}
// 已是 {x, y} 坐标对象
return selectorOrPos
}
return {
/**
* 沿拟人轨迹移动到目标后点击。使用 moveTo({x,y}) + page.mouse.click(x,y)
* 规避 ghost-cursor 传坐标/ElementHandle 时调 element.evaluate 的崩溃问题。
* @param {string | {x: number, y: number} | import('puppeteer').ElementHandle} selectorOrPos
*/
async click (selectorOrPos) {
const pos = await resolvePos(selectorOrPos)
await cursor.moveTo(pos)
await page.mouse.click(pos.x, pos.y)
},
/**
* 沿拟人轨迹移动到目标(不点击)
* @param {string | {x: number, y: number} | import('puppeteer').ElementHandle} selectorOrPos
*/
async move (selectorOrPos) {
const pos = await resolvePos(selectorOrPos)
await cursor.moveTo(pos)
}
}
}
// Fallback: ghost-cursor 未安装时退化为普通点击(打印警告)
console.warn('[humanMouse] ghost-cursor 未安装,退化为普通 page.click()。建议安装 ghost-cursor 以规避 BOSS 鼠标轨迹埋点检测。')
return {
async click (selectorOrPos) {
if (typeof selectorOrPos === 'string') {
await page.click(selectorOrPos)
} else if (selectorOrPos && typeof selectorOrPos.x === 'number') {
await page.mouse.click(selectorOrPos.x, selectorOrPos.y)
}
},
async move (selectorOrPos) {
if (typeof selectorOrPos === 'string') {
try {
const el = await page.$(selectorOrPos)
if (el) {
const box = await el.boundingBox()
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
}
}
} catch (_) { /* ignore */ }
} else if (selectorOrPos && typeof selectorOrPos.x === 'number') {
await page.mouse.move(selectorOrPos.x, selectorOrPos.y)
}
}
}
}

View File

@@ -0,0 +1,550 @@
import { sleep, sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { EventEmitter } from 'node:events'
import { setDomainLocalStorage } from '@geekgeekrun/utils/puppeteer/local-storage.mjs'
import { readConfigFile, readStorageFile, writeStorageFile, ensureConfigFileExist, ensureStorageFileExist, getMergedJobConfig } from './runtime-file-utils.mjs'
import {
BOSS_RECOMMEND_PAGE_URL,
BOSS_CHAT_PAGE_URL,
RECOMMEND_JOB_DROPDOWN_LABEL_SELECTOR,
RECOMMEND_JOB_ITEM_SELECTOR
} from './constant.mjs'
import { setupNetworkInterceptor, setupCanvasTextHook } from './resume-extractor.mjs'
import { parseCandidateList, filterCandidates, scrollAndLoadMore } from './candidate-processor.mjs'
import { processCandidate, checkDailyLimit, clickNotInterested } from './chat-handler.mjs'
import { setLevel, debug as logDebug, info as logInfo, warn as logWarn, error as logError } from './logger.mjs'
export { default as startBossChatPageProcess } from './chat-page-processor.mjs'
ensureConfigFileExist()
ensureStorageFileExist()
/** 招聘端自动化事件总线(参照 autoStartChatEventBus 模式) */
export const bossAutoBrowseEventBus = new EventEmitter()
/**
* @type { import('puppeteer') }
*/
let puppeteer
let StealthPlugin
let LaodengPlugin
let AnonymizeUaPlugin
/**
* 初始化 Puppeteerpuppeteer-extra + stealth + laodeng + anonymize-ua
* @returns {{ puppeteer: import('puppeteer'), StealthPlugin: unknown, LaodengPlugin: unknown, AnonymizeUaPlugin: unknown }}
*/
export async function initPuppeteer () {
logDebug('[boss-auto-browse] initPuppeteer: 开始动态加载插件')
const importResult = await Promise.all([
import('puppeteer-extra'),
import('puppeteer-extra-plugin-stealth'),
import('@geekgeekrun/puppeteer-extra-plugin-laodeng'),
import('puppeteer-extra-plugin-anonymize-ua')
])
puppeteer = importResult[0].default
StealthPlugin = importResult[1].default
LaodengPlugin = importResult[2].default
AnonymizeUaPlugin = importResult[3].default
puppeteer.use(StealthPlugin())
puppeteer.use(LaodengPlugin())
puppeteer.use(AnonymizeUaPlugin({ makeWindows: false }))
logDebug('[boss-auto-browse] initPuppeteer: 插件已注册')
return {
puppeteer,
StealthPlugin,
LaodengPlugin,
AnonymizeUaPlugin
}
}
/** 招聘端 localStorage 生效的页面 URL与 geek 端一致使用 desktop */
const localStoragePageUrl = 'https://www.zhipin.com/desktop/'
/**
* 启动浏览器并导航到沟通页(供多职位队列中「仅沟通页」场景使用)
* @returns {{ browser: import('puppeteer').Browser, page: import('puppeteer').Page }}
*/
export async function launchBrowserAndNavigateToChat () {
if (!puppeteer) await initPuppeteer()
const headless = process.env.HEADLESS === '1'
const browser = await puppeteer.launch({
headless,
ignoreHTTPSErrors: true,
protocolTimeout: 120000,
defaultViewport: { width: 1440, height: 900 - 140 }
})
const page = (await browser.pages())[0]
const bossCookies = readStorageFile('boss-cookies.json')
const bossLocalStorage = readStorageFile('boss-local-storage.json')
if (Array.isArray(bossCookies) && bossCookies.length > 0) {
await page.setCookie(...bossCookies)
}
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
await page.goto(BOSS_CHAT_PAGE_URL, { timeout: 60 * 1000 })
await page.waitForFunction(() => document.readyState === 'complete', { timeout: 120 * 1000 })
await new Promise(r => setTimeout(r, 1500))
return { browser, page }
}
/**
* 将当前 page 的 cookie 和 localStorage 持久化到本地
* @param { import('puppeteer').Page } page
*/
async function storeStorage (page) {
const [cookies, localStorage] = await Promise.all([
page.cookies(),
page.evaluate(() => {
return JSON.stringify(window.localStorage)
}).then(res => JSON.parse(res))
])
return Promise.all([
writeStorageFile('boss-cookies.json', cookies),
writeStorageFile('boss-local-storage.json', localStorage)
])
}
/**
* 招聘端 hooks 类型(由调用方传入,此处仅作文档说明)
* - beforeBrowserLaunch: AsyncSeriesHook
* - afterBrowserLaunch: AsyncSeriesHook
* - beforeNavigateToRecommend: AsyncSeriesHook
* - onCandidateListLoaded: AsyncSeriesHook
* - onCandidateFiltered: AsyncSeriesWaterfallHook
* - beforeStartChat: AsyncSeriesHook
* - afterChatStarted: AsyncSeriesHook
* - onError: AsyncSeriesHook
* - onComplete: AsyncSeriesHook
*/
/**
* 招聘端自动浏览与开聊主入口
* @param {{
* beforeBrowserLaunch?: import('tapable').AsyncSeriesHook<[]>,
* afterBrowserLaunch?: import('tapable').AsyncSeriesHook<[]>,
* beforeNavigateToRecommend?: import('tapable').AsyncSeriesHook<[]>,
* onCandidateListLoaded?: import('tapable').AsyncSeriesHook<[]>,
* onCandidateFiltered?: import('tapable').AsyncSeriesWaterfallHook<[unknown, unknown]>,
* beforeStartChat?: import('tapable').AsyncSeriesHook<[unknown]>,
* afterChatStarted?: import('tapable').AsyncSeriesHook<[unknown, unknown]>,
* onError?: import('tapable').AsyncSeriesHook<[unknown]>,
* onComplete?: import('tapable').AsyncSeriesHook<[]>
* }} hooksFromCaller
* @param {{ returnBrowser?: boolean }} [opts] - 若 true结束时不关闭 browser 并返回 { browser, page },由调用方关闭
*/
/**
* 在推荐页切换到指定职位(主页面操作,不在 iframe 内)。
* @param {import('puppeteer').Page} page
* @param {string} jobId
*/
async function switchRecommendJobId (page, jobId) {
try {
await page.click(RECOMMEND_JOB_DROPDOWN_LABEL_SELECTOR)
await page.waitForSelector(RECOMMEND_JOB_ITEM_SELECTOR, { timeout: 5000 })
const found = await page.evaluate((jid) => {
const item = document.querySelector(`#headerWrap ul.job-list li.job-item[value="${jid}"]`)
if (!item) return false
item.click()
return true
}, jobId)
if (!found) {
logWarn(`[boss-auto-browse] 职位 ${jobId} 未在下拉列表中找到,将使用默认职位继续`)
// 关闭下拉
await page.keyboard.press('Escape')
return
}
// 等候选人列表重新加载
await new Promise(r => setTimeout(r, 500))
logInfo(`[boss-auto-browse] 已切换到职位 ${jobId}`)
} catch (e) {
logWarn(`[boss-auto-browse] 切换推荐页职位失败(${e.message}),将使用默认职位继续`)
}
}
export default async function startBossAutoBrowse (hooksFromCaller, opts = {}) {
const hooks = hooksFromCaller || {}
const returnBrowser = opts.returnBrowser === true
const jobId = opts.jobId ?? null
const existingBrowser = opts.browser ?? null
const existingPage = opts.page ?? null
const reuseBrowser = !!(existingBrowser && existingPage)
setLevel((readConfigFile('boss-recruiter.json') || {}).logLevel || 'info')
if (!puppeteer) {
logDebug('[boss-auto-browse] puppeteer 未初始化,正在 initPuppeteer()')
await initPuppeteer()
}
/** @type { import('puppeteer').Browser } */
let browser
/** @type { import('puppeteer').Page } */
let page
try {
if (reuseBrowser) {
browser = existingBrowser
page = existingPage
logDebug('[boss-auto-browse] 复用已有浏览器实例')
} else {
await hooks.beforeBrowserLaunch?.promise?.()
const headlessEnv = process.env.HEADLESS
const headless = headlessEnv === '1'
logDebug('[boss-auto-browse] 即将启动浏览器', { headless, HEADLESS_env: headlessEnv ?? null })
browser = await puppeteer.launch({
headless,
ignoreHTTPSErrors: true,
protocolTimeout: 120000,
defaultViewport: {
width: 1440,
height: 900 - 140
}
})
page = (await browser.pages())[0]
await hooks.afterBrowserLaunch?.promise?.()
}
const bossCookies = readStorageFile('boss-cookies.json')
const bossLocalStorage = readStorageFile('boss-local-storage.json')
// -----------------------------------------------------------------------
// 直接导航到推荐牛人页(注入 Cookie / localStorage 后 goto复用浏览器时若已在推荐页可跳过 goto
// -----------------------------------------------------------------------
await hooks.beforeNavigateToRecommend?.promise?.()
if (Array.isArray(bossCookies) && bossCookies.length > 0) {
await page.setCookie(...bossCookies)
}
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
const alreadyOnRecommend = page.url().startsWith(BOSS_RECOMMEND_PAGE_URL)
if (!alreadyOnRecommend) {
await page.goto(BOSS_RECOMMEND_PAGE_URL, { timeout: 60 * 1000 })
}
await page.waitForFunction(
() => document.readyState === 'complete',
{ timeout: 120 * 1000 }
)
// 等待 SPA 路由稳定readyState=complete 后 SPA 可能还会重定向)
await new Promise(r => setTimeout(r, 1500))
await page.bringToFront()
if (
page.url().startsWith('https://www.zhipin.com/web/common/403.html') ||
page.url().startsWith('https://www.zhipin.com/web/common/error.html')
) {
throw new Error('ACCESS_IS_DENIED')
}
/**
* 检查是否需要登录(包含重定向到首页的情况)
* @returns {Promise<boolean>}
*/
const checkNeedLogin = async () => {
const url = page.url()
// 首页 / 登录页 / 非推荐牛人页都视为需要登录
if (url === 'https://www.zhipin.com/' || url === 'https://www.zhipin.com') {
return true
}
return page.evaluate((recommendUrl) => {
const href = location.href
return !href.startsWith(recommendUrl) || /\/login|\/wapi\/zppassport\//.test(href)
}, BOSS_RECOMMEND_PAGE_URL)
}
/**
* 等待用户登录并回到推荐牛人页
*/
const waitForLoginAndRedirect = async () => {
logInfo('[boss-auto-browse] 未登录或已过期,请在推荐牛人 Tab 中完成登录,登录成功后将继续执行…')
await page.waitForFunction(
(recommendUrl) => {
const href = location.href
return href.startsWith(recommendUrl) && document.readyState === 'complete'
},
{ timeout: 300 * 1000 },
BOSS_RECOMMEND_PAGE_URL
)
// 再等待 SPA 稳定
await new Promise(r => setTimeout(r, 1500))
await storeStorage(page).catch(() => {})
logInfo('[boss-auto-browse] 登录成功,已保存 Cookie。')
}
if (await checkNeedLogin()) {
await waitForLoginAndRedirect()
} else {
await storeStorage(page).catch(() => {})
}
// 切换职位(若指定了 jobId 且非全部职位标志)
if (jobId && jobId !== '-1' && jobId !== '0') {
await switchRecommendJobId(page, jobId)
}
// -----------------------------------------------------------------------
// 获取推荐牛人 iframe 的 Frame 对象(候选人列表在 iframe 内渲染)
// iframe 在 Vue 异步渲染期间可能多次导航Frame 对象会被销毁重建,需重试获取
// 若在等待过程中发现页面跳转到非推荐牛人页(如首页),则触发登录等待
// -----------------------------------------------------------------------
logDebug('[boss-auto-browse] 等待候选人列表渲染iframe...')
const getRecommendFrame = () => {
return page.frames().find(f => f.name() === 'recommendFrame') ?? null
}
const recommendFrame = await (async () => {
const deadline = Date.now() + 60 * 1000
while (Date.now() < deadline) {
// 若页面被重定向(首页或登录页),等待用户重新登录
if (await checkNeedLogin()) {
await waitForLoginAndRedirect()
// 登录后重置计时器,再给 60s 等待 iframe
// (通过 continue 重新进入循环deadline 已过则会退出,不过登录成功后通常很快出现)
}
try {
const f = getRecommendFrame()
if (!f) {
await new Promise(r => setTimeout(r, 500))
continue
}
// 尝试在 frame 内查找候选人列表
const el = await f.waitForSelector('ul.card-list > li.card-item', { timeout: 5000 })
if (el) return f
} catch (_) {
// frame 导航中或 context 销毁,稍等重试
await new Promise(r => setTimeout(r, 500))
}
}
throw new Error('等待推荐牛人 iframe 候选人列表超时60s')
})()
logInfo('[boss-auto-browse] 候选人列表已就绪')
// -----------------------------------------------------------------------
// 设置网络拦截器 + Canvas hook登录成功后立即启动仅针对推荐牛人 Tab
// -----------------------------------------------------------------------
const { getInterceptedData } = setupNetworkInterceptor(page)
await setupCanvasTextHook(page)
// -----------------------------------------------------------------------
// 读取配置(若指定 jobId 则使用 per-job 合并配置)
// -----------------------------------------------------------------------
const baseConfig = readConfigFile('boss-recruiter.json') || {}
const config = jobId ? getMergedJobConfig(jobId) : { ...baseConfig, candidateFilter: readConfigFile('candidate-filter.json') || {} }
setLevel(config?.logLevel || 'info')
let filterConfig = config.candidateFilter || readConfigFile('candidate-filter.json') || {}
const recommendPageOpts = config?.recommendPage || baseConfig?.recommendPage || {}
const clickNotInterestedForFiltered = recommendPageOpts.clickNotInterestedForFiltered !== false
const runOnceAfterComplete = recommendPageOpts.runOnceAfterComplete === true
const delayBetweenNotInterestedMs = recommendPageOpts.delayBetweenNotInterestedMs ?? [800, 2500]
filterConfig = { ...filterConfig, skipViewedCandidates: recommendPageOpts.skipViewedCandidates ?? filterConfig.skipViewedCandidates }
const maxChatPerRun = config?.autoChat?.maxChatPerRun ?? 50
let chatCount = 0
let notInterestedLimitReached = false // 当天"不感兴趣"上限,达到后跳过点击但继续打招呼
// -----------------------------------------------------------------------
// 主循环:解析 → 筛选 → 开聊 → 翻页/滚动
// -----------------------------------------------------------------------
await hooks.onCandidateListLoaded?.promise?.()
mainLoop: while (true) {
// a. 解析候选人列表(在 iframe 的 frame 内操作)
logDebug('[boss-auto-browse] 主循环:开始解析候选人列表...')
let candidates = []
try {
candidates = await parseCandidateList(recommendFrame)
logInfo('[boss-auto-browse] 解析完成,共', candidates.length, '人')
} catch (parseErr) {
logWarn('[boss-auto-browse] parseCandidateList 失败,跳过本轮:', parseErr.message)
}
if (candidates.length === 0) {
logDebug('[boss-auto-browse] 候选人列表为空,尝试滚动加载…')
const hasMore = await scrollAndLoadMore(recommendFrame).catch(() => false)
if (!hasMore) {
logInfo('[boss-auto-browse] 没有更多候选人,结束本次运行。')
break mainLoop
}
await sleepWithRandomDelay(1000)
continue
}
// b. 筛选候选人(经由 onCandidateFiltered waterfall hook让外部插件也能参与过滤
const rawFilterResult = filterCandidates(candidates, filterConfig)
const skippedListForLog = Array.isArray(rawFilterResult?.skipped)
? [...rawFilterResult.skipped]
: []
let filterResult = rawFilterResult
if (hooks.onCandidateFiltered?.promise) {
try {
const hookResult = await hooks.onCandidateFiltered.promise(candidates, filterResult)
if (hookResult != null && (Array.isArray(hookResult.matched) || Array.isArray(hookResult.skipped))) {
filterResult = hookResult
}
} catch (_) { /* hook 出错不影响主流程 */ }
}
// filterResult.matched 的每项是 { candidate, filterResult } 包装对象;无人 tap 时 hook 返回 undefined用 rawFilterResult
const matchedItems = Array.isArray(filterResult?.matched) ? filterResult.matched : rawFilterResult.matched || []
// 将每个 matched candidate 映射回 candidates 数组中的原始索引,用于在 iframe li.card-item 列表中定位
const matched = matchedItems.map(item => {
const c = item?.candidate ?? item
const originalIndex = candidates.indexOf(c)
return { candidate: c, originalIndex: originalIndex >= 0 ? originalIndex : 0 }
})
for (const item of skippedListForLog) {
const candidate = item?.candidate ?? item
const fr = item?.filterResult ?? item
const name = candidate?.geekName ?? candidate?.encryptGeekId ?? '?'
const detail = fr?.reasonDetail ?? `不满足条件 ${fr?.reason ?? 'unknown'}`
logInfo(`[boss-auto-browse] 跳过 ${name}${detail}`)
}
if (skippedListForLog.length > 0) {
const reasonCounts = {}
for (const item of skippedListForLog) {
const r = item?.filterResult?.reason ?? 'unknown'
reasonCounts[r] = (reasonCounts[r] || 0) + 1
}
logInfo('[boss-auto-browse] 跳过原因统计:', reasonCounts)
}
if (matched.length > 0) {
const passedNames = matched.map(m => m.candidate?.geekName ?? m.candidate?.encryptGeekId ?? '?').join('、')
logInfo('[boss-auto-browse] 本轮通过筛选:', passedNames)
}
logInfo(`[boss-auto-browse] 本轮候选人:共 ${candidates.length} 人,筛选通过 ${matched.length}`)
// 对未通过筛选的候选人点击"不感兴趣",并按筛选原因选对应弹窗选项以优化 BOSS 推荐;每次点击间隔随机延迟(反检测)
if (clickNotInterestedForFiltered && !notInterestedLimitReached && skippedListForLog.length > 0) {
const cursor = await (await import('./humanMouse.mjs')).createHumanCursor(page)
const indexToFilterResult = new Map()
for (const item of skippedListForLog) {
const idx = candidates.indexOf(item?.candidate ?? item)
if (idx >= 0) indexToFilterResult.set(idx, item?.filterResult ?? item)
}
const sortedIndices = skippedListForLog
.map(s => candidates.indexOf(s?.candidate ?? s))
.filter(i => i >= 0)
.sort((a, b) => b - a)
logInfo('[boss-auto-browse] 将对', sortedIndices.length, '人点击"不感兴趣"(原因与筛选一致)')
const delayRange = Array.isArray(delayBetweenNotInterestedMs) && delayBetweenNotInterestedMs.length >= 2
? delayBetweenNotInterestedMs
: [800, 2500]
for (let i = 0; i < sortedIndices.length; i++) {
const idx = sortedIndices[i]
const fr = indexToFilterResult.get(idx)
logDebug('[boss-auto-browse] 正在对 index=', idx, ' 点击"不感兴趣"reason=', fr?.reason ?? 'unknown', '')
try {
const niResult = await clickNotInterested(recommendFrame, idx, cursor, {
logPrefix: '[boss-auto-browse]',
filterResult: fr
})
if (niResult === 'NOT_INTERESTED_LIMIT_REACHED') {
notInterestedLimitReached = true
logInfo('[boss-auto-browse] 当天"不感兴趣"上限已达,本次及后续轮次将跳过,继续处理打招呼')
break
}
if (i < sortedIndices.length - 1) {
const [minMs, maxMs] = delayRange
const delay = minMs + Math.random() * (maxMs - minMs)
await sleep(delay)
}
} catch (e) {
logWarn('[boss-auto-browse] 点击不感兴趣失败index=', idx, ':', e?.message)
}
}
}
if (matched.length === 0) {
// 全被过滤掉,继续翻页/滚动加载下一批
logDebug('[boss-auto-browse] 本轮无匹配候选人,继续滚动加载…')
const hasMore = await scrollAndLoadMore(recommendFrame).catch(() => false)
if (!hasMore) {
logInfo('[boss-auto-browse] 已加载全部候选人,结束本次运行。')
break mainLoop
}
await sleepWithRandomDelay(1500)
continue
}
// c. 逐一处理匹配的候选人
for (let i = 0; i < matched.length; i++) {
const { candidate, originalIndex } = matched[i]
// 检查每日限额(在主页面检查)
const limitStatus = await checkDailyLimit(page).catch(() => ({ limitReached: false }))
if (limitStatus.limitReached) {
logInfo('[boss-auto-browse] 今日沟通人数已达上限,停止运行。')
break mainLoop
}
if (chatCount >= maxChatPerRun) {
logInfo(`[boss-auto-browse] 本次运行已开聊 ${chatCount} 人,达到上限,停止运行。`)
break mainLoop
}
// d. 开聊(在 iframe frame 内操作,弹窗处理在主页面)
logDebug('[boss-auto-browse] 开始处理候选人', candidate.geekName, 'index=', originalIndex, '')
let procesResult
try {
procesResult = await processCandidate(
recommendFrame,
candidate,
config,
hooks,
{ getInterceptedData, candidateIndex: originalIndex, mainPage: page }
)
} catch (procErr) {
logError('[boss-auto-browse] processCandidate 异常(', candidate.geekName, ':', procErr.message)
continue
}
const { chatResult } = procesResult
if (chatResult.success) {
chatCount++
await hooks.onProgress?.promise?.({ phase: 'recommend', current: chatCount, max: maxChatPerRun }).catch(() => {})
logInfo('[boss-auto-browse] ✓ 已向', candidate.geekName, '发送招呼(本次共', chatCount, '人)')
} else {
logInfo('[boss-auto-browse] ✗', candidate.geekName, '开聊失败:', chatResult.reason)
if (chatResult.reason === 'DAILY_LIMIT_REACHED' || chatResult.reason === 'RISK_CONTROL') {
break mainLoop
}
}
}
// e. 滚动加载 / 翻页(在 iframe frame 内操作)
logDebug('[boss-auto-browse] 本轮匹配已处理完,滚动加载更多…')
const hasMore = await scrollAndLoadMore(recommendFrame).catch(() => false)
if (!hasMore) {
logInfo('[boss-auto-browse] 已加载全部候选人,结束本次运行。')
break mainLoop
}
await sleepWithRandomDelay(1500)
}
await hooks.onComplete?.promise?.()
logInfo('[boss-auto-browse] 本次运行完成,共成功开聊', chatCount, '人。')
if (returnBrowser && browser && page) {
return { browser, page }
}
} catch (err) {
await hooks.onError?.promise?.(err)
throw err
} finally {
if (browser && !returnBrowser) {
try {
await browser.close()
} catch (e) {
void e
}
}
}
}

View File

@@ -0,0 +1,310 @@
/**
* llm-rubric.mjs
*
* LLM-based resume evaluation using Rubric (knockouts + dimensions).
* Used when resumeLlmConfig.rubric is present in job filter.
*/
import { readConfigFile } from './runtime-file-utils.mjs'
import { debug as logDebug, info as logInfo, warn as logWarn, error as logError } from './logger.mjs'
const RESUME_TEXT_MAX_CHARS = 3500
const LOG = '[llm-rubric]'
/**
* 将 providers 数组展开为 flat model 列表,每个 model 携带所属 provider 的 baseURL/apiKey。
* 同时兼容旧格式(直接含 models 字段的配置)。
* @param {object} config
* @returns {Array<{ id, baseURL, apiKey, model, enabled, thinking, name }>}
*/
function flattenModels (config) {
if (Array.isArray(config.providers)) {
return config.providers.flatMap((p) =>
(p.models ?? []).map((m) => ({
...m,
baseURL: p.baseURL,
apiKey: p.apiKey
}))
)
}
// 旧格式兜底(迁移前可能在 runtime 里读到)
if (Array.isArray(config.models)) {
return config.models
}
return []
}
/**
* 获取启用的招聘端 LLM 配置,从 boss-llm.json 按 purpose 选取模型。
* boss-llm.json 格式: { providers: [...], purposeDefaultModelId: { resume_screening: "uuid" } }
* @param {string} [purpose='resume_screening'] - 用途 key
* @param {string|null} [preferModelId=null] - 指定模型 id优先
* @returns {{ baseURL: string, apiKey: string, model: string, thinking?: { enabled: boolean, budget: number } } | null}
*/
export function getEnabledLlmClient (purpose = 'resume_screening', preferModelId = null) {
const raw = readConfigFile('boss-llm.json')
const models = flattenModels(raw ?? {})
if (models.length === 0) return null
// 指定 modelId优先使用需启用
if (preferModelId) {
const preferred = models.find((m) => m.id === preferModelId && m.enabled !== false)
if (preferred?.baseURL && preferred?.apiKey && preferred?.model) {
logDebug(LOG, 'use preferred modelId', preferModelId, preferred.model)
return {
baseURL: preferred.baseURL,
apiKey: preferred.apiKey,
model: preferred.model,
thinking: preferred.thinking ?? null
}
}
logWarn(LOG, 'preferred modelId not found/enabled', preferModelId)
}
// 优先按 purposeDefaultModelId 精确匹配
const defaultId =
raw?.purposeDefaultModelId?.[purpose] ?? raw?.purposeDefaultModelId?.['default']
let selected = defaultId
? models.find((m) => m.id === defaultId && m.enabled !== false)
: null
// 回退: 找第一个启用的模型
if (!selected) {
selected = models.find((m) => m.enabled !== false)
}
if (!selected || !selected.baseURL || !selected.apiKey || !selected.model) return null
logDebug(LOG, 'selected model', { purpose, modelId: selected.id, model: selected.model })
return {
baseURL: selected.baseURL,
apiKey: selected.apiKey,
model: selected.model,
thinking: selected.thinking ?? null
}
}
/**
* 根据 Rubric 评估简历。
* @param {string} resumeText - 简历全文
* @param {{ knockouts?: string[], dimensions?: Array<{ name: string, weight: number, criteria: Record<string, string> }>, passThreshold?: number }} rubricConfig
* @param {{ modelId?: string | null }} [options]
* @returns {Promise<{ isPassed: boolean, totalScore: number, reason: string }>} 失败时默认通过
*/
export async function evaluateResumeByRubric (resumeText, rubricConfig, options = {}) {
const defaultResult = { isPassed: true, totalScore: 0, reason: 'LLM 调用失败,默认通过' }
const modelId = typeof options?.modelId === 'string' ? options.modelId : null
const client = getEnabledLlmClient('resume_screening', modelId)
if (!client) return defaultResult
const knockouts = Array.isArray(rubricConfig?.knockouts) ? rubricConfig.knockouts : []
const dimensions = Array.isArray(rubricConfig?.dimensions) ? rubricConfig.dimensions : []
const passThreshold = typeof rubricConfig?.passThreshold === 'number' ? rubricConfig.passThreshold : 75
if (dimensions.length === 0) {
return { isPassed: true, totalScore: 100, reason: '无评分维度,默认通过' }
}
const truncatedResume = (resumeText || '(无简历内容)').slice(0, RESUME_TEXT_MAX_CHARS)
const dimensionsDesc = dimensions
.map((d) => {
const criteriaStr = Object.entries(d.criteria || {})
.map(([k, v]) => `${k}分: ${v}`)
.join('')
return `- ${d.name}(权重${d.weight}%${criteriaStr}`
})
.join('\n')
let systemContent = `你是一个招聘筛选助手。请根据以下评分标准对候选人简历进行结构化评估。
【一票否决项】若简历不满足以下任一项,直接返回 knockout_failed: true无需计算维度分
${knockouts.length > 0 ? knockouts.map((k) => `- ${k}`).join('\n') : '(无)'}
【评分维度】每个维度打 1/3/5 分按权重加权得到总分满100
${dimensionsDesc}
请仅以 JSON 格式回复,不要包含其他内容。格式:
{
"knockout_failed": true或false,
"knockout_reason": "若不通过则填写原因,否则填空字符串",
"dimension_scores": { "维度名": 1或3或5, ... },
"reasoning": "简要判断理由"
}`
try {
logInfo(LOG, 'evaluateResumeByRubric start', {
model: client.model,
resumeChars: truncatedResume.length,
dims: dimensions.length,
knockouts: knockouts.length,
passThreshold
})
const { completes } = await import('@geekgeekrun/utils/gpt-request.mjs')
const completion = await completes(
{
baseURL: client.baseURL,
apiKey: client.apiKey,
model: client.model,
max_tokens: 500,
response_format: { type: 'json_object' }
},
[
{ role: 'system', content: systemContent },
{ role: 'user', content: truncatedResume }
]
)
const raw = completion?.choices?.[0]?.message?.content?.trim()
logDebug(LOG, 'evaluateResumeByRubric raw length', raw?.length ?? 0)
if (!raw) return defaultResult
const jsonStr = raw.replace(/^[\s\S]*?(\{[\s\S]*\})[\s\S]*$/, '$1')
const parsed = JSON.parse(jsonStr)
if (parsed.knockout_failed === true) {
return {
isPassed: false,
totalScore: 0,
reason: String(parsed.knockout_reason || parsed.reasoning || '一票否决')
}
}
const scores = parsed.dimension_scores || {}
let weightedSum = 0
let totalWeight = 0
const dimensionResults = []
for (const d of dimensions) {
const score = scores[d.name]
const num = typeof score === 'number' ? Math.min(5, Math.max(1, score)) : 3
const weight = typeof d.weight === 'number' ? d.weight : 100 / dimensions.length
weightedSum += (num / 5) * weight
totalWeight += weight
dimensionResults.push({ name: d.name, score: num, weight })
}
const totalScore = totalWeight > 0 ? Math.round((weightedSum / totalWeight) * 100) : 0
const isPassed = totalScore >= passThreshold
return {
isPassed,
totalScore,
reason: String(parsed.reasoning || ''),
dimensionResults
}
} catch (err) {
logError(LOG, 'evaluateResumeByRubric error', err?.message || err)
return { ...defaultResult, reason: `评估异常: ${err?.message || err}` }
}
}
/**
* 根据岗位描述JD生成 Rubric 结构。
* @param {string} sourceJd - 岗位描述或招聘要求
* @param {{ modelId?: string | null }} [options]
* @returns {Promise<{ rubric: { knockouts: string[], dimensions: Array<{ name: string, weight: number, criteria: Record<string, string> }> } }>}
*/
export async function generateRubricFromJd (sourceJd, options = {}) {
const defaultRubric = {
knockouts: [],
dimensions: [
{ name: '综合匹配度', weight: 100, criteria: { '1': '不符合', '3': '部分符合', '5': '完全符合' } }
]
}
// 允许为“Rubric 生成”单独指定模型;旧配置未配置 rubric_generation 时,会自动回退到 default/第一个启用模型
const modelId = typeof options?.modelId === 'string' ? options.modelId : null
const client = getEnabledLlmClient('rubric_generation', modelId)
if (!client) return { rubric: defaultRubric }
const systemContent = `你是一个资深 HR擅长将招聘需求转化为可量化的候选人评分体系Rubric
请仔细阅读用户提供的岗位描述JD从中提取并生成
1. knockouts一票否决项
- 不满足任意一项即直接淘汰
- 数量:根据 JD 实际硬性要求决定,通常 2~4 条
- 只写岗位明确说明的硬性条件(禁止背景、资质门槛、明确排除项等),不要臆造
- 每条独立,简洁具体,不超过 30 字
2. dimensions评分维度
- 数量:根据 JD 核心能力要求决定,通常 3~5 个
- 每个维度必须对应 JD 中一个独立的、具体的能力方向(如:实验操作能力、研究独立性、沟通表达能力、工具学习能力等)
- 严禁出现「综合匹配度」「整体匹配」「岗位匹配度」等笼统无意义的维度名称
- weight 之和必须精确等于 100
- criteria 必须是具体的行为或成果描述,严禁使用「不符合/部分符合/完全符合」这类无意义模板:
- "1":候选人完全不具备该维度的能力或经验(举例说明具体缺失表现)
- "3":候选人具备基础能力,但深度或广度不足(举例说明具体不足之处)
- "5":候选人在该维度有突出表现,与岗位高度匹配(举例说明具体优秀表现)
仅以 JSON 格式回复,不要包含任何其他文字,不要有 markdown 代码块。格式:
{
"knockouts": ["否决项1", "否决项2"],
"dimensions": [
{
"name": "维度名称",
"weight": 30,
"criteria": {
"1": "1分的具体行为描述",
"3": "3分的具体行为描述",
"5": "5分的具体行为描述"
}
}
]
}`
try {
logInfo(LOG, 'generateRubricFromJd start', { model: client.model, jdChars: String(sourceJd || '').length })
const { completes } = await import('@geekgeekrun/utils/gpt-request.mjs')
const completion = await completes(
{
baseURL: client.baseURL,
apiKey: client.apiKey,
model: client.model,
max_tokens: 2000,
response_format: { type: 'json_object' }
},
[
{ role: 'system', content: systemContent },
{ role: 'user', content: sourceJd || '(请输入岗位描述)' }
]
)
const raw = completion?.choices?.[0]?.message?.content?.trim()
logDebug(LOG, 'generateRubricFromJd raw length', raw?.length ?? 0)
if (!raw) return { rubric: defaultRubric }
const jsonStr = raw.replace(/^[\s\S]*?(\{[\s\S]*\})[\s\S]*$/, '$1')
const parsed = JSON.parse(jsonStr)
const knockouts = Array.isArray(parsed.knockouts)
? parsed.knockouts.filter((k) => typeof k === 'string').slice(0, 5)
: []
let dimensions
const dimList = Array.isArray(parsed.dimensions) ? parsed.dimensions : []
const dimCount = Math.min(5, dimList.length) || 1
dimensions = dimList
.filter((d) => d && typeof d.name === 'string' && d.criteria && typeof d.criteria === 'object')
.slice(0, 5)
.map((d) => ({
name: String(d.name),
weight: typeof d.weight === 'number' ? Math.max(0, Math.min(100, d.weight)) : 100 / dimCount,
criteria: {
'1': String(d.criteria['1'] || d.criteria[1] || ''),
'3': String(d.criteria['3'] || d.criteria[3] || ''),
'5': String(d.criteria['5'] || d.criteria[5] || '')
}
}))
if (dimensions.length === 0) dimensions = defaultRubric.dimensions
// 归一化权重
const totalWeight = dimensions.reduce((s, d) => s + (d.weight || 0), 0)
if (totalWeight > 0) {
dimensions = dimensions.map((d) => ({
...d,
weight: Math.round((100 * (d.weight || 0)) / totalWeight)
}))
}
return { rubric: { knockouts, dimensions } }
} catch (err) {
logError(LOG, 'generateRubricFromJd error', err?.message || err)
return { rubric: defaultRubric }
}
}

View File

@@ -0,0 +1,40 @@
/**
* 招聘端推荐页 / 沟通页日志,支持按级别过滤。
* 级别debug < info < warn < error。setLevel 由主流程在读取 config 后调用。
*/
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }
let currentLevel = LEVELS.info
export function setLevel (level) {
const n = LEVELS[level]
if (n !== undefined) currentLevel = n
else currentLevel = LEVELS.info
}
export function getLevel () {
return currentLevel
}
function log (minLevel, method, ...args) {
if (currentLevel <= minLevel) {
console[method](...args)
}
}
export function debug (...args) {
log(LEVELS.debug, 'log', ...args)
}
export function info (...args) {
log(LEVELS.info, 'log', ...args)
}
export function warn (...args) {
log(LEVELS.warn, 'warn', ...args)
}
export function error (...args) {
log(LEVELS.error, 'error', ...args)
}

View File

@@ -0,0 +1,24 @@
{
"name": "@geekgeekrun/boss-auto-browse-and-chat",
"private": true,
"version": "1.0.0",
"description": "boss-auto-browse-and-chat",
"module": "./index.mjs",
"type": "module",
"scripts": {
},
"author": "geekgeekrun",
"license": "ISC",
"dependencies": {
"@geekgeekrun/utils": "workspace:*",
"@geekgeekrun/sqlite-plugin": "workspace:*",
"@geekgeekrun/puppeteer-extra-plugin-laodeng": "workspace:*",
"json5": "^2.2.3",
"puppeteer": "24.19.0",
"puppeteer-extra": "3.3.6",
"puppeteer-extra-plugin-anonymize-ua": "2.4.6",
"puppeteer-extra-plugin-stealth": "2.11.2",
"tapable": "^2.2.1",
"ghost-cursor": "^1.3.1"
}
}

View File

@@ -0,0 +1,341 @@
/**
* 招聘端简历数据提取工具:网络请求拦截 + Canvas 文字提取
*
* 沟通页在线简历有两套不同的数据(详见 plan/chat_page_resume_flow.md
* - 简单摘要geek/info、historyMsg body.resume 等 API 返回的只有简单工作单位、学校等,可拦截后 parseGeekInfoFromIntercepted 得到。
* - 完整版(图片里那种):接收加密数据 → WASMRust decrypt.rsBase64+AES解密 → 仅绘制到 #resume 的 Canvas无明文 API要拿完整版要么 Canvas hook有反爬风险要么逆向 examples/wasm_canvas_bg-1.0.2-5057.dcmp 的解密逻辑。
*/
// ---------------------------------------------------------------------------
// 网络拦截
// ---------------------------------------------------------------------------
/** 需要拦截的 URL 关键词(招聘端简历/候选人相关 API含沟通页 geek/info */
const INTERCEPT_URL_KEYWORDS = ['resume', 'geek/info', 'geek/detail']
/**
* 从 URL 中提取用于存储的 path 部分(便于去重与查找)
* @param {string} url - 完整 URL
* @returns {string} path 或原 URL
*/
function getPathFromUrl (url) {
try {
const u = new URL(url)
return u.pathname || url
} catch {
return url
}
}
/**
* 判断 URL 是否包含任一拦截关键词
* @param {string} url
* @returns {boolean}
*/
function shouldIntercept (url) {
return INTERCEPT_URL_KEYWORDS.some(kw => url.includes(kw))
}
/**
* 在页面上设置网络响应拦截器,收集简历相关 API 的 JSON 响应。
* 与 laodeng 无冲突,仅在 Node 侧监听 response 事件。
*
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
* @returns {{ getInterceptedData: () => Map<string, unknown> }} 返回 getInterceptedData调用后返回当前收集到的数据并清空
*/
export function setupNetworkInterceptor (page) {
const intercepted = new Map()
page.on('response', async (response) => {
const url = response.url()
if (!shouldIntercept(url)) return
const path = getPathFromUrl(url)
try {
const contentType = response.headers()['content-type'] || ''
if (!contentType.includes('application/json')) return
const data = await response.json()
intercepted.set(path, data)
} catch (_) {
// 非 JSON 或解析失败则忽略
}
})
/**
* 获取当前已拦截的数据并清空 Map便于单次详情页使用后取数
* @returns {Map<string, unknown>} 本次拦截到的数据path -> parsed JSON
*/
function getInterceptedData () {
const snapshot = new Map(intercepted)
intercepted.clear()
return snapshot
}
/**
* 查看当前已拦截的数据(不清空),便于在不消费数据的情况下检查是否有新数据
* @returns {Map<string, unknown>} 当前拦截到的数据快照path -> parsed JSON
*/
function peekInterceptedData () {
return new Map(intercepted)
}
return { getInterceptedData, peekInterceptedData }
}
// ---------------------------------------------------------------------------
// 从拦截的 geek/info API 解析简历(沟通页推荐,不碰 Canvas
// ---------------------------------------------------------------------------
/** 沟通页 geek/info API 路径特征 */
const GEEK_INFO_PATH = 'geek/info'
/**
* 从 getInterceptedData() 的 Map 中取出 geek/info 的响应并解析为结构化数据与拼接文案。
* 注意geek/info 的 zpData.data 仅为简单摘要(工作单位、学校、经历列表等),与 #resume 上 WASM 解密后绘制的完整版简历不是同一份数据。
*
* @param {Map<string, unknown>} interceptedMap - getInterceptedData() 的返回值
* @returns {{ data: object | null, text: string }} data 为 zpData.datatext 为摘要拼接(供简单关键词/LLM 筛选)
*/
export function parseGeekInfoFromIntercepted (interceptedMap) {
if (!interceptedMap || interceptedMap.size === 0) {
return { data: null, text: '' }
}
for (const [path, raw] of interceptedMap) {
if (!path || !path.includes(GEEK_INFO_PATH)) continue
const res = typeof raw === 'object' && raw !== null && 'zpData' in raw
? raw
: null
if (!res || !res.zpData || !res.zpData.data) {
return { data: null, text: '' }
}
const d = res.zpData.data
const parts = []
if (d.name) parts.push(d.name)
if (d.ageDesc) parts.push(d.ageDesc)
if (d.workYear) parts.push(d.workYear)
if (d.edu) parts.push(d.edu)
if (d.positionStatus) parts.push(d.positionStatus)
if (d.school) parts.push(d.school)
if (d.major) parts.push(d.major)
if (d.city) parts.push(d.city)
if (d.salaryDesc || d.price) parts.push(d.salaryDesc || d.price)
if (d.positionName || d.toPosition) parts.push(d.positionName || d.toPosition)
if (Array.isArray(d.workExpList) && d.workExpList.length > 0) {
parts.push('工作经历:')
d.workExpList.forEach(w => {
parts.push([w.timeDesc, w.company, w.positionName].filter(Boolean).join(' '))
})
}
if (Array.isArray(d.eduExpList) && d.eduExpList.length > 0) {
parts.push('教育经历:')
d.eduExpList.forEach(e => {
parts.push([e.timeDesc, e.school, e.major, e.degree].filter(Boolean).join(' '))
})
}
const text = parts.join('\n')
return { data: d, text }
}
return { data: null, text: '' }
}
// ---------------------------------------------------------------------------
// Canvas 文字 Hook与 laodeng 兼容)— 非 BOSS 自带,可能被反爬检测,沟通页请用 API 拦截
// ---------------------------------------------------------------------------
/**
* 在页面上通过 evaluateOnNewDocument 注入 Canvas fillText hook将绘制文字收集到主页面 window.__canvasCapturedText。
*
* 实现原理:
* - evaluateOnNewDocument 会在主页面和每一个 iframe 中各执行一次。
* - 在线简历 iframe 带有 sandbox 属性且不含 allow-same-origin主页面无法访问其 contentWindow
* 因此必须在 iframe 自身的执行上下文内直接 hook CanvasRenderingContext2D.prototype.fillText。
* - iframe 内 hook 到的文字通过 window.top.postMessage 批量发回主页面(同 origin 或跨 origin 均可用)。
* - 主页面监听 message 事件并累积到 window.__canvasCapturedText。
*
* @param {import('puppeteer').Page} page - Puppeteer 页面实例(必须在 page.goto 之前调用)
* @returns {Promise<{ getCapturedText: (page: import('puppeteer').Page) => Promise<Array<{text: string, x: number, y: number}>> }>}
*/
export async function setupCanvasTextHook (page) {
// 转发浏览器内部 [canvasHook] 日志到 Node 侧,便于调试
page.on('console', (msg) => {
const text = msg.text()
if (text.startsWith('[canvasHook]')) {
console.log('[canvasHook-browser]', text)
}
})
await page.evaluateOnNewDocument(() => {
// 此脚本在每个 frame主页面 + 所有 iframe中各执行一次。
// 策略:
// 主页面 → 初始化收集数组,监听来自 iframe 的 postMessage
// iframe → 直接 hook 当前窗口的 fillText批量 postMessage 到 window.top
const isTopFrame = (window === window.top)
if (isTopFrame) {
window.__canvasCapturedText = []
window.addEventListener('message', (evt) => {
if (evt.data && evt.data.__bossCanvasHook && Array.isArray(evt.data.__bossCanvasHook)) {
if (!window.__canvasCapturedText) window.__canvasCapturedText = []
for (const item of evt.data.__bossCanvasHook) {
window.__canvasCapturedText.push(item)
}
console.log('[canvasHook] main received ' + evt.data.__bossCanvasHook.length + ' items, total ' + window.__canvasCapturedText.length)
}
})
console.log('[canvasHook] main: message listener set')
}
// 在当前 window无论是主页面还是 iframe上 hook fillText
try {
const proto = window.CanvasRenderingContext2D?.prototype
if (!proto) { console.log('[canvasHook] CanvasRenderingContext2D.prototype not found'); return }
if (proto._bossHooked) { console.log('[canvasHook] already hooked, skip'); return }
proto._bossHooked = true
const origFillText = proto.fillText
if (typeof origFillText !== 'function') { console.log('[canvasHook] fillText is not a function'); return }
// 批量缓冲,用 setTimeout(0) 在一个事件循环 tick 后统一发送WASM 会在同一个同步调用栈内连续 fillText
const captured = []
let flushScheduled = false
const flush = () => {
flushScheduled = false
if (captured.length === 0) return
const items = captured.splice(0)
if (isTopFrame) {
if (!window.__canvasCapturedText) window.__canvasCapturedText = []
for (const item of items) window.__canvasCapturedText.push(item)
console.log('[canvasHook] main fillText wrote ' + items.length + ' items')
} else {
try {
window.top.postMessage({ __bossCanvasHook: items }, '*')
console.log('[canvasHook] iframe postMessage sent ' + items.length + ' items')
} catch (e) {
console.log('[canvasHook] postMessage failed: ' + e.message)
}
}
}
const scheduleFlush = () => {
if (!flushScheduled) { flushScheduled = true; setTimeout(flush, 0) }
}
Object.defineProperty(proto, 'fillText', {
value: new Proxy(origFillText, {
apply (target, thisArg, args) {
const [text, x, y] = args
if (typeof text === 'string' && text.trim()) {
captured.push({ text, x: Number(x) || 0, y: Number(y) || 0 })
scheduleFlush()
}
return Reflect.apply(target, thisArg, args)
}
}),
writable: true,
configurable: true
})
console.log('[canvasHook] fillText hook installed, isTopFrame=' + isTopFrame + ' href=' + window.location.href)
} catch (e) {
console.log('[canvasHook] hook install error: ' + e.message)
}
})
/**
* 从主页面读取当前收集的 Canvas 文字并清空。
* @param {import('puppeteer').Page} p - 同一页面实例
* @returns {Promise<Array<{text: string, x: number, y: number}>>}
*/
async function getCapturedText (p) {
// 给浏览器 150ms 处理待发送的 setTimeout(0)/postMessage 队列
await p.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)))
const result = await p.evaluate(() => {
const arr = window.__canvasCapturedText || []
const copy = arr.map(({ text, x, y }) => ({ text, x, y }))
window.__canvasCapturedText = []
return copy
})
return result
}
/**
* 清空主页面收集数组(不返回数据),用于切换候选人前丢弃上一个 iframe 的残留数据。
* @param {import('puppeteer').Page} p - 同一页面实例
*/
async function clearCapturedText (p) {
await p.evaluate(() => { window.__canvasCapturedText = [] })
}
return { getCapturedText, clearCapturedText }
}
// ---------------------------------------------------------------------------
// 文字按行整理
// ---------------------------------------------------------------------------
/**
* 将 Canvas 捕获的 { text, x, y } 数组按行分组并排序,拼接成按行排列的文字数组。
* 同一行y 坐标差 < 5px同一行内按 x 排序后去重(相邻 x 差 ≤1px 的视为多次渲染同一字符),再拼接。
*
* BOSS 直聘在线简历会用多个叠加 Canvas高清/普通各一层)以及 WASM 多次渲染,
* 导致同一字符在相同坐标被 fillText 多次,必须去重否则每字会重复 N 次。
*
* @param {Array<{text: string, x: number, y: number}>} capturedTextArray - Canvas 捕获结果
* @returns {string[]} 按行排列的文字数组(已去重)
*/
export function extractResumeText (capturedTextArray) {
if (!Array.isArray(capturedTextArray) || capturedTextArray.length === 0) {
return []
}
const Y_THRESHOLD = 5
// x 坐标差在此范围内视为同一位置的重复绘制(多层/多次渲染)
const X_DEDUP_THRESHOLD = 1
const rows = new Map()
for (const { text, x, y } of capturedTextArray) {
const yKey = Math.round(y / Y_THRESHOLD) * Y_THRESHOLD
if (!rows.has(yKey)) {
rows.set(yKey, [])
}
rows.get(yKey).push({ text, x })
}
const sortedY = Array.from(rows.keys()).sort((a, b) => a - b)
const lines = sortedY.map(yKey => {
const items = rows.get(yKey)
items.sort((a, b) => a.x - b.x)
// 去重:相邻项 x 差 ≤ X_DEDUP_THRESHOLD 时视为同一字符的重复绘制,保留第一个
const deduped = items.filter((item, i) =>
i === 0 || Math.abs(item.x - items[i - 1].x) > X_DEDUP_THRESHOLD
)
return deduped.map(it => it.text).join('')
})
return lines
}
// ---------------------------------------------------------------------------
// 统一获取简历数据API 优先Canvas 兜底)
// ---------------------------------------------------------------------------
/**
* 优先从拦截的 API 数据中取简历,若无则从页面 window.__canvasCapturedText 中提取(需先调用 setupCanvasTextHook
*
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
* @param {Map<string, unknown>} interceptedData - setupNetworkInterceptor 返回的 getInterceptedData() 的结果
* @returns {Promise<{ source: 'api' | 'canvas', data: unknown }>} source 为 'api' 时 data 为 API 响应对象;为 'canvas' 时为 extractResumeText 的结果(字符串数组)
*/
export async function getResumeData (page, interceptedData) {
if (interceptedData && interceptedData.size > 0) {
const firstEntry = interceptedData.entries().next()
if (!firstEntry.done) {
const [path, data] = firstEntry.value
return { source: 'api', data: { path, ...(typeof data === 'object' && data !== null ? data : { value: data }) } }
}
}
const captured = await page.evaluate(() => {
const arr = window.__canvasCapturedText || []
const copy = arr.map(({ text, x, y }) => ({ text, x, y }))
window.__canvasCapturedText = []
return copy
})
const lines = extractResumeText(captured)
return { source: 'canvas', data: lines }
}

View File

@@ -0,0 +1,355 @@
/**
* resume-scorer.mjs
*
* Rule-based candidate scoring module for boss-auto-browse-and-chat.
* No AI / external dependencies — pure JS logic.
*
* Scoring breakdown (total 100 points):
* - educationLevel : 020
* - experience : 020
* - skills : 030
* - city : 010
* - salary : 010
* - completeness : 010
*
* Usage:
* If boss-recruiter.json contains { "scoring": { "enabled": true } },
* call rankCandidates() after filterCandidates() to sort matched candidates
* by score (descending) before deciding to initiate chat.
*/
// ---------------------------------------------------------------------------
// Default scoring configuration
// ---------------------------------------------------------------------------
/**
* Default scoring configuration.
* Can be overridden via boss-recruiter.json's "scoring" field.
*
* @type {ScoringConfig}
*/
export const defaultScoringConfig = {
enabled: false,
/** Minimum total score required to initiate chat (0 = no threshold). */
minScoreToChat: 0,
/**
* Education level weights.
* Keys are lowercase substrings matched against candidateInfo.educationLevel.
*/
educationWeights: {
博士: 20,
硕士: 15,
本科: 10,
大专: 5,
},
/**
* Maximum points for each scoring dimension.
* Adjust to re-weight dimensions without changing the scoring logic.
*/
maxScores: {
education: 20,
experience: 20,
skills: 30,
city: 10,
salary: 10,
completeness: 10,
},
};
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Parse a work-experience string like "3年" / "3-5年" / "10年以上" into
* a representative numeric year value (the lower bound).
*
* @param {string|null|undefined} workExpStr
* @returns {number} years of experience (NaN if not parseable)
*/
function parseWorkExpYears(workExpStr) {
if (!workExpStr) return NaN;
// e.g. "3-5年" → 3, "10年以上" → 10, "3年" → 3, "应届" → 0
if (/应届/.test(workExpStr)) return 0;
const match = workExpStr.match(/(\d+)/);
return match ? parseInt(match[1], 10) : NaN;
}
/**
* Parse a salary string like "8k-12k" / "15-25k" / "面议" into
* a [min, max] tuple in units of 千元 (k).
*
* @param {string|null|undefined} salaryStr
* @returns {[number, number]} [min, max], both NaN if not parseable
*/
function parseSalaryRange(salaryStr) {
if (!salaryStr) return [NaN, NaN];
if (/面议/.test(salaryStr)) return [NaN, NaN];
// e.g. "8k-12k", "15-25K", "8000-12000"
const match = salaryStr.toLowerCase().match(/(\d+)[k]?\s*[-~]\s*(\d+)[k]?/);
if (!match) return [NaN, NaN];
let min = parseInt(match[1], 10);
let max = parseInt(match[2], 10);
// Normalize to 千元: if values look like full yuan (> 1000), divide by 1000
if (min > 1000) min = Math.round(min / 1000);
if (max > 1000) max = Math.round(max / 1000);
return [min, max];
}
// ---------------------------------------------------------------------------
// Individual dimension scorers
// ---------------------------------------------------------------------------
/**
* Score education level (0 maxScores.education).
*
* @param {string|null|undefined} educationLevel
* @param {ScoringConfig} config
* @returns {number}
*/
function scoreEducation(educationLevel, config) {
const max = config.maxScores.education;
if (!educationLevel) return 0;
const level = educationLevel.trim();
for (const [keyword, points] of Object.entries(config.educationWeights)) {
if (level.includes(keyword)) {
// Scale the configured weight to fit within maxScores.education
const configuredMax = Math.max(...Object.values(config.educationWeights));
return Math.round((points / configuredMax) * max);
}
}
return 0;
}
/**
* Score work experience against the filter's expectWorkExpRange
* (0 maxScores.experience).
*
* Full score if within range; linearly decreasing outside range.
*
* @param {string|null|undefined} workExpStr
* @param {number[]} expectWorkExpRange [min, max] years
* @param {ScoringConfig} config
* @returns {number}
*/
function scoreExperience(workExpStr, expectWorkExpRange, config) {
const max = config.maxScores.experience;
const years = parseWorkExpYears(workExpStr);
if (isNaN(years)) return Math.round(max * 0.5); // unknown → neutral score
const [expMin, expMax] = expectWorkExpRange;
if (years >= expMin && years <= expMax) return max;
// Distance-based decay: -2 pts per year outside range, floor at 0
const distance = years < expMin ? expMin - years : years - expMax;
return Math.max(0, max - distance * 2);
}
/**
* Score skill keyword matches (0 maxScores.skills).
*
* @param {string|null|undefined} skillsStr comma/space-separated skills
* @param {string[]} expectSkillKeywords
* @param {ScoringConfig} config
* @returns {number}
*/
function scoreSkills(skillsStr, expectSkillKeywords, config) {
const max = config.maxScores.skills;
if (!expectSkillKeywords || expectSkillKeywords.length === 0) {
// No keywords configured → full score (no penalty)
return max;
}
if (!skillsStr) return 0;
const skillsLower = skillsStr.toLowerCase();
let matched = 0;
for (const kw of expectSkillKeywords) {
if (skillsLower.includes(kw.toLowerCase())) matched++;
}
return Math.round((matched / expectSkillKeywords.length) * max);
}
/**
* Score city match (0 maxScores.city).
*
* @param {string|null|undefined} city
* @param {string[]} expectCityList
* @param {ScoringConfig} config
* @returns {number}
*/
function scoreCity(city, expectCityList, config) {
const max = config.maxScores.city;
if (!expectCityList || expectCityList.length === 0) return max;
if (!city) return 0;
const cityLower = city.toLowerCase();
const matched = expectCityList.some((c) => cityLower.includes(c.toLowerCase()));
return matched ? max : 0;
}
/**
* Score salary expectation against expectSalaryRange (0 maxScores.salary).
*
* @param {string|null|undefined} salaryExpect
* @param {number[]} expectSalaryRange [min, max] in 千元; [0,0] means no constraint
* @param {ScoringConfig} config
* @returns {number}
*/
function scoreSalary(salaryExpect, expectSalaryRange, config) {
const max = config.maxScores.salary;
const [filterMin, filterMax] = expectSalaryRange;
// No salary constraint configured
if (filterMin === 0 && filterMax === 0) return max;
const [candMin, candMax] = parseSalaryRange(salaryExpect);
if (isNaN(candMin) || isNaN(candMax)) return Math.round(max * 0.5); // unknown → neutral
// Check overlap: candidate range overlaps with expected range
const effectiveFilterMax = filterMax === 0 ? Infinity : filterMax;
const hasOverlap = candMin <= effectiveFilterMax && candMax >= filterMin;
if (hasOverlap) return max;
// Partial score based on proximity
const gap = candMin > effectiveFilterMax
? candMin - effectiveFilterMax
: filterMin - candMax;
return Math.max(0, max - gap);
}
/**
* Score resume completeness (0 maxScores.completeness).
*
* Awards points based on which profile fields are present.
*
* @param {CandidateInfo} candidateInfo
* @param {ScoringConfig} config
* @returns {number}
*/
function scoreCompleteness(candidateInfo, config) {
const max = config.maxScores.completeness;
const fields = [
candidateInfo.educationLevel,
candidateInfo.workExpYears,
candidateInfo.city,
candidateInfo.jobTitle,
candidateInfo.salaryExpect,
candidateInfo.skills,
];
const filled = fields.filter((v) => v && String(v).trim().length > 0).length;
return Math.round((filled / fields.length) * max);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* @typedef {Object} ScoringConfig
* @property {boolean} enabled
* @property {number} minScoreToChat
* @property {Object.<string, number>} educationWeights
* @property {Object.<string, number>} maxScores
*/
/**
* @typedef {Object} CandidateInfo
* @property {string} encryptGeekId
* @property {string} geekName
* @property {string|null} educationLevel
* @property {string|null} workExpYears
* @property {string|null} city
* @property {string|null} jobTitle
* @property {string|null} salaryExpect
* @property {string|null} skills
*/
/**
* @typedef {Object} ScoreBreakdown
* @property {number} education
* @property {number} experience
* @property {number} skills
* @property {number} city
* @property {number} salary
* @property {number} completeness
*/
/**
* @typedef {Object} ScoreResult
* @property {number} totalScore 0100
* @property {ScoreBreakdown} breakdown
*/
/**
* Score a single candidate using rule-based logic.
*
* The filterConfig (from candidate-filter.json) provides the target ranges /
* lists; scoringConfig (from boss-recruiter.json's "scoring" field, merged
* with defaultScoringConfig) controls weights.
*
* @param {CandidateInfo} candidateInfo
* @param {string|null} resumeText Raw resume text extracted via canvas / API
* (currently reserved for future keyword extraction; not yet used).
* @param {Object} filterConfig Contents of candidate-filter.json
* @param {ScoringConfig} [scoringConfig] Merged with defaultScoringConfig
* @returns {ScoreResult}
*/
export function scoreCandidate(candidateInfo, resumeText, filterConfig, scoringConfig) {
const cfg = Object.assign({}, defaultScoringConfig, scoringConfig, {
maxScores: Object.assign({}, defaultScoringConfig.maxScores, scoringConfig?.maxScores),
educationWeights: Object.assign(
{},
defaultScoringConfig.educationWeights,
scoringConfig?.educationWeights
),
});
const fc = filterConfig || {};
const education = scoreEducation(candidateInfo.educationLevel, cfg);
const experience = scoreExperience(
candidateInfo.workExpYears,
fc.expectWorkExpRange || [0, 99],
cfg
);
const skills = scoreSkills(
candidateInfo.skills,
fc.expectSkillKeywords || [],
cfg
);
const city = scoreCity(candidateInfo.city, fc.expectCityList || [], cfg);
const salary = scoreSalary(
candidateInfo.salaryExpect,
fc.expectSalaryRange || [0, 0],
cfg
);
const completeness = scoreCompleteness(candidateInfo, cfg);
const breakdown = { education, experience, skills, city, salary, completeness };
const totalScore = Object.values(breakdown).reduce((sum, v) => sum + v, 0);
return { totalScore, breakdown };
}
/**
* Score and rank a list of candidates, returning them sorted by totalScore
* descending.
*
* Each element in the returned array is the original candidate object
* augmented with a `scoreResult` property.
*
* @param {CandidateInfo[]} candidates
* @param {Object} filterConfig Contents of candidate-filter.json
* @param {ScoringConfig} [scoringConfig]
* @returns {(CandidateInfo & { scoreResult: ScoreResult })[]}
*/
export function rankCandidates(candidates, filterConfig, scoringConfig) {
return candidates
.map((candidate) => ({
...candidate,
scoreResult: scoreCandidate(candidate, null, filterConfig, scoringConfig),
}))
.sort((a, b) => b.scoreResult.totalScore - a.scoreResult.totalScore);
}

View File

@@ -0,0 +1,381 @@
import fs from 'node:fs'
import fsPromise from 'node:fs/promises'
import path from 'node:path'
import os from 'node:os'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const defaultBossRecruiterConf = require('./default-config-file/boss-recruiter.json')
const defaultCandidateFilterConf = require('./default-config-file/candidate-filter.json')
const defaultBossCookieStorage = require('./default-storage-file/boss-cookies.json')
const defaultBossLocalStorageStorage = require('./default-storage-file/boss-local-storage.json')
export const configFileNameList = ['boss-recruiter.json', 'candidate-filter.json']
const defaultConfigFileContentMap = {
'boss-recruiter.json': JSON.stringify(defaultBossRecruiterConf),
'candidate-filter.json': JSON.stringify(defaultCandidateFilterConf)
}
const runtimeFolderPath = path.join(os.homedir(), '.geekgeekrun')
export const configFolderPath = path.join(runtimeFolderPath, 'config')
export const writeConfigFile = async (fileName, content, { isSync } = {}) => {
const filePath = path.join(configFolderPath, fileName)
const fileContent = JSON.stringify(content)
if (isSync) {
fs.writeFileSync(filePath, fileContent)
} else {
return fsPromise.writeFile(filePath, fileContent)
}
}
const ensureRuntimeFolderPathExist = () => {
if (!fs.existsSync(runtimeFolderPath)) {
fs.mkdirSync(runtimeFolderPath)
}
;['config', 'storage'].forEach(dirPath => {
if (!fs.existsSync(path.join(runtimeFolderPath, dirPath))) {
fs.mkdirSync(path.join(runtimeFolderPath, dirPath))
}
})
}
export const ensureConfigFileExist = () => {
ensureRuntimeFolderPathExist()
configFileNameList.forEach(fileName => {
if (!fs.existsSync(path.join(configFolderPath, fileName))) {
fs.writeFileSync(
path.join(configFolderPath, fileName),
defaultConfigFileContentMap[fileName]
)
}
})
}
export const readConfigFile = (fileName) => {
const joinedPath = path.join(configFolderPath, fileName)
if (!fs.existsSync(joinedPath)) {
ensureConfigFileExist()
}
let o
try {
o = JSON.parse(fs.readFileSync(joinedPath))
} catch {
if (fs.existsSync(joinedPath)) fs.unlinkSync(joinedPath)
if (defaultConfigFileContentMap[fileName]) {
ensureConfigFileExist()
o = JSON.parse(defaultConfigFileContentMap[fileName])
} else {
o = null
}
}
return o
}
export const storageFilePath = path.join(runtimeFolderPath, 'storage')
export const storageFileNameList = ['boss-cookies.json', 'boss-local-storage.json']
const defaultStorageFileContentMap = {
'boss-cookies.json': JSON.stringify(defaultBossCookieStorage),
'boss-local-storage.json': JSON.stringify(defaultBossLocalStorageStorage)
}
export const ensureStorageFileExist = () => {
ensureRuntimeFolderPathExist()
storageFileNameList.forEach(fileName => {
if (!fs.existsSync(path.join(storageFilePath, fileName))) {
fs.writeFileSync(
path.join(storageFilePath, fileName),
defaultStorageFileContentMap[fileName]
)
}
})
}
export const readStorageFile = (fileName, { isJson } = {}) => {
isJson = isJson ?? true
const joinedPath = path.join(storageFilePath, fileName)
if (!fs.existsSync(joinedPath)) {
ensureStorageFileExist()
}
let o
try {
const content = fs.readFileSync(joinedPath)
if (isJson) {
o = JSON.parse(content)
} else {
o = content.toString()
}
} catch {
if (fs.existsSync(joinedPath)) fs.unlinkSync(joinedPath)
ensureStorageFileExist()
if (isJson) {
o = JSON.parse(defaultStorageFileContentMap[fileName] ?? 'null')
} else {
o = defaultStorageFileContentMap[fileName] ?? null
}
}
return o
}
export const writeStorageFile = async (fileName, content, { isJson } = {}) => {
isJson = isJson ?? true
const filePath = path.join(storageFilePath, fileName)
let fileContent
if (isJson) {
fileContent = JSON.stringify(content)
} else {
fileContent = content
}
return fsPromise.writeFile(filePath, fileContent)
}
const bossJobsConfigFileName = 'boss-jobs-config.json'
export const readBossJobsConfig = () => {
ensureRuntimeFolderPathExist()
const filePath = path.join(configFolderPath, bossJobsConfigFileName)
if (!fs.existsSync(filePath)) {
return { jobs: [] }
}
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
} catch {
return { jobs: [] }
}
}
export const writeBossJobsConfig = async (config) => {
ensureRuntimeFolderPathExist()
const filePath = path.join(configFolderPath, bossJobsConfigFileName)
return fsPromise.writeFile(filePath, JSON.stringify(config))
}
/**
* 将 boss-jobs-config 的 filter含 *Enabled 字段)转换为 candidate-filter 格式,
* 供 filterCandidates / 推荐页 / 沟通页 preFilter 使用。
*/
function jobFilterToCandidateFilter (jobFilter) {
if (!jobFilter || typeof jobFilter !== 'object') {
return {}
}
const f = jobFilter
const expectCityList = f.expectCityEnabled && Array.isArray(f.expectCityList)
? f.expectCityList
: []
const expectEducationRegExpStr = f.expectEducationEnabled && typeof f.expectEducationRegExpStr === 'string'
? f.expectEducationRegExpStr
: ''
const [workMinDefault, workMaxDefault] = Array.isArray(f.expectWorkExpRange) && f.expectWorkExpRange.length >= 2
? f.expectWorkExpRange
: [0, 99]
const expectWorkExpRange = [
f.expectWorkExpMinEnabled ? workMinDefault : 0,
f.expectWorkExpMaxEnabled ? workMaxDefault : 99
]
const [salMinDefault, salMaxDefault] = Array.isArray(f.expectSalaryRange) && f.expectSalaryRange.length >= 2
? f.expectSalaryRange
: [0, 0]
const expectSalaryRange = [
f.expectSalaryMinEnabled ? salMinDefault : 0,
f.expectSalaryMaxEnabled ? salMaxDefault : 0
]
return {
expectCityList,
expectEducationRegExpStr,
expectWorkExpRange,
expectSalaryRange,
expectSalaryWhenNegotiable: f.expectSalaryWhenNegotiable || 'exclude',
expectSkillKeywords: [],
blockCandidateNameRegExpStr: '',
skipViewedCandidates: false
}
}
/**
* 将 boss-jobs-config 的 filter 转换为 chatPage.filter 格式(简历筛选)。
* 优先级resumeLlmEnabledrubric> resumeLlmEnabledrule> resumeKeywordsEnabled > resumeRegExpEnabled
*/
function jobFilterToChatPageFilter (jobFilter) {
if (!jobFilter || typeof jobFilter !== 'object') {
return { mode: 'keywords', keywordList: [], llmRule: '', llmConfig: null }
}
const f = jobFilter
// resumeLlmConfigRubric 模式)优先
if (f.resumeLlmEnabled && f.resumeLlmConfig?.rubric) {
return {
mode: 'llm',
keywordList: [],
llmRule: f.resumeLlmConfig.sourceJd || '',
llmConfig: f.resumeLlmConfig
}
}
if (f.resumeLlmEnabled && typeof f.resumeLlmRule === 'string') {
return { mode: 'llm', keywordList: [], llmRule: f.resumeLlmRule, llmConfig: null }
}
if (f.resumeKeywordsEnabled && Array.isArray(f.resumeKeywords)) {
return { mode: 'keywords', keywordList: f.resumeKeywords, llmRule: '', llmConfig: null }
}
// resumeRegExpEnabledchat-page 暂无 regex 模式,暂不筛选(全部通过),后续可扩展
if (f.resumeRegExpEnabled && typeof f.resumeRegExpStr === 'string' && f.resumeRegExpStr) {
return { mode: 'keywords', keywordList: [], llmRule: '', llmConfig: null }
}
return { mode: 'keywords', keywordList: [], llmRule: '', llmConfig: null }
}
export const getMergedJobConfig = (jobId) => {
const recruiterConfig = readConfigFile('boss-recruiter.json') || {}
const candidateFilterConfig = readConfigFile('candidate-filter.json') || {}
if (!jobId) {
return {
...recruiterConfig,
candidateFilter: candidateFilterConfig
}
}
const jobsConfig = readBossJobsConfig()
const jobEntry = (jobsConfig.jobs || []).find(j => (j.jobId || j.id) === jobId)
if (!jobEntry) {
return {
...recruiterConfig,
candidateFilter: candidateFilterConfig
}
}
const jobFilter = jobEntry.filter
const candidateFilter = jobFilterToCandidateFilter(jobFilter)
const chatPageFilter = jobFilterToChatPageFilter(jobFilter)
return {
...recruiterConfig,
candidateFilter,
chatPage: {
...(recruiterConfig.chatPage || {}),
preFilter: candidateFilter,
filter: {
...(recruiterConfig.chatPage?.filter || {}),
...chatPageFilter
}
},
_jobMeta: { jobId: jobEntry.jobId || jobEntry.id, jobName: jobEntry.jobName || jobEntry.name }
}
}
// ── 招聘端 LLM 配置boss-llm.json───────────────────────────────────────────
const bossLlmConfigFileName = 'boss-llm.json'
const defaultBossLlmConfig = { providers: [], purposeDefaultModelId: {} }
/**
* 将旧格式flat models 数组迁移为新格式providers 数组)。
* 按 baseURL 分组,同一 baseURL 的模型归入同一 provider。
*/
function migrateFlatModelsToProviders (oldConfig) {
const grouped = {}
for (const m of oldConfig.models) {
const key = m.baseURL ?? ''
if (!grouped[key]) {
grouped[key] = {
id: crypto.randomUUID(),
name: m.baseURL ?? '',
baseURL: m.baseURL ?? '',
apiKey: m.apiKey ?? '',
models: []
}
}
const { baseURL: _b, apiKey: _a, ...modelFields } = m
grouped[key].models.push(modelFields)
}
return {
providers: Object.values(grouped),
purposeDefaultModelId: oldConfig.purposeDefaultModelId ?? {}
}
}
export const readBossLlmConfig = () => {
ensureRuntimeFolderPathExist()
const filePath = path.join(configFolderPath, bossLlmConfigFileName)
if (!fs.existsSync(filePath)) {
return { ...defaultBossLlmConfig }
}
let raw
try {
raw = JSON.parse(fs.readFileSync(filePath, 'utf8'))
} catch {
return { ...defaultBossLlmConfig }
}
// 旧格式迁移:有 models 字段但无 providers 字段
if (Array.isArray(raw.models) && !Array.isArray(raw.providers)) {
const migrated = migrateFlatModelsToProviders(raw)
// 写回文件,完成一次性迁移
try {
fs.writeFileSync(filePath, JSON.stringify(migrated))
} catch {
// 写回失败不影响本次使用
}
return migrated
}
// 兼容/修复:为 providers/models 补齐缺失字段(尤其是 model.id
if (!Array.isArray(raw.providers)) {
return { ...defaultBossLlmConfig }
}
let mutated = false
for (const p of raw.providers) {
if (!p || typeof p !== 'object') continue
if (!Array.isArray(p.models)) {
p.models = []
mutated = true
}
for (const m of p.models) {
if (!m || typeof m !== 'object') continue
if (typeof m.id !== 'string' || !m.id) {
m.id = crypto.randomUUID()
mutated = true
}
// enabled 默认 true不写也视为启用但旧数据可能缺失
if (typeof m.enabled !== 'boolean') {
m.enabled = true
mutated = true
}
if (!m.thinking || typeof m.thinking !== 'object') {
m.thinking = { enabled: false, budget: 2048 }
mutated = true
} else {
if (typeof m.thinking.enabled !== 'boolean') {
m.thinking.enabled = false
mutated = true
}
if (typeof m.thinking.budget !== 'number') {
m.thinking.budget = 2048
mutated = true
}
}
}
}
if (mutated) {
try {
fs.writeFileSync(filePath, JSON.stringify(raw))
} catch {
// ignore
}
}
return raw
}
export const writeBossLlmConfig = async (config) => {
ensureRuntimeFolderPathExist()
const filePath = path.join(configFolderPath, bossLlmConfigFileName)
return fsPromise.writeFile(filePath, JSON.stringify(config))
}

View File

@@ -0,0 +1,49 @@
import path from 'node:path'
import * as url from 'node:url'
import { sleep } from '@geekgeekrun/utils/sleep.mjs'
import childProcess from 'node:child_process'
import { BOSS_AUTO_ERROR_EXIT_CODE } from './enums.mjs'
const rerunInterval = (() => {
let v = Number(process.env.MAIN_BOSS_AUTO_BROWSE_RERUN_INTERVAL)
if (isNaN(v)) {
v = 3000
}
return v
})()
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
function runWithDaemon () {
const subProcessOfCore = childProcess.spawn(
'node',
[path.join(__dirname, 'main.mjs')],
{
stdio: ['inherit', 'inherit', 'inherit', 'pipe', 'ipc'],
env: {
...process.env,
MAIN_BOSS_AUTO_BROWSE_RERUN_INTERVAL: rerunInterval
}
}
)
subProcessOfCore.once(
'exit',
async (exitCode) => {
if (
[...Object.values(BOSS_AUTO_ERROR_EXIT_CODE)]
.filter(it => typeof it === 'number')
.includes(exitCode)
) {
console.log(`[Run core daemon] Child process exit with reason ${BOSS_AUTO_ERROR_EXIT_CODE[exitCode]}.`)
process.exit(exitCode)
return
}
console.log(`[Run core daemon] Child process exit with code ${exitCode}, an internal error may not be caught, and will be restarted in ${rerunInterval}ms.`)
await sleep(rerunInterval)
runWithDaemon()
}
)
}
runWithDaemon()

View File

@@ -0,0 +1,57 @@
/**
* 招聘端自动浏览运行状态枚举
*/
export const BossAutoProcessStatus = (() => {
const enums = {
IDLE: 'idle',
RUNNING: 'running',
PAUSED: 'paused',
STOPPED: 'stopped',
ERROR: 'error',
LOGIN_REQUIRED: 'login_required'
}
const kvList = Object.entries(enums)
kvList.forEach(([k, v]) => {
enums[v] = k
})
return enums
})()
/**
* 候选人筛选结果枚举(用于说明匹配/排除原因)
*/
export const CandidateFilterResult = (() => {
const enums = {
MATCHED: 'matched',
SKIPPED_CITY: 'skipped_city',
SKIPPED_EDUCATION: 'skipped_education',
SKIPPED_WORK_EXP: 'skipped_work_exp',
SKIPPED_SALARY: 'skipped_salary',
SKIPPED_SKILLS: 'skipped_skills',
SKIPPED_BLOCKLIST: 'skipped_blocklist'
}
const kvList = Object.entries(enums)
kvList.forEach(([k, v]) => {
enums[v] = k
})
return enums
})()
/**
* 招聘端运行错误退出码(供 daemon 识别后不再重启)
*/
export const BOSS_AUTO_ERROR_EXIT_CODE = (() => {
const enums = {
NORMAL: 0,
COOKIE_INVALID: 81,
LOGIN_STATUS_INVALID: 82,
ERR_INTERNET_DISCONNECTED: 83,
ACCESS_IS_DENIED: 84,
PUPPETEER_IS_NOT_EXECUTABLE: 85
}
const kvList = Object.entries(enums)
kvList.forEach(([k, v]) => {
enums[v] = k
})
return enums
})()

View File

@@ -0,0 +1,101 @@
import startBossAutoBrowse from '@geekgeekrun/boss-auto-browse-and-chat/index.mjs'
import { readConfigFile } from '@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
import {
AsyncSeriesHook,
AsyncSeriesWaterfallHook
} from 'tapable'
import path from 'node:path'
import { readStorageFile, storageFilePath } from '@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
const getPublicDbFilePath = () => path.join(storageFilePath, 'public.db')
import { sleep } from '@geekgeekrun/utils/sleep.mjs'
import { BOSS_AUTO_ERROR_EXIT_CODE } from './enums.mjs'
import SqlitePluginModule from '@geekgeekrun/sqlite-plugin'
const { default: SqlitePlugin } = SqlitePluginModule
const rerunInterval = (() => {
let v = Number(process.env.MAIN_BOSS_AUTO_BROWSE_RERUN_INTERVAL)
if (isNaN(v)) {
v = 3000
}
return v
})()
process.on('disconnect', () => {
process.exit()
})
const bossCookies = readStorageFile('boss-cookies.json')
console.log('[run-core-of-boss-auto][debug] readStorageFile(boss-cookies.json) result meta', {
isArray: Array.isArray(bossCookies),
length: Array.isArray(bossCookies) ? bossCookies.length : null,
sampleDomains: Array.isArray(bossCookies)
? [...new Set(bossCookies.slice(0, 5).map(x => x && x.domain).filter(Boolean))]
: null
})
const initPlugins = (hooks) => {
new SqlitePlugin(getPublicDbFilePath()).apply(hooks)
}
const main = async () => {
if (!bossCookies?.length) {
console.error('There is no cookies. You can save a copy with EditThisCookie extension.')
process.exit(BOSS_AUTO_ERROR_EXIT_CODE.COOKIE_INVALID)
}
const hooks = {
beforeBrowserLaunch: new AsyncSeriesHook(),
afterBrowserLaunch: new AsyncSeriesHook(),
beforeNavigateToRecommend: new AsyncSeriesHook(),
onCandidateListLoaded: new AsyncSeriesHook(),
onCandidateFiltered: new AsyncSeriesWaterfallHook(['candidates', 'filterResult']),
beforeStartChat: new AsyncSeriesHook(['candidate']),
afterChatStarted: new AsyncSeriesHook(['candidate', 'result']),
onError: new AsyncSeriesHook(['error']),
onComplete: new AsyncSeriesHook()
}
initPlugins(hooks)
while (true) {
try {
await startBossAutoBrowse(hooks)
const cfg = readConfigFile('boss-recruiter.json') || {}
if (cfg?.recommendPage?.runOnceAfterComplete) {
console.log('[Run core main] runOnceAfterComplete is true, exiting after one run.')
break
}
const rerunMs = cfg?.recommendPage?.rerunIntervalMs ?? rerunInterval
console.log(`[Run core main] Run completed normally. Next run in ${rerunMs}ms.`)
await sleep(rerunMs)
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('LOGIN_STATUS_INVALID')) {
process.exit(BOSS_AUTO_ERROR_EXIT_CODE.LOGIN_STATUS_INVALID)
break
}
if (err.message.includes('ERR_INTERNET_DISCONNECTED')) {
process.exit(BOSS_AUTO_ERROR_EXIT_CODE.ERR_INTERNET_DISCONNECTED)
break
}
if (err.message.includes('ACCESS_IS_DENIED')) {
process.exit(BOSS_AUTO_ERROR_EXIT_CODE.ACCESS_IS_DENIED)
break
}
}
console.error(err)
const errCfg = readConfigFile('boss-recruiter.json') || {}
const errRerunMs = errCfg?.recommendPage?.rerunIntervalMs ?? rerunInterval
console.log(`[Run core main] An internal error is caught, and browser will be restarted in ${errRerunMs}ms.`)
await sleep(errRerunMs)
}
}
}
;(async () => {
try {
await main()
} catch (err) {
console.error(err)
}
})()

View File

@@ -0,0 +1,19 @@
{
"name": "@geekgeekrun/run-core-of-boss-auto-browse",
"private": true,
"version": "1.0.0",
"description": "run-core-of-boss-auto-browse",
"module": "./main.mjs",
"type": "module",
"scripts": {
"start": "node ./daemon-main.mjs",
"start:bare": "node ./main.mjs"
},
"author": "geekgeekrun",
"license": "ISC",
"dependencies": {
"@geekgeekrun/boss-auto-browse-and-chat": "workspace:*",
"@geekgeekrun/utils": "workspace:*",
"@geekgeekrun/sqlite-plugin": "workspace:*"
}
}

View File

@@ -0,0 +1,30 @@
import * as typeorm from 'typeorm';
const { Entity, Column, PrimaryGeneratedColumn } = typeorm;
@Entity()
export class CandidateContactLog {
@PrimaryGeneratedColumn()
id: number;
@Column()
encryptGeekId: string;
@Column()
contactType: string;
@Column({
nullable: true
})
message: string | null;
@Column({
nullable: true
})
result: string | null;
@Column()
contactTime: Date;
@Column()
createdAt: Date;
}

View File

@@ -0,0 +1,73 @@
import * as typeorm from 'typeorm';
const { Entity, Column, PrimaryGeneratedColumn } = typeorm;
@Entity()
export class CandidateInfo {
@PrimaryGeneratedColumn()
id: number;
@Column({
unique: true
})
encryptGeekId: string;
@Column()
geekName: string;
@Column({
nullable: true
})
educationLevel: string | null;
@Column({
nullable: true
})
workExpYears: string | null;
@Column({
nullable: true
})
city: string | null;
@Column({
nullable: true
})
jobTitle: string | null;
@Column({
nullable: true
})
salaryExpect: string | null;
@Column({
nullable: true
})
skills: string | null;
@Column({
nullable: true
})
firstContactTime: Date | null;
@Column({
nullable: true
})
lastContactTime: Date | null;
@Column({
default: 'new'
})
status: string;
@Column({
type: 'text',
nullable: true
})
rawData: string | null;
@Column()
createdAt: Date;
@Column()
updatedAt: Date;
}

View File

@@ -12,6 +12,8 @@ import { MarkAsNotSuitLog } from "./entity/MarkAsNotSuitLog";
import { ChatMessageRecord } from "./entity/ChatMessageRecord";
import { LlmModelUsageRecord } from "./entity/LlmModelUsageRecord";
import { JobHireStatusRecord } from "./entity/JobHireStatusRecord";
import { CandidateInfo } from "./entity/CandidateInfo";
import { CandidateContactLog } from "./entity/CandidateContactLog";
function getBossInfoIfIsEqual (savedOne, currentOne) {
if (savedOne === currentOne) {
@@ -387,4 +389,67 @@ export async function getJobHireStatusRecord(
}
})
return result
}
// --- Candidate (recruiter side) handlers ---
export async function createOrUpdateCandidateInfo(
ds: DataSource,
payload: Partial<CandidateInfo>
) {
const repo = ds.getRepository(CandidateInfo);
const now = new Date();
const existing = payload.encryptGeekId
? await repo.findOne({ where: { encryptGeekId: payload.encryptGeekId } })
: null;
if (existing) {
Object.assign(existing, payload, { updatedAt: now });
return await repo.save(existing);
}
const entity = new CandidateInfo();
Object.assign(entity, payload, { createdAt: now, updatedAt: now });
return await repo.save(entity);
}
export async function insertCandidateContactLog(
ds: DataSource,
payload: Partial<CandidateContactLog>
) {
const repo = ds.getRepository(CandidateContactLog);
const entity = new CandidateContactLog();
const now = new Date();
Object.assign(entity, payload, { createdAt: now });
return await repo.save(entity);
}
export async function queryCandidateByEncryptId(
ds: DataSource,
encryptGeekId: string
) {
const repo = ds.getRepository(CandidateInfo);
return await repo.findOne({
where: { encryptGeekId }
});
}
/** Recent contact logs for recruiter webhook "last run" payload. Returns unique encryptGeekIds by most recent contact first. */
export async function getRecentCandidateContactLogs(
ds: DataSource,
limit = 50
): Promise<Array<{ encryptGeekId: string; contactTime: Date }>> {
const repo = ds.getRepository(CandidateContactLog);
const rows = await repo.find({
order: { contactTime: 'DESC' },
take: limit * 2,
select: ['encryptGeekId', 'contactTime']
});
const seen = new Set<string>();
const result: Array<{ encryptGeekId: string; contactTime: Date }> = [];
for (const r of rows) {
if (seen.has(r.encryptGeekId)) continue;
seen.add(r.encryptGeekId);
result.push({ encryptGeekId: r.encryptGeekId, contactTime: r.contactTime });
if (result.length >= limit) break;
}
return result;
}

View File

@@ -20,6 +20,8 @@ import { VMarkAsNotSuitLog } from "./entity/VMarkAsNotSuitLog"
import { ChatMessageRecord } from './entity/ChatMessageRecord'
import { LlmModelUsageRecord } from './entity/LlmModelUsageRecord'
import { JobHireStatusRecord } from './entity/JobHireStatusRecord'
import { CandidateInfo } from './entity/CandidateInfo'
import { CandidateContactLog } from './entity/CandidateContactLog'
import {
saveChatStartupRecord,
@@ -38,6 +40,7 @@ import { AddColumnForMarkAsNotSuitLog1746092370665 } from "./migrations/17460923
import { Init1000000000000 } from "./migrations/1000000000000-Init";
import { AddJobSourceColumnForChatStartupLogAndMarkAsNotSuitLog1752380078526 } from "./migrations/1752380078526-AddJobSourceColumnForChatStartupLogAndMarkAsNotSuitLog";
import { AddJobHireStatusTable1766466476822 } from "./migrations/1766466476822-AddJobHireStatusTable";
import { AddCandidateTables1766466476823 } from "./migrations/1766466476823-AddCandidateTables";
import chunk from 'lodash/chunk'
import * as typeorm from 'typeorm'
@@ -69,6 +72,8 @@ export function initDb(dbFilePath) {
ChatMessageRecord,
LlmModelUsageRecord,
JobHireStatusRecord,
CandidateInfo,
CandidateContactLog,
],
migrations: [
Init1000000000000,
@@ -76,7 +81,8 @@ export function initDb(dbFilePath) {
UpdateBossInfoTable1732032381304,
AddColumnForMarkAsNotSuitLog1746092370665,
AddJobSourceColumnForChatStartupLogAndMarkAsNotSuitLog1752380078526,
AddJobHireStatusTable1766466476822
AddJobHireStatusTable1766466476822,
AddCandidateTables1766466476823
],
migrationsRun: true
});
@@ -95,26 +101,30 @@ export default class SqlitePlugin {
userInfo = null
apply(hooks) {
hooks.pageGotten.tap(
'SqlitePlugin',
(page) => {
page.on('response', async (response) => {
const ds = await this.initPromise;
if (response.url().startsWith('https://www.zhipin.com/wapi/zpgeek/job/detail.json')) {
const data = await response.json()
if (data.code === 0) {
await saveJobInfoFromRecommendPage(await ds, data.zpData)
await saveJobHireStatusRecord(await ds, {
encryptJobId: data.zpData.jobInfo.encryptId,
hireStatus: JobHireStatus.HIRING,
lastSeenDate: new Date()
})
// 仅当调用方提供对应 hook 时才注册geek 端有 pageGotten/userInfoResponse 等,招聘端是 beforeBrowserLaunch/onCandidateListLoaded 等,无则跳过)
if (hooks.pageGotten) {
hooks.pageGotten.tap(
'SqlitePlugin',
(page) => {
page.on('response', async (response) => {
const ds = await this.initPromise;
if (response.url().startsWith('https://www.zhipin.com/wapi/zpgeek/job/detail.json')) {
const data = await response.json()
if (data.code === 0) {
await saveJobInfoFromRecommendPage(await ds, data.zpData)
await saveJobHireStatusRecord(await ds, {
encryptJobId: data.zpData.jobInfo.encryptId,
hireStatus: JobHireStatus.HIRING,
lastSeenDate: new Date()
})
}
}
}
})
}
)
hooks.userInfoResponse.tapPromise(
})
}
)
}
if (hooks.userInfoResponse) {
hooks.userInfoResponse.tapPromise(
"SqlitePlugin",
async (userInfoResponse) => {
if (!userInfoResponse || userInfoResponse.code !== 0) {
@@ -134,7 +144,9 @@ export default class SqlitePlugin {
return await userInfoRepository.save(user);
}
);
hooks.mainFlowWillLaunch.tapPromise(
}
if (hooks.mainFlowWillLaunch) {
hooks.mainFlowWillLaunch.tapPromise(
"SqlitePlugin",
async ({
jobNotMatchStrategy,
@@ -212,40 +224,47 @@ export default class SqlitePlugin {
}
}
);
}
hooks.jobDetailIsGetFromRecommendList.tapPromise("SqlitePlugin", async (_jobInfo) => {
const ds = await this.initPromise;
await saveJobInfoFromRecommendPage(ds, _jobInfo);
});
hooks.jobDetailIsGetFromRecommendList.tapPromise("SqlitePlugin", async ({ jobInfo }) => {
const ds = await this.initPromise;
return await saveJobHireStatusRecord(ds, {
encryptJobId: jobInfo.encryptId,
hireStatus: JobHireStatus.HIRING,
lastSeenDate: new Date()
if (hooks.jobDetailIsGetFromRecommendList) {
hooks.jobDetailIsGetFromRecommendList.tapPromise("SqlitePlugin", async (_jobInfo) => {
const ds = await this.initPromise;
await saveJobInfoFromRecommendPage(ds, _jobInfo);
});
});
hooks.newChatStartup.tapPromise("SqlitePlugin", async (_jobInfo, { chatStartupFrom = ChatStartupFrom.AutoFromRecommendList, jobSource = undefined } = {}) => {
const ds = await this.initPromise;
return await saveChatStartupRecord(ds, _jobInfo, this.userInfo, {
autoStartupChatRecordId: this.runRecordId,
chatStartupFrom,
jobSource
hooks.jobDetailIsGetFromRecommendList.tapPromise("SqlitePlugin", async ({ jobInfo }) => {
const ds = await this.initPromise;
return await saveJobHireStatusRecord(ds, {
encryptJobId: jobInfo.encryptId,
hireStatus: JobHireStatus.HIRING,
lastSeenDate: new Date()
});
});
});
}
hooks.jobMarkedAsNotSuit.tapPromise("SqlitePlugin", async (_jobInfo, { markFrom = ChatStartupFrom.AutoFromRecommendList, markReason = undefined, extInfo = undefined, markOp = undefined, jobSource = undefined } = {}) => {
const ds = await this.initPromise;
return await saveMarkAsNotSuitRecord(ds, _jobInfo, this.userInfo, {
autoStartupChatRecordId: this.runRecordId,
markFrom,
markReason,
extInfo,
markOp,
jobSource
if (hooks.newChatStartup) {
hooks.newChatStartup.tapPromise("SqlitePlugin", async (_jobInfo, { chatStartupFrom = ChatStartupFrom.AutoFromRecommendList, jobSource = undefined } = {}) => {
const ds = await this.initPromise;
return await saveChatStartupRecord(ds, _jobInfo, this.userInfo, {
autoStartupChatRecordId: this.runRecordId,
chatStartupFrom,
jobSource
});
});
});
}
if (hooks.jobMarkedAsNotSuit) {
hooks.jobMarkedAsNotSuit.tapPromise("SqlitePlugin", async (_jobInfo, { markFrom = ChatStartupFrom.AutoFromRecommendList, markReason = undefined, extInfo = undefined, markOp = undefined, jobSource = undefined } = {}) => {
const ds = await this.initPromise;
return await saveMarkAsNotSuitRecord(ds, _jobInfo, this.userInfo, {
autoStartupChatRecordId: this.runRecordId,
markFrom,
markReason,
extInfo,
markOp,
jobSource
});
});
}
}
}

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddCandidateTables1766466476823 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS "candidate_info" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "encryptGeekId" varchar UNIQUE NOT NULL, "geekName" varchar NOT NULL, "educationLevel" varchar, "workExpYears" varchar, "city" varchar, "jobTitle" varchar, "salaryExpect" varchar, "skills" varchar, "firstContactTime" datetime, "lastContactTime" datetime, "status" varchar NOT NULL DEFAULT 'new', "rawData" text, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL);`
);
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS "candidate_contact_log" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "encryptGeekId" varchar NOT NULL, "contactType" varchar NOT NULL, "message" varchar, "result" varchar, "contactTime" datetime NOT NULL, "createdAt" datetime NOT NULL);`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
}
}

View File

@@ -7,6 +7,7 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": false,
"skipLibCheck": true
},
"exclude": ["node_modules"],
}

View File

@@ -26,6 +26,7 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0",
"@geekgeekrun/boss-auto-browse-and-chat": "workspace:*",
"@geekgeekrun/launch-bosszhipin-login-page-with-preload-extension": "workspace:*",
"@geekgeekrun/pm": "workspace:*",
"@geekgeekrun/puppeteer-extra-plugin-laodeng": "workspace:*",

View File

@@ -16,3 +16,18 @@ export const getAutoStartChatSteps = () => [
describe: '登录状态检查'
}
]
export const getBossAutoBrowseSteps = () => [
{
id: 'worker-launch',
describe: '启动子进程'
},
{
id: 'puppeteer-executable-check',
describe: 'Puppeteer 可执行程序检查'
},
{
id: 'login-status-check',
describe: '登录状态检查(若浏览器弹出请以招聘者身份登录)'
}
]

View File

@@ -74,7 +74,6 @@ export const checkUpdateForUi = async (): Promise<NewReleaseInfo | null> => {
gtag('check_update_error', { err: JSON.stringify(err) })
console.log(err)
}
console.log(releaseList)
const availableRelease = releaseList.find((it) => !it.draft && !it.prerelease)
if (!availableRelease) {
return null
@@ -90,7 +89,6 @@ export const checkUpdateForUi = async (): Promise<NewReleaseInfo | null> => {
currentVersion: packageJson.version,
newVersion: availableReleaseVersion
})
console.log(targetAsset)
return {
releaseVersion: availableReleaseVersion,
releasePageUrl: availableRelease.html_url,

View File

@@ -0,0 +1,399 @@
import fs from 'node:fs'
import path from 'node:path'
import os from 'node:os'
export interface WebhookConfig {
enabled: boolean
url: string
method: 'POST' | 'PUT' | 'PATCH'
headers: Record<string, string>
/** 发送模式batch=轮次结束汇总发送realtime=每打招呼后立即发送一条 */
sendMode?: 'batch' | 'realtime'
/** 请求体格式multipart 时每条候选人为一个请求(支持直传 Paperless 等) */
contentType?: 'application/json' | 'multipart/form-data'
payloadOptions: {
includeBasicInfo: boolean
includeFilterReason: boolean
includeLlmConclusion: boolean
includeResume: 'none' | 'path' | 'base64'
}
/** 失败重试次数0 不重试 */
retryTimes?: number
/** 首次重试延迟(毫秒),之后指数退避 */
retryDelayMs?: number
/** 最终失败时是否写入本地队列文件,便于后续重发 */
queueFileOnFailure?: boolean
}
export interface CandidateBasicInfo {
name: string
education?: string
workExpYears?: number
city?: string
salary?: string
skills?: string[]
}
export interface CandidateFilterReport {
matched: boolean
matchedRules?: string[]
skippedReason?: string
score?: number
}
export interface CandidateResult {
basicInfo?: CandidateBasicInfo
filterReport?: CandidateFilterReport
llmConclusion?: string
resumeFile?: {
path?: string
base64?: string
filename?: string
}
}
export interface WebhookPayload {
runId: string
timestamp: string
summary: {
total: number
matched: number
skipped: number
}
candidates: CandidateResult[]
}
const DEFAULT_CONFIG: Partial<WebhookConfig> = {
sendMode: 'batch',
contentType: 'application/json',
retryTimes: 3,
retryDelayMs: 1000,
queueFileOnFailure: false
}
export function normalizeWebhookConfig(config: Partial<WebhookConfig> | null): WebhookConfig | null {
if (!config || !config.url) return null
return {
enabled: config.enabled ?? false,
url: config.url,
method: config.method ?? 'POST',
headers: config.headers ?? {},
sendMode: config.sendMode ?? 'batch',
contentType: config.contentType ?? 'application/json',
payloadOptions: {
includeBasicInfo: config.payloadOptions?.includeBasicInfo ?? true,
includeFilterReason: config.payloadOptions?.includeFilterReason ?? true,
includeLlmConclusion: config.payloadOptions?.includeLlmConclusion ?? true,
includeResume: config.payloadOptions?.includeResume ?? 'path'
},
retryTimes: config.retryTimes ?? DEFAULT_CONFIG.retryTimes,
retryDelayMs: config.retryDelayMs ?? DEFAULT_CONFIG.retryDelayMs,
queueFileOnFailure: config.queueFileOnFailure ?? false
}
}
function filterOneCandidate(
config: WebhookConfig,
c: CandidateResult
): Partial<CandidateResult> {
const result: Partial<CandidateResult> = {}
if (config.payloadOptions.includeBasicInfo) {
result.basicInfo = c.basicInfo
}
if (config.payloadOptions.includeFilterReason) {
result.filterReport = c.filterReport
}
if (config.payloadOptions.includeLlmConclusion && c.llmConclusion) {
result.llmConclusion = c.llmConclusion
}
if (config.payloadOptions.includeResume !== 'none' && c.resumeFile) {
if (config.payloadOptions.includeResume === 'path') {
result.resumeFile = { path: c.resumeFile.path, filename: c.resumeFile.filename }
} else if (config.payloadOptions.includeResume === 'base64' && c.resumeFile.path) {
try {
const fileBuffer = fs.readFileSync(c.resumeFile.path)
result.resumeFile = {
base64: fileBuffer.toString('base64'),
filename: c.resumeFile.filename
}
} catch {
result.resumeFile = { path: c.resumeFile.path, filename: c.resumeFile.filename }
}
} else if (config.payloadOptions.includeResume === 'base64' && c.resumeFile.base64) {
result.resumeFile = { base64: c.resumeFile.base64, filename: c.resumeFile.filename }
}
}
return result
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/** 写入失败队列文件JSONL便于后续重试或脚本重发 */
function appendToQueueFile(payload: WebhookPayload, storageDir: string): void {
try {
const queuePath = path.join(storageDir, 'webhook-failed-queue.jsonl')
const line = JSON.stringify({ ts: new Date().toISOString(), payload }) + '\n'
fs.appendFileSync(queuePath, line)
console.log(`[webhook] 已写入失败队列: ${queuePath}`)
} catch (e) {
console.error('[webhook] 写入失败队列失败', e)
}
}
async function doOneRequest(
config: WebhookConfig,
method: string,
url: string,
headers: Record<string, string>,
body: string | FormData
): Promise<{ status: number; body: string }> {
const isFormData = body instanceof FormData
const finalHeaders = { ...headers }
if (isFormData) {
delete finalHeaders['Content-Type']
} else {
finalHeaders['Content-Type'] = finalHeaders['Content-Type'] ?? 'application/json'
}
const response = await fetch(url, {
method,
headers: finalHeaders,
body: body as BodyInit
})
const responseBody = await response.text()
return { status: response.status, body: responseBody }
}
async function sendOneRequestWithRetry(
config: WebhookConfig,
method: string,
url: string,
headers: Record<string, string>,
body: string | FormData,
logPrefix: string,
storageDir: string,
payloadForQueue: WebhookPayload | null
): Promise<{ status: number; body: string }> {
const times = Math.max(0, config.retryTimes ?? 0)
let lastError: Error | null = null
let lastResult: { status: number; body: string } | null = null
let delay = config.retryDelayMs ?? 1000
for (let attempt = 0; attempt <= times; attempt++) {
try {
lastResult = await doOneRequest(config, method, url, headers, body)
if (lastResult.status >= 200 && lastResult.status < 300) {
if (attempt > 0) {
console.log(`[webhook] ${logPrefix} 重试第 ${attempt} 次成功HTTP ${lastResult.status}`)
}
return lastResult
}
if (lastResult.status < 500) {
return lastResult
}
lastError = new Error(`HTTP ${lastResult.status}`)
} catch (e) {
lastError = e instanceof Error ? e : new Error(String(e))
}
if (attempt < times) {
console.log(`[webhook] ${logPrefix}${attempt + 1} 次失败,${delay}ms 后重试: ${lastError?.message}`)
await sleep(delay)
delay *= 2
}
}
if (config.queueFileOnFailure && payloadForQueue) {
appendToQueueFile(payloadForQueue, storageDir)
}
if (lastResult) return lastResult
throw lastError ?? new Error('Unknown error')
}
/** 获取 storage 目录(用于失败队列),主进程/worker 可传入 options.storageDir 避免异步 */
export async function getWebhookStorageDir(): Promise<string> {
try {
const { storageFilePath } = (await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)) as { storageFilePath: string }
return storageFilePath
} catch {
return path.join(os.homedir(), '.geekgeekrun', 'storage')
}
}
export async function sendWebhook(
config: WebhookConfig,
payload: WebhookPayload,
options?: { storageDir?: string }
): Promise<{ status: number; body: string }> {
const normalized = normalizeWebhookConfig(config) as WebhookConfig
const storageDir = options?.storageDir ?? (await getWebhookStorageDir())
const headers = { ...normalized.headers }
const filteredCandidates = payload.candidates.map((c) => filterOneCandidate(normalized, c))
const runId = payload.runId
const timestamp = payload.timestamp
const summary = payload.summary
if (normalized.contentType === 'multipart/form-data') {
let lastStatus = 0
let lastBody = ''
for (let i = 0; i < filteredCandidates.length; i++) {
const singleSummary = {
total: 1,
matched: filteredCandidates[i].filterReport?.matched !== false ? 1 : 0,
skipped: filteredCandidates[i].filterReport?.matched === false ? 1 : 0
}
const form = new FormData()
form.append('runId', runId)
form.append('timestamp', timestamp)
form.append('summary', JSON.stringify(singleSummary))
form.append('candidate', JSON.stringify(filteredCandidates[i]))
const c = payload.candidates[i]
if (
normalized.payloadOptions.includeResume !== 'none' &&
c?.resumeFile?.path &&
fs.existsSync(c.resumeFile.path)
) {
const buf = fs.readFileSync(c.resumeFile.path)
const filename = c.resumeFile.filename ?? path.basename(c.resumeFile.path)
form.append('document', new Blob([buf]), filename)
}
const singlePayload: WebhookPayload = {
runId,
timestamp,
summary: singleSummary,
candidates: [payload.candidates[i]]
}
console.log(
`[webhook] 发送 multipart ${normalized.method} ${normalized.url},第 ${i + 1}/${filteredCandidates.length}`
)
const res = await sendOneRequestWithRetry(
normalized,
normalized.method,
normalized.url,
headers,
form,
`multipart #${i + 1}`,
storageDir,
singlePayload
)
lastStatus = res.status
lastBody = res.body
const preview = res.body.length > 200 ? res.body.slice(0, 200) + '...' : res.body
console.log(`[webhook] 响应 HTTP ${res.status}body: ${preview}`)
}
return { status: lastStatus, body: lastBody }
}
const body = JSON.stringify({
runId,
timestamp,
summary,
candidates: filteredCandidates
})
if (!headers['Content-Type']) {
headers['Content-Type'] = 'application/json'
}
console.log(
`[webhook] 发送 ${normalized.method} ${normalized.url}runId=${runId}candidates=${filteredCandidates.length}`
)
const result = await sendOneRequestWithRetry(
normalized,
normalized.method,
normalized.url,
headers,
body,
'batch',
storageDir,
payload
)
const bodyPreview =
result.body.length > 200 ? result.body.slice(0, 200) + '...' : result.body
console.log(`[webhook] 响应 HTTP ${result.status}body: ${bodyPreview}`)
return result
}
export function buildMockPayload(): WebhookPayload {
return {
runId: `mock-${Date.now()}`,
timestamp: new Date().toISOString(),
summary: { total: 2, matched: 1, skipped: 1 },
candidates: [
{
basicInfo: {
name: '张三(测试)',
education: '本科',
workExpYears: 3,
city: '北京',
salary: '15-25K',
skills: ['Vue', 'React', 'TypeScript']
},
filterReport: {
matched: true,
matchedRules: ['education', 'workExp', 'skills'],
score: 85
},
llmConclusion: '候选人技能与岗位匹配度较高,建议优先沟通。'
},
{
basicInfo: {
name: '李四(测试)',
education: '大专',
workExpYears: 1,
city: '上海',
salary: '8-12K',
skills: ['HTML', 'CSS']
},
filterReport: {
matched: false,
skippedReason: 'education_not_match'
}
}
]
}
}
/** 从 SQLite 查询最近一轮联系人,组装为 WebhookPayload用于手动触发真实数据 */
export async function buildPayloadFromDb(dbPath: string): Promise<WebhookPayload | null> {
const { initDb } = await import('@geekgeekrun/sqlite-plugin')
const {
getRecentCandidateContactLogs,
queryCandidateByEncryptId
} = await import('@geekgeekrun/sqlite-plugin/dist/handlers')
const ds = await initDb(dbPath)
const logs = await getRecentCandidateContactLogs(ds, 50)
if (logs.length === 0) {
return null
}
const candidates: CandidateResult[] = []
for (const { encryptGeekId } of logs) {
const info = await queryCandidateByEncryptId(ds, encryptGeekId)
if (!info) continue
const skills = info.skills ? (info.skills.trim() ? info.skills.split(/\s*[,]\s*/) : []) : undefined
candidates.push({
basicInfo: {
name: info.geekName,
education: info.educationLevel ?? undefined,
workExpYears: info.workExpYears != null ? Number(info.workExpYears) : undefined,
city: info.city ?? undefined,
salary: info.salaryExpect ?? undefined,
skills
}
})
}
if (candidates.length === 0) {
return null
}
const runId = `manual-${Date.now()}`
const timestamp = new Date().toISOString()
return {
runId,
timestamp,
summary: { total: candidates.length, matched: candidates.length, skipped: 0 },
candidates
}
}

View File

@@ -0,0 +1,398 @@
import { app, dialog } from 'electron'
import { AsyncSeriesHook, AsyncSeriesWaterfallHook } from 'tapable'
import { sleep } from '@geekgeekrun/utils/sleep.mjs'
import { AUTO_CHAT_ERROR_EXIT_CODE } from '../../../common/enums/auto-start-chat'
import attachListenerForKillSelfOnParentExited from '../../utils/attachListenerForKillSelfOnParentExited'
import minimist from 'minimist'
import SqlitePluginModule from '@geekgeekrun/sqlite-plugin'
import { connectToDaemon, sendToDaemon } from '../OPEN_SETTING_WINDOW/connect-to-daemon'
import { checkShouldExit } from '../../utils/worker'
import initPublicIpc from '../../utils/initPublicIpc'
import { forwardConsoleLogToDaemon } from '../../utils/forwardConsoleLogToDaemon'
import { getLastUsedAndAvailableBrowser } from '../DOWNLOAD_DEPENDENCIES/utils/browser-history'
import path from 'path'
const { default: SqlitePlugin } = SqlitePluginModule
process.on('SIGTERM', () => {
console.log('收到SIGTERM信号正在退出')
process.exit(0)
})
const rerunInterval = (() => {
let v = Number(process.env.MAIN_BOSSGEEKGO_RERUN_INTERVAL)
if (isNaN(v)) {
v = 3000
}
return v
})()
const initPlugins = async (hooks) => {
const { storageFilePath } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
new SqlitePlugin(path.join(storageFilePath, 'public.db')).apply(hooks)
}
const runRecordId = minimist(process.argv.slice(2))['run-record-id'] ?? null
const log = (msg: string) => {
console.log(`[boss-worker] ${msg}`)
}
const runAutoBrowseAndChat = async () => {
app.dock?.hide()
log('runAutoBrowseAndChat 开始')
log(`正在查找可用浏览器...`)
let puppeteerExecutable = await getLastUsedAndAvailableBrowser()
if (!puppeteerExecutable) {
log('未找到可用浏览器,退出')
await dialog.showMessageBox({
type: `error`,
message: `未找到可用的浏览器`,
detail: `请重新运行本程序,按照提示安装、配置浏览器`
})
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: {
id: 'puppeteer-executable-check',
status: 'rejected'
},
runRecordId
}
})
app.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE)
return
}
log(`找到浏览器: ${puppeteerExecutable.executablePath}`)
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: {
id: 'puppeteer-executable-check',
status: 'fulfilled'
},
runRecordId
}
})
process.env.PUPPETEER_EXECUTABLE_PATH = puppeteerExecutable.executablePath
log('正在动态 import boss package...')
type BossAutoBrowseModule = {
default: (hooks: any, opts?: { returnBrowser?: boolean; jobId?: string; browser?: any; page?: any }) => Promise<void | { browser: any; page: any }>
startBossChatPageProcess: (hooks: any, options?: { browser?: any; page?: any; jobId?: string }) => Promise<void>
initPuppeteer: () => Promise<any>
launchBrowserAndNavigateToChat?: () => Promise<{ browser: any; page: any }>
bossAutoBrowseEventBus: InstanceType<typeof import('node:events').EventEmitter>
}
const {
default: startBossAutoBrowse,
startBossChatPageProcess,
initPuppeteer,
launchBrowserAndNavigateToChat,
bossAutoBrowseEventBus
} = (await import('@geekgeekrun/boss-auto-browse-and-chat/index.mjs')) as unknown as BossAutoBrowseModule
log('boss package import 完成,初始化 puppeteer...')
process.on('disconnect', () => {
app.exit()
})
await initPuppeteer()
log('puppeteer 初始化完成,初始化 hooks 和插件...')
const hooks = {
beforeBrowserLaunch: new AsyncSeriesHook(['_']),
afterBrowserLaunch: new AsyncSeriesHook(['_']),
beforeNavigateToRecommend: new AsyncSeriesHook(['_']),
onCandidateListLoaded: new AsyncSeriesHook(['_']),
onCandidateFiltered: new AsyncSeriesWaterfallHook(['candidates', 'filterResult'] as any),
beforeStartChat: new AsyncSeriesHook(['candidate']),
afterChatStarted: new AsyncSeriesHook(['candidate', 'result'] as any),
onError: new AsyncSeriesHook(['error']),
onComplete: new AsyncSeriesHook(['_']),
onProgress: new AsyncSeriesHook(['payload'] as any)
}
await initPlugins(hooks)
log('插件初始化完成,即将启动浏览器...')
hooks.beforeBrowserLaunch.tapPromise('log', async () => { log('beforeBrowserLaunch') })
hooks.afterBrowserLaunch.tapPromise('log', async () => { log('afterBrowserLaunch - 浏览器已启动') })
hooks.beforeNavigateToRecommend.tapPromise('log', async () => { log('beforeNavigateToRecommend - 正在导航到推荐页') })
bossAutoBrowseEventBus.once('LOGIN_STATUS_INVALID', () => {})
hooks.onCandidateListLoaded.tap('sendLoginStatusCheck', () => {
log('onCandidateListLoaded - 登录成功,候选人列表已加载')
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: {
id: 'login-status-check',
status: 'fulfilled'
},
runRecordId
}
})
})
// Accumulate candidate results for webhook reporting
const sessionCandidates: Array<{
basicInfo?: Record<string, unknown>
filterReport?: Record<string, unknown>
llmConclusion?: string
resumeFile?: { path?: string; filename?: string }
}> = []
hooks.afterChatStarted.tapPromise('collectCandidateForWebhook', async (candidate: unknown) => {
const c = candidate as Record<string, unknown>
const entry = {
basicInfo: c?.info as Record<string, unknown> | undefined,
filterReport: {
matched: true,
matchedRules: (c?.matchedRules as string[] | undefined) ?? [],
score: c?.score as number | undefined
},
llmConclusion: c?.llmConclusion as string | undefined,
resumeFile: c?.resumeFilePath
? { path: c.resumeFilePath as string, filename: c?.resumeFileName as string | undefined }
: undefined
}
sessionCandidates.push(entry)
// 逐条实时触发:每打招呼后立即发送一条 webhook
try {
const { readConfigFile: readBossConfigFile, storageFilePath } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const webhookConfig = readBossConfigFile('webhook.json')
if (webhookConfig?.enabled && webhookConfig?.url && webhookConfig?.sendMode === 'realtime') {
const { sendWebhook, normalizeWebhookConfig } = await import('../../features/webhook/index')
const normalized = normalizeWebhookConfig(webhookConfig)
if (normalized?.sendMode === 'realtime') {
const runId = `run-${runRecordId ?? Date.now()}`
const timestamp = new Date().toISOString()
const webhookPayload = {
runId,
timestamp,
summary: { total: 1, matched: 1, skipped: 0 },
candidates: [entry]
}
log(`webhook 实时发送 1 条候选人...`)
await sendWebhook(normalized, webhookPayload, { storageDir: storageFilePath })
log(`webhook 实时发送完成`)
}
}
} catch (realtimeErr) {
log(
`webhook 实时发送失败(不影响主流程):${realtimeErr instanceof Error ? realtimeErr.message : String(realtimeErr)}`
)
}
})
hooks.onProgress.tap('sendProgressToGui', (payload: unknown) => {
const p = payload as { phase?: string; current?: number; max?: number }
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'boss-auto-browse-progress',
workerId: 'bossAutoBrowseAndChatMain',
runRecordId,
phase: p?.phase,
current: p?.current ?? 0,
max: p?.max ?? 0
}
})
})
while (true) {
try {
const { readBossJobsConfig, getMergedJobConfig } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const jobsConfig = readBossJobsConfig()
const sequenceJobs = (jobsConfig.jobs || []).filter(
(j: any) => j.sequence?.enabled === true
)
if (sequenceJobs.length > 0) {
log(`检测到多职位队列,共 ${sequenceJobs.length} 个职位,依次执行...`)
let sharedBrowser: any = null
let sharedPage: any = null
try {
for (const job of sequenceJobs) {
const jid = job.jobId ?? job.id
const jname = job.jobName ?? job.name
log(`开始执行职位 ${jid}${jname}...`)
void getMergedJobConfig(jid)
const runRecommend = job.sequence?.runRecommend !== false
const runChat = job.sequence?.runChat !== false
if (runChat && !sharedPage) {
log(`[${jid}] 仅沟通页,先启动浏览器...`)
const boot = await launchBrowserAndNavigateToChat()
sharedBrowser = boot.browser
sharedPage = boot.page
}
if (runRecommend) {
log(`[${jid}] 执行推荐页...`)
const result = await startBossAutoBrowse(hooks, {
returnBrowser: true,
jobId: jid,
browser: sharedBrowser ?? undefined,
page: sharedPage ?? undefined
} as any)
if (result?.browser) {
sharedBrowser = result.browser
sharedPage = result.page
}
}
if (runChat && sharedBrowser && sharedPage) {
log(`[${jid}] 执行沟通页...`)
await startBossChatPageProcess(hooks, {
browser: sharedBrowser,
page: sharedPage,
jobId: jid
})
}
}
} finally {
if (sharedBrowser) {
try {
await sharedBrowser.close()
} catch (e) {
void e
}
sharedBrowser = null
sharedPage = null
}
}
} else {
log('开始执行 startBossAutoBrowse推荐页...')
const result = await startBossAutoBrowse(hooks, { returnBrowser: true })
if (result?.browser && result?.page) {
try {
log('推荐页完成,开始处理沟通页未读...')
await startBossChatPageProcess(hooks, { browser: result.browser, page: result.page })
} finally {
try {
await result.browser.close()
} catch (e) {
void e
}
}
}
}
log('startBossAutoBrowse + 沟通页 完成,检查 webhook 配置...')
try {
const { readConfigFile: readBossConfigFile, storageFilePath } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const webhookConfig = readBossConfigFile('webhook.json')
if (!webhookConfig?.enabled || !webhookConfig?.url) {
log('webhook 未启用或未配置 URL跳过发送')
} else if (webhookConfig.sendMode === 'realtime') {
log('webhook 为实时模式,已在每条打招呼后发送,跳过汇总发送')
} else if (sessionCandidates.length === 0) {
log('本轮无候选人数据,跳过 webhook')
} else {
const { sendWebhook } = await import('../../features/webhook/index')
const matched = sessionCandidates.filter((c) => c.filterReport?.matched !== false).length
const skipped = sessionCandidates.length - matched
const webhookPayload = {
runId: `run-${runRecordId ?? Date.now()}`,
timestamp: new Date().toISOString(),
summary: { total: sessionCandidates.length, matched, skipped },
candidates: sessionCandidates as Parameters<typeof sendWebhook>[1]['candidates']
}
log(`正在发送 webhook${sessionCandidates.length} 条候选人数据...`)
const webhookResult = await sendWebhook(webhookConfig, webhookPayload, {
storageDir: storageFilePath
})
log(`webhook 发送完成HTTP ${webhookResult.status}body 长度 ${webhookResult.body.length}`)
}
} catch (webhookErr) {
log(`webhook 发送失败(不影响主流程):${webhookErr instanceof Error ? webhookErr.message : String(webhookErr)}`)
}
sessionCandidates.length = 0
log('等待下次运行...')
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('LOGIN_STATUS_INVALID')) {
await dialog.showMessageBox({
type: `error`,
message: `登录状态无效`,
detail: `请重新登录BOSS直聘招聘端`
})
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.LOGIN_STATUS_INVALID)
break
}
if (err.message.includes('ERR_INTERNET_DISCONNECTED')) {
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ERR_INTERNET_DISCONNECTED)
break
}
if (err.message.includes('ACCESS_IS_DENIED')) {
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ACCESS_IS_DENIED)
break
}
if (
err.message.includes(`Could not find Chrome`) ||
err.message.includes(`no executable was found`)
) {
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE)
break
}
}
console.error(err)
const shouldExit = await checkShouldExit()
if (shouldExit) {
app.exit()
return
}
console.log(
`[Boss Auto Browse Main] An internal error is caught, and browser will be restarted in ${rerunInterval}ms.`
)
await sleep(rerunInterval)
}
}
}
export const waitForProcessHandShakeAndRunAutoChat = async () => {
await app.whenReady()
app.on('window-all-closed', () => {
// keep process alive while worker is running
})
initPublicIpc()
await connectToDaemon()
forwardConsoleLogToDaemon('bossAutoBrowseAndChatMain', runRecordId)
await sendToDaemon(
{
type: 'ping'
},
{
needCallback: true
}
)
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: {
id: 'worker-launch',
status: 'fulfilled'
},
runRecordId
}
})
runAutoBrowseAndChat()
}
attachListenerForKillSelfOnParentExited()

View File

@@ -0,0 +1,244 @@
/**
* 招聘端调试工具 worker启动浏览器到沟通页然后等待主进程通过 stdio fd3 发来的调试命令。
* 主进程 spawn 时 stdio 为 [inherit, inherit, inherit, 'pipe', 'pipe'],故 fd3=父写→子读fd4=子写→父读。
* Node 的 process.stdio 只有 [0,1,2],子进程需用 fs 从 fd3/fd4 建流。
* 每条命令为 JSON 对象,字段:{ type: string, id: string, ...params }
* 每条响应为 JSON 对象,字段:{ id: string, ok: boolean, result?: any, error?: string }
*
* 支持的命令类型:
* - ping探活返回 { ok: true }
* - dismiss-intent-dialog关闭「意向沟通」弹窗
* - close-online-resume关闭在线简历弹窗
* - open-online-resume打开当前会话的在线简历
* - check-attach-resume检查当前会话是否有附件简历消息
* - request-attach-resume请求附件简历点击按钮+确认弹窗)
* - download-attach-resume预览并下载当前会话已有的附件简历
* - accept-incoming-attach-resume同意对方发来的附件简历请求
* - get-panel-name获取右侧面板当前候选人姓名
* - extract-resume-text打开当前会话的在线简历用 Canvas hook 提取纯文本,完成后关闭弹窗
*/
import * as fs from 'node:fs'
import { app } from 'electron'
import attachListenerForKillSelfOnParentExited from '../../utils/attachListenerForKillSelfOnParentExited'
import { getLastUsedAndAvailableBrowser } from '../DOWNLOAD_DEPENDENCIES/utils/browser-history'
import * as JSONStream from 'JSONStream'
import { pipeWriteRegardlessError } from '../utils/pipe'
const log = (msg: string) => console.log(`[boss-chat-debug-worker] ${msg}`)
// 子进程侧fd3=读主进程发来的命令fd4=写(回 READY/命令结果)
const cmdReadStream = fs.createReadStream(null, { fd: 3 })
const replyWriteStream = fs.createWriteStream(null, { fd: 4 })
const send = (obj: object) => {
pipeWriteRegardlessError(replyWriteStream, JSON.stringify(obj) + '\n')
}
const runDebug = async () => {
app.dock?.hide()
log('启动调试工具...')
const puppeteerExecutable = await getLastUsedAndAvailableBrowser()
if (!puppeteerExecutable) {
log('未找到可用浏览器,退出')
send({ type: 'READY', ok: false, error: 'NO_BROWSER' })
app.exit(1)
return
}
log(`找到浏览器: ${puppeteerExecutable.executablePath}`)
process.env.PUPPETEER_EXECUTABLE_PATH = puppeteerExecutable.executablePath
log('动态 import boss package...')
const { initPuppeteer } = (await import('@geekgeekrun/boss-auto-browse-and-chat/index.mjs')) as any
const {
requestAttachmentResume,
openOnlineResume,
hasIncomingAttachResumeRequest,
acceptIncomingAttachResume,
openPreviewAndDownloadPdf,
} = (await import('@geekgeekrun/boss-auto-browse-and-chat/chat-page-resume.mjs')) as any
const { setupCanvasTextHook, extractResumeText } = (await import('@geekgeekrun/boss-auto-browse-and-chat/resume-extractor.mjs')) as any
const {
BOSS_CHAT_PAGE_URL,
CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR,
CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR,
CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR,
CHAT_PAGE_ACTIVE_NAME_SELECTOR,
CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR
} = (await import('@geekgeekrun/boss-auto-browse-and-chat/constant.mjs')) as any
const { createHumanCursor } = (await import('@geekgeekrun/boss-auto-browse-and-chat/humanMouse.mjs')) as any
const { readStorageFile, ensureStorageFileExist } = (await import('@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs')) as any
const { setDomainLocalStorage } = (await import('@geekgeekrun/utils/puppeteer/local-storage.mjs')) as any
const { puppeteer } = await initPuppeteer()
ensureStorageFileExist()
log('启动浏览器...')
const browser = await puppeteer.launch({
headless: false,
ignoreHTTPSErrors: true,
protocolTimeout: 120000,
defaultViewport: { width: 1440, height: 900 - 140 }
})
const page = (await browser.pages())[0]
const { getCapturedText, clearCapturedText } = await setupCanvasTextHook(page)
const bossCookies = readStorageFile('boss-cookies.json')
const bossLocalStorage = readStorageFile('boss-local-storage.json')
if (Array.isArray(bossCookies) && bossCookies.length > 0) {
await page.setCookie(...bossCookies)
}
await setDomainLocalStorage(browser, 'https://www.zhipin.com/desktop/', bossLocalStorage || {})
await page.goto(BOSS_CHAT_PAGE_URL, { timeout: 60000 })
await page.waitForFunction(() => document.readyState === 'complete', { timeout: 120000 })
log('浏览器已就绪,发送 READY')
send({ type: 'READY', ok: true })
browser.once('disconnected', () => {
log('浏览器已关闭,退出')
app.exit(0)
})
// 监听主进程发来的调试命令(从 fd3 读)
cmdReadStream.pipe(JSONStream.parse()).on('data', async (cmd: any) => {
const { id, type } = cmd ?? {}
if (!id || !type) return
log(`收到命令: ${type} (id=${id})`)
const reply = (ok: boolean, result?: any, error?: string) => {
send({ id, ok, result, error })
}
try {
const cursor = await createHumanCursor(page)
switch (type) {
case 'ping':
reply(true, 'pong')
break
case 'get-panel-name': {
const name = await page.$eval(CHAT_PAGE_ACTIVE_NAME_SELECTOR, (el: any) => el.textContent?.trim() ?? '').catch(() => '')
reply(true, { name })
break
}
case 'dismiss-intent-dialog': {
const btn = await page.$(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR).catch(() => null)
if (!btn) { reply(true, { found: false }); break }
await page.click(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR).catch(() => {
page.click(CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR).catch(() => {})
})
await page.waitForSelector(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, { hidden: true, timeout: 3000 }).catch(() => {})
reply(true, { found: true })
break
}
case 'close-online-resume': {
const btn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null)
if (!btn) { reply(true, { found: false }); break }
await page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => {})
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 4000 }).catch(() => {})
reply(true, { found: true })
break
}
case 'open-online-resume': {
const ok = await openOnlineResume(page, { cursor, clearCapturedText })
reply(ok, { opened: ok })
break
}
case 'check-attach-resume': {
const btn = await page.$(CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR).catch(() => null)
reply(true, { hasAttachment: !!btn })
break
}
case 'request-attach-resume': {
const result = await requestAttachmentResume(page, { cursor })
reply(result.requested, result)
break
}
case 'download-attach-resume': {
const result = await openPreviewAndDownloadPdf(page, null, { cursor })
reply(result.clickedDownload, result)
break
}
case 'accept-incoming-attach-resume': {
const hasIncoming = await hasIncomingAttachResumeRequest(page)
if (!hasIncoming) { reply(true, { found: false }); break }
const accepted = await acceptIncomingAttachResume(page, { cursor })
reply(accepted, { found: true, accepted })
break
}
case 'extract-resume-text': {
// 1. 清空上次 Canvas 捕获数据
await clearCapturedText(page)
// 2. 打开在线简历openOnlineResume 内部等待 iframe 出现)
const opened = await openOnlineResume(page, { cursor, clearCapturedText })
if (!opened) {
reply(false, null, '未找到「查看在线简历」按钮或 iframe 未出现')
break
}
// 3. 稳定轮询 Canvas 渲染(与 chat-page-processor 逻辑一致)
const POLL_INTERVAL_MS = 400
const STABLE_POLLS_NEEDED = 2
const CANVAS_POLL_TIMEOUT = 8000
const canvasDeadline = Date.now() + CANVAS_POLL_TIMEOUT
let lastCount = -1
let stableCount = 0
while (Date.now() < canvasDeadline) {
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS))
const currentCount = await page.evaluate(() => (window as any).__canvasCapturedText?.length ?? 0)
if (currentCount > 0 && currentCount === lastCount) {
stableCount++
if (stableCount >= STABLE_POLLS_NEEDED) break
} else {
stableCount = currentCount > 0 ? 1 : 0
}
lastCount = currentCount
}
// 4. 读取并清空捕获数据
const captured = await getCapturedText(page)
const rawLines: string[] = extractResumeText(captured)
// 5. 过滤字体预热行(含「活跃」的行之前丢弃)
const filteredLines = (() => {
const idx = rawLines.findIndex((l: string) => l.includes('活跃'))
if (idx > 0) return rawLines.slice(idx)
return rawLines.filter((l: string) => /[\u4e00-\u9fff]/.test(l))
})()
const resumeText = filteredLines.join('\n')
// 6. 关闭简历弹窗
await page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => {})
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 3000 }).catch(() => {})
reply(true, { resumeText, charCount: resumeText.length })
break
}
default:
reply(false, null, `未知命令: ${type}`)
}
} catch (err: any) {
log(`命令 ${type} 执行出错: ${err?.message}`)
reply(false, null, err?.message ?? String(err))
}
})
}
export const waitForProcessHandShakeAndRunDebug = async () => {
await app.whenReady()
app.on('window-all-closed', () => {
// keep alive
})
runDebug()
}
attachListenerForKillSelfOnParentExited()

View File

@@ -0,0 +1,291 @@
import { app, dialog } from 'electron'
import { AsyncSeriesHook, AsyncSeriesWaterfallHook } from 'tapable'
import { sleep } from '@geekgeekrun/utils/sleep.mjs'
import { AUTO_CHAT_ERROR_EXIT_CODE } from '../../../common/enums/auto-start-chat'
import attachListenerForKillSelfOnParentExited from '../../utils/attachListenerForKillSelfOnParentExited'
import minimist from 'minimist'
import SqlitePluginModule from '@geekgeekrun/sqlite-plugin'
import { connectToDaemon, sendToDaemon } from '../OPEN_SETTING_WINDOW/connect-to-daemon'
import { checkShouldExit } from '../../utils/worker'
import initPublicIpc from '../../utils/initPublicIpc'
import { forwardConsoleLogToDaemon } from '../../utils/forwardConsoleLogToDaemon'
import { getLastUsedAndAvailableBrowser } from '../DOWNLOAD_DEPENDENCIES/utils/browser-history'
import path from 'path'
const { default: SqlitePlugin } = SqlitePluginModule
process.on('SIGTERM', () => {
console.log('收到SIGTERM信号正在退出')
process.exit(0)
})
const rerunInterval = (() => {
let v = Number(process.env.MAIN_BOSSGEEKGO_RERUN_INTERVAL)
if (isNaN(v)) {
v = 3000
}
return v
})()
const initPlugins = async (hooks) => {
const { storageFilePath } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
new SqlitePlugin(path.join(storageFilePath, 'public.db')).apply(hooks)
}
const runRecordId = minimist(process.argv.slice(2))['run-record-id'] ?? null
const log = (msg: string) => {
console.log(`[boss-chat-page-worker] ${msg}`)
}
const runChatPage = async () => {
app.dock?.hide()
log('runChatPage 开始')
log(`正在查找可用浏览器...`)
let puppeteerExecutable = await getLastUsedAndAvailableBrowser()
if (!puppeteerExecutable) {
log('未找到可用浏览器,退出')
await dialog.showMessageBox({
type: `error`,
message: `未找到可用的浏览器`,
detail: `请重新运行本程序,按照提示安装、配置浏览器`
})
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: {
id: 'puppeteer-executable-check',
status: 'rejected'
},
runRecordId
}
})
app.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE)
return
}
log(`找到浏览器: ${puppeteerExecutable.executablePath}`)
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: {
id: 'puppeteer-executable-check',
status: 'fulfilled'
},
runRecordId
}
})
process.env.PUPPETEER_EXECUTABLE_PATH = puppeteerExecutable.executablePath
log('正在动态 import boss package...')
type BossAutoBrowseModule = {
startBossChatPageProcess: (hooks: any, options?: { browser?: any; page?: any; getCapturedText?: any; clearCapturedText?: any }) => Promise<void>
initPuppeteer: () => Promise<{ puppeteer: any }>
}
const {
startBossChatPageProcess,
initPuppeteer
} = (await import('@geekgeekrun/boss-auto-browse-and-chat/index.mjs')) as unknown as BossAutoBrowseModule
const { setupCanvasTextHook } = (await import('@geekgeekrun/boss-auto-browse-and-chat/resume-extractor.mjs')) as any
log('boss package import 完成,初始化 puppeteer...')
process.on('disconnect', () => {
app.exit()
})
const { puppeteer } = await initPuppeteer()
log('puppeteer 初始化完成,初始化 hooks 和插件...')
const hooks = {
beforeBrowserLaunch: new AsyncSeriesHook(['_']),
afterBrowserLaunch: new AsyncSeriesHook(['_']),
beforeNavigateToRecommend: new AsyncSeriesHook(['_']),
onCandidateListLoaded: new AsyncSeriesHook(['_']),
onCandidateFiltered: new AsyncSeriesWaterfallHook(['candidates', 'filterResult'] as any),
beforeStartChat: new AsyncSeriesHook(['candidate']),
afterChatStarted: new AsyncSeriesHook(['candidate', 'result'] as any),
onError: new AsyncSeriesHook(['error']),
onComplete: new AsyncSeriesHook(['_']),
onProgress: new AsyncSeriesHook(['payload'] as any)
}
await initPlugins(hooks)
log('插件初始化完成')
hooks.afterBrowserLaunch.tapPromise('log', async () => { log('afterBrowserLaunch - 浏览器已启动') })
hooks.onProgress.tap('sendProgressToGui', (payload: unknown) => {
const p = payload as { phase?: string; current?: number; max?: number }
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'boss-auto-browse-progress',
workerId: 'bossChatPageMain',
runRecordId,
phase: p?.phase,
current: p?.current ?? 0,
max: p?.max ?? 0
}
})
})
const { readStorageFile, ensureStorageFileExist } = await import('@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs') as any
ensureStorageFileExist()
const { BOSS_CHAT_PAGE_URL } = await import('@geekgeekrun/boss-auto-browse-and-chat/constant.mjs') as any
const { setDomainLocalStorage } = await import('@geekgeekrun/utils/puppeteer/local-storage.mjs') as any
const localStoragePageUrl = 'https://www.zhipin.com/desktop/'
while (true) {
let browser: any = null
try {
const { readConfigFile: readCfg } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
) as any
const cfg = readCfg('boss-recruiter.json') as {
chatPage?: {
runOnceAfterComplete?: boolean
rerunIntervalMs?: number
keepBrowserOpenAfterRun?: boolean
}
}
const runOnceAfterComplete = cfg?.chatPage?.runOnceAfterComplete === true
const keepBrowserOpenAfterRun = cfg?.chatPage?.keepBrowserOpenAfterRun === true
log('启动浏览器...')
await hooks.beforeBrowserLaunch?.promise?.()
const headless = process.env.HEADLESS === '1'
browser = await puppeteer.launch({
headless,
ignoreHTTPSErrors: true,
protocolTimeout: 120000,
defaultViewport: { width: 1440, height: 900 - 140 }
})
await hooks.afterBrowserLaunch?.promise?.()
const bossCookies = readStorageFile('boss-cookies.json')
const bossLocalStorage = readStorageFile('boss-local-storage.json')
const page = (await browser.pages())[0]
// 注入 Canvas fillText hook必须在页面导航前注入evaluateOnNewDocument
const { getCapturedText, clearCapturedText } = await setupCanvasTextHook(page)
if (Array.isArray(bossCookies) && bossCookies.length > 0) {
await page.setCookie(...bossCookies)
}
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
await page.goto(BOSS_CHAT_PAGE_URL, { timeout: 60 * 1000 })
await page.waitForFunction(() => document.readyState === 'complete', { timeout: 120 * 1000 })
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: { id: 'login-status-check', status: 'fulfilled' },
runRecordId
}
})
log('开始执行 startBossChatPageProcess沟通页...')
await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText })
log('startBossChatPageProcess 完成')
if (runOnceAfterComplete) {
if (keepBrowserOpenAfterRun) {
log('运行已结束,浏览器保持打开,请手动关闭浏览器窗口后将自动退出')
await new Promise<void>((resolve) => {
browser!.once('disconnected', () => resolve())
})
} else {
try { await browser.close() } catch (e) { void e }
}
log('已配置 runOnceAfterComplete本次运行后停止')
process.exit(0)
}
try { await browser.close() } catch (e) { void e }
browser = null
const rerunMs = cfg?.chatPage?.rerunIntervalMs ?? rerunInterval
log(`下次运行将在 ${rerunMs}ms 后开始`)
await sleep(rerunMs)
} catch (err) {
if (browser) {
try { await browser.close() } catch (e) { void e }
browser = null
}
if (err instanceof Error) {
if (err.message.includes('LOGIN_STATUS_INVALID')) {
await dialog.showMessageBox({
type: `error`,
message: `登录状态无效`,
detail: `请重新登录BOSS直聘招聘端`
})
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.LOGIN_STATUS_INVALID)
break
}
if (err.message.includes('ERR_INTERNET_DISCONNECTED')) {
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ERR_INTERNET_DISCONNECTED)
break
}
if (err.message.includes('ACCESS_IS_DENIED')) {
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ACCESS_IS_DENIED)
break
}
if (
err.message.includes(`Could not find Chrome`) ||
err.message.includes(`no executable was found`)
) {
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE)
break
}
}
console.error('[Boss Chat Page Main] error:', err instanceof Error ? `${err.name}: ${err.message}\n${err.stack}` : JSON.stringify(err))
const shouldExit = await checkShouldExit()
if (shouldExit) {
app.exit()
return
}
const { readConfigFile: readErrCfg } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
) as any
const errCfg = readErrCfg('boss-recruiter.json') as { chatPage?: { rerunIntervalMs?: number } }
const errRerunMs = errCfg?.chatPage?.rerunIntervalMs ?? rerunInterval
log(`发生错误,浏览器将在 ${errRerunMs}ms 后重启`)
await sleep(errRerunMs)
}
}
}
export const waitForProcessHandShakeAndRunAutoChat = async () => {
await app.whenReady()
app.on('window-all-closed', () => {
// keep process alive while worker is running
})
initPublicIpc()
await connectToDaemon()
forwardConsoleLogToDaemon('bossChatPageMain', 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
}
})
runChatPage()
}
attachListenerForKillSelfOnParentExited()

View File

@@ -0,0 +1,281 @@
import { app, dialog } from 'electron'
import { AsyncSeriesHook, AsyncSeriesWaterfallHook } from 'tapable'
import { sleep } from '@geekgeekrun/utils/sleep.mjs'
import { AUTO_CHAT_ERROR_EXIT_CODE } from '../../../common/enums/auto-start-chat'
import attachListenerForKillSelfOnParentExited from '../../utils/attachListenerForKillSelfOnParentExited'
import minimist from 'minimist'
import SqlitePluginModule from '@geekgeekrun/sqlite-plugin'
import { connectToDaemon, sendToDaemon } from '../OPEN_SETTING_WINDOW/connect-to-daemon'
import { checkShouldExit } from '../../utils/worker'
import initPublicIpc from '../../utils/initPublicIpc'
import { forwardConsoleLogToDaemon } from '../../utils/forwardConsoleLogToDaemon'
import { getLastUsedAndAvailableBrowser } from '../DOWNLOAD_DEPENDENCIES/utils/browser-history'
import path from 'path'
const { default: SqlitePlugin } = SqlitePluginModule
process.on('SIGTERM', () => {
console.log('收到SIGTERM信号正在退出')
process.exit(0)
})
const defaultRerunInterval = (() => {
const v = Number(process.env.MAIN_BOSSGEEKGO_RERUN_INTERVAL)
return Number.isNaN(v) ? 3000 : v
})()
const initPlugins = async (hooks) => {
const { storageFilePath } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
new SqlitePlugin(path.join(storageFilePath, 'public.db')).apply(hooks)
}
const argv = minimist(process.argv.slice(2))
const runRecordId = argv['run-record-id'] ?? null
const jobId: string | null = argv['job-id'] ?? null
const log = (msg: string) => {
console.log(`[boss-recommend-worker] ${msg}`)
}
const runRecommend = async () => {
app.dock?.hide()
log('runRecommend 开始')
log(`正在查找可用浏览器...`)
let puppeteerExecutable = await getLastUsedAndAvailableBrowser()
if (!puppeteerExecutable) {
log('未找到可用浏览器,退出')
await dialog.showMessageBox({
type: `error`,
message: `未找到可用的浏览器`,
detail: `请重新运行本程序,按照提示安装、配置浏览器`
})
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: {
id: 'puppeteer-executable-check',
status: 'rejected'
},
runRecordId
}
})
app.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE)
return
}
log(`找到浏览器: ${puppeteerExecutable.executablePath}`)
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: {
id: 'puppeteer-executable-check',
status: 'fulfilled'
},
runRecordId
}
})
process.env.PUPPETEER_EXECUTABLE_PATH = puppeteerExecutable.executablePath
log('正在动态 import boss package...')
type BossAutoBrowseModule = {
default: (hooks: any, opts?: { returnBrowser?: boolean }) => Promise<void | { browser: any; page: any }>
initPuppeteer: () => Promise<any>
bossAutoBrowseEventBus: InstanceType<typeof import('node:events').EventEmitter>
}
const {
default: startBossAutoBrowse,
initPuppeteer,
bossAutoBrowseEventBus
} = (await import('@geekgeekrun/boss-auto-browse-and-chat/index.mjs')) as unknown as BossAutoBrowseModule
log('boss package import 完成,初始化 puppeteer...')
process.on('disconnect', () => {
app.exit()
})
await initPuppeteer()
log('puppeteer 初始化完成,初始化 hooks 和插件...')
const hooks = {
beforeBrowserLaunch: new AsyncSeriesHook(['_']),
afterBrowserLaunch: new AsyncSeriesHook(['_']),
beforeNavigateToRecommend: new AsyncSeriesHook(['_']),
onCandidateListLoaded: new AsyncSeriesHook(['_']),
onCandidateFiltered: new AsyncSeriesWaterfallHook(['candidates', 'filterResult'] as any),
beforeStartChat: new AsyncSeriesHook(['candidate']),
afterChatStarted: new AsyncSeriesHook(['candidate', 'result'] as any),
onError: new AsyncSeriesHook(['error']),
onComplete: new AsyncSeriesHook(['_']),
onProgress: new AsyncSeriesHook(['payload'] as any)
}
await initPlugins(hooks)
log('插件初始化完成,即将启动浏览器...')
hooks.beforeBrowserLaunch.tapPromise('log', async () => { log('beforeBrowserLaunch') })
hooks.afterBrowserLaunch.tapPromise('log', async () => { log('afterBrowserLaunch - 浏览器已启动') })
hooks.beforeNavigateToRecommend.tapPromise('log', async () => { log('beforeNavigateToRecommend - 正在导航到推荐页') })
bossAutoBrowseEventBus.once('LOGIN_STATUS_INVALID', () => {})
hooks.onCandidateListLoaded.tap('sendLoginStatusCheck', () => {
log('onCandidateListLoaded - 登录成功,候选人列表已加载')
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: {
id: 'login-status-check',
status: 'fulfilled'
},
runRecordId
}
})
})
hooks.onProgress.tap('sendProgressToGui', (payload: unknown) => {
const p = payload as { phase?: string; current?: number; max?: number }
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'boss-auto-browse-progress',
workerId: 'bossRecommendMain',
runRecordId,
phase: p?.phase,
current: p?.current ?? 0,
max: p?.max ?? 0
}
})
})
while (true) {
try {
const { readConfigFile } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const cfg = readConfigFile('boss-recruiter.json') as {
recommendPage?: {
runOnceAfterComplete?: boolean
rerunIntervalMs?: number
keepBrowserOpenAfterRun?: boolean
}
}
const runOnceAfterComplete = cfg?.recommendPage?.runOnceAfterComplete === true
// 仅招聘端推荐页:运行结束后是否保持浏览器打开(与应聘端无关)
const keepBrowserOpenAfterRun = cfg?.recommendPage?.keepBrowserOpenAfterRun === true
const returnBrowser = runOnceAfterComplete && keepBrowserOpenAfterRun
if (jobId) {
const { getMergedJobConfig } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const mergedCfg = getMergedJobConfig(jobId)
log(`使用 job-id=${jobId} 的合并配置`)
Object.assign(cfg, mergedCfg)
}
log('开始执行 startBossAutoBrowse推荐页...')
const result = await startBossAutoBrowse(hooks, { returnBrowser, jobId: jobId ?? undefined } as any)
if (result?.browser) {
if (keepBrowserOpenAfterRun) {
log('运行已结束,浏览器保持打开,请手动关闭浏览器窗口后将自动退出')
await new Promise<void>((resolve) => {
result!.browser!.once('disconnected', () => resolve())
})
process.exit(0)
}
try {
await result.browser.close()
} catch (e) {
void e
}
}
log('startBossAutoBrowse 完成,等待下次运行...')
if (runOnceAfterComplete) {
log('已配置 runOnceAfterComplete本次运行后停止')
process.exit(0)
}
const rerunMs = cfg?.recommendPage?.rerunIntervalMs ?? defaultRerunInterval
log(`下次运行将在 ${rerunMs}ms 后开始`)
await sleep(rerunMs)
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('LOGIN_STATUS_INVALID')) {
await dialog.showMessageBox({
type: `error`,
message: `登录状态无效`,
detail: `请重新登录BOSS直聘招聘端`
})
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.LOGIN_STATUS_INVALID)
break
}
if (err.message.includes('ERR_INTERNET_DISCONNECTED')) {
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ERR_INTERNET_DISCONNECTED)
break
}
if (err.message.includes('ACCESS_IS_DENIED')) {
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ACCESS_IS_DENIED)
break
}
if (
err.message.includes(`Could not find Chrome`) ||
err.message.includes(`no executable was found`)
) {
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE)
break
}
}
console.error('[Boss Recommend Main] error:', err instanceof Error ? `${err.name}: ${err.message}\n${err.stack}` : JSON.stringify(err))
const shouldExit = await checkShouldExit()
if (shouldExit) {
app.exit()
return
}
const { readConfigFile: readCfg } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const errCfg = readCfg('boss-recruiter.json') as {
recommendPage?: { rerunIntervalMs?: number }
}
const errRerunMs = errCfg?.recommendPage?.rerunIntervalMs ?? defaultRerunInterval
console.log(
`[Boss Recommend Main] An internal error is caught, and browser will be restarted in ${errRerunMs}ms.`
)
await sleep(errRerunMs)
}
}
}
export const waitForProcessHandShakeAndRunAutoChat = async () => {
await app.whenReady()
app.on('window-all-closed', () => {
// keep process alive while worker is running
})
initPublicIpc()
await connectToDaemon()
forwardConsoleLogToDaemon('bossRecommendMain', 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
}
})
runRecommend()
}
attachListenerForKillSelfOnParentExited()

View File

@@ -7,6 +7,7 @@ import {
readStorageFile,
storageFilePath
} from '@geekgeekrun/geek-auto-start-chat-with-boss/runtime-file-utils.mjs'
import { ChildProcess } from 'child_process'
import * as JSONStream from 'JSONStream'
import { checkCookieListFormat } from '../../../../common/utils/cookie'
@@ -420,7 +421,7 @@ export default function initIpc() {
modal: true,
show: true
})
const defer = Promise.withResolvers()
const defer = Promise.withResolvers<void>()
async function saveLlmConfigHandler(_, configToSave) {
await writeConfigFile('llm.json', configToSave)
defer.resolve()
@@ -436,13 +437,52 @@ export default function initIpc() {
})
ipcMain.on('close-llm-config', () => llmConfigWindow?.close())
// ── 招聘端 LLM 配置 (boss-llm.json) ─────────────────────────────────────────
ipcMain.handle('boss-fetch-llm-config', async () => {
const { readBossLlmConfig } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
return readBossLlmConfig()
})
ipcMain.handle('boss-save-llm-config', async (_, payload) => {
const { writeBossLlmConfig } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const config = typeof payload === 'string' ? JSON.parse(payload) : payload
return await writeBossLlmConfig(config)
})
ipcMain.handle('boss-test-llm-endpoint', async (_, model: {
baseURL: string
apiKey: string
}) => {
try {
// 使用 GET /models 验证 baseURL + apiKey 的连通性,不消耗任何 token
const { net } = await import('electron')
const url = model.baseURL.replace(/\/$/, '') + '/models'
const res = await net.fetch(url, {
headers: { Authorization: `Bearer ${model.apiKey}` }
})
if (!res.ok) {
const body = await res.text().catch(() => '')
return { ok: false, error: `${res.status} ${res.statusText}${body ? ': ' + body.slice(0, 200) : ''}` }
}
return { ok: true }
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
return { ok: false, error: msg }
}
})
// ── end 招聘端 LLM 配置窗口 ──────────────────────────────────────────────────
ipcMain.handle('resume-edit', async () => {
createResumeEditorWindow({
parent: mainWindow!,
modal: true,
show: true
})
const defer = Promise.withResolvers()
const defer = Promise.withResolvers<void>()
async function saveResumeHandler(_, resumeContent) {
await writeConfigFile('resumes.json', [
{
@@ -607,4 +647,681 @@ export default function initIpc() {
ipcMain.handle('exit-app-immediately', () => {
app.exit(0)
})
ipcMain.handle('fetch-webhook-config', async () => {
const { readConfigFile: readBossConfigFile } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
return readBossConfigFile('webhook.json') ?? null
})
ipcMain.handle('save-webhook-config', async (_, payload) => {
const { readConfigFile: readBossConfigFile, writeConfigFile: writeBossConfigFile } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const config = JSON.parse(payload)
const existing = readBossConfigFile('webhook.json') ?? {}
return await writeBossConfigFile('webhook.json', { ...existing, ...config })
})
ipcMain.handle('test-webhook', async () => {
const { readConfigFile: readBossConfigFile, storageFilePath } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const config = readBossConfigFile('webhook.json')
if (!config?.url) {
throw new Error('未配置 Webhook URL')
}
const { sendWebhook, buildMockPayload } = await import('../../../features/webhook/index')
const result = await sendWebhook(config, buildMockPayload(), {
storageDir: storageFilePath
})
console.log(`[webhook] 保存并测试发送完成HTTP ${result.status}`)
return result
})
ipcMain.handle('trigger-webhook-manually', async (_, useRealData?: boolean) => {
const { readConfigFile: readBossConfigFile, storageFilePath } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const config = readBossConfigFile('webhook.json')
if (!config?.url) {
throw new Error('未配置 Webhook URL')
}
const pathModule = await import('node:path')
const {
sendWebhook,
buildMockPayload,
buildPayloadFromDb
} = await import('../../../features/webhook/index')
const dbPath = pathModule.default.join(storageFilePath, 'public.db')
let payload =
useRealData === true
? await buildPayloadFromDb(dbPath)
: null
if (!payload) {
payload = buildMockPayload()
payload.runId = `manual-${Date.now()}`
}
const result = await sendWebhook(config, payload, {
storageDir: storageFilePath
})
console.log(
`[webhook] 手动触发完成runId=${payload.runId}${useRealData ? '真实数据' : 'Mock'}HTTP ${result.status}`
)
return result
})
ipcMain.handle('run-boss-recommend', async () => {
const mode = 'bossRecommendMain'
const { runRecordId } = await runCommon({ mode })
daemonEE.on('message', function handler(message) {
if (message.workerId !== mode) {
return
}
if (message.type === 'worker-exited') {
mainWindow?.webContents.send('worker-exited', message)
}
})
return { runRecordId }
})
ipcMain.handle('stop-boss-recommend', async () => {
mainWindow?.webContents.send('boss-recommend-stopping')
const p = new Promise((resolve) => {
daemonEE.on('message', function handler(message) {
if (message.workerId !== 'bossRecommendMain') {
return
}
if (message.type === 'worker-exited') {
daemonEE.off('message', handler)
resolve(undefined)
}
})
})
await sendToDaemon(
{
type: 'stop-worker',
workerId: 'bossRecommendMain'
},
{
needCallback: true
}
)
await p
mainWindow?.webContents.send('boss-recommend-stopped')
})
ipcMain.handle('run-boss-chat-page', async () => {
const mode = 'bossChatPageMain'
const { runRecordId } = await runCommon({ mode })
daemonEE.on('message', function handler(message) {
if (message.workerId !== mode) {
return
}
if (message.type === 'worker-exited') {
mainWindow?.webContents.send('worker-exited', message)
}
})
return { runRecordId }
})
ipcMain.handle('stop-boss-chat-page', async () => {
mainWindow?.webContents.send('boss-chat-page-stopping')
const p = new Promise((resolve) => {
daemonEE.on('message', function handler(message) {
if (message.workerId !== 'bossChatPageMain') {
return
}
if (message.type === 'worker-exited') {
daemonEE.off('message', handler)
resolve(undefined)
}
})
})
await sendToDaemon(
{
type: 'stop-worker',
workerId: 'bossChatPageMain'
},
{
needCallback: true
}
)
await p
mainWindow?.webContents.send('boss-chat-page-stopped')
})
// ── 招聘端调试工具 ──────────────────────────────────────────────────────────
// 通过 stdio fd3 双向 JSON 通信主进程发命令worker 返回结果。
let bossChatDebugProcess: ChildProcess | null = null
let bossChatDebugReadyDefer: PromiseWithResolvers<void> | null = null
const bossChatDebugPendingCmds = new Map<string, PromiseWithResolvers<any>>()
const closeBossChatDebug = () => {
if (bossChatDebugProcess && !bossChatDebugProcess.killed) {
try {
bossChatDebugProcess.kill('SIGTERM')
} catch {
// Process may already have exited (e.g. user closed browser); ignore
}
}
bossChatDebugProcess = null
bossChatDebugReadyDefer = null
}
ipcMain.handle('open-boss-chat-debug', async (ev) => {
// 若 worker 已在运行,直接返回
if (bossChatDebugProcess && !bossChatDebugProcess.killed) {
return { ok: true, alreadyRunning: true }
}
let puppeteerExecutable = await getLastUsedAndAvailableBrowser()
if (!puppeteerExecutable) {
try {
const parent = BrowserWindow.fromWebContents(ev.sender) || undefined
await configWithBrowserAssistant({ autoFind: true, windowOption: { parent, modal: !!parent, show: true } })
puppeteerExecutable = await getLastUsedAndAvailableBrowser()
} catch { /**/ }
}
if (!puppeteerExecutable) {
return { ok: false, error: 'NO_BROWSER' }
}
bossChatDebugReadyDefer = Promise.withResolvers()
bossChatDebugProcess = childProcess.spawn(
process.argv[0],
process.env.NODE_ENV === 'development'
? [process.argv[1], '--mode=bossChatDebugMain']
: ['--mode=bossChatDebugMain'],
{
env: { ...process.env, PUPPETEER_EXECUTABLE_PATH: puppeteerExecutable.executablePath, GEEKGEEKRUND_PIPE_NAME: process.env.GEEKGEEKRUND_PIPE_NAME },
stdio: ['inherit', 'inherit', 'inherit', 'pipe', 'pipe']
}
)
bossChatDebugProcess.once('exit', () => {
bossChatDebugProcess = null
bossChatDebugReadyDefer = null
mainWindow?.webContents.send('boss-chat-debug-exited')
for (const [, defer] of bossChatDebugPendingCmds) {
defer.reject(new Error('worker exited'))
}
bossChatDebugPendingCmds.clear()
})
// fd3=父写→子读fd4=子写→父读;主进程从 stdio[4] 读 worker 的 READY/响应
;(bossChatDebugProcess.stdio[4] as NodeJS.ReadableStream).pipe(JSONStream.parse()).on('data', (msg: any) => {
if (msg?.type === 'READY') {
if (msg.ok) {
bossChatDebugReadyDefer?.resolve()
mainWindow?.webContents.send('boss-chat-debug-ready')
} else {
bossChatDebugReadyDefer?.reject(new Error(msg.error ?? 'READY failed'))
}
return
}
// 命令响应(有 id
if (msg?.id) {
const defer = bossChatDebugPendingCmds.get(msg.id)
if (defer) {
bossChatDebugPendingCmds.delete(msg.id)
if (msg.ok) { defer.resolve(msg.result) } else { defer.reject(new Error(msg.error ?? 'command failed')) }
}
}
})
try {
await bossChatDebugReadyDefer.promise
return { ok: true }
} catch (err: any) {
closeBossChatDebug()
return { ok: false, error: err?.message }
}
})
ipcMain.handle('boss-debug-command', async (_, cmd: { type: string; [k: string]: any }) => {
if (!bossChatDebugProcess || bossChatDebugProcess.killed) {
return { ok: false, error: 'DEBUG_WORKER_NOT_RUNNING' }
}
const id = Math.random().toString(36).slice(2)
const defer = Promise.withResolvers<any>()
bossChatDebugPendingCmds.set(id, defer)
pipeWriteRegardlessError(
bossChatDebugProcess.stdio[3] as WriteStream,
JSON.stringify({ ...cmd, id }) + '\n'
)
try {
const result = await Promise.race([
defer.promise,
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 30000))
])
return { ok: true, result }
} catch (err: any) {
bossChatDebugPendingCmds.delete(id)
return { ok: false, error: err?.message }
}
})
ipcMain.handle('close-boss-chat-debug', () => {
closeBossChatDebug()
return { ok: true }
})
// ── end 招聘端调试工具 ───────────────────────────────────────────────────────
ipcMain.handle('run-boss-auto-browse-and-chat', async () => {
const mode = 'bossAutoBrowseAndChatMain'
const { runRecordId } = await runCommon({ mode })
daemonEE.on('message', function handler(message) {
if (message.workerId !== mode) {
return
}
if (message.type === 'worker-exited') {
mainWindow?.webContents.send('worker-exited', message)
}
})
return { runRecordId }
})
ipcMain.handle('stop-boss-auto-browse-and-chat', async () => {
mainWindow?.webContents.send('boss-auto-browse-and-chat-stopping')
const p = new Promise((resolve) => {
daemonEE.on('message', function handler(message) {
if (message.workerId !== 'bossAutoBrowseAndChatMain') {
return
}
if (message.type === 'worker-exited') {
daemonEE.off('message', handler)
resolve(undefined)
}
})
})
await sendToDaemon(
{
type: 'stop-worker',
workerId: 'bossAutoBrowseAndChatMain'
},
{
needCallback: true
}
)
await p
mainWindow?.webContents.send('boss-auto-browse-and-chat-stopped')
})
ipcMain.handle('check-boss-recruiter-cookie-file', async () => {
const { readStorageFile } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const cookies = readStorageFile('boss-cookies.json')
return checkCookieListFormat(cookies)
})
ipcMain.handle('save-boss-recruiter-config', async (_, payload) => {
const { readConfigFile: readBossConfigFile, writeConfigFile: writeBossConfigFile } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
payload = JSON.parse(payload)
const bossRecruiterConfig = readBossConfigFile('boss-recruiter.json') || {}
if (hasOwn(payload, 'logLevel')) {
bossRecruiterConfig.logLevel = payload.logLevel
}
if (hasOwn(payload, 'targetJobId')) {
bossRecruiterConfig.targetJobId = payload.targetJobId
}
if (hasOwn(payload, 'autoChat')) {
bossRecruiterConfig.autoChat = {
...bossRecruiterConfig.autoChat,
...payload.autoChat
}
}
if (hasOwn(payload, 'chatPage')) {
const chat = { ...bossRecruiterConfig.chatPage, ...payload.chatPage }
if (chat.filter) {
if (Array.isArray(payload.chatPage?.filter?.keywordList)) {
chat.filter.keywordList = payload.chatPage.filter.keywordList
} else if (typeof payload.chatPage?.filter?.keywordListStr === 'string') {
chat.filter.keywordList = payload.chatPage.filter.keywordListStr
.split(/[,]/)
.map((s) => String(s).trim())
.filter(Boolean)
}
}
bossRecruiterConfig.chatPage = chat
}
if (hasOwn(payload, 'recommendPage')) {
bossRecruiterConfig.recommendPage = {
...bossRecruiterConfig.recommendPage,
...payload.recommendPage
}
}
const candidateFilterConfig = readBossConfigFile('candidate-filter.json') || {}
if (hasOwn(payload, 'expectCityList')) {
candidateFilterConfig.expectCityList = payload.expectCityList
}
if (hasOwn(payload, 'expectEducationRegExpStr')) {
candidateFilterConfig.expectEducationRegExpStr = payload.expectEducationRegExpStr
}
if (hasOwn(payload, 'expectWorkExpRange')) {
candidateFilterConfig.expectWorkExpRange = payload.expectWorkExpRange
}
if (hasOwn(payload, 'expectSalaryRange')) {
candidateFilterConfig.expectSalaryRange = payload.expectSalaryRange
}
if (hasOwn(payload, 'expectSalaryWhenNegotiable')) {
candidateFilterConfig.expectSalaryWhenNegotiable = payload.expectSalaryWhenNegotiable
}
if (hasOwn(payload, 'expectSkillKeywords')) {
candidateFilterConfig.expectSkillKeywords = payload.expectSkillKeywords
}
if (hasOwn(payload, 'blockCandidateNameRegExpStr')) {
candidateFilterConfig.blockCandidateNameRegExpStr = payload.blockCandidateNameRegExpStr
}
if (hasOwn(payload, 'skipViewedCandidates')) {
candidateFilterConfig.skipViewedCandidates = payload.skipViewedCandidates
}
return await Promise.all([
writeBossConfigFile('boss-recruiter.json', bossRecruiterConfig),
writeBossConfigFile('candidate-filter.json', candidateFilterConfig)
])
})
ipcMain.handle('fetch-boss-recruiter-config-file-content', async () => {
const { readConfigFile: readBossConfigFile } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
return {
config: {
'boss-recruiter.json': readBossConfigFile('boss-recruiter.json'),
'candidate-filter.json': readBossConfigFile('candidate-filter.json')
}
}
})
ipcMain.handle('fetch-boss-jobs-config', async () => {
const { readBossJobsConfig } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
return readBossJobsConfig()
})
ipcMain.handle('save-boss-jobs-config', async (_, payload) => {
const { readBossJobsConfig, writeBossJobsConfig } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const incoming = typeof payload === 'string' ? JSON.parse(payload) : payload
const existing = readBossJobsConfig()
const config = {
...existing,
jobs: incoming.jobs ?? existing.jobs ?? []
}
return await writeBossJobsConfig(config)
})
ipcMain.handle('generate-llm-rubric', async (_, payload: { sourceJd?: string; modelId?: string | null }) => {
const { setLevel, debug: logDebug, info: logInfo, error: logError } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/logger.mjs'
)
const { readConfigFile } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const config = readConfigFile('boss-recruiter.json') || {}
setLevel((config as { logLevel?: string }).logLevel || 'info')
const LOG = '[generate-llm-rubric/ipc]'
const { generateRubricFromJd } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/llm-rubric.mjs'
)
const sourceJd = typeof payload?.sourceJd === 'string' ? payload.sourceJd : ''
const modelId = typeof payload?.modelId === 'string' ? payload.modelId : null
logInfo(LOG, 'start', { jdChars: sourceJd.length, modelId })
try {
const res = await generateRubricFromJd(sourceJd, { modelId })
logDebug(LOG, 'done', { knockouts: res?.rubric?.knockouts?.length, dims: res?.rubric?.dimensions?.length })
return res
} catch (err: any) {
logError(LOG, 'error', err?.message ?? err)
throw err
}
})
// ── 调试工具 LLM 接口 ─────────────────────────────────────────────────────────
// llm-screen-resume: 主进程侧直接调用 evaluateResumeByRubric无需浏览器
ipcMain.handle('llm-screen-resume', async (_, payload: {
resumeText: string
jobId?: string
rubric?: { knockouts: string[]; dimensions: any[]; passThreshold?: number }
}) => {
const { evaluateResumeByRubric } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/llm-rubric.mjs'
) as any
const { readBossJobsConfig } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
let rubricConfig: { knockouts?: string[]; dimensions?: any[]; passThreshold?: number } | null = null
if (payload?.jobId) {
const jobsConfig = readBossJobsConfig()
const job = (jobsConfig.jobs || []).find((j: any) => (j.jobId ?? j.id) === payload.jobId)
const llmConfig = job?.filter?.resumeLlmConfig
if (llmConfig?.rubric) {
rubricConfig = {
knockouts: llmConfig.rubric.knockouts,
dimensions: llmConfig.rubric.dimensions,
passThreshold: llmConfig.passThreshold ?? 75
}
}
} else if (payload?.rubric) {
rubricConfig = payload.rubric
}
if (!rubricConfig) {
return { ok: false, error: '未找到 Rubric 配置,请选择已配置 LLM 的职位或手动填写 Rubric JSON' }
}
const result = await evaluateResumeByRubric(payload.resumeText ?? '', rubricConfig)
return { ok: true, ...result }
})
ipcMain.handle('apply-rubric-to-job', async (_, payload: {
jobId: string
rubric: { knockouts: string[]; dimensions: any[] }
passThreshold?: number
}) => {
const { readBossJobsConfig, writeBossJobsConfig } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
)
const config = readBossJobsConfig()
const jobs: any[] = config.jobs ?? []
const idx = jobs.findIndex((j: any) => (j.jobId ?? j.id) === payload.jobId)
if (idx === -1) return { ok: false, error: `未找到职位 ${payload.jobId}` }
const job = jobs[idx]
if (!job.filter) job.filter = {}
if (!job.filter.resumeLlmConfig) job.filter.resumeLlmConfig = {}
job.filter.resumeLlmEnabled = true
job.filter.resumeLlmConfig.rubric = { knockouts: payload.rubric.knockouts, dimensions: payload.rubric.dimensions }
job.filter.resumeLlmConfig.passThreshold = payload.passThreshold ?? 75
await writeBossJobsConfig({ ...config, jobs })
return { ok: true }
})
// ── end 调试工具 LLM 接口 ──────────────────────────────────────────────────────
ipcMain.handle('sync-boss-job-list', async (ev) => {
const { setLevel, debug: logDebug, info: logInfo } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/logger.mjs'
)
const { readConfigFile, readBossJobsConfig, writeBossJobsConfig, readStorageFile: readBossStorageFile } =
await import('@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs')
const config = readConfigFile('boss-recruiter.json') || {}
setLevel((config as { logLevel?: string }).logLevel || 'info')
const LOG = '[sync-boss-job-list]'
const sendToGui = (message: string) => {
mainWindow?.webContents?.send('worker-to-gui-message', {
data: { type: 'worker-log' as const, workerId: 'syncBossJobList', message }
})
}
const toStr = (msg: string, ...rest: unknown[]) =>
[msg, ...rest].map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')
const log = (msg: string, ...rest: unknown[]) => {
logInfo(LOG, msg, ...rest)
sendToGui(toStr(msg, ...rest))
}
const logDebugSync = (msg: string, ...rest: unknown[]) => {
logDebug(LOG, msg, ...rest)
sendToGui(toStr(msg, ...rest))
}
log('查找可用浏览器...')
let puppeteerExecutable = await getLastUsedAndAvailableBrowser()
if (!puppeteerExecutable) {
try {
const parent = BrowserWindow.fromWebContents(ev.sender) || undefined
await configWithBrowserAssistant({
autoFind: true,
windowOption: { parent, modal: !!parent, show: true }
})
puppeteerExecutable = await getLastUsedAndAvailableBrowser()
} catch {
//
}
}
if (!puppeteerExecutable) {
log('未找到可用浏览器')
throw new Error('NO_BROWSER')
}
log(`使用浏览器: ${puppeteerExecutable.executablePath}`)
log('初始化 Puppeteer...')
const { initPuppeteer } = await import('@geekgeekrun/boss-auto-browse-and-chat/index.mjs') as any
process.env.PUPPETEER_EXECUTABLE_PATH = puppeteerExecutable.executablePath
const { puppeteer } = await initPuppeteer()
const bossCookies = readBossStorageFile('boss-cookies.json')
const { setDomainLocalStorage } = await import('@geekgeekrun/utils/puppeteer/local-storage.mjs') as any
const bossLocalStorage = readBossStorageFile('boss-local-storage.json')
// 与招聘端调试工具一致:非 headless、相同 viewport 与 protocolTimeout避免站点对 headless 做差异化或拦截
log('启动浏览器(非 headless与调试工具一致...')
const browser = await puppeteer.launch({
headless: false,
ignoreHTTPSErrors: true,
protocolTimeout: 120000,
defaultViewport: { width: 1440, height: 900 - 140 }
})
const {
BOSS_CHAT_INDEX_URL,
CHAT_PAGE_JOB_DROPDOWN_SELECTOR,
CHAT_PAGE_JOB_ITEM_SELECTOR
} = await import('@geekgeekrun/boss-auto-browse-and-chat/constant.mjs')
try {
const page = (await browser.pages())[0]
if (Array.isArray(bossCookies) && bossCookies.length > 0) {
log(`注入 ${bossCookies.length} 条 Cookie`)
await page.setCookie(...bossCookies)
}
await setDomainLocalStorage(browser, 'https://www.zhipin.com/desktop/', bossLocalStorage || {})
log(`导航到沟通页: ${BOSS_CHAT_INDEX_URL}`)
await page.goto(BOSS_CHAT_INDEX_URL, { timeout: 60000 })
const urlAfterGoto = page.url()
logDebugSync(`goto 后当前 URL: ${urlAfterGoto}`)
logDebugSync('等待 document.readyState === complete...')
await page.waitForFunction(
() => document.readyState === 'complete',
{ timeout: 120000 }
)
await new Promise((r) => setTimeout(r, 1500))
const urlAfterReady = page.url()
logDebugSync(`readyState 完成且等待 1.5s 后 URL: ${urlAfterReady}`)
if (
urlAfterReady.startsWith('https://www.zhipin.com/web/common/403.html') ||
urlAfterReady.startsWith('https://www.zhipin.com/web/common/error.html')
) {
log('当前为 403/error 页,拒绝访问')
throw new Error('ACCESS_IS_DENIED')
}
const needLogin = await page.evaluate(
(chatUrl: string) => {
const href = location.href
return (
!href.startsWith(chatUrl) ||
/\/login|\/wapi\/zppassport\//.test(href)
)
},
BOSS_CHAT_INDEX_URL
)
logDebugSync(`needLogin=${needLogin}`)
if (needLogin) {
log('未在沟通页或需登录,抛出 NEED_LOGIN')
throw new Error('NEED_LOGIN')
}
logDebugSync(`等待职位下拉按钮: ${CHAT_PAGE_JOB_DROPDOWN_SELECTOR}`)
await page.waitForSelector(CHAT_PAGE_JOB_DROPDOWN_SELECTOR, { timeout: 30000 })
logDebugSync('职位下拉按钮已出现,点击展开')
await page.click(CHAT_PAGE_JOB_DROPDOWN_SELECTOR)
logDebugSync(`等待职位列表项: ${CHAT_PAGE_JOB_ITEM_SELECTOR}`)
await page.waitForSelector(CHAT_PAGE_JOB_ITEM_SELECTOR, { timeout: 10000 })
const fetchedJobs: Array<{ jobId: string; jobName: string }> = await page.evaluate(
(sel: string) => {
const items = document.querySelectorAll(sel)
return Array.from(items)
.map((li: Element) => ({
jobId: (li as HTMLElement).getAttribute('value') || '',
jobName: (li.textContent || '').trim()
}))
.filter((j) => j.jobId && j.jobId !== '-1')
},
CHAT_PAGE_JOB_ITEM_SELECTOR
)
log(`已获取职位数: ${fetchedJobs.length}`)
const existing = readBossJobsConfig()
const existingMap = new Map((existing.jobs || []).map((j: any) => [j.jobId ?? j.id, j]))
const mergedJobs = fetchedJobs.map((j) => {
const prev = existingMap.get(j.jobId)
if (prev) {
return { ...prev, jobName: j.jobName }
}
return {
jobId: j.jobId,
jobName: j.jobName,
sequence: { enabled: true, runRecommend: true, runChat: true },
candidateFilter: {},
autoChat: {},
chatPage: {}
}
})
const updatedConfig = { ...existing, jobs: mergedJobs }
await writeBossJobsConfig(updatedConfig)
log('同步完成,已保存配置')
return { jobs: mergedJobs }
} catch (err: any) {
try {
const pages = await browser.pages()
const p = pages[0]
if (p && !p.isClosed()) {
const debugUrl = p.url()
const debugTitle = await p.title()
logDebugSync(`出错时页面 URL: ${debugUrl}, title: ${debugTitle}`)
}
} catch (_) {
// 忽略调试信息获取失败
}
throw err
} finally {
try {
log('关闭浏览器...')
await browser.close()
} catch {
//
}
}
})
}

57
packages/ui/src/main/global.d.ts vendored Normal file
View File

@@ -0,0 +1,57 @@
interface BossJobEntry {
id: string
name: string
sequence?: { enabled?: boolean; runRecommend?: boolean; runChat?: boolean }
overrides?: Record<string, any>
[key: string]: any
}
interface BossJobsConfig {
jobs: BossJobEntry[]
[key: string]: any
}
declare module '@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs' {
export const configFolderPath: string
export const storageFilePath: string
export const configFileNameList: string[]
export const storageFileNameList: string[]
export function ensureConfigFileExist(): void
export function ensureStorageFileExist(): void
export function readConfigFile(fileName: string): any
export function writeConfigFile(fileName: string, content: any, options?: { isSync?: boolean }): Promise<void>
export function readStorageFile(fileName: string, options?: { isJson?: boolean }): any
export function writeStorageFile(fileName: string, content: any, options?: { isJson?: boolean }): Promise<void>
export function readBossJobsConfig(): BossJobsConfig
export function writeBossJobsConfig(config: BossJobsConfig): Promise<void>
export function getMergedJobConfig(jobId: string | null | undefined): Record<string, any>
export function readBossLlmConfig(): {
providers: Array<{
id: string
name: string
baseURL: string
apiKey: string
models: Array<{
id: string
name: string
model: string
enabled: boolean
thinking?: { enabled: boolean; budget: number }
}>
}>
purposeDefaultModelId: Record<string, string>
}
export function writeBossLlmConfig(config: any): Promise<void>
}
declare module '@geekgeekrun/utils/puppeteer/local-storage.mjs' {
export function setDomainLocalStorage(browser: any, url: string, storage: Record<string, any>): Promise<void>
}
declare module '@geekgeekrun/boss-auto-browse-and-chat/index.mjs' {
import { EventEmitter } from 'node:events'
export const bossAutoBrowseEventBus: EventEmitter
export function initPuppeteer(): Promise<any>
export default function startBossAutoBrowse(hooks: any, opts?: { returnBrowser?: boolean }): Promise<void | { browser: any; page: any }>
export function startBossChatPageProcess(hooks: any, options?: { browser?: any; page?: any }): Promise<void>
}

View File

@@ -61,6 +61,34 @@ const runMode = commandlineArgs['mode']
await import('./flow/LAUNCH_DAEMON')
break
}
case 'bossAutoBrowseAndChatMain': {
const { waitForProcessHandShakeAndRunAutoChat } = await import(
'./flow/BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index'
)
waitForProcessHandShakeAndRunAutoChat()
break
}
case 'bossRecommendMain': {
const { waitForProcessHandShakeAndRunAutoChat } = await import(
'./flow/BOSS_RECOMMEND_MAIN/index'
)
waitForProcessHandShakeAndRunAutoChat()
break
}
case 'bossChatPageMain': {
const { waitForProcessHandShakeAndRunAutoChat } = await import(
'./flow/BOSS_CHAT_PAGE_MAIN/index'
)
waitForProcessHandShakeAndRunAutoChat()
break
}
case 'bossChatDebugMain': {
const { waitForProcessHandShakeAndRunDebug } = await import(
'./flow/BOSS_CHAT_DEBUG_MAIN/index'
)
waitForProcessHandShakeAndRunDebug()
break
}
// #endregion
// #region user entry

View File

@@ -0,0 +1,29 @@
import { sendToDaemon } from '../flow/OPEN_SETTING_WINDOW/connect-to-daemon'
export function forwardConsoleLogToDaemon(workerId: string, runRecordId: string | null) {
let isForwarding = false
const forward = (prefix: string, originalFn: (...args: any[]) => void, args: any[]) => {
originalFn(...args)
if (isForwarding) return
isForwarding = true
try {
const body = args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')
const message = prefix ? `${prefix} ${body}` : body
sendToDaemon({
type: 'worker-to-gui-message',
data: { type: 'worker-log', workerId, message, runRecordId }
})
} finally {
isForwarding = false
}
}
const originalLog = console.log.bind(console)
const originalWarn = console.warn.bind(console)
const originalError = console.error.bind(console)
console.log = (...args: any[]) => forward('', originalLog, args)
console.warn = (...args: any[]) => forward('[WARN]', originalWarn, args)
console.error = (...args: any[]) => forward('[ERROR]', originalError, args)
}

View File

@@ -113,6 +113,16 @@ export default function initPublicIpc() {
return null
})
ipcMain.on('toggle-devtools', (ev) => {
const win = BrowserWindow.fromWebContents(ev.sender)
if (!win) return
if (win.webContents.isDevToolsOpened()) {
win.webContents.closeDevTools()
} else {
win.webContents.openDevTools()
}
})
ipcMain.handle('fetch-config-file-content', async () => {
const configFileContentList = configFileNameList.map((fileName) => {
return readConfigFile(fileName)

View File

@@ -1,9 +1,19 @@
import path from 'node:path'
import os from 'node:os'
import fs from 'node:fs'
import { execSync } from 'node:child_process'
import dayjs from 'dayjs'
export default function overrideConsole() {
// 在 Windows 下将控制台代码页设为 UTF-8避免中文在终端中显示为乱码
if (os.platform() === 'win32') {
try {
execSync('cmd /c "chcp 65001 >nul"', { stdio: 'ignore', windowsHide: true })
} catch {
// 忽略 chcp 失败(如无控制台时)
}
}
const originConsoleLog = console.log.bind(console)
const originConsoleWarn = console.warn.bind(console)
const originConsoleError = console.error.bind(console)
@@ -15,13 +25,16 @@ export default function overrideConsole() {
}
const logFileStream = fs.createWriteStream(path.join(logDirPath, `log.log`), {
flags: 'a' // 追加模式
flags: 'a', // 追加模式
encoding: 'utf8'
})
const warnFileStream = fs.createWriteStream(path.join(logDirPath, `warn.log`), {
flags: 'a' // 追加模式
flags: 'a',
encoding: 'utf8'
})
const errorFileStream = fs.createWriteStream(path.join(logDirPath, `error.log`), {
flags: 'a' // 追加模式
flags: 'a',
encoding: 'utf8'
})
console.log = (...args: any[]) => {

View File

@@ -41,6 +41,18 @@
</li>
</ul>
</div>
<div
v-if="props.workerId === 'bossAutoBrowseAndChatMain' && (bossProgress.recommend.max > 0 || bossProgress.chatPage.max > 0)"
class="progress-block"
mb8px
>
<div v-if="bossProgress.recommend.max > 0" class="progress-line">
推荐页已开聊 {{ bossProgress.recommend.current }} / {{ bossProgress.recommend.max }}
</div>
<div v-if="bossProgress.chatPage.max > 0" class="progress-line">
沟通页已处理 {{ bossProgress.chatPage.current }} / {{ bossProgress.chatPage.max }}
</div>
</div>
<div flex justify-between items-center w-full>
<div>
{{ runningStatusTextMapByCode[currentRunningStatus] }}
@@ -49,6 +61,17 @@
<slot name="op-buttons" :current-running-status="currentRunningStatus" />
</div>
</div>
<div
v-if="workerLogs.length"
mt8px
style="max-height: 120px; overflow-y: auto; background: #f5f5f5; border-radius: 6px; padding: 6px 8px"
>
<div
v-for="(line, i) in workerLogs"
:key="i"
style="font-size: 11px; color: #666; line-height: 1.5; word-break: break-all; font-family: monospace"
>{{ line }}</div>
</div>
</div>
</div>
</el-dialog>
@@ -69,6 +92,10 @@ const props = defineProps({
},
runRecordId: {
type: Number
},
getSteps: {
type: Function,
default: getAutoStartChatSteps
}
})
// const taskManagerStore = useTaskManagerStore()
@@ -78,6 +105,14 @@ const props = defineProps({
// })
// })
const steps = ref([])
const workerLogs = ref<string[]>([])
const bossProgress = ref<{
recommend: { current: number; max: number }
chatPage: { current: number; max: number }
}>({
recommend: { current: 0, max: 0 },
chatPage: { current: 0, max: 0 }
})
const stepsForRender = computed(() => {
const clonedSteps = JSON.parse(JSON.stringify(steps.value))
if (clonedSteps.some((it) => it.status === 'rejected')) {
@@ -96,10 +131,12 @@ const runningStatusTextMapByCode = {
}
const currentRunningStatus = ref(RUNNING_STATUS_ENUM.RUNNING)
function fillEmptySteps() {
const arr = getAutoStartChatSteps()
const arr = props.getSteps()
arr.forEach((it) => (it.status = 'todo'))
steps.value = arr
currentRunningStatus.value = RUNNING_STATUS_ENUM.RUNNING
workerLogs.value = []
bossProgress.value = { recommend: { current: 0, max: 0 }, chatPage: { current: 0, max: 0 } }
}
watch(() => props.runRecordId, fillEmptySteps, {
immediate: true
@@ -121,6 +158,22 @@ watch(
const { ipcRenderer } = electron
function messageHandler(ev, { data }) {
if (data.type === 'worker-log' && data.workerId === props.workerId) {
workerLogs.value.push(data.message)
return
}
if (
data.type === 'boss-auto-browse-progress' &&
data.workerId === props.workerId &&
(data.runRecordId == null || data.runRecordId === props.runRecordId)
) {
if (data.phase === 'recommend') {
bossProgress.value.recommend = { current: data.current ?? 0, max: data.max ?? 0 }
} else if (data.phase === 'chatPage') {
bossProgress.value.chatPage = { current: data.current ?? 0, max: data.max ?? 0 }
}
return
}
if (
data.type !== 'prerequisite-step-by-step-checkstep-by-step-check' ||
data.runRecordId !== props.runRecordId
@@ -227,6 +280,13 @@ ipcRenderer.on('worker-exited', (ev, payload) => {
padding: var(--el-dialog-padding-primary);
//border-radius: 0 0 20px 20px;
border-radius: 20px;
.progress-block {
font-size: 13px;
color: #333;
.progress-line {
line-height: 1.6;
}
}
}
}
}

View File

@@ -0,0 +1,441 @@
<template>
<div class="boss-llm-config__wrap">
<div class="page-header">
<div class="page-title">招聘端大语言模型配置</div>
<div class="page-desc">
配置用于简历筛选招呼语生成等功能的 LLM 模型此配置独立于应聘端保存到
<code>boss-llm.json</code>
</div>
</div>
<!-- 模型列表 -->
<div v-if="models.length" class="model-list">
<el-card
v-for="(m, idx) in models"
:key="m.id"
class="model-card"
shadow="hover"
>
<div class="model-card-header">
<div class="model-card-title">
<el-switch v-model="m.enabled" style="margin-right: 8px" />
<el-input
v-model="m.name"
placeholder="模型别名例如DeepSeek-R1 简历筛选)"
class="model-name-input"
size="small"
/>
</div>
<div class="model-card-actions">
<el-button
size="small"
:loading="m._testing"
@click="handleTestEndpoint(m)"
>
测试连接
</el-button>
<el-button
size="small"
type="danger"
text
@click="removeModel(idx)"
>
删除
</el-button>
</div>
</div>
<el-form label-position="top" class="model-form">
<div class="form-row-2">
<el-form-item label="API Base URL">
<el-input v-model="m.baseURL" placeholder="https://api.siliconflow.cn/v1" />
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="m.apiKey" type="password" show-password placeholder="sk-xxx" />
</el-form-item>
</div>
<el-form-item label="Model ID">
<el-input v-model="m.model" placeholder="Pro/deepseek-ai/DeepSeek-R1" />
</el-form-item>
<!-- 推理模型 -->
<el-form-item>
<template #label>
<span>推理模型Thinking</span>
<el-tooltip content="支持 DeepSeek-R1、Qwen3、GLM 推理系列等。开启后 max_tokens 会自动调整为 budget×2 以防输出被截断。" placement="top">
<el-icon style="margin-left: 4px; cursor: help;"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<div class="thinking-row">
<el-checkbox v-model="m.thinking.enabled" label="启用 Thinking" />
<el-input-number
v-if="m.thinking.enabled"
v-model="m.thinking.budget"
:min="256"
:max="32768"
:step="512"
controls-position="right"
style="width: 160px; margin-left: 16px"
/>
<span v-if="m.thinking.enabled" class="form-tip" style="margin-left: 8px">Token 预算</span>
</div>
</el-form-item>
<!-- 测试结果 -->
<el-alert
v-if="m._testResult"
:type="m._testResult.ok ? 'success' : 'error'"
:title="m._testResult.ok ? '连接成功' : `连接失败:${m._testResult.error}`"
show-icon
:closable="false"
style="margin-top: 4px"
/>
</el-form>
</el-card>
</div>
<el-empty v-else description="暂无模型,请添加" />
<!-- 添加模型 -->
<div class="add-model-bar">
<el-button type="primary" plain @click="addModel">+ 添加模型</el-button>
<el-dropdown @command="addPreset">
<el-button plain>
从预设添加 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="p in presets"
:key="p.name"
:command="p"
>
{{ p.name }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<!-- 用途默认模型 -->
<el-card class="section" style="margin-top: 16px">
<div class="section-title">各用途默认模型</div>
<div class="section-desc">当同一用途有多个模型时指定优先使用哪一个</div>
<el-form label-position="top">
<div class="form-row-2">
<el-form-item
v-for="purpose in purposes"
:key="purpose.key"
:label="purpose.label"
>
<el-select
v-model="purposeDefaultModelId[purpose.key]"
clearable
placeholder="(跟随第一个启用的模型)"
style="width: 100%"
>
<el-option
v-for="m in models.filter(m => m.enabled)"
:key="m.id"
:label="m.name || m.model"
:value="m.id"
/>
</el-select>
</el-form-item>
</div>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="action-bar">
<el-button :loading="isSaving" type="primary" @click="handleSave">保存配置</el-button>
<el-button @click="handleClose">关闭</el-button>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { InfoFilled, ArrowDown } from '@element-plus/icons-vue'
const { ipcRenderer } = electron
// ── 数据类型 ─────────────────────────────────────────────────────────────────
interface ThinkingConfig {
enabled: boolean
budget: number
}
interface ModelEntry {
id: string
name: string
baseURL: string
apiKey: string
model: string
enabled: boolean
thinking: ThinkingConfig
// UI 临时状态
_testing?: boolean
_testResult?: { ok: boolean; error?: string } | null
}
// ── 状态 ─────────────────────────────────────────────────────────────────────
const models = ref<ModelEntry[]>([])
const purposeDefaultModelId = ref<Record<string, string>>({})
const isSaving = ref(false)
const purposes = [
{ key: 'resume_screening', label: '简历筛选' },
{ key: 'rubric_generation', label: '自动生成评分标准' },
{ key: 'greeting_generation', label: '招呼语生成' },
{ key: 'message_rewrite', label: '消息续写' },
{ key: 'default', label: '默认' }
]
// ── 预设模板 ─────────────────────────────────────────────────────────────────
const presets = [
{
name: 'SiliconFlow - DeepSeek-R1',
baseURL: 'https://api.siliconflow.cn/v1',
model: 'Pro/deepseek-ai/DeepSeek-R1',
thinking: { enabled: true, budget: 2048 }
},
{
name: 'SiliconFlow - DeepSeek-V3',
baseURL: 'https://api.siliconflow.cn/v1',
model: 'Pro/deepseek-ai/DeepSeek-V3',
thinking: { enabled: false, budget: 2048 }
},
{
name: 'DeepSeek 官方 - DeepSeek-R1',
baseURL: 'https://api.deepseek.com/v1',
model: 'deepseek-reasoner',
thinking: { enabled: false, budget: 2048 }
},
{
name: 'DeepSeek 官方 - DeepSeek-V3',
baseURL: 'https://api.deepseek.com/v1',
model: 'deepseek-chat',
thinking: { enabled: false, budget: 2048 }
},
{
name: '阿里云百炼 - Qwen-Plus',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
model: 'qwen-plus',
thinking: { enabled: false, budget: 2048 }
},
{
name: 'Ollama 本地 - qwen2.5',
baseURL: 'http://localhost:11434/v1',
model: 'qwen2.5:latest',
thinking: { enabled: false, budget: 2048 }
}
]
function newModelEntry(overrides: Partial<ModelEntry> = {}): ModelEntry {
return {
id: crypto.randomUUID(),
name: '',
baseURL: '',
apiKey: '',
model: '',
enabled: true,
thinking: { enabled: false, budget: 2048 },
_testing: false,
_testResult: null,
...overrides
}
}
// ── 生命周期 ─────────────────────────────────────────────────────────────────
onMounted(async () => {
try {
const config = await ipcRenderer.invoke('boss-fetch-llm-config')
models.value = (config?.models ?? []).map((m: any) => ({
...newModelEntry(),
...m,
thinking: { enabled: false, budget: 2048, ...(m.thinking ?? {}) }
}))
purposeDefaultModelId.value = config?.purposeDefaultModelId ?? {}
} catch (err) {
console.error('[BossLlmConfig] 加载配置失败', err)
}
})
// ── CRUD ──────────────────────────────────────────────────────────────────────
function addModel() {
models.value.push(newModelEntry())
}
function addPreset(preset: typeof presets[0]) {
models.value.push(newModelEntry({
name: preset.name,
baseURL: preset.baseURL,
model: preset.model,
thinking: { ...preset.thinking }
} as Partial<ModelEntry>))
}
function removeModel(idx: number) {
models.value.splice(idx, 1)
}
// ── 测试连接 ─────────────────────────────────────────────────────────────────
async function handleTestEndpoint(m: ModelEntry) {
m._testing = true
m._testResult = null
try {
const res = await ipcRenderer.invoke('boss-test-llm-endpoint', {
baseURL: m.baseURL,
apiKey: m.apiKey,
model: m.model,
thinking: m.thinking
})
m._testResult = res
} catch (err: any) {
m._testResult = { ok: false, error: err?.message }
} finally {
m._testing = false
}
}
// ── 保存 ─────────────────────────────────────────────────────────────────────
async function handleSave() {
isSaving.value = true
try {
const config = {
models: models.value.map(({ _testing, _testResult, ...rest }) => rest),
purposeDefaultModelId: purposeDefaultModelId.value
}
await ipcRenderer.invoke('boss-save-llm-config', JSON.stringify(config))
ElMessage({ type: 'success', message: '配置已保存' })
} catch (err: any) {
ElMessage({ type: 'error', message: `保存失败:${err?.message}` })
} finally {
isSaving.value = false
}
}
function handleClose() {
ipcRenderer.send('close-boss-llm-config')
}
</script>
<style lang="scss" scoped>
.boss-llm-config__wrap {
padding: 24px;
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
height: 100vh;
overflow-y: auto;
box-sizing: border-box;
.page-header {
.page-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 6px;
}
.page-desc {
font-size: 13px;
color: #909399;
line-height: 1.7;
}
}
.model-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.model-card {
.model-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
.model-card-title {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
.model-name-input {
flex: 1;
max-width: 340px;
}
}
.model-card-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
}
.model-form {
.form-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
}
.thinking-row {
display: flex;
align-items: center;
}
.form-tip {
font-size: 12px;
color: #909399;
}
}
}
.add-model-bar {
display: flex;
gap: 12px;
align-items: center;
}
.section {
.section-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 6px;
}
.section-desc {
font-size: 12px;
color: #909399;
margin-bottom: 14px;
}
.form-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
}
}
.action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 0 8px;
border-top: 1px solid #ebeef5;
position: sticky;
bottom: 0;
background: #fff;
z-index: 1;
}
}
</style>

View File

@@ -0,0 +1,313 @@
<template>
<div class="boss-auto-browse-and-chat__wrap">
<div class="main__wrap">
<el-form ref="formRef" :model="formContent" label-position="top">
<el-card class="config-section">
<el-form-item label="招呼语(全局默认)" prop="autoChat.greetingMessage">
<el-input
v-model="formContent.autoChat.greetingMessage"
type="textarea"
:autosize="{ minRows: 1 }"
placeholder="向候选人发送的第一条消息(各职位可在「职位配置」页覆盖)"
/>
</el-form-item>
<el-form-item label="每次最多开聊人数(全局默认)" prop="autoChat.maxChatPerRun">
<el-input-number
v-model="formContent.autoChat.maxChatPerRun"
:min="1"
:max="200"
controls-position="right"
/>
<div class="form-tip">单轮运行中最多向多少人发送招呼各职位可在职位配置页覆盖</div>
</el-form-item>
<el-form-item label="两次开聊间隔(毫秒)">
<div class="range-input-wrap">
<el-input-number
v-model="formContent.autoChat.delayBetweenChats[0]"
:min="0"
controls-position="right"
placeholder="最小值"
/>
<span class="range-sep">~</span>
<el-input-number
v-model="formContent.autoChat.delayBetweenChats[1]"
:min="0"
controls-position="right"
placeholder="最大值"
/>
</div>
</el-form-item>
</el-card>
<el-card class="config-section">
<el-form-item mb0>
<div class="section-title">推荐页运行策略</div>
</el-form-item>
<el-form-item>
<el-checkbox v-model="formContent.recommendRunOnceAfterComplete">
单轮运行完成后停止不再自动重启
</el-checkbox>
</el-form-item>
<el-form-item>
<el-checkbox v-model="formContent.recommendClickNotInterestedForFiltered">
对未通过筛选的候选人自动点击"不感兴趣"
</el-checkbox>
</el-form-item>
<el-form-item>
<el-checkbox v-model="formContent.recommendSkipViewedCandidates">
跳过已读候选人卡片 has-viewed
</el-checkbox>
</el-form-item>
<el-form-item label="两轮之间的等待间隔(毫秒,反检测)">
<el-input-number
v-model="formContent.recommendRerunIntervalMs"
:min="1000"
controls-position="right"
placeholder="默认 3000"
/>
</el-form-item>
<el-form-item label='每次点击"不感兴趣"之间的间隔(毫秒,随机[min,max],反检测)'>
<div class="range-input-wrap">
<el-input-number
v-model="formContent.recommendDelayBetweenNotInterestedMs[0]"
:min="300"
controls-position="right"
placeholder="最小"
/>
<span class="range-sep">~</span>
<el-input-number
v-model="formContent.recommendDelayBetweenNotInterestedMs[1]"
:min="300"
controls-position="right"
placeholder="最大"
/>
</div>
</el-form-item>
<el-form-item>
<el-checkbox v-model="formContent.recommendKeepBrowserOpenAfterRun">
单轮结束后保持浏览器打开仅招聘端推荐页需同时勾选"单轮运行完成后停止"关闭浏览器窗口后自动退出
</el-checkbox>
</el-form-item>
</el-card>
<div class="action-bar">
<el-button :loading="isSaving" @click="handleSave">仅保存配置</el-button>
<el-button type="primary" :loading="isSaving" @click="handleSubmit">
保存配置并开始招聘
</el-button>
</div>
</el-form>
</div>
<!-- RunningOverlay -->
<div
class="running-overlay__wrap"
:style="{
pointerEvents: 'none'
}"
>
<RunningOverlay
ref="runningOverlayRef"
worker-id="bossRecommendMain"
:run-record-id="runRecordId"
:get-steps="getBossAutoBrowseSteps"
>
<template #op-buttons="{ currentRunningStatus }">
<div>
<template v-if="currentRunningStatus === RUNNING_STATUS_ENUM.RUNNING">
<el-button
type="danger"
plain
:loading="isStopButtonLoading"
@click="handleStopButtonClick"
>结束任务</el-button
>
</template>
<template v-else>
<el-button
type="primary"
@click="
() => {
runningOverlayRef?.hide?.()
}
"
>关闭</el-button
>
</template>
</div>
</template>
</RunningOverlay>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import RunningOverlay from '@renderer/features/RunningOverlay/index.vue'
import { RUNNING_STATUS_ENUM } from '../../../../../common/enums/auto-start-chat'
import { getBossAutoBrowseSteps } from '../../../../../common/prerequisite-step-by-step-check'
const { ipcRenderer } = electron
const formRef = ref()
const isSaving = ref(false)
const runRecordId = ref<number | null>(null)
const runningOverlayRef = ref<InstanceType<typeof RunningOverlay> | null>(null)
const isStopButtonLoading = ref(false)
const formContent = reactive({
autoChat: {
greetingMessage: '',
maxChatPerRun: 50,
delayBetweenChats: [3000, 8000] as [number, number]
},
recommendRunOnceAfterComplete: false,
recommendClickNotInterestedForFiltered: true,
recommendSkipViewedCandidates: false,
recommendRerunIntervalMs: 3000,
recommendDelayBetweenNotInterestedMs: [800, 2500] as [number, number],
recommendKeepBrowserOpenAfterRun: false
})
onMounted(async () => {
try {
const result = await ipcRenderer.invoke('fetch-boss-recruiter-config-file-content')
const recruiterConfig = result?.config?.['boss-recruiter.json'] || {}
formContent.autoChat.greetingMessage = recruiterConfig.autoChat?.greetingMessage ?? ''
formContent.autoChat.maxChatPerRun = recruiterConfig.autoChat?.maxChatPerRun ?? 50
formContent.autoChat.delayBetweenChats = recruiterConfig.autoChat?.delayBetweenChats ?? [
3000, 8000
]
const recommendPage = recruiterConfig.recommendPage ?? {}
formContent.recommendRunOnceAfterComplete = recommendPage.runOnceAfterComplete ?? false
formContent.recommendClickNotInterestedForFiltered =
recommendPage.clickNotInterestedForFiltered ?? true
formContent.recommendSkipViewedCandidates = recommendPage.skipViewedCandidates ?? false
formContent.recommendRerunIntervalMs = recommendPage.rerunIntervalMs ?? 3000
formContent.recommendDelayBetweenNotInterestedMs =
Array.isArray(recommendPage.delayBetweenNotInterestedMs) &&
recommendPage.delayBetweenNotInterestedMs.length >= 2
? recommendPage.delayBetweenNotInterestedMs
: [800, 2500]
formContent.recommendKeepBrowserOpenAfterRun =
recommendPage.keepBrowserOpenAfterRun ?? false
} catch (err) {
console.error(err)
}
})
const doSave = async () => {
const payload = {
autoChat: {
greetingMessage: formContent.autoChat.greetingMessage,
maxChatPerRun: formContent.autoChat.maxChatPerRun,
delayBetweenChats: formContent.autoChat.delayBetweenChats
},
recommendPage: {
runOnceAfterComplete: formContent.recommendRunOnceAfterComplete,
clickNotInterestedForFiltered: formContent.recommendClickNotInterestedForFiltered,
skipViewedCandidates: formContent.recommendSkipViewedCandidates,
rerunIntervalMs: formContent.recommendRerunIntervalMs,
delayBetweenNotInterestedMs: formContent.recommendDelayBetweenNotInterestedMs,
keepBrowserOpenAfterRun: formContent.recommendKeepBrowserOpenAfterRun
}
}
await ipcRenderer.invoke('save-boss-recruiter-config', JSON.stringify(payload))
}
const handleSave = async () => {
isSaving.value = true
try {
await doSave()
ElMessage({ type: 'success', message: '配置已保存' })
} catch (err) {
ElMessage({ type: 'error', message: '保存失败' })
console.error(err)
} finally {
isSaving.value = false
}
}
const handleSubmit = async () => {
isSaving.value = true
try {
await doSave()
runningOverlayRef.value?.show()
const { runRecordId: rrId } = await ipcRenderer.invoke('run-boss-recommend')
runRecordId.value = rrId
} catch (err) {
console.error(err)
} finally {
isSaving.value = false
}
}
const handleStopButtonClick = async () => {
isStopButtonLoading.value = true
try {
await ipcRenderer.invoke('stop-boss-recommend')
runningOverlayRef.value?.hide()
} finally {
isStopButtonLoading.value = false
}
}
</script>
<style lang="scss" scoped>
.boss-auto-browse-and-chat__wrap {
width: 100%;
height: 100%;
overflow: auto;
position: relative;
.main__wrap {
padding: 24px;
max-width: 800px;
margin: 0 auto;
}
.config-section {
margin-bottom: 16px;
.section-title {
font-size: 14px;
font-weight: 500;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.4;
}
}
.range-input-wrap {
display: flex;
align-items: center;
gap: 8px;
.range-sep {
color: #999;
}
}
.action-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 0;
}
}
.running-overlay__wrap {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,214 @@
<template>
<div class="boss-auto-sequence__wrap">
<div class="main__wrap">
<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-column label="执行推荐牛人" width="120" align="center">
<template #default="{ row }">
<el-checkbox v-model="row.sequence.runRecommend" :disabled="!row.sequence.enabled" />
</template>
</el-table-column>
<el-table-column label="执行沟通页" width="110" align="center">
<template #default="{ row }">
<el-checkbox v-model="row.sequence.runChat" :disabled="!row.sequence.enabled" />
</template>
</el-table-column>
</el-table>
<div class="queue-save-bar">
<el-button :loading="isSavingQueue" @click="handleSaveQueue">保存队列配置</el-button>
</div>
</template>
</el-card>
<el-card class="config-section">
<template #header>
<span>自动顺序执行</span>
</template>
<p class="desc">
依次执行推荐牛人 - 自动开聊沟通页两个任务配置分别在对应页面中设置
</p>
<div class="action-bar">
<el-button type="primary" :loading="isSaving" @click="handleSubmit">
开始自动顺序执行
</el-button>
</div>
</el-card>
</div>
<div
class="running-overlay__wrap"
:style="{
pointerEvents: 'none'
}"
>
<RunningOverlay
ref="runningOverlayRef"
worker-id="bossAutoBrowseAndChatMain"
:run-record-id="runRecordId"
:get-steps="getBossAutoBrowseSteps"
>
<template #op-buttons="{ currentRunningStatus }">
<div>
<template v-if="currentRunningStatus === RUNNING_STATUS_ENUM.RUNNING">
<el-button
type="danger"
plain
:loading="isStopButtonLoading"
@click="handleStopButtonClick"
>结束任务</el-button
>
</template>
<template v-else>
<el-button
type="primary"
@click="
() => {
runningOverlayRef?.hide?.()
}
"
>关闭</el-button
>
</template>
</div>
</template>
</RunningOverlay>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import RunningOverlay from '@renderer/features/RunningOverlay/index.vue'
import { RUNNING_STATUS_ENUM } from '../../../../../common/enums/auto-start-chat'
import { getBossAutoBrowseSteps } from '../../../../../common/prerequisite-step-by-step-check'
const { ipcRenderer } = electron
const isSaving = ref(false)
const runRecordId = ref<number | null>(null)
const runningOverlayRef = ref<InstanceType<typeof RunningOverlay> | null>(null)
const isStopButtonLoading = ref(false)
// ---- 职位执行队列 ----
interface JobSequenceItem {
jobId: string
jobName: string
sequence: { enabled: boolean; runRecommend: boolean; runChat: boolean }
[key: string]: unknown
}
const jobsList = ref<JobSequenceItem[]>([])
const isSavingQueue = ref(false)
onMounted(async () => {
try {
const result = await ipcRenderer.invoke('fetch-boss-jobs-config')
jobsList.value = result?.jobs ?? []
} catch (err) {
console.error(err)
}
})
const handleSaveQueue = async () => {
isSavingQueue.value = true
try {
await ipcRenderer.invoke('save-boss-jobs-config', JSON.stringify({ jobs: jobsList.value }))
ElMessage({ type: 'success', message: '队列配置已保存' })
} catch (err) {
ElMessage({ type: 'error', message: '保存失败' })
console.error(err)
} finally {
isSavingQueue.value = false
}
}
const handleSubmit = async () => {
isSaving.value = true
try {
runningOverlayRef.value?.show()
const { runRecordId: rrId } = await ipcRenderer.invoke('run-boss-auto-browse-and-chat')
runRecordId.value = rrId
} catch (err) {
console.error(err)
} finally {
isSaving.value = false
}
}
const handleStopButtonClick = async () => {
isStopButtonLoading.value = true
try {
await ipcRenderer.invoke('stop-boss-auto-browse-and-chat')
runningOverlayRef.value?.hide()
} finally {
isStopButtonLoading.value = false
}
}
</script>
<style lang="scss" scoped>
.boss-auto-sequence__wrap {
width: 100%;
height: 100%;
overflow: auto;
position: relative;
.main__wrap {
padding: 24px;
max-width: 800px;
margin: 0 auto;
}
.config-section {
margin-bottom: 16px;
}
.desc {
margin: 0 0 1em;
font-size: 14px;
color: #606266;
line-height: 1.6;
}
.queue-save-bar {
display: flex;
align-items: center;
padding: 12px 0 0;
}
.action-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0 0;
}
}
.running-overlay__wrap {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,220 @@
<template>
<div class="boss-chat-page__wrap">
<div class="main__wrap">
<el-form ref="formRef" :model="formContent" label-position="top">
<el-card class="config-section">
<el-form-item mb0>
<div class="section-title">沟通页运行策略</div>
</el-form-item>
<el-form-item label="每次最多处理未读会话数">
<el-input-number
v-model="formContent.chatPage.maxProcessPerRun"
:min="1"
:max="100"
controls-position="right"
/>
</el-form-item>
<el-form-item>
<el-checkbox v-model="formContent.chatPage.runOnceAfterComplete">
单轮运行完成后停止不再自动重启
</el-checkbox>
</el-form-item>
<el-form-item>
<el-checkbox v-model="formContent.chatPage.keepBrowserOpenAfterRun">
单轮结束后保持浏览器打开需同时勾选单轮运行完成后停止关闭浏览器后自动退出
</el-checkbox>
</el-form-item>
<el-form-item label="两轮之间的等待间隔(毫秒)">
<el-input-number
v-model="formContent.chatPage.rerunIntervalMs"
:min="1000"
controls-position="right"
placeholder="默认 3000"
/>
</el-form-item>
</el-card>
<el-alert
title="候选人筛选条件及简历筛选规则请在「职位配置」页面按职位配置"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<div class="action-bar">
<el-button :loading="isSaving" @click="handleSave">仅保存配置</el-button>
<el-button type="primary" :loading="isSaving" @click="handleSubmit">
保存配置并开始处理沟通页
</el-button>
</div>
</el-form>
</div>
<div
class="running-overlay__wrap"
:style="{
pointerEvents: 'none'
}"
>
<RunningOverlay
ref="runningOverlayRef"
worker-id="bossChatPageMain"
:run-record-id="runRecordId"
:get-steps="getBossAutoBrowseSteps"
>
<template #op-buttons="{ currentRunningStatus }">
<div>
<template v-if="currentRunningStatus === RUNNING_STATUS_ENUM.RUNNING">
<el-button
type="danger"
plain
:loading="isStopButtonLoading"
@click="handleStopButtonClick"
>结束任务</el-button
>
</template>
<template v-else>
<el-button
type="primary"
@click="
() => {
runningOverlayRef?.hide?.()
}
"
>关闭</el-button
>
</template>
</div>
</template>
</RunningOverlay>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import RunningOverlay from '@renderer/features/RunningOverlay/index.vue'
import { RUNNING_STATUS_ENUM } from '../../../../../common/enums/auto-start-chat'
import { getBossAutoBrowseSteps } from '../../../../../common/prerequisite-step-by-step-check'
const { ipcRenderer } = electron
const formRef = ref()
const isSaving = ref(false)
const runRecordId = ref<number | null>(null)
const runningOverlayRef = ref<InstanceType<typeof RunningOverlay> | null>(null)
const isStopButtonLoading = ref(false)
const formContent = reactive({
chatPage: {
maxProcessPerRun: 20,
runOnceAfterComplete: false,
keepBrowserOpenAfterRun: false,
rerunIntervalMs: 3000
}
})
onMounted(async () => {
try {
const result = await ipcRenderer.invoke('fetch-boss-recruiter-config-file-content')
const recruiterConfig = result?.config?.['boss-recruiter.json'] || {}
const chatPage = recruiterConfig.chatPage ?? {}
formContent.chatPage.maxProcessPerRun = chatPage.maxProcessPerRun ?? 20
formContent.chatPage.runOnceAfterComplete = chatPage.runOnceAfterComplete ?? false
formContent.chatPage.keepBrowserOpenAfterRun = chatPage.keepBrowserOpenAfterRun ?? false
formContent.chatPage.rerunIntervalMs = chatPage.rerunIntervalMs ?? 3000
} catch (err) {
console.error(err)
}
})
const doSave = async () => {
const payload = {
chatPage: {
maxProcessPerRun: formContent.chatPage.maxProcessPerRun,
runOnceAfterComplete: formContent.chatPage.runOnceAfterComplete,
keepBrowserOpenAfterRun: formContent.chatPage.keepBrowserOpenAfterRun,
rerunIntervalMs: formContent.chatPage.rerunIntervalMs
}
}
await ipcRenderer.invoke('save-boss-recruiter-config', JSON.stringify(payload))
}
const handleSave = async () => {
isSaving.value = true
try {
await doSave()
ElMessage({ type: 'success', message: '配置已保存' })
} catch (err) {
ElMessage({ type: 'error', message: '保存失败' })
console.error(err)
} finally {
isSaving.value = false
}
}
const handleSubmit = async () => {
isSaving.value = true
try {
await doSave()
runningOverlayRef.value?.show()
const { runRecordId: rrId } = await ipcRenderer.invoke('run-boss-chat-page')
runRecordId.value = rrId
} catch (err) {
console.error(err)
} finally {
isSaving.value = false
}
}
const handleStopButtonClick = async () => {
isStopButtonLoading.value = true
try {
await ipcRenderer.invoke('stop-boss-chat-page')
runningOverlayRef.value?.hide()
} finally {
isStopButtonLoading.value = false
}
}
</script>
<style lang="scss" scoped>
.boss-chat-page__wrap {
width: 100%;
height: 100%;
overflow: auto;
position: relative;
.main__wrap {
padding: 24px;
max-width: 800px;
margin: 0 auto;
}
.config-section {
margin-bottom: 16px;
.section-title {
font-size: 14px;
font-weight: 500;
}
}
.action-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 0;
}
}
.running-overlay__wrap {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,847 @@
<template>
<div class="debug-tool__wrap">
<div class="main__wrap">
<!-- 顶部控制栏 -->
<el-card class="section">
<div class="section-title">招聘端调试工具</div>
<div class="section-desc">
启动浏览器并打开沟通页在右侧手动选中一条会话再用下方按钮测试各项功能<br />
<strong>Tab A</strong> 需要浏览器已就绪<strong>Tab BLLM 筛选</strong>运行评估生成 Rubric不需要浏览器
</div>
<div class="action-bar">
<el-button
type="primary"
:loading="isLaunching"
:disabled="isReady"
@click="handleLaunch"
>
{{ isReady ? '浏览器已启动' : '启动浏览器' }}
</el-button>
<el-button :disabled="!isReady" @click="handleClose">关闭浏览器</el-button>
<el-tag v-if="isReady" type="success">已就绪</el-tag>
<el-tag v-else-if="isLaunching" type="warning">启动中...</el-tag>
<el-tag v-else type="info">未启动</el-tag>
</div>
</el-card>
<!-- Tab 切换 -->
<el-tabs v-model="activeTab" class="debug-tabs">
<!-- Tab A: 简历操作 -->
<el-tab-pane label="简历操作" name="resume">
<el-card class="section" :class="{ disabled: !isReady }">
<div class="section-title">当前会话操作</div>
<div class="cmd-grid">
<div v-for="cmd in commands" :key="cmd.type" class="cmd-item">
<el-button
:loading="runningCmd === cmd.type"
:disabled="!isReady || (runningCmd !== null && runningCmd !== cmd.type)"
@click="handleCmd(cmd.type)"
>
{{ cmd.label }}
</el-button>
<span v-if="results[cmd.type]" class="cmd-result" :class="results[cmd.type].ok ? 'ok' : 'err'">
{{ results[cmd.type].text }}
</span>
</div>
</div>
</el-card>
</el-tab-pane>
<!-- Tab B: LLM 筛选 -->
<el-tab-pane label="LLM 筛选" name="llm">
<!-- 区域 1生成 Rubric工作流起点 -->
<el-card class="section">
<div class="section-title">区域 1生成 Rubric</div>
<div class="section-desc">
输入 JD 自动生成评分标准生成后可直接编辑 JSON再点用于评估传到区域 2
</div>
<div class="llm-label">岗位描述JD</div>
<el-input
v-model="generateJd"
type="textarea"
:rows="5"
placeholder="粘贴岗位描述、招聘要求或标杆简历片段..."
/>
<div style="display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap;">
<el-button :loading="isGenerating" type="primary" plain @click="handleGenerateRubric">
生成 Rubric
</el-button>
<template v-if="generatedRubricJson">
<el-button @click="copyGeneratedRubric">📋 复制 JSON</el-button>
<el-button type="success" plain @click="useGeneratedRubricForEval">
用于评估
</el-button>
<el-divider direction="vertical" />
<el-select
v-model="applyTargetJobId"
placeholder="选择目标职位..."
clearable
style="width: 200px"
size="small"
>
<el-option
v-for="j in allJobs"
:key="j.jobId"
:label="j.jobName"
:value="j.jobId"
/>
</el-select>
<el-button
:disabled="!applyTargetJobId"
:loading="isApplying"
type="primary"
size="small"
@click="handleApplyRubricToJob"
>
应用到职位配置
</el-button>
</template>
</div>
<template v-if="generatedRubricJson !== null">
<div class="llm-label" style="margin-top: 10px;">
生成结果可编辑
<el-text size="small" type="info" style="margin-left: 6px;">修改后点用于评估即时生效</el-text>
</div>
<el-input
v-model="generatedRubricJson"
type="textarea"
:rows="12"
/>
</template>
</el-card>
<!-- 区域 2提取简历文本 -->
<el-card class="section">
<div class="section-title">区域 2提取简历文本</div>
<div class="section-desc">需浏览器已就绪且已在沟通页选中一条会话</div>
<el-button
:loading="isExtractingText"
:disabled="!isReady || isExtractingText"
type="primary"
plain
@click="handleExtractResumeText"
>
📄 提取当前简历文本
</el-button>
<template v-if="extractedResumeText">
<div class="llm-label" style="margin-top: 10px;">
提取结果{{ extractedResumeText.length }}
</div>
<el-input
:model-value="extractedResumeText"
type="textarea"
:rows="6"
readonly
class="extracted-text"
/>
</template>
</el-card>
<!-- 区域 3运行 Rubric 评估 -->
<el-card class="section">
<div class="section-title">区域 3运行 Rubric 评估</div>
<div class="section-desc">不需要浏览器直接调用 LLM API需已配置 <code>boss-llm.json</code></div>
<!-- Rubric 来源选择 -->
<div class="llm-label">Rubric 来源</div>
<el-radio-group v-model="screenRubricSource" class="rubric-source-group">
<el-radio value="job">从职位配置读取</el-radio>
<el-radio value="manual">手动填写 JSON</el-radio>
</el-radio-group>
<!-- 职位选择 -->
<template v-if="screenRubricSource === 'job'">
<div class="llm-label" style="margin-top: 10px;">选择职位</div>
<el-select
v-model="screenJobId"
placeholder="选择已配置 resumeLlmEnabled 的职位"
style="width: 100%"
@change="loadJobRubricPreview"
>
<el-option
v-for="j in llmEnabledJobs"
:key="j.jobId"
:label="j.jobName"
:value="j.jobId"
/>
</el-select>
<div v-if="jobRubricPreview" class="rubric-preview">
<div class="rubric-preview-title">Rubric 预览</div>
<pre class="rubric-pre">{{ jobRubricPreview }}</pre>
</div>
<el-alert v-else-if="screenJobId" type="warning" show-icon :closable="false" style="margin-top: 8px">
该职位未配置 resumeLlmConfig.rubric请先在职位配置页面生成
</el-alert>
</template>
<!-- 手动填写 -->
<template v-else>
<div class="llm-label" style="margin-top: 10px;">
Rubric JSON
<el-text size="small" type="info">
格式: {"knockouts":[],"dimensions":[],"passThreshold":75}
</el-text>
<el-button
v-if="generatedRubricJson"
size="small"
text
style="margin-left: 8px"
@click="manualRubricJson = generatedRubricJson"
>
从区域 1 填入
</el-button>
</div>
<el-input
v-model="manualRubricJson"
type="textarea"
:rows="5"
placeholder='{"knockouts":["必须拥有X经验"],"dimensions":[{"name":"技术能力","weight":100,"criteria":{"1":"无","3":"有","5":"精通"}}],"passThreshold":75}'
/>
</template>
<!-- 简历文本 -->
<div class="llm-label" style="margin-top: 12px;">简历文本来自区域 2 或手动粘贴</div>
<el-input
v-model="screenResumeText"
type="textarea"
:rows="5"
placeholder="简历全文..."
/>
<div v-if="extractedResumeText && !screenResumeText" class="form-tip">
点击使用区域 2 文本快速填入
<el-button size="small" text @click="screenResumeText = extractedResumeText">使用区域 2 文本</el-button>
</div>
<el-button
:loading="isScreening"
type="primary"
style="margin-top: 10px"
@click="handleLlmScreen"
>
🤖 运行 LLM 评估
</el-button>
<!-- 评估结果 -->
<template v-if="screenResult">
<div class="screen-result" :class="screenResult.isPassed ? 'passed' : 'failed'">
<div class="screen-result-status">
{{ screenResult.isPassed ? '✅ 通过' : '❌ 未通过' }}
<span class="screen-score">{{ screenResult.totalScore }} / 100 </span>
</div>
<!-- 分维度得分 -->
<div v-if="screenResult.dimensionResults?.length" class="dimension-scores">
<div
v-for="d in screenResult.dimensionResults"
:key="d.name"
class="dim-row"
>
<span class="dim-name">{{ d.name }}</span>
<el-progress
:percentage="Math.round((d.score / 5) * 100)"
:color="dimColor(d.score)"
:stroke-width="8"
style="flex: 1; min-width: 80px;"
/>
<span class="dim-score">{{ d.score }}/5</span>
</div>
</div>
<div class="screen-reason">{{ screenResult.reason }}</div>
</div>
</template>
</el-card>
</el-tab-pane>
</el-tabs>
<!-- 统一操作日志 -->
<el-card class="section log-section">
<div class="section-title">
操作日志
<el-button size="small" text @click="logs = []">清空</el-button>
</div>
<div ref="logContainerRef" class="log-content">
<div v-for="(line, i) in logs" :key="i" class="log-line" :class="line.type">
<span class="log-time">{{ line.time }}</span>
<span class="log-msg">{{ line.msg }}</span>
</div>
<div v-if="logs.length === 0" class="log-empty">暂无日志</div>
</div>
</el-card>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, nextTick, onMounted, onUnmounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
const { ipcRenderer } = electron
// ── 浏览器状态 ──────────────────────────────────────────────────────────────
const isLaunching = ref(false)
const isReady = ref(false)
const runningCmd = ref<string | null>(null)
const activeTab = ref<'resume' | 'llm'>('resume')
// ── 日志 ────────────────────────────────────────────────────────────────────
type LogLine = { time: string; msg: string; type: 'info' | 'ok' | 'err' }
const logs = ref<LogLine[]>([])
const logContainerRef = ref<HTMLElement | null>(null)
type CmdResult = { ok: boolean; text: string }
const results = ref<Record<string, CmdResult>>({})
// ── Tab A 命令列表 ──────────────────────────────────────────────────────────
const commands = [
{ type: 'get-panel-name', label: '获取当前面板姓名' },
{ type: 'dismiss-intent-dialog', label: '关闭「意向沟通」弹窗' },
{ type: 'close-online-resume', label: '关闭在线简历弹窗' },
{ type: 'open-online-resume', label: '打开在线简历' },
{ type: 'check-attach-resume', label: '检查附件简历(是否有「点击预览」)' },
{ type: 'accept-incoming-attach-resume', label: '同意对方发来的附件请求(仅当出现「是否同意」时)' },
{ type: 'request-attach-resume', label: '请求附件简历' },
{ type: 'download-attach-resume', label: '预览并下载附件简历' },
{ type: 'ping', label: 'Ping探活' },
]
// ── Tab B 状态 ──────────────────────────────────────────────────────────────
// 区域 1生成 Rubric
const generateJd = ref('')
const isGenerating = ref(false)
const generatedRubricJson = ref<string | null>(null)
const applyTargetJobId = ref('')
const isApplying = ref(false)
const allJobs = ref<{ jobId: string; jobName: string }[]>([])
// 区域 2提取简历文本
const isExtractingText = ref(false)
const extractedResumeText = ref('')
// 区域 3运行评估
const screenRubricSource = ref<'job' | 'manual'>('manual')
const screenJobId = ref('')
const screenResumeText = ref('')
const manualRubricJson = ref('')
const isScreening = ref(false)
type DimResult = { name: string; score: number; weight: number }
const screenResult = ref<{
isPassed: boolean
totalScore: number
reason: string
dimensionResults?: DimResult[]
} | null>(null)
const jobRubricPreview = ref('')
const llmEnabledJobs = computed(() => allJobs.value.filter((j: any) => (j as any).filter?.resumeLlmEnabled))
// ── 通用 ─────────────────────────────────────────────────────────────────────
function addLog(msg: string, type: LogLine['type'] = 'info') {
const now = new Date()
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
logs.value.push({ time, msg, type })
nextTick(() => {
if (logContainerRef.value) logContainerRef.value.scrollTop = logContainerRef.value.scrollHeight
})
}
function dimColor(score: number) {
if (score >= 4) return '#67c23a'
if (score >= 3) return '#e6a23c'
return '#f56c6c'
}
// ── 生命周期 / IPC 监听 ───────────────────────────────────────────────────────
ipcRenderer.on('boss-chat-debug-exited', () => {
isReady.value = false
addLog('浏览器已关闭', 'err')
})
onMounted(async () => {
try {
const result = await ipcRenderer.invoke('fetch-boss-jobs-config')
allJobs.value = (result?.jobs ?? []).map((j: any) => ({
jobId: j.jobId ?? j.id,
jobName: j.jobName ?? j.name ?? j.jobId ?? j.id,
filter: j.filter
}))
} catch {
// 忽略
}
})
onUnmounted(() => {
ipcRenderer.removeAllListeners('boss-chat-debug-exited')
})
// ── 浏览器控制 ────────────────────────────────────────────────────────────────
const handleLaunch = async () => {
isLaunching.value = true
addLog('正在启动浏览器...')
try {
const res = await ipcRenderer.invoke('open-boss-chat-debug')
if (res?.ok) {
isReady.value = true
addLog(res.alreadyRunning ? '浏览器已在运行' : '浏览器启动成功,已打开沟通页', 'ok')
} else {
addLog(`启动失败: ${res?.error ?? '未知错误'}`, 'err')
ElMessage({ type: 'error', message: `启动失败: ${res?.error}` })
}
} catch (err: any) {
addLog(`启动异常: ${err?.message}`, 'err')
ElMessage({ type: 'error', message: err?.message })
} finally {
isLaunching.value = false
}
}
const handleClose = async () => {
await ipcRenderer.invoke('close-boss-chat-debug')
isReady.value = false
addLog('已关闭浏览器')
}
// ── Tab A命令执行 ───────────────────────────────────────────────────────────
const handleCmd = async (type: string) => {
runningCmd.value = type
results.value[type] = { ok: true, text: '执行中...' }
addLog(`${type}`)
try {
const res = await ipcRenderer.invoke('boss-debug-command', { type })
const ok = res?.ok === true
const detail = JSON.stringify(res?.result ?? res?.error ?? '')
results.value[type] = { ok, text: ok ? `${detail}` : `${res?.error ?? detail}` }
addLog(`${type}: ${ok ? '成功' : '失败'} ${detail}`, ok ? 'ok' : 'err')
} catch (err: any) {
results.value[type] = { ok: false, text: `${err?.message}` }
addLog(`${type}: 异常 ${err?.message}`, 'err')
} finally {
runningCmd.value = null
}
}
// ── Tab B区域 2 — 提取简历文本 ──────────────────────────────────────────────
const handleExtractResumeText = async () => {
isExtractingText.value = true
addLog('→ extract-resume-text通过浏览器提取简历')
try {
const res = await ipcRenderer.invoke('boss-debug-command', { type: 'extract-resume-text' })
if (res?.ok) {
extractedResumeText.value = res.result?.resumeText ?? ''
screenResumeText.value = extractedResumeText.value
addLog(`← 提取成功,共 ${res.result?.charCount ?? 0}`, 'ok')
} else {
addLog(`← 提取失败: ${res?.error}`, 'err')
ElMessage({ type: 'error', message: `提取失败: ${res?.error}` })
}
} catch (err: any) {
addLog(`← 提取异常: ${err?.message}`, 'err')
ElMessage({ type: 'error', message: err?.message })
} finally {
isExtractingText.value = false
}
}
// ── Tab B区域 3 — Rubric 来源 ───────────────────────────────────────────────
function loadJobRubricPreview(jobId: string) {
jobRubricPreview.value = ''
const job = allJobs.value.find((j: any) => j.jobId === jobId)
const rubric = (job as any)?.filter?.resumeLlmConfig?.rubric
if (rubric) {
jobRubricPreview.value = JSON.stringify(
{
knockouts: rubric.knockouts,
dimensions: rubric.dimensions,
passThreshold: (job as any)?.filter?.resumeLlmConfig?.passThreshold ?? 75
},
null,
2
)
}
}
// ── Tab B区域 3 — 运行 LLM 评估 ─────────────────────────────────────────────
const handleLlmScreen = async () => {
if (!screenResumeText.value.trim()) {
ElMessage({ type: 'warning', message: '请先填入简历文本' })
return
}
isScreening.value = true
screenResult.value = null
addLog('→ llm-screen-resume')
let payload: Record<string, any> = { resumeText: screenResumeText.value }
if (screenRubricSource.value === 'job') {
if (!screenJobId.value) {
ElMessage({ type: 'warning', message: '请选择职位' })
isScreening.value = false
return
}
payload.jobId = screenJobId.value
} else {
try {
payload.rubric = JSON.parse(manualRubricJson.value)
} catch {
ElMessage({ type: 'error', message: 'Rubric JSON 格式错误,请检查' })
isScreening.value = false
return
}
}
try {
const res = await ipcRenderer.invoke('llm-screen-resume', payload)
if (res?.ok === false) {
addLog(`← LLM 评估失败: ${res.error}`, 'err')
ElMessage({ type: 'error', message: res.error })
} else {
screenResult.value = {
isPassed: res.isPassed,
totalScore: res.totalScore,
reason: res.reason,
dimensionResults: res.dimensionResults
}
addLog(
`← LLM 评估完成:${res.isPassed ? '通过' : '未通过'} ${res.totalScore}${res.reason}`,
res.isPassed ? 'ok' : 'err'
)
}
} catch (err: any) {
addLog(`← LLM 评估异常: ${err?.message}`, 'err')
ElMessage({ type: 'error', message: err?.message })
} finally {
isScreening.value = false
}
}
// ── Tab B区域 1 — 生成 Rubric ────────────────────────────────────────────────
const handleGenerateRubric = async () => {
if (!generateJd.value.trim()) {
ElMessage({ type: 'warning', message: '请先输入岗位描述' })
return
}
isGenerating.value = true
generatedRubricJson.value = null
addLog('→ generate-llm-rubric')
try {
const res = await ipcRenderer.invoke('generate-llm-rubric', { sourceJd: generateJd.value })
if (res?.rubric) {
generatedRubricJson.value = JSON.stringify(
{ ...res.rubric, passThreshold: 75 },
null,
2
)
addLog(`← 生成成功:${res.rubric.knockouts?.length ?? 0} 个否决项,${res.rubric.dimensions?.length ?? 0} 个维度`, 'ok')
} else {
addLog('← 生成失败,请检查 LLM 配置', 'err')
ElMessage({ type: 'error', message: '生成失败,请检查 boss-llm.json 配置' })
}
} catch (err: any) {
addLog(`← 生成异常: ${err?.message}`, 'err')
ElMessage({ type: 'error', message: err?.message })
} finally {
isGenerating.value = false
}
}
async function copyGeneratedRubric() {
try {
await navigator.clipboard.writeText(generatedRubricJson.value ?? '')
ElMessage({ type: 'success', message: '已复制到剪贴板' })
} catch {
ElMessage({ type: 'error', message: '复制失败,请手动选中文本复制' })
}
}
function useGeneratedRubricForEval() {
if (!generatedRubricJson.value) return
manualRubricJson.value = generatedRubricJson.value
screenRubricSource.value = 'manual'
ElMessage({ type: 'success', message: '已填入区域 3切换为手动模式' })
}
async function handleApplyRubricToJob() {
if (!applyTargetJobId.value || !generatedRubricJson.value) return
let rubric: any
try {
rubric = JSON.parse(generatedRubricJson.value)
} catch {
ElMessage({ type: 'error', message: 'Rubric JSON 格式错误' })
return
}
isApplying.value = true
addLog(`→ 应用 Rubric 到职位 ${applyTargetJobId.value}`)
try {
const res = await ipcRenderer.invoke('apply-rubric-to-job', {
jobId: applyTargetJobId.value,
rubric: { knockouts: rubric.knockouts, dimensions: rubric.dimensions },
passThreshold: rubric.passThreshold ?? 75
})
if (res?.ok) {
addLog(`← 应用成功`, 'ok')
ElMessage({ type: 'success', message: '已成功应用到职位配置' })
// refresh jobs list
const result = await ipcRenderer.invoke('fetch-boss-jobs-config')
allJobs.value = (result?.jobs ?? []).map((j: any) => ({
jobId: j.jobId ?? j.id,
jobName: j.jobName ?? j.name ?? j.jobId ?? j.id,
filter: j.filter
}))
} else {
addLog(`← 应用失败: ${res?.error}`, 'err')
ElMessage({ type: 'error', message: res?.error ?? '应用失败' })
}
} catch (err: any) {
addLog(`← 应用异常: ${err?.message}`, 'err')
ElMessage({ type: 'error', message: err?.message })
} finally {
isApplying.value = false
}
}
</script>
<style lang="scss" scoped>
.debug-tool__wrap {
width: 100%;
height: 100%;
overflow: auto;
.main__wrap {
padding: 24px;
max-width: 760px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.section {
&.disabled {
opacity: 0.6;
pointer-events: none;
}
.section-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.section-desc {
font-size: 13px;
color: #909399;
margin-bottom: 16px;
line-height: 1.7;
}
}
.action-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.debug-tabs {
:deep(.el-tabs__content) {
padding: 0;
}
:deep(.el-tab-pane) {
display: flex;
flex-direction: column;
gap: 16px;
}
}
.cmd-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
.cmd-item {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
.el-button {
flex-shrink: 0;
}
.cmd-result {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
&.ok { color: #67c23a; }
&.err { color: #f56c6c; }
}
}
}
// Tab B styles
.llm-label {
font-size: 13px;
font-weight: 500;
color: #606266;
margin-bottom: 6px;
}
.rubric-source-group {
margin-bottom: 4px;
}
.rubric-preview {
margin-top: 8px;
background: #f5f7fa;
border-radius: 4px;
padding: 10px;
.rubric-preview-title {
font-size: 12px;
color: #909399;
margin-bottom: 6px;
}
.rubric-pre {
font-size: 12px;
font-family: monospace;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
max-height: 160px;
overflow: auto;
}
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.screen-result {
margin-top: 12px;
padding: 12px 16px;
border-radius: 6px;
border-left: 4px solid;
&.passed {
background: #f0f9eb;
border-color: #67c23a;
.screen-result-status { color: #67c23a; }
}
&.failed {
background: #fef0f0;
border-color: #f56c6c;
.screen-result-status { color: #f56c6c; }
}
.screen-result-status {
font-weight: 600;
font-size: 15px;
display: flex;
align-items: center;
gap: 12px;
.screen-score {
font-size: 14px;
font-weight: 400;
}
}
.dimension-scores {
margin: 10px 0 8px;
display: flex;
flex-direction: column;
gap: 6px;
.dim-row {
display: flex;
align-items: center;
gap: 8px;
.dim-name {
font-size: 12px;
color: #606266;
width: 130px;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dim-score {
font-size: 12px;
color: #606266;
width: 32px;
flex-shrink: 0;
text-align: right;
}
}
}
.screen-reason {
font-size: 13px;
color: #606266;
margin-top: 6px;
line-height: 1.6;
}
}
.extracted-text {
margin-top: 6px;
:deep(textarea) {
font-family: monospace;
font-size: 12px;
}
}
.log-section {
.log-content {
height: 260px;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
background: #f5f7fa;
border-radius: 4px;
padding: 8px;
.log-line {
display: flex;
gap: 8px;
margin-bottom: 2px;
line-height: 1.5;
.log-time { color: #909399; flex-shrink: 0; }
.log-msg { word-break: break-all; }
&.ok .log-msg { color: #67c23a; }
&.err .log-msg { color: #f56c6c; }
}
.log-empty {
color: #c0c4cc;
text-align: center;
margin-top: 40px;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,528 @@
<template>
<div class="boss-llm-config__wrap">
<div class="page-header">
<div class="page-title">招聘端大语言模型配置</div>
<div class="page-desc">
为不同 API 服务商配置模型同一服务商的多个模型共享 API Key配置保存到
<code>boss-llm.json</code>
</div>
</div>
<!-- Provider 列表 -->
<div v-if="providers.length" class="provider-list">
<el-card
v-for="(p, pIdx) in providers"
:key="p.id"
class="provider-card"
shadow="hover"
>
<!-- Provider 头部 -->
<div class="provider-header">
<div class="provider-header-left">
<el-input
v-model="p.name"
placeholder="服务商名称例如SiliconFlow"
class="provider-name-input"
size="small"
/>
</div>
<el-button
size="small"
type="danger"
text
@click="removeProvider(pIdx)"
>
删除服务商
</el-button>
</div>
<!-- Provider 连接信息 -->
<div class="form-row-2 provider-conn">
<el-form-item label="API Base URL">
<el-input v-model="p.baseURL" placeholder="https://api.siliconflow.cn/v1" />
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="p.apiKey" type="password" show-password placeholder="sk-xxx" />
</el-form-item>
</div>
<!-- Provider 下的模型列表 -->
<div class="model-list">
<div
v-for="(m, mIdx) in p.models"
:key="m.id"
class="model-row"
>
<div class="model-row-header">
<el-switch v-model="m.enabled" style="flex-shrink: 0" />
<el-input
v-model="m.name"
placeholder="模型别名例如DeepSeek-R1 简历筛选)"
class="model-name-input"
size="small"
/>
<el-button
size="small"
:loading="m._testing"
@click="handleTestEndpoint(p, m)"
>
测试连接
</el-button>
<el-button
size="small"
type="danger"
text
@click="removeModel(pIdx, mIdx)"
>
删除
</el-button>
</div>
<div class="form-row-2 model-fields">
<el-form-item label="Model ID">
<el-input v-model="m.model" placeholder="Pro/deepseek-ai/DeepSeek-R1" />
</el-form-item>
<el-form-item>
<template #label>
<span>推理模型</span>
<el-tooltip
content="支持 DeepSeek-R1、Qwen3 等 thinking model。开启后会自动调整 max_tokens。"
placement="top"
>
<el-icon style="margin-left: 4px; cursor: help"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<div class="thinking-row">
<el-checkbox v-model="m.thinking.enabled" label="启用" />
<el-input-number
v-if="m.thinking.enabled"
v-model="m.thinking.budget"
:min="256"
:max="32768"
:step="512"
controls-position="right"
style="width: 130px; margin-left: 12px"
/>
<span v-if="m.thinking.enabled" class="form-tip" style="margin-left: 6px">Token 预算</span>
</div>
</el-form-item>
</div>
<el-alert
v-if="m._testResult"
:type="m._testResult.ok ? 'success' : 'error'"
:title="m._testResult.ok ? '连接成功' : `连接失败:${m._testResult.error}`"
show-icon
:closable="false"
style="margin-top: 4px"
/>
</div>
</div>
<!-- 添加模型按钮 -->
<div class="add-model-bar">
<el-button size="small" plain @click="addModel(pIdx)">+ 添加模型</el-button>
</div>
</el-card>
</div>
<el-empty v-else description="暂无服务商,请添加" />
<!-- 添加 Provider -->
<div class="add-provider-bar">
<el-button type="primary" plain @click="addProvider">+ 添加服务商</el-button>
<el-dropdown @command="addPreset">
<el-button plain>
从预设添加 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="preset in presets"
:key="preset.name"
:command="preset"
>
{{ preset.name }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<!-- 各用途默认模型 -->
<el-card class="section" style="margin-top: 16px">
<div class="section-title">各用途默认模型</div>
<div class="section-desc">当同一用途有多个模型时指定优先使用哪一个</div>
<div class="form-row-2">
<el-form-item
v-for="purpose in purposes"
:key="purpose.key"
:label="purpose.label"
>
<el-select
v-model="purposeDefaultModelId[purpose.key]"
clearable
placeholder="(跟随第一个启用的模型)"
style="width: 100%"
>
<el-option
v-for="m in allEnabledModels"
:key="m.id"
:label="m.displayName"
:value="m.id"
/>
</el-select>
</el-form-item>
</div>
</el-card>
<!-- 操作栏 -->
<div class="action-bar">
<el-button :loading="isSaving" type="primary" @click="handleSave">保存配置</el-button>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { InfoFilled, ArrowDown } from '@element-plus/icons-vue'
const { ipcRenderer } = electron
// ── 数据类型 ─────────────────────────────────────────────────────────────────
interface ThinkingConfig {
enabled: boolean
budget: number
}
interface ModelEntry {
id: string
name: string
model: string
enabled: boolean
thinking: ThinkingConfig
_testing?: boolean
_testResult?: { ok: boolean; error?: string } | null
}
interface ProviderEntry {
id: string
name: string
baseURL: string
apiKey: string
models: ModelEntry[]
}
// ── 状态 ─────────────────────────────────────────────────────────────────────
const providers = ref<ProviderEntry[]>([])
const purposeDefaultModelId = ref<Record<string, string>>({})
const isSaving = ref(false)
const purposes = [
{ key: 'resume_screening', label: '简历筛选' },
{ key: 'greeting_generation', label: '招呼语生成' },
{ key: 'message_rewrite', label: '消息续写' },
{ key: 'default', label: '默认' }
]
// 所有已启用的模型(展平),用于用途默认模型下拉
const allEnabledModels = computed(() =>
providers.value.flatMap((p) =>
p.models
.filter((m) => m.enabled)
.map((m) => ({
id: m.id,
displayName: `${p.name ? p.name + ' / ' : ''}${m.name || m.model}`
}))
)
)
// ── 预设模板provider 维度)─────────────────────────────────────────────────
const presets = [
{
name: 'SiliconFlow',
baseURL: 'https://api.siliconflow.cn/v1',
models: [
{ name: 'DeepSeek-R1', model: 'Pro/deepseek-ai/DeepSeek-R1', thinking: { enabled: true, budget: 2048 } },
{ name: 'DeepSeek-V3', model: 'Pro/deepseek-ai/DeepSeek-V3', thinking: { enabled: false, budget: 2048 } }
]
},
{
name: 'DeepSeek 官方',
baseURL: 'https://api.deepseek.com/v1',
models: [
{ name: 'DeepSeek-R1', model: 'deepseek-reasoner', thinking: { enabled: false, budget: 2048 } },
{ name: 'DeepSeek-V3', model: 'deepseek-chat', thinking: { enabled: false, budget: 2048 } }
]
},
{
name: '阿里云百炼',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
models: [
{ name: 'Qwen-Plus', model: 'qwen-plus', thinking: { enabled: false, budget: 2048 } }
]
},
{
name: 'Ollama 本地',
baseURL: 'http://localhost:11434/v1',
models: [
{ name: 'qwen2.5', model: 'qwen2.5:latest', thinking: { enabled: false, budget: 2048 } }
]
}
]
// ── 工厂函数 ─────────────────────────────────────────────────────────────────
function newModel(overrides: Partial<ModelEntry> = {}): ModelEntry {
return {
id: crypto.randomUUID(),
name: '',
model: '',
enabled: true,
thinking: { enabled: false, budget: 2048 },
_testing: false,
_testResult: null,
...overrides,
thinking: { enabled: false, budget: 2048, ...(overrides.thinking ?? {}) }
}
}
function newProvider(overrides: Partial<ProviderEntry> = {}): ProviderEntry {
return {
id: crypto.randomUUID(),
name: '',
baseURL: '',
apiKey: '',
models: [],
...overrides
}
}
// ── 生命周期 ─────────────────────────────────────────────────────────────────
onMounted(async () => {
try {
const config = await ipcRenderer.invoke('boss-fetch-llm-config')
providers.value = (config?.providers ?? []).map((p: any) => ({
...newProvider(),
...p,
models: (p.models ?? []).map((m: any) => ({
...newModel(),
...m,
thinking: { enabled: false, budget: 2048, ...(m.thinking ?? {}) }
}))
}))
purposeDefaultModelId.value = config?.purposeDefaultModelId ?? {}
} catch (err) {
console.error('[BossLlmConfig] 加载配置失败', err)
}
})
// ── CRUD ─────────────────────────────────────────────────────────────────────
function addProvider() {
providers.value.push(newProvider())
}
function removeProvider(pIdx: number) {
providers.value.splice(pIdx, 1)
}
function addModel(pIdx: number) {
providers.value[pIdx].models.push(newModel())
}
function removeModel(pIdx: number, mIdx: number) {
providers.value[pIdx].models.splice(mIdx, 1)
}
function addPreset(preset: typeof presets[0]) {
providers.value.push(
newProvider({
name: preset.name,
baseURL: preset.baseURL,
models: preset.models.map((m) => newModel(m as Partial<ModelEntry>))
})
)
}
// ── 测试连接 ─────────────────────────────────────────────────────────────────
async function handleTestEndpoint(p: ProviderEntry, m: ModelEntry) {
m._testing = true
m._testResult = null
try {
const res = await ipcRenderer.invoke('boss-test-llm-endpoint', {
baseURL: p.baseURL,
apiKey: p.apiKey
})
m._testResult = res
} catch (err: any) {
m._testResult = { ok: false, error: err?.message }
} finally {
m._testing = false
}
}
// ── 保存 ─────────────────────────────────────────────────────────────────────
async function handleSave() {
isSaving.value = true
try {
const config = {
providers: providers.value.map((p) => ({
id: p.id,
name: p.name,
baseURL: p.baseURL,
apiKey: p.apiKey,
models: p.models.map(({ _testing, _testResult, ...rest }) => rest)
})),
purposeDefaultModelId: purposeDefaultModelId.value
}
await ipcRenderer.invoke('boss-save-llm-config', JSON.stringify(config))
ElMessage({ type: 'success', message: '配置已保存' })
} catch (err: any) {
ElMessage({ type: 'error', message: `保存失败:${err?.message}` })
} finally {
isSaving.value = false
}
}
</script>
<style lang="scss" scoped>
.boss-llm-config__wrap {
padding: 24px;
max-width: 900px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
overflow-y: auto;
box-sizing: border-box;
.page-header {
.page-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 6px;
}
.page-desc {
font-size: 13px;
color: #909399;
line-height: 1.7;
}
}
.provider-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.provider-card {
.provider-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.provider-header-left {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
margin-right: 12px;
}
.provider-name-input {
flex: 1;
max-width: 280px;
}
}
.provider-conn {
margin-bottom: 16px;
}
.model-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.model-row {
background: #f8fdfb;
border: 1px solid #dce8e6;
border-radius: 6px;
padding: 12px 14px;
.model-row-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
.model-name-input {
flex: 1;
min-width: 0;
}
}
.model-fields {
margin-bottom: 0;
}
}
.add-model-bar {
margin-top: 12px;
}
}
.form-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
}
.thinking-row {
display: flex;
align-items: center;
}
.form-tip {
font-size: 12px;
color: #909399;
}
.add-provider-bar {
display: flex;
gap: 12px;
align-items: center;
}
.section {
.section-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 6px;
}
.section-desc {
font-size: 12px;
color: #909399;
margin-bottom: 14px;
}
}
.action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 0 8px;
border-top: 1px solid #ebeef5;
position: sticky;
bottom: 0;
background: #fff;
z-index: 1;
}
}
</style>

View File

@@ -2,13 +2,13 @@
<div class="group-item">
<div class="group-title">全局设置</div>
<div flex flex-col class="link-list">
<a href="javascript:void(0)" @click="handleClickConfigCommonJobCondition">
<a v-if="showJobCondition" href="javascript:void(0)" @click="handleClickConfigCommonJobCondition">
公共职位筛选条件
</a>
<a href="javascript:void(0)" @click="handleClickBrowserSetting">
编辑浏览器偏好<TopRight w-1em h-1em mr10px />
</a>
<a href="javascript:void(0)" @click="handleClickConfigLlm">
<a v-if="showLlmConfig" href="javascript:void(0)" @click="handleClickConfigLlm">
配置大语言模型
<div>
<el-tooltip
@@ -47,6 +47,10 @@
</template>
<script lang="ts" setup>
withDefaults(defineProps<{ showJobCondition?: boolean; showLlmConfig?: boolean }>(), {
showJobCondition: true,
showLlmConfig: true
})
import { gtagRenderer } from '@renderer/utils/gtag'
import { ElMessage } from 'element-plus'
import { TopRight, QuestionFilled } from '@element-plus/icons-vue'

View File

@@ -0,0 +1,73 @@
<template>
<div class="group-item">
<div class="group-title">招聘BOSS</div>
<div flex flex-col class="link-list">
<RouterLink :to="{ name: 'BossJobConfig' }">
职位配置
</RouterLink>
<RouterLink :to="{ name: 'BossAutoBrowseAndChat' }">
推荐牛人 - 自动开聊
</RouterLink>
<RouterLink :to="{ name: 'BossChatPage' }">
沟通
</RouterLink>
<RouterLink :to="{ name: 'BossAutoSequence' }">
自动顺序执行
</RouterLink>
<RouterLink :to="{ name: 'WebhookIntegration' }">
Webhook / 外部集成
</RouterLink>
<a href="javascript:void(0)" @click="handleClickRecruiterLogin">
编辑登录凭据<TopRight w-1em h-1em mr10px />
</a>
<a href="javascript:void(0)" @click="handleLaunchRecruiterBossSite">
手动逛逛<TopRight w-1em h-1em mr10px />
</a>
<RouterLink :to="{ name: 'BossDebugTool' }">
招聘端调试工具
</RouterLink>
<RouterLink :to="{ name: 'BossLlmConfig' }">
配置大语言模型
</RouterLink>
</div>
</div>
</template>
<script lang="ts" setup>
import { ElMessage, ElMessageBox } from 'element-plus'
import { debounce } from 'lodash'
import { TopRight } from '@element-plus/icons-vue'
import { gtagRenderer } from '@renderer/utils/gtag'
const handleClickRecruiterLogin = async () => {
try {
await ElMessageBox.confirm(
'BOSS 直聘的招聘端和求职端是两个独立身份。请在接下来的登录页面中,确保以「招聘者」身份登录(即登录后能看到"推荐牛人"等招聘功能)。',
'注意:使用招聘端账号登录',
{
confirmButtonText: '我知道了,去登录',
cancelButtonText: '取消',
type: 'warning'
}
)
await electron.ipcRenderer.invoke('login-with-cookie-assistant')
ElMessage({ type: 'success', message: '登录凭据保存成功' })
} catch {
//
}
}
const handleLaunchRecruiterBossSite = debounce(
async () => {
gtagRenderer('launch_recruiter_boss_site_clicked')
return await electron.ipcRenderer.invoke('open-site-with-boss-cookie', {
url: `https://www.zhipin.com/web/chat/recommend`
})
},
1000,
{ leading: true, trailing: false }
)
</script>
<style scoped lang="scss" src="./style.scss"></style>

View File

@@ -0,0 +1,481 @@
<template>
<div class="webhook-integration__wrap">
<div class="main__wrap">
<el-form ref="formRef" :model="formContent" label-position="top">
<!-- 基础设置 -->
<el-card class="config-section">
<el-form-item>
<div class="section-title">基础设置</div>
</el-form-item>
<el-form-item label="启用 Webhook">
<el-switch v-model="formContent.enabled" />
<span class="hint-text">关闭后任务结束时不发送也可手动触发</span>
</el-form-item>
<el-form-item
label="目标 URL"
prop="url"
:rules="[{ validator: validateUrl, trigger: 'blur' }]"
>
<el-input
v-model="formContent.url"
placeholder="https://your-paperless.example.com/api/documents/post_document/"
:disabled="!formContent.enabled"
/>
</el-form-item>
<el-form-item label="请求方法">
<el-select v-model="formContent.method" :disabled="!formContent.enabled">
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="PATCH" value="PATCH" />
</el-select>
</el-form-item>
<el-form-item label="发送模式">
<el-radio-group v-model="formContent.sendMode" :disabled="!formContent.enabled">
<el-radio value="batch">轮次结束汇总发送</el-radio>
<el-radio value="realtime">逐条实时发送每打招呼后立即发一条</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="请求体格式">
<el-radio-group v-model="formContent.contentType" :disabled="!formContent.enabled">
<el-radio value="application/json">JSON</el-radio>
<el-radio value="multipart/form-data">Multipart直传 Paperless </el-radio>
</el-radio-group>
</el-form-item>
</el-card>
<!-- 请求头 -->
<el-card class="config-section">
<el-form-item>
<div class="section-title">请求头Headers</div>
</el-form-item>
<div class="header-presets">
<span class="preset-label">快速模板</span>
<el-button size="small" @click="addPresetHeader('Authorization', 'Token YOUR_TOKEN')"
>Authorization Token</el-button
>
<el-button size="small" @click="addPresetHeader('X-API-Key', 'YOUR_API_KEY')"
>X-API-Key</el-button
>
</div>
<div
v-for="(header, index) in formContent.headers"
:key="index"
class="header-row"
>
<el-input
v-model="header.key"
placeholder="Header 名称"
class="header-key-input"
/>
<span class="header-sep">:</span>
<el-input
v-model="header.value"
placeholder="Header 值"
class="header-value-input"
/>
<el-button
type="danger"
plain
:icon="Delete"
circle
size="small"
@click="removeHeader(index)"
/>
</div>
<el-button class="add-header-btn" @click="addHeader">+ 添加请求头</el-button>
</el-card>
<!-- Payload 选项 -->
<el-card class="config-section">
<el-form-item>
<div class="section-title">Payload 内容选项</div>
</el-form-item>
<el-form-item>
<el-checkbox v-model="formContent.payloadOptions.includeBasicInfo">
包含候选人基本信息姓名学历工作年限薪资技能等
</el-checkbox>
</el-form-item>
<el-form-item>
<el-checkbox v-model="formContent.payloadOptions.includeFilterReason">
包含筛选理由 / 评分报告为什么选中或跳过此候选人
</el-checkbox>
</el-form-item>
<el-form-item>
<el-checkbox v-model="formContent.payloadOptions.includeLlmConclusion">
包含 LLM 评估结论如果启用了大语言模型筛选
</el-checkbox>
</el-form-item>
<el-form-item label="简历文件">
<el-radio-group v-model="formContent.payloadOptions.includeResume">
<el-radio value="none">不包含</el-radio>
<el-radio value="path">本地文件路径</el-radio>
<el-radio value="base64">Base64 编码内容</el-radio>
</el-radio-group>
<div class="hint-text" style="margin-top: 4px">
注意若沟通页配置了
<code>chatPage.attachmentResume.skipDownload: true</code>BOSS
已设置附件简历自动发邮箱无需下载则系统不会下载 PDF
此字段在 Webhook Payload 中将始终为空
</div>
</el-form-item>
</el-card>
<!-- 重试与队列 -->
<el-card class="config-section">
<el-form-item>
<div class="section-title">重试与失败队列</div>
</el-form-item>
<el-form-item label="失败重试次数">
<el-input-number
v-model="formContent.retryTimes"
:min="0"
:max="10"
:disabled="!formContent.enabled"
/>
<span class="hint-text">0 表示不重试首次失败后按延迟指数退避</span>
</el-form-item>
<el-form-item label="首次重试延迟(毫秒)">
<el-input-number
v-model="formContent.retryDelayMs"
:min="500"
:max="30000"
:step="500"
:disabled="!formContent.enabled"
/>
</el-form-item>
<el-form-item>
<el-checkbox v-model="formContent.queueFileOnFailure" :disabled="!formContent.enabled">
最终失败时写入本地队列文件webhook-failed-queue.jsonl便于后续重发
</el-checkbox>
</el-form-item>
</el-card>
<!-- 操作栏 -->
<div class="action-bar">
<el-button :loading="isSaving" @click="handleSave">仅保存配置</el-button>
<el-button type="primary" :loading="isSaving || isTesting" @click="handleSaveAndTest">
保存并测试发送
</el-button>
<el-checkbox v-model="manualTriggerUseRealData" class="manual-trigger-checkbox">
使用真实数据数据库最近联系人
</el-checkbox>
<el-button
:loading="isTriggering"
:disabled="!formContent.url"
@click="handleManualTrigger"
>
{{ manualTriggerUseRealData ? '手动触发(真实数据)' : '手动触发Mock 数据)' }}
</el-button>
</div>
</el-form>
<!-- 测试结果 -->
<el-card v-if="testResult" class="config-section test-result-card">
<div class="section-title">
上次测试结果
<el-tag
:type="testResult.status >= 200 && testResult.status < 300 ? 'success' : 'danger'"
size="small"
>
HTTP {{ testResult.status }}
</el-tag>
</div>
<pre class="test-result-body">{{ testResult.formattedBody }}</pre>
</el-card>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Delete } from '@element-plus/icons-vue'
const { ipcRenderer } = electron
const formRef = ref()
const isSaving = ref(false)
const isTesting = ref(false)
const isTriggering = ref(false)
const testResult = ref<{ status: number; formattedBody: string } | null>(null)
const manualTriggerUseRealData = ref(false)
interface HeaderEntry {
key: string
value: string
}
const formContent = reactive({
enabled: false,
url: '',
method: 'POST' as 'POST' | 'PUT' | 'PATCH',
sendMode: 'batch' as 'batch' | 'realtime',
contentType: 'application/json' as 'application/json' | 'multipart/form-data',
headers: [] as HeaderEntry[],
payloadOptions: {
includeBasicInfo: true,
includeFilterReason: true,
includeLlmConclusion: true,
includeResume: 'path' as 'none' | 'path' | 'base64'
},
retryTimes: 3,
retryDelayMs: 1000,
queueFileOnFailure: false
})
function headersArrayToObject(arr: HeaderEntry[]): Record<string, string> {
const obj: Record<string, string> = {}
for (const { key, value } of arr) {
if (key.trim()) {
obj[key.trim()] = value
}
}
return obj
}
function headersObjectToArray(obj: Record<string, string>): HeaderEntry[] {
return Object.entries(obj || {}).map(([key, value]) => ({ key, value }))
}
function validateUrl(_: unknown, value: string, callback: (err?: Error) => void) {
if (formContent.enabled && value && !/^https?:\/\/.+/.test(value)) {
callback(new Error('URL 必须以 http:// 或 https:// 开头'))
} else {
callback()
}
}
function addHeader() {
formContent.headers.push({ key: '', value: '' })
}
function removeHeader(index: number) {
formContent.headers.splice(index, 1)
}
function addPresetHeader(key: string, value: string) {
const existing = formContent.headers.find((h) => h.key === key)
if (existing) {
existing.value = value
} else {
formContent.headers.push({ key, value })
}
}
onMounted(async () => {
try {
const config = await ipcRenderer.invoke('fetch-webhook-config')
if (config) {
formContent.enabled = config.enabled ?? false
formContent.url = config.url ?? ''
formContent.method = config.method ?? 'POST'
formContent.sendMode = config.sendMode ?? 'batch'
formContent.contentType = config.contentType ?? 'application/json'
formContent.headers = headersObjectToArray(config.headers ?? {})
formContent.payloadOptions.includeBasicInfo = config.payloadOptions?.includeBasicInfo ?? true
formContent.payloadOptions.includeFilterReason =
config.payloadOptions?.includeFilterReason ?? true
formContent.payloadOptions.includeLlmConclusion =
config.payloadOptions?.includeLlmConclusion ?? true
formContent.payloadOptions.includeResume = config.payloadOptions?.includeResume ?? 'path'
formContent.retryTimes = config.retryTimes ?? 3
formContent.retryDelayMs = config.retryDelayMs ?? 1000
formContent.queueFileOnFailure = config.queueFileOnFailure ?? false
}
} catch (err) {
console.error(err)
}
})
function buildSavePayload() {
return {
enabled: formContent.enabled,
url: formContent.url,
method: formContent.method,
sendMode: formContent.sendMode,
contentType: formContent.contentType,
headers: headersArrayToObject(formContent.headers),
payloadOptions: { ...formContent.payloadOptions },
retryTimes: formContent.retryTimes,
retryDelayMs: formContent.retryDelayMs,
queueFileOnFailure: formContent.queueFileOnFailure
}
}
async function doSave() {
await ipcRenderer.invoke('save-webhook-config', JSON.stringify(buildSavePayload()))
}
const handleSave = async () => {
isSaving.value = true
try {
await doSave()
ElMessage({ type: 'success', message: '配置已保存' })
} catch (err) {
ElMessage({ type: 'error', message: '保存失败' })
console.error(err)
} finally {
isSaving.value = false
}
}
const handleSaveAndTest = async () => {
if (!formContent.url) {
ElMessage({ type: 'warning', message: '请先填写目标 URL' })
return
}
isSaving.value = true
isTesting.value = true
testResult.value = null
try {
await doSave()
const result = await ipcRenderer.invoke('test-webhook')
let formattedBody = result.body
try {
formattedBody = JSON.stringify(JSON.parse(result.body), null, 2)
} catch {
// keep as-is
}
testResult.value = { status: result.status, formattedBody }
if (result.status >= 200 && result.status < 300) {
ElMessage({ type: 'success', message: `测试成功HTTP ${result.status}` })
} else {
ElMessage({ type: 'warning', message: `服务器返回 HTTP ${result.status}` })
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
ElMessage({ type: 'error', message: `请求失败:${message}` })
testResult.value = { status: 0, formattedBody: message }
} finally {
isSaving.value = false
isTesting.value = false
}
}
const handleManualTrigger = async () => {
if (!formContent.url) {
ElMessage({ type: 'warning', message: '请先填写目标 URL' })
return
}
isTriggering.value = true
testResult.value = null
try {
await doSave()
const result = await ipcRenderer.invoke(
'trigger-webhook-manually',
manualTriggerUseRealData.value
)
let formattedBody = result.body
try {
formattedBody = JSON.stringify(JSON.parse(result.body), null, 2)
} catch {
// keep as-is
}
testResult.value = { status: result.status, formattedBody }
if (result.status >= 200 && result.status < 300) {
ElMessage({ type: 'success', message: `手动触发成功HTTP ${result.status}` })
} else {
ElMessage({ type: 'warning', message: `服务器返回 HTTP ${result.status}` })
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
ElMessage({ type: 'error', message: `触发失败:${message}` })
} finally {
isTriggering.value = false
}
}
</script>
<style lang="scss" scoped>
.webhook-integration__wrap {
width: 100%;
height: 100%;
overflow: auto;
.main__wrap {
padding: 24px;
max-width: 800px;
margin: 0 auto;
}
.config-section {
margin-bottom: 16px;
}
.section-title {
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.hint-text {
margin-left: 12px;
font-size: 12px;
color: #909399;
}
.header-presets {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
.preset-label {
font-size: 12px;
color: #606266;
}
}
.header-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.header-key-input {
flex: 0 0 200px;
}
.header-value-input {
flex: 1;
}
.header-sep {
color: #999;
}
}
.add-header-btn {
margin-top: 4px;
}
.action-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 0;
flex-wrap: wrap;
.manual-trigger-checkbox {
margin-right: 8px;
}
}
.test-result-card {
.test-result-body {
margin: 12px 0 0;
padding: 12px;
background: #f5f7fa;
border-radius: 4px;
font-size: 12px;
font-family: 'Consolas', 'Monaco', monospace;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow: auto;
}
}
}
</style>

View File

@@ -1,15 +1,59 @@
<template>
<div class="flex h100vh">
<div class="flex flex-col min-w200px w200px pt30px pl30px aside-nav of-hidden">
<div class="nav-list flex-1 of-auto pl20px ml--20px">
<div class="flex h100vh of-hidden">
<div class="flex flex-col min-w200px w200px pt16px pl30px aside-nav of-hidden">
<!-- 身份切换 -->
<div class="identity-switcher">
<el-segmented
v-model="identityMode"
:options="identityOptions"
size="small"
block
@change="handleIdentityChange"
/>
</div>
<div class="nav-list flex-1 of-auto pl20px ml--20px mt12px">
<RouterLink v-show="false" to="./TaskManager">任务管理</RouterLink>
<BossPart />
<template v-if="identityMode === 'geek'">
<BossPart />
<hr class="group-divider" />
<RunDataRecordPart />
</template>
<template v-else>
<RecruiterPart />
</template>
<hr class="group-divider" />
<GlobalConfigPart />
<hr class="group-divider" />
<RunDataRecordPart />
<GlobalConfigPart
:show-job-condition="identityMode === 'geek'"
:show-llm-config="identityMode === 'geek'"
/>
</div>
<div class="pt-16px pb-16px flex-0 font-size-12px">
<div v-if="identityMode === 'recruiter'" class="recruiter-tools">
<span class="recruiter-tools__log-level">
日志级别
<el-select
v-model="recruiterLogLevel"
size="small"
class="recruiter-tools__log-level-select"
@change="handleRecruiterLogLevelChange"
>
<el-option label="info" value="info" />
<el-option label="debug" value="debug" />
<el-option label="warn" value="warn" />
<el-option label="error" value="error" />
</el-select>
</span>
<el-button
type="text"
size="small"
:class="{ active: logPanelOpen }"
class="tool-btn"
@click="toggleLogPanel"
>运行日志</el-button>
|
<el-button type="text" size="small" class="tool-btn" @click="handleToggleDevTools">调试工具</el-button>
</div>
<div v-if="updateStore.availableNewRelease" mb16px>
<div
:style="{
@@ -57,19 +101,86 @@
</KeepAlive>
</RouterView>
</div>
<div
v-show="logPanelOpen && identityMode === 'recruiter'"
class="log-panel"
:style="{ width: logPanelWidth + 'px' }"
>
<div class="log-panel-resizer" @mousedown="startResize" />
<div class="log-panel-header">
<span>运行日志</span>
<button class="log-clear-btn" @click="workerLogs = []">清空</button>
</div>
<div ref="logScrollRef" class="log-panel-body">
<div v-if="!workerLogs.length" class="log-empty">暂无日志</div>
<div v-for="(line, i) in workerLogs" :key="i" class="log-line">{{ line }}</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { useRouter } from 'vue-router'
import { nextTick, ref, watch, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import useBuildInfo from '@renderer/hooks/useBuildInfo'
import { gtagRenderer } from '@renderer/utils/gtag'
import { useUpdateStore, useTaskManagerStore } from '../../store/index'
import BossPart from './LeftNavBar/BossPart.vue'
import RecruiterPart from './LeftNavBar/RecruiterPart.vue'
import GlobalConfigPart from './LeftNavBar/GlabalConfigPart.vue'
import RunDataRecordPart from './LeftNavBar/RunDataRecordPart.vue'
useRouter()
const router = useRouter()
const route = useRoute()
const RECRUITER_ROUTES = [
'BossJobConfig',
'BossAutoBrowseAndChat',
'BossChatPage',
'BossAutoSequence',
'WebhookIntegration',
'BossDebugTool',
'BossLlmConfig'
]
function getIdentityFromRoute(routeName: string | null | symbol): 'geek' | 'recruiter' {
if (typeof routeName === 'string' && RECRUITER_ROUTES.includes(routeName)) {
return 'recruiter'
}
return 'geek'
}
const identityMode = ref<'geek' | 'recruiter'>(getIdentityFromRoute(route.name))
const identityOptions = [
{ label: '找工作', value: 'geek' },
{ label: '招人才', value: 'recruiter' }
]
watch(
() => route.name,
(name) => {
identityMode.value = getIdentityFromRoute(name)
}
)
watch(
() => route.path,
(path) => {
if (path.startsWith('/main-layout/')) {
localStorage.setItem('geekgeekrun_last_main_layout_path', path)
}
},
{ immediate: true }
)
function handleIdentityChange(val: string) {
gtagRenderer('identity_mode_changed', { val })
if (val === 'geek') {
router.replace('/main-layout/GeekAutoStartChatWithBoss')
} else {
router.replace('/main-layout/BossAutoBrowseAndChat')
}
}
const { buildInfo } = useBuildInfo()
const handleFeedbackClick = () => {
@@ -93,11 +204,116 @@ function handleViewNewReleaseClick() {
const taskManagerStore = useTaskManagerStore()
void taskManagerStore
// --- 招聘端日志面板 ---
const RECRUITER_WORKER_IDS = [
'bossRecommendMain',
'bossChatPageMain',
'bossAutoBrowseAndChatMain',
'syncBossJobList'
]
const LOG_PANEL_STORAGE_KEY = 'geekgeekrun_log_panel_open'
const LOG_PANEL_WIDTH_KEY = 'geekgeekrun_log_panel_width'
const MAX_LOG_LINES = 500
const logPanelOpen = ref(localStorage.getItem(LOG_PANEL_STORAGE_KEY) !== 'false')
const logPanelWidth = ref(Number(localStorage.getItem(LOG_PANEL_WIDTH_KEY)) || 300)
const workerLogs = ref<string[]>([])
const logScrollRef = ref<HTMLElement | null>(null)
function toggleLogPanel() {
logPanelOpen.value = !logPanelOpen.value
localStorage.setItem(LOG_PANEL_STORAGE_KEY, String(logPanelOpen.value))
}
const DEVTOOLS_STORAGE_KEY = 'geekgeekrun_devtools_open'
const devToolsOpen = ref(false)
function handleToggleDevTools() {
electron.ipcRenderer.send('toggle-devtools')
devToolsOpen.value = !devToolsOpen.value
localStorage.setItem(DEVTOOLS_STORAGE_KEY, String(devToolsOpen.value))
}
// 招聘端日志级别(推荐页 / 沟通页 / Webhook 等共用,存于 boss-recruiter.json
const recruiterLogLevel = ref<string>('info')
async function loadRecruiterLogLevel() {
try {
const result = await electron.ipcRenderer.invoke('fetch-boss-recruiter-config-file-content')
const config = result?.config?.['boss-recruiter.json'] || {}
recruiterLogLevel.value = config.logLevel ?? 'info'
} catch {
//
}
}
function handleRecruiterLogLevelChange(val: string) {
electron.ipcRenderer.invoke('save-boss-recruiter-config', JSON.stringify({ logLevel: val }))
}
watch(
() => identityMode.value,
(mode) => {
if (mode === 'recruiter') loadRecruiterLogLevel()
},
{ immediate: true }
)
onMounted(() => {
if (identityMode.value === 'recruiter') loadRecruiterLogLevel()
if (localStorage.getItem(DEVTOOLS_STORAGE_KEY) === 'true') {
devToolsOpen.value = true
electron.ipcRenderer.send('toggle-devtools')
}
})
function startResize(e: MouseEvent) {
const startX = e.clientX
const startWidth = logPanelWidth.value
document.body.style.userSelect = 'none'
const onMove = (ev: MouseEvent) => {
const delta = startX - ev.clientX
logPanelWidth.value = Math.max(160, Math.min(800, startWidth + delta))
}
const onUp = () => {
document.body.style.userSelect = ''
localStorage.setItem(LOG_PANEL_WIDTH_KEY, String(logPanelWidth.value))
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
watch(
() => workerLogs.value.length,
() => {
if (!logPanelOpen.value) return
nextTick(() => {
if (logScrollRef.value) {
logScrollRef.value.scrollTop = logScrollRef.value.scrollHeight
}
})
}
)
const { ipcRenderer } = electron
ipcRenderer.on('worker-to-gui-message', (_, { data }) => {
if (data.type === 'worker-log' && RECRUITER_WORKER_IDS.includes(data.workerId)) {
const time = new Date().toLocaleTimeString()
workerLogs.value.push(`[${time}] ${data.message}`)
if (workerLogs.value.length > MAX_LOG_LINES) {
workerLogs.value.shift()
}
}
})
</script>
<style lang="scss" scoped>
.aside-nav {
background-image: linear-gradient(45deg, #eaf4f1, #dcf6f2);
.identity-switcher {
padding-right: 20px;
}
.nav-list {
hr.group-divider {
width: 100%;
@@ -110,18 +326,112 @@ void taskManagerStore
}
}
.feedback-button-area,
.update-button-area {
.update-button-area,
.recruiter-tools {
:deep(.el-button) {
height: fit-content;
padding: 0;
margin-left: 0;
}
}
.recruiter-tools {
margin-bottom: 8px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
.recruiter-tools__log-level {
display: inline-flex;
align-items: center;
gap: 6px;
margin-right: 4px;
.recruiter-tools__log-level-select {
width: 88px;
}
}
:deep(.tool-btn.active) {
color: #32726c;
font-weight: 600;
}
}
}
.router-view-wrap {
display: flex;
flex: 1;
min-width: 0;
height: 100%;
box-shadow: -4px 1px 20px rgb(50 114 108 / 29%);
}
.log-panel {
flex-shrink: 0;
position: relative;
display: flex;
flex-direction: column;
border-left: 1px solid #dce8e6;
background: #f8fdfb;
height: 100%;
overflow: hidden;
.log-panel-resizer {
position: absolute;
left: 0;
top: 0;
width: 4px;
height: 100%;
cursor: ew-resize;
z-index: 1;
&:hover {
background: #b3c8c3;
}
}
.log-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
color: #32726c;
border-bottom: 1px solid #dce8e6;
flex-shrink: 0;
.log-clear-btn {
padding: 1px 8px;
font-size: 11px;
border: 1px solid #b3c8c3;
border-radius: 3px;
background: transparent;
color: #666;
cursor: pointer;
&:hover {
background: #dcf6f2;
}
}
}
.log-panel-body {
flex: 1;
overflow-y: auto;
padding: 6px 8px;
.log-empty {
font-size: 11px;
color: #aaa;
text-align: center;
margin-top: 20px;
}
.log-line {
font-size: 11px;
font-family: monospace;
color: #444;
line-height: 1.6;
word-break: break-all;
border-bottom: 1px solid #eef4f2;
padding: 1px 0;
}
}
}
</style>

View File

@@ -69,7 +69,10 @@ const routes: Array<RouteRecordRaw> = [
{
path: '/main-layout',
component: () => import('@renderer/page/MainLayout/index.vue'),
redirect: '/main-layout/GeekAutoStartChatWithBoss',
redirect: () => {
const lastPath = localStorage.getItem('geekgeekrun_last_main_layout_path')
return lastPath || '/main-layout/GeekAutoStartChatWithBoss'
},
children: [
{
path: 'taskManager',
@@ -126,6 +129,62 @@ const routes: Array<RouteRecordRaw> = [
meta: {
title: '公司库'
}
},
{
name: 'BossJobConfig',
path: 'BossJobConfig',
component: () => import('@renderer/page/MainLayout/BossJobConfig/index.vue'),
meta: {
title: '职位配置'
}
},
{
name: 'BossAutoBrowseAndChat',
path: 'BossAutoBrowseAndChat',
component: () => import('@renderer/page/MainLayout/BossAutoBrowseAndChat/index.vue'),
meta: {
title: '推荐牛人 - 自动开聊'
}
},
{
name: 'BossChatPage',
path: 'BossChatPage',
component: () => import('@renderer/page/MainLayout/BossChatPage/index.vue'),
meta: {
title: '沟通'
}
},
{
name: 'BossAutoSequence',
path: 'BossAutoSequence',
component: () => import('@renderer/page/MainLayout/BossAutoSequence/index.vue'),
meta: {
title: '自动顺序执行'
}
},
{
name: 'WebhookIntegration',
path: 'WebhookIntegration',
component: () => import('@renderer/page/MainLayout/WebhookIntegration/index.vue'),
meta: {
title: 'Webhook / 外部集成'
}
},
{
name: 'BossDebugTool',
path: 'BossDebugTool',
component: () => import('@renderer/page/MainLayout/BossDebugTool/index.vue'),
meta: {
title: '招聘端调试工具'
}
},
{
name: 'BossLlmConfig',
path: 'BossLlmConfig',
component: () => import('@renderer/page/MainLayout/BossLlmConfig/index.vue'),
meta: {
title: '招聘端大语言模型配置'
}
}
]
},

View File

@@ -1,10 +1,20 @@
import OpenAI from "openai";
/**
* 调用 Chat Completions API支持推理模型thinking 参数)。
*
* @param {{ baseURL: string, apiKey: string, model: string, max_tokens?: number, temperature?: number, thinking?: { enabled?: boolean, budget?: number } }} config
* @param {Array<{ role: string, content: string }>} messages
*/
export async function completes(
{
baseURL,
apiKey,
model
model,
max_tokens,
temperature,
thinking,
response_format
},
messages
) {
@@ -13,14 +23,49 @@ export async function completes(
apiKey,
});
const completion = await openai.chat.completions.create({
const isThinking = !!(thinking?.enabled && thinking?.budget)
// 推理模型开启 thinking 时max_tokens 必须大于 thinking_budget否则会因长度上限截断 JSON。
// 调用方若未显式传 max_tokens按是否启用 thinking 给一个安全的默认值。
const resolvedMaxTokens =
typeof max_tokens === 'number'
? max_tokens
: isThinking
? 8192
: 1200
// temperature推理模型启用 thinking 时建议 ≥0.5(部分 provider 限制),普通 JSON 输出用 0.1。
const resolvedTemperature =
typeof temperature === 'number'
? temperature
: isThinking
? 0.6
: 0.1
const createParams = {
messages,
model,
frequency_penalty: 0,
max_tokens: 100,
temperature: 0.1
});
max_tokens: resolvedMaxTokens,
temperature: resolvedTemperature,
}
console.log(completion.choices[0].message.content);
if (isThinking) {
// SiliconFlow / 火山方舟等兼容顶层参数OpenAI SDK 通过 extra_body 透传其他字段
createParams.enable_thinking = true
createParams.thinking_budget = thinking.budget
}
if (response_format) {
createParams.response_format = response_format
}
const completion = await openai.chat.completions.create(createParams);
// reasoning_content 仅推理模型填充,普通模型为 undefined
const msg = completion.choices[0].message
if (msg.reasoning_content) {
console.log('[gpt-request] reasoning_content:', String(msg.reasoning_content ?? '').slice(0, 200))
}
console.log('[gpt-request] content:', (msg.content ?? '').slice(0, 200));
return completion;
}

View File

@@ -4,6 +4,8 @@ export function sleep (t) {
})
}
export function sleepWithRandomDelay (base) {
return sleep(base + Math.random()*1000)
export function sleepWithRandomDelay (min, max) {
const lo = min ?? 0
const hi = max ?? (lo + 1000)
return sleep(lo + Math.random() * (hi - lo))
}

5
plan/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
*.log
*.log*
*.txt
*.resolved

125
plan/STATUS_2026-03-18.md Normal file
View File

@@ -0,0 +1,125 @@
# 2026-03-18 阶段性现状Recruiter / 招聘端)
> 目标:先阶段性提交到 GitHub并打包一版用于自测与联调。
## 1. 当前可用功能(已跑通)
### 1.1 沟通页Chat Page
- **流程状态**:整体可用(你反馈“沟通部分是正常的”)。
- **核心能力**
- 读取会话列表,按未读/条件筛选并逐个处理
- 在线简历Canvas/WASM提取文本
- 附件简历下载 PDF或按配置跳过下载仅索取
- 关键词/LLM 两种筛选模式(由配置决定)
- SQLite 持久化:候选人信息 + 联系日志
## 2. 已实现但需要修复/补齐的点(高优)
### 2.1 推荐牛人Recommend Page状态判断不精确
当前问题集中在“**已读 / 已打招呼 / 已处理**”的精确判定与去重策略上,导致:
- **重复扫描**:列表滚动/刷新后同一候选人可能再次进入候选集合
- **误判**UI 上看起来“已读/已沟通”的项,程序未必能稳定识别并跳过
- **流程不闭环**:未通过筛选的候选人虽可点“不感兴趣”移除,但与“已处理”的统一标准需要收敛
建议修复方向(后续实现):
- **以 encryptGeekId 做强去重**:以 DB 的 `CandidateInfo` + `CandidateContactLog` 为准,而不是仅依赖 DOM class
- **明确三类状态并落库**
- viewed已查看过该卡片/详情)
- greeted/contacted已发起打招呼/已在沟通列表出现)
- processed本轮已筛选/已触发过动作:打招呼/不感兴趣/跳过原因)
- **推荐页“已读”判断统一**:将 DOM class`has-viewed`)作为辅助信号,最终以 DB 的处理记录兜底
#### 2.1.1 状态定义(建议统一口径)
为避免 UI 信号不稳定导致误判,建议在推荐页/沟通页统一以下状态口径(按强度从弱到强):
- **seen看过卡片**:本轮在推荐列表中“见过”该候选人(只要 parse 到就算)
- **viewed点开详情**:本轮实际点击过卡片,弹出简历详情 dialog
- **greeted已打招呼动作发起**:本轮点击过 `btn-greet`(无论是否出现“知道了”提示)
- **contacted沟通关系建立**:候选人出现在沟通列表 / 能打开对话(强信号,跨轮有效)
- **processed本轮已处理闭环**:本轮对该候选人做过“打招呼 / 不感兴趣 / 明确跳过原因”中的任一动作
其中:
- **跨轮去重**:以 `contacted` + `greeted`(落库)作为主去重条件
- **本轮去重**:以 `seen/processed`(内存 Set + 可选落库)作为本轮兜底,防止滚动加载重复出现
#### 2.1.2 判定信号(优先级建议)
- **强信号(优先)**
- DB`CandidateContactLog`(已发起打招呼/已建立联系/已处理原因)
- 沟通页:会话列表里能定位到该 geekId若能取到/ 或者能够打开会话窗口
- **弱信号(仅辅助)**
- 推荐列表 DOM class如 has-viewed/已沟通样式)
- 按钮文案变化(“继续沟通/已打招呼”等)与 tooltip
> 原则:**弱信号只能用来“提前跳过”提升效率,不能作为“唯一依据”防止重复打招呼。**
#### 2.1.3 需要补齐的落库字段/记录(文档层面建议)
当前已引入 `CandidateInfo` / `CandidateContactLog` 两张表。为了让“推荐页状态闭环”更稳,建议后续补齐:
- **CandidateInfo建议**
- `encryptGeekId` / `geekId`(至少一个可稳定取到的唯一标识)
- `lastSeenAt` / `lastViewedAt`(可选)
- **CandidateContactLog建议**
- `action`: `greet` \| `not_interested` \| `skip` \| `contacted`
- `reason`: 不感兴趣原因 / skip 原因(如 “重复推荐”“已联系”“日限已达”)
- `runId`: 本轮 run 标识(用于 webhook/batch 汇总)
#### 2.1.4 验收标准(修复完成的判定)
- **不重复打招呼**:同一 `encryptGeekId` 在跨轮运行中不会重复点击 greet除非用户手动清库
- **状态可追溯**:任意一个被跳过/打招呼/不感兴趣的候选人,都能在 DB 中找到对应记录与原因
- **滚动加载不抖动**:列表滚动/翻页后重复出现的 card 不会触发重复处理
### 2.2 Webhook功能已接入但未完整测试 & 功能安排待完善
- **现状**
- UI 有 Webhook 配置页(保存 / 测试发送 / 手动触发)
- 主进程已提供 IPCfetch/save/test/trigger
- 顺序执行 worker 在轮次结束具备触发点(按设计收集本轮候选人后发送)
- **未完成/风险点**
- **真实环境联调未验证**接口返回码、鉴权、网络失败重试、multipart/JSON 兼容
- **触发策略待定**batch / realtime 的行为与页面提示需要一致
- **数据来源一致性**:候选人数据字段(简历路径/base64、LLM 结论、筛选理由)在不同路径(推荐页/沟通页)是否都能填充需要校验
最小测试清单(打包后自测):
- **配置保存/读取**:重启后配置仍存在
- **测试发送Mock**`test-webhook` 成功返回并展示响应体
- **手动触发Mock + 真实数据)**:当 DB 有数据时能构建 payload无数据时回退 Mock
- **失败重试**:断网/返回 500 时日志与 UI 提示符合预期
## 3. 本次阶段性提交包含内容(范围说明)
- **新增 recruiter 自动化核心**`packages/boss-auto-browse-and-chat`
- **新增 recruiter headless 入口**`packages/run-core-of-boss-auto-browse`
- **SQLite plugin 扩展**:新增 `CandidateInfo` / `CandidateContactLog` 表与 handler
- **UI 扩展**
- 招聘端身份入口、路由与页面(推荐/沟通/顺序/调试/Webhook/LLM 配置)
- RunningOverlay 招聘端进度展示(推荐页/沟通页进度 + worker 日志)
- 招聘端日志面板(主界面右侧可拖拽)
- **Windows 控制台中文编码修复**main 进程日志文件统一 `utf8`
## 4. 近期修复优先级(建议)
1. **推荐页状态判定/去重落库**(避免重复打招呼/重复处理)
2. **Webhook 真实联调 + 文档对齐**(完成闭环,尤其 payload 字段与失败策略)
3. **推荐页与沟通页的“已处理”统一协议**本轮处理、跨轮去重、UI 展示)
## 5. 打包自测建议(本次提交后)
- **推荐牛人**
- 连续运行 2 次,确认不会对同一候选人重复触发 greet
- 触发一次“不感兴趣”,确认该候选人从列表消失且 DB 有记录
- **沟通页**
- 未读会话筛选与处理数量上限生效
- 在线简历/附件简历两种路径至少各跑通 1 次
- **Webhook**
- 用可控的 echo server如本地/测试环境)跑通 test 与手动触发
- 断网/返回 500 时,重试次数与 UI 提示符合预期(至少能看到失败原因)

View File

@@ -0,0 +1,89 @@
# 招聘端自动浏览:沟通 / 推荐牛人 双 Tab 逻辑
本文档描述招聘端自动化boss-auto-browse-and-chat**「沟通」与「推荐牛人」分两个浏览器 Tab** 的设计、URL、选择器及主流程便于后续维护与排查。
---
## 1. 为什么分成两个 Tab
- **沟通**`/web/chat/index`):左侧会话列表、聊天窗口、查看在线简历/附件简历等,入口多为「沟通」。
- **推荐牛人**`/web/chat/recommend`):候选人列表、打招呼、继续沟通等,入口为「推荐牛人」。
若在同一个 Tab 内通过侧栏切换,容易出现:
- 登录后默认落在「沟通」页,列表选择器针对的是推荐页 DOM解析到 0 人;
- 同一页面来回切换容易与 BOSS 前端状态/路由耦合,增加超时与不稳定。
因此采用 **两个独立 Tab**Tab1 固定为沟通页Tab2 固定为推荐牛人页;主循环(解析列表、筛选、打招呼)**只操作推荐牛人 Tab**,沟通 Tab 仅作展示或后续扩展(如从沟通页拉会话列表)。
---
## 2. Tab 与 URL 对应关系
| Tab | 用途 | URL | 常量名 |
|-----|------|-----|--------|
| **Tab1首个页面** | 沟通 | `https://www.zhipin.com/web/chat/index` | `BOSS_CHAT_INDEX_URL` / `BOSS_CHAT_PAGE_URL` |
| **Tab2新建页面** | 推荐牛人 | `https://www.zhipin.com/web/chat/recommend` | `BOSS_RECOMMEND_PAGE_URL` |
- 代码中:`pageChat` = Tab1`page`(推荐牛人) = Tab2。
- Cookie / localStorage 在浏览器上下文中共享,两个 Tab 都会带上登录态;为保险起见,当前实现会在两个 Tab 加载前对首个页面注入 Cookie再对推荐牛人 Tab 单独注入一次后 `goto` 推荐页。
---
## 3. 主流程顺序index.mjs 简版)
1. **Launch 浏览器**,得到 `pageChat = browser.pages()[0]`
2. **Tab1 沟通**`pageChat.goto(BOSS_CHAT_INDEX_URL)`,注入 Cookie + `setDomainLocalStorage`
3. **Tab2 推荐牛人**`page = await browser.newPage()`,注入 Cookie`page.goto(BOSS_RECOMMEND_PAGE_URL)``page.bringToFront()`
4. **登录检测**:在 `page`(推荐牛人 Tab上根据 URL 判断是否在推荐页且未跳转登录;若需登录则等待用户在该 Tab 内登录,再 `storeStorage`
5. **网络/Canvas 拦截**:仅对 `page` 设置 `setupNetworkInterceptor``setupCanvasTextHook`
6. **主循环**:解析列表、筛选、打招呼、滚动加载等 **全部在 `page`(推荐牛人 Tab上执行**`pageChat` 不参与主循环。
---
## 4. 推荐牛人页选择器(主循环用)
主循环依赖的列表与操作均在 **推荐牛人 Tab** 上,选择器见 `packages/boss-auto-browse-and-chat/constant.mjs`
| 常量 | 说明 |
|------|------|
| `CANDIDATE_LIST_SELECTOR` | 候选人列表容器 |
| `CANDIDATE_ITEM_SELECTOR` | 单条候选人条目 |
| `CANDIDATE_NAME_SELECTOR` | 条目内姓名 |
| `CHAT_START_BUTTON_SELECTOR` | 打招呼按钮 |
| `GREETING_SENT_KNOW_BTN_SELECTOR` | 弹窗「知道了」 |
| `CONTINUE_CHAT_BUTTON_SELECTOR` | 继续沟通 |
| `CHAT_INPUT_SELECTOR` | 聊天输入框(如 `#boss-chat-global-input` |
---
## 5. 沟通页选择器(沟通 Tab / 后续扩展)
沟通 Tab 当前主要用于展示;若后续做「沟通页会话列表遍历、要简历」等,会用到以下选择器(同上 constant.mjs
| 常量 | 说明 |
|------|------|
| `CHAT_PAGE_USER_LIST_SELECTOR` | 左侧会话列表容器 |
| `CHAT_PAGE_ITEM_SELECTOR` | 单条会话 item |
| `CHAT_PAGE_NAME_SELECTOR` / `CHAT_PAGE_JOB_SELECTOR` | 会话项内姓名、职位 |
| `CHAT_PAGE_UNREAD_FILTER_SELECTOR` | 未读筛选按钮 |
| `CHAT_PAGE_ONLINE_RESUME_SELECTOR` 等 | 在线简历、附件简历、下载 PDF 等 |
沟通页列表结构也可参考 `examples/沟通-列表.md`
---
## 6. 侧栏「推荐牛人」入口(仅作备用)
若将来不再使用双 Tab而改回单 Tab 内切换,可使用:
- **选择器**`RECOMMEND_MENU_BUTTON_SELECTOR` = `#wrap > div.side-wrap.side-wrap-v2 > div > dl.menu-recommend > dt > a`
- 当前主流程 **已不再使用** 该选择器,推荐牛人逻辑仅在 Tab2 的 `BOSS_RECOMMEND_PAGE_URL` 上执行。
---
## 7. 相关文件
- 主流程:`packages/boss-auto-browse-and-chat/index.mjs`(双 Tab 创建、推荐牛人 Tab 主循环)
- 选择器与 URL`packages/boss-auto-browse-and-chat/constant.mjs`
- 沟通页简历流程:`plan/chat_page_resume_flow.md`

View File

@@ -0,0 +1,325 @@
# 沟通页简历流程与实现说明
本文档记录「沟通页:先看在线简历 → 关键词/LLM 筛选 → 再请求附件简历 → 对方同意后下载 PDF」的流程与实现方式便于后续维护和对接。
> **完整版简历 Canvas/WASM 破解方案**已由 Claude Code 分析并验证,**详见 [plan/cv_canvas_solution.md](cv_canvas_solution.md)**(含 fillText Hook、get_export_geek_detail_info、注入方式与后处理
---
## 1. 流程概览
| 步骤 | 说明 | 是否需对方同意 |
|------|------|----------------|
| 看在线简历 | 点「查看在线简历」,获取候选人简历内容用于筛选 | **否** |
| 关键词/LLM 筛选 | 用在线简历全文做关键词或 LLM 筛选,决定是否要附件 | 不涉及 |
| 请求附件简历 | 点「附件简历」→ 确认「确定向牛人索取简历吗」 | **是**(发出请求) |
| 对方同意后收 PDF | 对方同意后PDF 会作为**新消息**发到聊天里(**异步** | **是**(等对方) |
| 下载 PDF | 在消息里点「点击预览附件简历」→ 弹窗里点「下载 PDF」 | 不涉及 |
- **看在线简历**:无需对方同意,点开即可。
- **下载 PDF**必须先生成「请求附件简历」等对方同意后PDF 在新消息里出现,再点预览→下载。
---
## 2. 在线简历数据来源:两套不同的东西
### 2.1 两套数据要分清
| 来源 | 内容 | 用途 |
|------|------|------|
| **聊天/API 的简单摘要** | `geek/info``zpData.data``historyMsg``body.resume`:只有简单工作单位、学校、职位名等**摘要**,无完整经历描述、技能等 | 聊天框展示、列表展示 |
| **完整版简历(图片里那种)** | **加密数据** → 前端接收 → **WASM 解密**Rust `decrypt.rs`,含 Base64 + AES**仅绘制到 Canvas**,无明文接口暴露 | #resume 页面里看到的完整简历内容 |
也就是说:**完整版**和 **geek/info / 聊天消息里的 resume 不是同一份数据**。完整版是「加密 → WASM 解密 → 直接画到 Canvas」目前没有公开的明文 API 能拿到和图片里一模一样的全文。
### 2.2 完整版简历的链路WASM
- 沟通页点「查看在线简历」后,打开 `https://www.zhipin.com/web/frame/c-resume/?source=chat-resume-online`,页面只有 `<div id="resume"></div>`
- 前端会拿到**加密的简历数据**(可能随 geek/info 或另一接口下发),传给 WASM`wasm_canvas`Rust 编译)。
- WASM 内 **`src/decrypt.rs`** 做 Base64 解码 + AES 解密,得到明文后再在 Canvas 上通过 **fillText** 等绘制JS 胶水里有 `wasm_canvas_bg_js_wbg_fillText_*` 的 import
- WASM 导出 **`get_export_geek_detail_info()`**,可能用于把解密后的某部分数据回传给 JS但具体返回什么需看反编译或运行时行为。
- 反编译结果在 **`examples/wasm_canvas_bg-1.0.2-5057.dcmp`**(体量很大)。当前已能确认的线索:
- **`src/decrypt.rs`**、**"Decrypted data is empty"**、**"Base64 decode error"**、**"Encrypted data is empty"**:解密在 Rust 侧,含 Base64 解码与解密步骤。
- 依赖 **aes-0.8.4**Cargo 路径中出现):解密算法为 AES。
- **`wasm_canvas_bg_js_wbg_fillText_*`**WASM 通过 JS import 调用 `fillText` 把解密后的文字画到 Canvas。
- **`export function start(...)`**:入口,接收 container、content、**geek_info_encrypt_string**、geek_info_other_fields 等,即加密字符串由 JS 传入。
- **`export function get_export_geek_detail_info():int`**:导出函数,返回类型为 int可能为指针/句柄),需进一步看其实现或运行时行为才能判断是否可拿到解密后的全文或结构化数据。
- 后续逆向可重点查:加密数据从哪个接口/字段来、key/iv 从哪来、解密后是否写入某全局或通过 callback 回传。
### 2.3 拿到完整版明文的几种方式(不必破解 AES也不必非要 OCR
**重要**不需要「破解加密」也能拿到完整版明文。WASM 解密后要画字,会调用浏览器的 **`fillText(text, x, y)`**,此时 **`text` 在 JS 侧已经是明文**。我们 hook 的是「解密之后、画上去之前」的这一瞬,拿到的就是明文,不是密文。
| 方式 | 是否要破解/OCR | 说明 | 风险/成本 |
|------|----------------|------|------------|
| **Canvas hook推荐优先试** | 否 | 在页面注入前用 **`setupCanvasTextHook(page)`** 劫持 `fillText`WASM 解密后调 `fillText(明文, x, y)` 即被记录,再用 **`getCapturedText(page)`** 取回。项目已实现,见 `resume-extractor.mjs`。 | 有被反爬检测的可能;可先小范围用,若账号无异常再放宽。 |
| **只用简单摘要** | 否 | 用 **geek/info**、**historyMsg body.resume** 的摘要做筛选。已实现。 | 无;但内容不是完整版,筛选粒度粗。 |
| **OCR** | 不破解,但需 OCR | 打开在线简历后对 **#resume** 或整页截图,用 Tesseract / 云 OCR 识别。不依赖 hook不碰加解密。 | 需接截图+OCR 管线,识别率受字体/排版影响;不做解密。 |
| **逆向 WASM 在 Node 里解密** | 要逆向,不要 OCR | 在 .dcmp 里理清加密数据从哪来、key/iv 从哪来,在 Node 里复现解密,得到明文。 | 工作量大,且 key/iv 被打散,作者暂未分析出如何恢复。 |
| **修改 WASM 从内存取明文(原作者思路)** | 不恢复 key/iv改 WASM | WASM → WAT找到「解出明文」的代码位置`return` 提前返回WAT → WASM。运行时把含加密简历的网络响应喂给修改后的 WASM从内存里直接读解密结果。 | 需改 wasm 并维护;不依赖 key/iv 恢复。 |
| **get_export_geek_detail_info** | 否(若接口返回明文) | 若 BOSS 前端在简历加载完后通过该导出把解密结果暴露给 JS可在 `page.evaluate` 里调 WASM 实例拿到。 | 需确认该导出是否真的返回可读字符串/对象,且能从我们脚本访问到。 |
**建议**
- 若需要**完整版**做关键词/LLM 筛选:**优先用 Canvas hook**(已实现,无需破解、无需 OCR。若担心风控可先小流量试或只在本地/测试环境用。
- 若不能接受任何 hook**简单摘要** 做粗筛,或上 **OCR** 方案(截图 + Tesseract/云 OCR
- **不必**「只能破解或只能 OCR」二选一hook 是在解密后、绘制前截获明文,是当前最省事的完整版方案。
### 2.4 Canvas hook 实现细节关键iframe sandbox 限制)
在线简历 iframe 的 HTML 特征2026-03-17 从实际保存页面分析):
```html
<iframe
sandbox="allow-popups allow-top-navigation-by-user-activation allow-scripts
allow-modals allow-downloads allow-pointer-lock allow-presentation"
src="/web/frame/c-resume/?source=chat-resume-online"
...
>
```
**注意 sandbox 没有 `allow-same-origin`**。根据 HTML 规范,不含 `allow-same-origin` 时即便 URL 与主页面同源iframe 也被视为**跨域**opaque origin。因此从主页面访问 `iframeEl.contentWindow.CanvasRenderingContext2D.prototype` 会抛 `SecurityError`
**错误方式**(历史实现,现已废弃):
主页面用 MutationObserver 监听 iframe 插入,再通过 `contentWindow` 注入 hook → 被 sandbox 拦截hook 永远不生效Canvas 始终 0 次。
**正确方式(当前实现)**
`evaluateOnNewDocument` 会在**每一个 frame含 iframe**中各执行一次,不受 sandbox 限制。实现策略:
- **在 iframe 上下文**`evaluateOnNewDocument` 直接 hook 当前 `window.CanvasRenderingContext2D.prototype.fillText`;捕获到文字后,用 `setTimeout(0)` 批量缓冲,再通过 `window.top.postMessage({ __bossCanvasHook: items }, '*')` 发回主页面。`postMessage` 是标准跨域通信接口sandbox 不阻断。
- **在主页面上下文**`evaluateOnNewDocument` 设置 `window.__canvasCapturedText = []` 并注册 `message` 事件监听器,收到 `__bossCanvasHook` 数据后追加到数组。
- **`getCapturedText(page)`**:先等 150ms确保 `setTimeout(0)` + postMessage 任务队列已处理),再用 `page.evaluate` 读取并清空 `window.__canvasCapturedText`
**完整版取字调用顺序**
1.`page.goto()` **之前** 调用 `setupCanvasTextHook(page)`(需 evaluateOnNewDocument 先注册)。
2. 选中某条会话,`openOnlineResume(page)` 点开在线简历,等 iframe 出现。
3. 轮询 `getCapturedText(page)` 直到返回非空数据WASM 渲染是异步的,通常 1~3s最长等 8s 后降级用 `geek/info` 摘要。
**调试**`setupCanvasTextHook` 内部已用 `page.on('console', ...)` 把浏览器侧所有 `[canvasHook]` 日志转发到 Node.js 输出,无需单独在浏览器 DevTools 里看。
### 2.4 当前实现位置与用法(仅简单摘要)
- **拦截**`resume-extractor.mjs`**`setupNetworkInterceptor(page)`**,会拦截含 `geek/info``resume``geek/detail` 的 JSON。
- **解析****`parseGeekInfoFromIntercepted(interceptedMap)`** 从拦截结果里取 geek/info 的 `zpData.data`,拼成 **摘要级**`text`(姓名、学校、单位、经历列表等),**不是**完整版简历全文。
- **沟通页****`getOnlineResumeDataFromApi(getInterceptedData)`** / **`getOnlineResumeText(page, { getInterceptedData })`** 返回的即是上述摘要,适合「用简单信息先筛一轮」;若需完整版,需采用 2.3 中 24 之一。
---
## 3. 候选人初步信息与筛选选项
### 3.1 点击 item card 后可获取的初步信息字段
点击左侧某条会话(`selectConversationById`)后,页面会触发 `geek/info` API 请求。通过 `setupNetworkInterceptor + parseGeekInfoFromIntercepted` 可拦截到以下字段(来自 `zpData.data`
| 字段 | 说明 | 示例 |
|------|------|------|
| `name` | 姓名 | 张三 |
| `ageDesc` | 年龄描述 | 26岁 |
| `workYear` | 工作年限描述 | 3-5年 |
| `edu` | 最高学历 | 本科 |
| `positionStatus` | 求职状态 | 离职-随时到岗 |
| `school` | 毕业院校 | 北京大学 |
| `major` | 专业 | 计算机科学 |
| `city` | 所在城市 | 北京 |
| `salaryDesc` / `price` | 期望薪资描述 | 15-25K |
| `positionName` / `toPosition` | 期望职位 | 前端工程师 |
| `workExpList[]` | 工作经历列表timeDesc, company, positionName | 2021-2024 字节跳动 前端 |
| `eduExpList[]` | 教育经历列表timeDesc, school, major, degree | 2017-2021 北大 计算机 本科 |
**注意**
- 这些字段在点击 item card`selectConversationById`)后、点「查看在线简历」之前,由 `geek/info` API 响应带来,无需额外操作。
- `workYear``edu``salaryDesc` 等字段与推荐页候选人列表的同名字段含义相同,可复用 `candidate-processor.mjs` 中的 `parseWorkExpYears``parseSalaryRange` 做结构化解析。
- 目前 `parseConversationList` 只从 DOM 中拿 `geekName` + `jobTitle`(来自左侧 item card 的 `span.geek-name` / `span.source-job`),结构化字段需等点击后拦截 `geek/info` 才能得到。
### 3.2 两阶段筛选机制
沟通页有两个可做筛选的时机,代价从低到高:
| 阶段 | 数据来源 | 触发时机 | 代价 |
|------|---------|----------|------|
| **阶段一:初步信息筛选** | `geek/info` 结构化字段(`edu``workYear``salaryDesc``city` 等) | 点击 item card 后、打开在线简历**之前** | 低(无需打开简历页,只需点击 item |
| **阶段二:简历全文筛选** | `geek/info` 全文摘要API 拦截)或 Canvas 完整版hook | 点击「查看在线简历」后 | 高(需额外点击、等待页面加载) |
**建议**:先用阶段一做快速初筛(学历、工作年限、薪资、城市),不通过者直接跳过,减少需要打开在线简历的候选人数量,降低操作频次和风控风险。
### 3.3 初步信息筛选选项(`boss-recruiter.json` `chatPage.preFilter`
对应推荐页的 `candidate-filter.json`,沟通页可在 `chatPage.preFilter` 中配置初步信息筛选条件:
```json
{
"chatPage": {
"preFilter": {
"expectCityList": ["北京", "上海"],
"expectEducationList": ["本科", "硕士", "博士"],
"expectWorkExpRange": [1, 5],
"expectSalaryRange": [15, 50],
"expectSalaryWhenNegotiable": "exclude",
"blockCandidateNameRegExpStr": "测试|内推"
}
}
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `expectCityList` | `string[]` | 期望城市白名单;空数组 = 不限 |
| `expectEducationList` | `string[]` | 期望学历白名单(如 `["本科","硕士","博士"]`);空 = 不限 |
| `expectWorkExpRange` | `[number, number]` | 工作年限范围 [min, max](年);[0, 99] = 不限 |
| `expectSalaryRange` | `[number, number]` | 薪资范围 [min, max](千/月 K[0, 0] = 不限≥100 的值自动折算为 K如 8000→8 |
| `expectSalaryWhenNegotiable` | `'exclude'\|'include'` | 薪资「面议」或无法解析时:`exclude`(默认)= 跳过,`include` = 通过 |
| `blockCandidateNameRegExpStr` | `string` | 姓名屏蔽正则,命中则跳过 |
**筛选逻辑与推荐页相同**,可直接复用 `candidate-processor.mjs``filterCandidates`
- `city``expectCityList` 精确匹配
- `education``expectEducationList` 精确匹配
- `workExp``parseWorkExpYears()` 后与 `expectWorkExpRange` 比较
- `salary``parseSalaryRange()` 后与 `expectSalaryRange` 比较
- `name``blockCandidateNameRegExpStr` 正则匹配
### 3.4 在线简历全文筛选选项(`chatPage.filter`
阶段二的全文筛选,通过 `chatPage.filter` 配置:
```json
{
"chatPage": {
"filter": {
"mode": "keywords",
"keywordList": ["Vue", "React", "TypeScript"],
"llmRule": ""
}
}
}
```
| 字段 | 说明 |
|------|------|
| `mode` | `"keywords"`(默认)或 `"llm"` |
| `keywordList` | `mode="keywords"` 时:简历全文中至少命中一个关键词则通过;空数组 = 全通过 |
| `llmRule` | `mode="llm"` 时:传给 LLM 的筛选规则描述LLM 出错时默认通过 |
**注意**:全文筛选优先使用 Canvas hook 抓取的**完整版简历文本**WASM 解密后 fillText 调用序列重组Canvas 为空时自动降级为 `geek/info` 拼接的**摘要级文本**。Canvas hook 已实现,见 §2.4 及 `resume-extractor.mjs`
---
## 4. 请求附件简历与下载 PDF异步
- **请求****`requestAttachmentResume(page)`** 点击「附件简历」并在确认弹窗中点击确认。此时只是发出请求,**不会立刻得到 PDF**。
- **等待**对方同意后PDF 会作为**新消息**出现在聊天区域(异步),消息内会有「点击预览附件简历」。
- **等待新消息****`waitForAttachmentResumeMessage(page, options)`** 轮询当前对话中的消息,直到某条消息内出现「点击预览附件简历」按钮。
- **下载****`openPreviewAndDownloadPdf(page, messageElement?, options)`** 在该条消息上点击「点击预览附件简历」,等预览弹窗出现后点击「下载 PDF」。下载目录可通过 Puppeteer 的 `Page.setDownloadBehavior` 等设置。
### 4.1 跳过下载开关(`chatPage.attachmentResume.skipDownload`
BOSS 直聘支持在账号设置里配置「收到附件简历自动转发到邮箱」。若已开启该功能,则无需在 Puppeteer 里额外下载 PDF可在 `boss-recruiter.json` 中添加:
```json
{
"chatPage": {
"attachmentResume": {
"skipDownload": true
}
}
}
```
| 值 | 行为 |
|----|------|
| `false`(默认) | 检测到附件简历消息后,打开预览弹窗并点击「下载 PDF」 |
| `true` | 仅发出索取请求,检测到附件简历消息后**跳过下载**(系统仍继续处理后续候选人) |
**对 Webhook 的影响**`skipDownload: true` 时系统不下载 PDF因此 Webhook Payload 中的 `resumeFile` 字段将始终为空,与 `payloadOptions.includeResume` 的配置无关。详见 [plan/webhook_integration.md § 4](webhook_integration.md)。
---
## 5. 选择器与常量
- 沟通页相关选择器与 URL 常量均在 **`packages/boss-auto-browse-and-chat/constant.mjs`** 中,以 `CHAT_PAGE_*``CHAT_PAGE_ONLINE_RESUME_*` 为前缀。
- 在线简历内容容器:**`CHAT_PAGE_ONLINE_RESUME_CONTENT_SELECTOR = '#resume'`**;其**完整版**内容由加密数据经 WASM 解密后绘制到 Canvas**不是** geek/info 的简单摘要。
### 5.1 沟通页 DOM 结构要点2026-03-17 从实际页面分析)
- **页面整体**:沟通页主体 UI 在**顶层页面**,并非 iframe。只有两个 `srcdoc` iframe① 推荐牛人子页(`name=recommendFrame`);② Canvas 简历渲染器(含 `#resume canvas`)。
- **左侧会话列表**结构(虚拟滚动):
```
.user-container > .user-list.b-scroll-stable
> div[role=group] ← 虚拟滚动容器
> div[role=listitem] ← 每条会话的外层
> div.geek-item-wrap
> div.geek-item ← 可点击行id="_<geekId>-0"data-id="<geekId>-0"
span.geek-name ← 候选人姓名
span.source-job ← 职位
span.badge-count ← 未读数角标(无未读时不存在,非 display:none 而是不渲染)
```
- **encryptGeekId**:在 `.geek-item[data-id="<id>-0"]` 上,取 `data-id` 去掉末尾 `-0` 即为 ID。列表项内**没有 href**,不能从链接提取。
- **在线简历按钮**`a.resume-btn-online`(无 hrefVue 点击事件)
- **在线简历弹窗**(点击按钮后出现):
```
div#boss-dynamic-dialog-<动态id>.dialog-wrap.active
└─ div.boss-popup__wrapper.boss-dialog.boss-dialog__wrapper
.resume-common-dialog.search-resume
.new-chat-resume-dialog-main-ui.resume-container
├─ div.boss-popup__content
│ └─ div.resume-recommend.resume-common-wrap
│ └─ div.resume-detail.iframe-resume-detail
│ └─ <iframe sandbox="allow-scripts ..." src="/web/frame/c-resume/...">
└─ div.boss-popup__close ← 关闭按钮selector: .resume-common-dialog .boss-popup__close
└─ i.icon-close
```
- 弹窗 ID`#boss-dynamic-dialog-...`)是动态生成的,**不能**用 ID 匹配,应用类名:`.resume-common-dialog .boss-popup__close`。
- 切换候选人时弹窗**不会自动关闭**,需在打开新候选人的在线简历之前先调用 `page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR)` 并等待关闭按钮从 DOM 消失(`waitForSelector(closeSelector, { hidden: true })`)。
- 等待「iframe 消失」来判断弹窗关闭是不稳定的;等「关闭按钮消失」更可靠。
- **附件简历按钮**`.resume-btn-file`div未发起请求时带 `disabled` class点击仍会触发确认弹窗
- **消息列表**`.chat-message-list .message-item`
- **对方发来的附件简历消息** HTML 特征:
```html
<div class="item-friend">
<div class="message-card-wrap boss-green">
<div class="message-card-buttons">
<span class="card-btn">点击预览附件简历</span>
</div>
</div>
```
选择器:`div.message-card-buttons > span.card-btn`
- **页面加载时机**`document.readyState='complete'` 之后Vue 虚拟滚动列表仍需时间渲染,必须用 `waitForSelector(CHAT_PAGE_ITEM_SELECTOR)` 等待至少一条 `.geek-item` 出现后再解析,否则得到空列表。
---
## 6. 与简历图片的对应关系(产品/测试对照)
- **简历图片 / #resume 上看到的完整版**:对应「**加密数据 → WASM 解密 → Canvas 绘制**」这一条链路,**不是** geek/info 或 historyMsg 的 JSON。
- **聊天框/列表里的简单信息**:对应 **geek/info** 的 **`zpData.data`**、**historyMsg** 里 **`body.resume`**(仅简单工作单位、学校等摘要)。
- 若自动化需要「和图片一致的完整版」做筛选:**推荐用 Canvas hook**解密后、fillText 时截获明文,无需破解、无需 OCR若不能接受 hook再考虑 OCR 或逆向 WASM。
---
## 7. 文件与职责小结
| 文件 | 职责 |
|------|------|
| `plan/chat_page_resume_flow.md` | 本文档:流程与实现说明 |
| **`plan/cv_canvas_solution.md`** | **完整版简历 Canvas/WASM 破解方案(已验证)** |
| **`plan/recruiter_mouse_trajectory.md`** | **招聘端拟人鼠标轨迹(反人机),各 Phase 涉及点击/移动时必读** |
| `packages/boss-auto-browse-and-chat/constant.mjs` | 沟通页选择器、#resume、URL 常量 |
| `packages/boss-auto-browse-and-chat/resume-extractor.mjs` | 网络拦截、parseGeekInfoFromIntercepted、Canvas hook |
| `packages/boss-auto-browse-and-chat/chat-page-resume.mjs` | openOnlineResume、getOnlineResumeDataFromApi、getOnlineResumeText、requestAttachmentResume、waitForAttachmentResumeMessage、openPreviewAndDownloadPdf |
| `packages/boss-auto-browse-and-chat/chat-page-processor.mjs` | 沟通页主流程parseConversationList、selectConversationById、screenCandidateWithLlm、startBossChatPageProcess含阶段一初步信息筛选和阶段二全文筛选逻辑读取 `chatPage.attachmentResume.skipDownload` 开关决定是否跳过 PDF 下载 |
| `packages/boss-auto-browse-and-chat/default-config-file/boss-recruiter.json` | 默认配置,含 `chatPage.attachmentResume.skipDownload`(默认 `false` |
| `packages/boss-auto-browse-and-chat/candidate-processor.mjs` | 共用筛选工具filterCandidates、parseWorkExpYears、parseSalaryRange可复用于沟通页初步信息筛选preFilter |
---
## 8. 原作者建议(招聘端反检测)
> 作者说明:之前调研过招聘端;在职时太忙没推下去,现在失业也没有招聘权限。若有人要做,以下为个人想法。
> 简历解密相关方案WASM/Canvas hook/get_export_geek_detail_info 等)已由 Claude Code 分析并验证,**详见 [plan/cv_canvas_solution.md](cv_canvas_solution.md)**。
### 8.1 鼠标轨迹(反人机)
- **现象**BOSS 会对招聘端**鼠标移动轨迹**做埋点,可能是判断人机的特征之一。
- **建议**:尝试借助一些库生成**拟人的鼠标轨迹**(例如贝塞尔曲线、随机抖动、加速度等),让 Puppeteer 操作时不是「瞬移」而是沿轨迹移动,降低被识别为脚本的概率。
---
*文档维护:随实现变更时请同步更新本 plan。*

194
plan/cv_canvas_solution.md Normal file
View File

@@ -0,0 +1,194 @@
# BOSS 直聘在线简历 Canvas/WASM 反爬机制分析与破解方案
## 一、机制概述
BOSS 直聘的「在线简历」页面采用了一套基于 WebAssembly 的反爬方案,核心目的是防止简历内容被直接抓取:
- 简历数据以**加密字符串**形式下发Base64 + AES-256 加密)
- 解密过程完全在 **WASM 沙箱内**完成JS 层无法直接访问明文
- 解密后的明文**逐字绘制到 Canvas**,用户看到的是像素而非 DOM 文本
- Canvas 内容无法被 `innerText``querySelector` 等常规手段提取
## 二、技术栈与文件
| 文件 | 作用 |
|------|------|
| `wasm_canvas_bg-1.0.2-5057.wasm` | 核心 WASM 二进制,含解密+渲染逻辑 |
| `wasm_canvas_bg-1.0.2-5057.dcmp` | 上述 WASM 的反编译结果(~23万行 |
| `wasm_canvas-1.0.2-5057.js` | wasm-bindgen 生成的 JS 胶水层 |
| `index-Ue9MaX2q.js` | 页面业务逻辑,负责初始化 WASM 并调用 `start()` |
加载链路:
```
zhipin.com 主页面
→ postMessage → iframe (c-resume)
→ index-Ue9MaX2q.js: initWasm() → __wbg_init()
→ 加载 wasm_canvas_bg-*.wasm
→ start(container, content, geek_info_encrypt_string, ...)
→ WASM 内部解密 → 逐字 fillText 到 Canvas
```
## 三、WASM 内部解密流程
基于对反编译文件的分析:
### 加密方案
- **Base64** 编码 + **AES-256** 加密 + **PKCS#7** 填充
- AES 实现aes-0.8.4 cratepure Rustfixslice32 实现)
- AES 模式:**未确认**CBC/CTR/GCM 均未在反编译文件中出现CBC 是推断)
- 密钥和 IV 打散在 WASM 二进制中,未恢复
### 关键函数(反编译行号)
| 函数 | 编号 | 行号 | 作用 |
|------|------|------|------|
| `start()` | func452 | 122298 | JS 入口,接收加密字符串 |
| `start_anonymous_resume()` | func453 | 122369 | 匿名简历入口 |
| `f_qg()` | func198 | 50684 | 核心编排Base64解码 + AES解密 |
| `f_hi()` | func241 | 94273 | 文本渲染循环 |
| `fillText` 调用点 | — | 94814 | 唯一的 fillText 调用 |
| `get_export_geek_detail_info()` | func4040 | 228899 | 导出解密后的结构化数据 |
### 调用链
```
start() [L122298]
f_qg() [L50684] — Base64解码 → AES解密 → PKCS#7去填充
f_hi() [L94273] — 遍历文本元素
wasm_canvas_bg_js_wbg_fillText_4a931850b976cc62(ctx, ptr, len, x, y) [L94814]
JS: getObject(ctx).fillText(getStringFromWasm0(ptr, len), x, y)
Canvas 像素渲染
```
### fillText JS 胶水层wasm_canvas-1.0.2-5057.js L344-346
```js
__wbg_fillText_4a931850b976cc62: function(arg0, arg1, arg2, arg3, arg4) {
getObject(arg0).fillText(getStringFromWasm0(arg1, arg2), arg3, arg4);
}
```
- `arg1`ptr+ `arg2`lenWASM 线性内存中的 UTF-8 字符串
- `getStringFromWasm0` 通过 `TextDecoder` 将其转为 JS 字符串
- **此处是明文暴露的最后一关**
### 关于 get_export_geek_detail_info
```js
// wasm_canvas-1.0.2-5057.js L46-48
export function get_export_geek_detail_info() {
const ret = wasm.get_export_geek_detail_info();
return takeObject(ret); // 返回 JS 对象,不是裸指针
}
```
- 返回值是 wasm-bindgen JS heap 对象,包含结构化简历数据
- 需通过模块导出的 wrapper 调用,不能直接访问 `wasm.*`
- 调用时机:`start()` 返回后
## 四、渲染特征
- 每个字符**单独调用一次 fillText**(逐字渲染,非逐行)
- 同一字符会被**绘制两次**(两个叠加 Canvas高清/普通各一层)
- 坐标系x 为横向像素位置y 为行基线位置(相同 y 值 = 同一行)
## 五、已验证的破解方案
### 方案一Hook CanvasRenderingContext2D.prototype.fillText推荐
**原理**:在 fillText 原型上插桩,收集所有绘制调用的文本和坐标。
**难点**:简历渲染在 iframe 内,且每次打开都是新 iframe 实例,需在 iframe 创建时立即注入。
**验证结果**:✅ 已成功提取完整简历明文
**检测风险**:直接替换 prototype 方法后,`fillText.toString()` 会暴露自定义函数体而非 `[native code]`,可被检测。使用 Proxy 方案可规避此问题。
**注入代码Proxy 版toString 保持 [native code]**
```js
// 在主页面 Console 执行,然后再打开简历
window._collected = [];
const observer = new MutationObserver(() => {
const iframe = document.querySelector('iframe[src*="c-resume"]');
if (iframe && !iframe._hooked) {
iframe._hooked = true;
iframe.addEventListener('load', () => {
const iwin = iframe.contentWindow;
if (!iwin) return;
const orig = iwin.CanvasRenderingContext2D.prototype.fillText;
Object.defineProperty(iwin.CanvasRenderingContext2D.prototype, 'fillText', {
value: new Proxy(orig, {
apply(target, thisArg, args) {
const [text, x, y] = args;
if (text && text.trim()) window._collected.push({ text, x, y });
return Reflect.apply(target, thisArg, args);
}
}),
writable: true,
configurable: true,
});
console.log('✓ hook 注入到新 iframe');
});
}
});
observer.observe(document.body, { childList: true, subtree: true });
```
**后处理(去重+按行合并)**
```js
const lines = {};
window._collected.forEach(({text, x, y}) => {
const row = Math.round(y);
if (!lines[row]) lines[row] = [];
lines[row].push({text, x});
});
const result = Object.keys(lines)
.map(Number)
.sort((a, b) => a - b)
.map(y => {
const sorted = lines[y].sort((a, b) => a.x - b.x);
// 去重:相同 x 位置(双层 Canvas 导致每字画两次)
const deduped = sorted.filter((item, i) =>
i === 0 || Math.abs(item.x - sorted[i-1].x) > 1
);
return deduped.map(c => c.text).join('');
})
.join('\n');
console.log(result);
```
### 方案二:调用 get_export_geek_detail_info()(待验证)
**原理**`start()` 完成后,调用官方导出函数直接获取结构化 JS 对象。
**优点**:数据结构化,包含字段语义(姓名、学历、工作经历等)
**待验证**
- WASM 模块实例在 `index-Ue9MaX2q.js` 中如何暴露
- `start()` 返回后立即可调用,还是需要等某个回调
- 返回对象的字段结构
### 方案三Hook WASM JS import备选
```js
// 在 iframe load 后,替换 WASM import 对象中的 fillText 绑定
// 比 prototype hook 更底层,但需要在 __wbg_init 之前注入
```
## 六、不推荐的方案
| 方案 | 原因 |
|------|------|
| OCR 识别 Canvas | 精度差,中文易误识别,成本高 |
| 恢复 AES key/IV | 密钥打散在二进制中,工程量大,版本升级即失效 |
| 修改 WASM 二进制插入 early return | 可行但维护成本极高,版本升级即失效 |
| DOM 文本提取 | 简历内容不在 DOM 中,无效 |
## 七、稳定性与风险
- fillText hook 方案**不依赖** WASM 内部实现,版本升级只要渲染方式不变就依然有效
- BOSS 直聘最后一页附有版权声明(见提取结果末尾),提醒数据仅限招聘目的使用
- 如 BOSS 直聘升级为 OffscreenCanvas 或 Worker 渲染prototype hook 会失效,需改为 hook Worker 内的 Canvas API

70
plan/logger_usage.md Normal file
View File

@@ -0,0 +1,70 @@
# 招聘端 logger 用法说明
> **定位**`packages/boss-auto-browse-and-chat/logger.mjs` 的 API、配置与使用约定。
> 最后更新2026-03-18
---
## 1. 用途与级别
- **用途**:招聘端推荐页、沟通页的统一日志输出,支持按级别过滤,避免生产环境刷屏。
- **级别**(由低到高):`debug` < `info` < `warn` < `error`
设置某一级别后,只会输出 **大于等于** 该级别的日志(例如设为 `info` 时,会输出 info / warn / error不输出 debug
---
## 2. API
| 接口 | 说明 |
|------|------|
| `setLevel(level)` | 设置当前最低输出级别。`level``'debug'` / `'info'` / `'warn'` / `'error'`;非法值会回退为 `'info'`。 |
| `getLevel()` | 返回当前级别对应的数字debug=0, info=1, warn=2, error=3。 |
| `debug(...args)` | 输出 debug 级日志,底层使用 `console.log`。 |
| `info(...args)` | 输出 info 级日志,底层使用 `console.log`。 |
| `warn(...args)` | 输出 warn 级日志,底层使用 `console.warn`。 |
| `error(...args)` | 输出 error 级日志,底层使用 `console.error`。 |
`debug` / `info` / `warn` / `error` 的调用方式与 `console.log` 一致,支持多参数和字符串替换。
---
## 3. 配置来源与谁调 setLevel
- **配置项**`config.logLevel`(来自 `~/.geekgeekrun/config/boss-recruiter.json` 或流程传入的 config 对象)。
- **调用时机**:由主流程在**读取 config 之后**调用一次 `setLevel`,后续所有模块共享同一级别。
- **实际调用位置**
- **推荐牛人**`index.mjs` 在启动时读取 `boss-recruiter.json`,调用 `setLevel((readConfigFile('boss-recruiter.json') || {}).logLevel || 'info')`;配置热更新时再次 `setLevel(config?.logLevel || 'info')`
- **沟通页**`chat-page-processor.mjs` 启动时根据传入的 `config` 调用 `setLevel(config.logLevel || 'info')`
- **默认级别**:未配置或配置缺失时为 `'info'`
---
## 4. 在业务模块中的使用方式
`boss-auto-browse-and-chat` 包内任意模块中:
```javascript
import { debug as logDebug, info as logInfo, warn as logWarn, error as logError } from './logger.mjs'
// 按需使用,语义与级别一致
logDebug('详细调试信息', someObject)
logInfo('正常流程提示')
logWarn('可恢复的异常或边界情况')
logError('错误信息', err)
```
约定:业务模块**只使用** `debug` / `info` / `warn` / `error`**不调用** `setLevel``setLevel` 仅由 `index.mjs``chat-page-processor.mjs` 在读取 config 后调用。
---
## 5. 配置文件示例
`boss-recruiter.json` 中可选增加:
```json
{
"logLevel": "debug"
}
```
合法值为 `"debug"` | `"info"` | `"warn"` | `"error"`。开发时可设为 `"debug"`,生产环境建议 `"info"``"warn"`

241
plan/multi-job-switching.md Normal file
View File

@@ -0,0 +1,241 @@
# 多职位切换功能实现计划
## Context
当前招聘端自动化只支持单一全局配置(`boss-recruiter.json`),对所有职位使用相同的候选人筛选规则、招呼语和执行策略。用户需要:
1. **同步职位列表** — 从浏览器获取当前账号下的职位列表
2. **分职位独立配置** — 每个职位独立保存筛选规则、招呼语等配置(无全局 fallback必须每职位单独配置允许从其他职位拷贝后修改
3. **自动顺序执行支持多职位** — 可选择哪几个职位执行,以及每个职位各执行推荐还是沟通(或两者)
---
## 配置文件结构设计
所有多职位相关配置合并到 **单个文件** `~/.geekgeekrun/config/boss-jobs-config.json`
### 新文件: `~/.geekgeekrun/config/boss-jobs-config.json`
```json
{
"lastSyncAt": "2026-03-18T00:00:00Z",
"jobs": [
{
"jobId": "297790627",
"jobName": "研究员 _ 杭州 12-18K",
"sequence": {
"enabled": true,
"runRecommend": true,
"runChat": true
},
"filter": {
"expectCityEnabled": true,
"expectCityList": ["杭州"],
"expectEducationEnabled": false,
"expectEducationRegExpStr": "",
"expectWorkExpMinEnabled": false,
"expectWorkExpMaxEnabled": false,
"expectWorkExpRange": [0, 99],
"expectSalaryMinEnabled": false,
"expectSalaryMaxEnabled": false,
"expectSalaryRange": [0, 0],
"expectSalaryWhenNegotiable": "exclude",
"resumeKeywordsEnabled": false,
"resumeKeywords": [],
"resumeRegExpEnabled": false,
"resumeRegExpStr": "",
"resumeLlmEnabled": false,
"resumeLlmRule": ""
}
}
]
}
```
**设计说明:**
- 每个 `filter` 字段都有对应的 `*Enabled` 布尔值UI 中每行均有 checkbox 控制是否启用
- **无全局 fallback** — 每个职位必须独立配置,不再读取或合并全局 `candidate-filter.json`
- `filter` 统一包含推荐牛人页、沟通页的所有筛选字段(字段按适用页面有标注)
- `sequence` 控制该职位在自动顺序执行中的角色
**字段适用页面:**
- `expectCityEnabled/expectCityList` — 推荐牛人页 + 沟通页
- `expectEducationEnabled/expectEducationRegExpStr` — 推荐牛人页 + 沟通页
- `expectWorkExpMinEnabled/expectWorkExpMaxEnabled/expectWorkExpRange` — 推荐牛人页 + 沟通页(上下限各自独立开关)
- `expectSalaryMinEnabled/expectSalaryMaxEnabled/expectSalaryRange/expectSalaryWhenNegotiable` — 推荐牛人页 + 沟通页(上下限各自独立开关)
- `resumeKeywordsEnabled/resumeKeywords` — 仅沟通页
- `resumeRegExpEnabled/resumeRegExpStr` — 仅沟通页
- `resumeLlmEnabled/resumeLlmRule` — 仅沟通页
**已移除字段(不在 filter 中):**
- 招呼语(`greetingEnabled/greetingMessage`)— 保留在「推荐牛人」页全局配置
- 每次最多开聊人数(`maxChatPerRunEnabled/maxChatPerRun`)— 保留在「推荐牛人」页全局配置
- 技能关键词(`expectSkillEnabled/expectSkillKeywordsStr`)— brief 页无技能字段,暂不实现
- 屏蔽姓名(`blockNameEnabled/blockCandidateNameRegExpStr`)— 同上,暂不实现
---
## 实现概述
### 新增页面职位配置BossJobConfig
- **路由名:** `BossJobConfig`,路径 `BossJobConfig`
- **文件:** `packages/ui/src/renderer/src/page/MainLayout/BossJobConfig/index.vue`
- **导航:** 在 `RecruiterPart.vue` 中位于招聘BOSS分组最顶部
- **功能:**
- 顶部操作栏:「同步职位列表」按钮(触发 `sync-boss-job-list`
- 职位列表使用 `el-collapse`,每个职位展开显示完整筛选表单
- 表单每个字段左侧有 checkbox 控制 `*Enabled`,未启用时输入控件 disabled
- 字段分为两组:`推荐牛人页 + 沟通页`default`仅沟通页`success
- 简历筛选为多选 checkbox支持同时启用关键词匹配、正则表达式匹配、大模型筛选全不勾选即不筛选
- 工作经验和薪资范围的上下限各自独立 checkbox 控制
- 「从其他职位拷贝配置」:点击后打开对话框,选择源职位,将其 filter 拷贝到当前职位并允许修改
- 每个职位有独立的「保存」按钮,调用 `save-boss-jobs-config`(合并写入)
### 修改:推荐牛人 - 自动开聊BossAutoBrowseAndChat
- **文件:** `packages/ui/src/renderer/src/page/MainLayout/BossAutoBrowseAndChat/index.vue`
- **变更:** 移除所有筛选条件字段(城市、学历、工作年限、薪资、技能、屏蔽姓名)
- **保留:** 招呼语(全局默认)、每次最多开聊人数(全局默认)、两次开聊间隔、推荐页运行策略(单轮停止、不感兴趣、跳过已读、间隔、保持浏览器)
- **新增提示:** `el-alert` 引导用户到「职位配置」页面配置筛选条件
### 修改沟通BossChatPage
- **文件:** `packages/ui/src/renderer/src/page/MainLayout/BossChatPage/index.vue`
- **变更:** 移除所有筛选条件字段preFilter + resumeFilter
- **保留:** 每次最多处理未读会话数、单轮运行完成后停止、保持浏览器打开、两轮之间的等待间隔
- **新增提示:** `el-alert` 引导用户到「职位配置」页面配置筛选条件
### 修改自动顺序执行BossAutoSequence
- **文件:** `packages/ui/src/renderer/src/page/MainLayout/BossAutoSequence/index.vue`
- **新增职位执行队列 section在原有执行策略配置上方**
- `onMounted` 调用 `fetch-boss-jobs-config` 获取职位列表
- `el-table` 展示职位列表每行包含职位名称、「纳入执行」checkbox`sequence.enabled`、「执行推荐牛人」checkbox`sequence.runRecommend`enabled 为 false 时 disabled、「执行沟通页」checkbox`sequence.runChat`enabled 为 false 时 disabled
- 若 jobs 为空:`el-alert` 提示先到「职位配置」页面同步职位列表
- 「保存队列配置」按钮 → `save-boss-jobs-config`
---
## IPC Handlers`packages/ui/src/main/flow/OPEN_SETTING_WINDOW/ipc/index.ts`
### `fetch-boss-jobs-config`
读取 `~/.geekgeekrun/config/boss-jobs-config.json`,返回整个配置对象(含 jobs 数组)。
### `save-boss-jobs-config`
- 参数:部分 jobs 配置的 JSON 字符串(可只含单个 job 的更新)
- 读取现有文件 → 深度合并(按 jobId 匹配) → 写回文件
### `sync-boss-job-list`
1. 启动临时 Puppeteer 浏览器(复用 initPuppeteer + cookie/localStorage 注入)
2. 导航到 `https://www.zhipin.com/web/chat/index`(沟通页)
3. **点击 `.chat-top-job`** 展开职位下拉菜单
4. 等待 `.ui-dropmenu-list:not(.sf-hidden) li` 出现timeout 10s
5. 提取各 `li``value`jobId和文本jobName过滤掉 value="-1"(全部)
6.`boss-jobs-config.json` 现有 jobs 合并(新职位添加默认结构,旧职位保留现有 filter 配置)
7. 关闭浏览器
8. 返回 `{ jobs: mergedJobs }`
**注意 CSS 选择器(已从实际 HTML 验证):**
- 沟通页职位选择器:`.chat-top-job` → 点击展开 → `.ui-dropmenu-list:not(.sf-hidden) li`
- 新同步职位的默认结构:`{ jobId, jobName, sequence: { enabled: true, runRecommend: true, runChat: true }, candidateFilter: {}, autoChat: {}, chatPage: {} }`
---
## runtime-file-utils 扩展(`packages/boss-auto-browse-and-chat/runtime-file-utils.mjs`
新增/修改工具函数:
- `readBossJobsConfig()` — 读取 `boss-jobs-config.json`,不存在返回 `{ jobs: [] }`
- `writeBossJobsConfig(config)` — 写入 `boss-jobs-config.json`
- `getMergedJobConfig(jobId)` — 按 `j.jobId || j.id`(兼容旧数据)查找 job 条目,返回合并后的运行时配置
**向后兼容:** `getMergedJobConfig` 和 sync handler 的 `existingMap` 均使用 `j.jobId ?? j.id` 兼容旧版本数据中 `id` 字段名。
---
## 导航 & 路由变更
### `packages/ui/src/renderer/src/router/index.ts`
`children` 数组中,`BossAutoBrowseAndChat` 之前新增:
```typescript
{
name: 'BossJobConfig',
path: 'BossJobConfig',
component: () => import('@renderer/page/MainLayout/BossJobConfig/index.vue'),
meta: { title: '职位配置' }
}
```
### `packages/ui/src/renderer/src/page/MainLayout/index.vue`
`RECRUITER_ROUTES` 数组中新增 `'BossJobConfig'`
### `packages/ui/src/renderer/src/page/MainLayout/LeftNavBar/RecruiterPart.vue`
在招聘BOSS分组最顶部新增
```vue
<RouterLink :to="{ name: 'BossJobConfig' }">职位配置</RouterLink>
```
---
## 关键文件清单
| 文件 | 变更类型 |
|------|----------|
| `packages/ui/src/renderer/src/page/MainLayout/BossJobConfig/index.vue` | **新建** — 职位配置专用页面 |
| `packages/ui/src/renderer/src/page/MainLayout/BossAutoBrowseAndChat/index.vue` | 修改 — 移除筛选字段,保留策略配置 |
| `packages/ui/src/renderer/src/page/MainLayout/BossChatPage/index.vue` | 修改 — 移除筛选字段,保留策略配置 |
| `packages/ui/src/renderer/src/page/MainLayout/BossAutoSequence/index.vue` | 修改 — 新增职位执行队列 section |
| `packages/ui/src/renderer/src/page/MainLayout/LeftNavBar/RecruiterPart.vue` | 修改 — 新增「职位配置」导航链接 |
| `packages/ui/src/renderer/src/page/MainLayout/index.vue` | 修改 — RECRUITER_ROUTES 新增 BossJobConfig |
| `packages/ui/src/renderer/src/router/index.ts` | 修改 — 新增 BossJobConfig 路由 |
| `packages/ui/src/main/flow/OPEN_SETTING_WINDOW/ipc/index.ts` | 修改 — 新增/修复 3 个 IPC handlers |
| `packages/boss-auto-browse-and-chat/runtime-file-utils.mjs` | 修改 — getMergedJobConfig 兼容 jobId/id |
---
## 可复用的现有代码
- `packages/boss-auto-browse-and-chat/runtime-file-utils.mjs``readConfigFile`, `writeConfigFile`(直接复用)
- `packages/boss-auto-browse-and-chat/index.mjs``initPuppeteer()`, cookie/localStorage 注入逻辑(复用于 sync-boss-job-list 启动浏览器)
- `packages/ui/src/main/flow/BOSS_RECOMMEND_MAIN/index.ts``initPlugins()` 和浏览器初始化模式
- `packages/ui/src/main/flow/OPEN_SETTING_WINDOW/ipc/index.ts``save-boss-recruiter-config` 的 read/merge/write 模式
---
## 待实现部分(核心自动化层)— 已实现 ✓
以下功能已实现:
### 推荐页自动化支持 targetJobId ✓
**文件:** `packages/boss-auto-browse-and-chat/index.mjs`
`startBossAutoBrowse(hooks, opts)` 支持 `opts.jobId`,并新增 `opts.browser`/`opts.page` 复用已有实例:
- 导航到推荐页后,若 `jobId` 存在且非 `-1`/`0`,自动 `switchRecommendJobId`
- 选择器:`#headerWrap .ui-dropmenu-label` + `#headerWrap ul.job-list li.job-item[value="{jobId}"]`
### 沟通页自动化支持 targetJobId ✓
**文件:** `packages/boss-auto-browse-and-chat/chat-page-processor.mjs`
`startBossChatPageProcess(hooks, opts)` 支持 `opts.jobId`
- 导航到沟通页后,点击 `.chat-top-job .ui-dropmenu-label` 展开,选中 `li[value="{jobId}"]`
### Worker 入口读取 per-job 配置 ✓
**文件:** `packages/ui/src/main/flow/BOSS_RECOMMEND_MAIN/index.ts``BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts`
- `--job-id` 命令行参数BOSS_RECOMMEND_MAIN 已有)
- `getMergedJobConfig(jobId)` 将 boss-jobs-config 的 `filter` 转为 candidateFilter / chatPage 格式并覆盖配置
### 自动顺序执行多职位队列循环 ✓
**文件:** `packages/ui/src/main/flow/BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts`
- 读取 `boss-jobs-config.json``sequence.enabled === true` 的 jobs
- 依次对每个 job 执行 runRecommend 和/或 runChat共用一个浏览器实例
- 仅沟通页场景:`launchBrowserAndNavigateToChat()` 启动浏览器并导航到沟通页
---
## 验证方案
1. **职位同步**: 点击「同步职位列表」后,检查 `~/.geekgeekrun/config/boss-jobs-config.json` 是否生成,职位名称是否正确
2. **per-job 配置保存**: 在「职位配置」页编辑某职位配置保存后,检查 `boss-jobs-config.json` 对应 job 的 filter 字段
3. **推荐页职位切换**: 在 BOSS_RECOMMEND_MAIN 添加日志,确认切换至目标职位后候选人列表刷新
4. **多职位队列执行**: 在 BossAutoSequence 配置 2 个职位的不同任务组合,启动后观察日志依次切换职位
5. **拷贝配置**: 从职位 A 拷贝配置到职位 B 后,确认 filter 字段正确复制sequence 字段保持职位 B 原有值)

393
plan/recommend_page_flow.md Normal file
View File

@@ -0,0 +1,393 @@
# 推荐牛人页Recommend Page完整逻辑文档
> **定位**:供 AI Agent 快速理解推荐牛人页的完整运行逻辑、DOM 结构、选择器与各模块分工。
> 最后更新2026-03-16
---
## 1. 入口与文件分工
| 文件 | 职责 |
|------|------|
| `packages/boss-auto-browse-and-chat/index.mjs` | 主入口 `startBossAutoBrowse(hooks, opts)`:浏览器启动、登录、主循环 |
| `packages/boss-auto-browse-and-chat/candidate-processor.mjs` | `parseCandidateList``filterCandidates``scrollAndLoadMore``navigateToNextPage` |
| `packages/boss-auto-browse-and-chat/chat-handler.mjs` | `viewCandidateDetail``startChatWithCandidate``processCandidate``checkDailyLimit` |
| `packages/boss-auto-browse-and-chat/resume-extractor.mjs` | 网络拦截 + Canvas iframe hook提取 Canvas 简历文字 |
| `packages/boss-auto-browse-and-chat/constant.mjs` | 所有 CSS 选择器与 URL 常量 |
| `packages/boss-auto-browse-and-chat/humanMouse.mjs` | `createHumanCursor(page)` — ghost-cursor 人类鼠标轨迹 |
---
## 2. 页面架构:主页面 + iframe关键
推荐牛人页使用 **两层 iframe** 架构,这是候选人列表选择器长期失效的根本原因:
```
主页面(/web/chat/recommend
└── iframe[name="recommendFrame"] ← 候选人列表在此 iframe 内!
src="/web/frame/recommend/?jobid=...&version=XXXX"
└── div.recommend-list-wrap
└── div#recommend-list.list-wrap.card-list-wrap
└── div.list-body
└── ul.card-list ← CANDIDATE_LIST_SELECTOR
└── li.card-item ← CANDIDATE_ITEM_SELECTOR
└── div.candidate-card-wrap
└── div.card-inner[data-geek][data-geekid]
├── div.col-1
│ ├── div.avatar-wrap > img.avatar
│ └── div.salary-wrap > span ← 薪资
├── div.col-2
│ ├── div.row.name-wrap
│ │ └── span.name ← 姓名
│ ├── div.row
│ │ └── div.join-text-wrap.base-info > span × N
│ │ └── 年龄 / 工作年限 / 学历 / 求职状态
│ └── div.row.row-flex.expect-wrap
│ └── span.content > div.join-text-wrap > span × N
│ └── 期望城市 / 期望职位
└── div.operate-side
└── div.button-chat-wrap.button-chat
└── button.btn.btn-greet ← 打招呼CHAT_START_BUTTON_SELECTOR
```
点击候选人卡片后,主页面弹出详情 dialog不在 iframe 内):
```
主页面 dialog点击卡片后弹出
└── div.boss-popup__wrapper.dialog-lib-resume.recommendV2
└── div.lib-resume-recommend.wasm-resume-layout
├── div.resume-detail-wrap
│ └── iframe[src="/web/frame/c-resume/?source=recommend"] ← 简历 Canvas 在此 iframe 内
│ └── canvas#resume ← WASM 解密后绘制
└── div.resume-right-side ← 右侧操作区(在主页面)
└── div.button-list-wrap > div.button-chat-wrap.resumeGreet
└── button.btn-v2.btn-sure-v2.btn-greet ← 也可点此打招呼
```
**关键 data 属性:**
- `div.card-inner[data-geek]` — encryptGeekId用于去重、写 DB
- `div.base-info span` 含"年"字 → workExp含学历关键词 → education
**操作对象分工:**
- **`recommendFrame`iframe Frame 对象)**parseCandidateList、scrollAndLoadMore、查找 btn-greet
- **主页面 `page`**「知道了」弹窗、风控检测、CHAT_INPUT_SELECTOR、checkDailyLimit
---
## 3. 选择器常量constant.mjs
所有候选人列表选择器均在 **iframe Frame** 内使用,不在主页面:
```js
// 在 recommendFrameiframe内使用
CANDIDATE_LIST_SELECTOR = 'ul.card-list'
CANDIDATE_ITEM_SELECTOR = 'ul.card-list > li.card-item'
CANDIDATE_NAME_SELECTOR = 'span.name'
CANDIDATE_DETAIL_SELECTOR = '' // 无独立详情面板
CHAT_START_BUTTON_SELECTOR = 'button.btn-greet' // 在 li.card-item 内
CONTINUE_CHAT_BUTTON_SELECTOR = 'div.operate-side div.button-chat'
// 在主页面page使用
GREETING_SENT_KNOW_BTN_SELECTOR = 'div.dialog-wrap button.btn-sure-v2'
CHAT_INPUT_SELECTOR = '#boss-chat-global-input'
RESUME_POPUP_CLOSE_SELECTOR = 'div.boss-popup__close' // 简历详情弹窗关闭
// 在 recommendFrameiframe内使用每条 card-item 内
NOT_INTERESTED_IN_ITEM_SELECTOR = 'div.tooltip-wrap.suitable' // 不感兴趣(点击后弹出原因)
NOT_INTERESTED_REASON_POPUP_SELECTOR = 'div.card-reason-f1.show' // 原因弹窗
NOT_INTERESTED_REASON_ITEMS_SELECTOR = 'div.card-reason-f1.show span.first-reason-item' // 所有选项,按 NOT_INTERESTED_REASON_MAP 匹配
NOT_INTERESTED_REASON_MAP = { city:'牛人距离远', education:'不考虑本科', salary:'期望薪资偏高', workExp:'工作经历和制剂研发无关', viewed:'重复推荐', skills:'其他原因', blockName:'其他原因' }
NOT_INTERESTED_REASON_FALLBACK = '其他原因'
BOSS_RECOMMEND_PAGE_URL = 'https://www.zhipin.com/web/chat/recommend'
BOSS_CHAT_INDEX_URL = 'https://www.zhipin.com/web/chat/index'
```
---
## 4. 主循环流程startBossAutoBrowse
```
startBossAutoBrowse(hooks, { returnBrowser? })
1. hooks.beforeBrowserLaunch
2. puppeteer.launch({ headless, viewport: 1440×760 })
3. page单 Tab → BOSS_RECOMMEND_PAGE_URL注入 cookie + localStorage
4. 登录检测URL 未在推荐页则等待用户手动登录(最长 5 分钟)→ storeStorage()
5. page.waitForSelector('iframe[name="recommendFrame"]') → recommendFrameHandle
6. recommendFrameHandle.contentFrame() → recommendFrameiframe Frame 对象)
7. recommendFrame.waitForSelector('ul.card-list > li.card-item', 30s)
8. setupNetworkInterceptor(page) // 拦截 resume/geek/info API主页面
9. setupCanvasTextHook(page) // MutationObserver 注入 c-resume iframe fillText hook
10. 读取 boss-recruiter.json / candidate-filter.json
11. hooks.onCandidateListLoadedGUI 登录状态 fulfilled
主循环 while(true):
a. parseCandidateList(recommendFrame) → candidates[] ← 在 iframe frame 内操作
├── 方式一Vue __vue__ / __vueParentComponent 获取数据(自动解析所有字段)
└── 方式二兜底DOM 解析
· encryptGeekId ← div.card-inner[data-geek]
· geekName ← span.name
· salary ← div.salary-wrap span
· workExp ← div.base-info span匹配 /年|经验不限/
· education ← div.base-info span匹配学历关键词
· city ← div.expect-wrap span.content div.join-text-wrap span[0]
· jobTitle ← div.expect-wrap span.content div.join-text-wrap span[1]
b. filterCandidates(candidates, filterConfig) + hooks.onCandidateFiltered
筛选条件city / education / workExp / salary / skills / blockName
c. for each matched candidate (index i):
checkDailyLimit(page) → 已达上限则 break mainLoop ← 在主页面检测
chatCount >= maxChatPerRun → break mainLoop
processCandidate(recommendFrame, candidate, config, hooks, { candidateIndex: i, mainPage: page })
└── viewCandidateDetail(recommendFrame, candidate, { candidateIndex: i })
· 拟人 cursor 点击 li.card-item[i](展开,在 iframe 内)
· 从 DOM / 网络拦截 / Canvas 提取简历文字
└── startChatWithCandidate(recommendFrame, candidate, greetingMessage, { candidateIndex: i, mainPage: page })
· 在 recommendFrame 的 li.card-item[i] 内找 button.btn-greet → cursor.click()iframe 内)
· mainPage.waitForSelector('div.dialog-wrap button.btn-sure-v2', 6s)(主页面等弹窗)
· cursor.click(knowBtn BoundingBox)(知道了,主页面弹窗)
· recommendFrame 内找 CONTINUE_CHAT_BUTTON_SELECTOR → click()(可选)
· mainPage 的 CHAT_INPUT_SELECTOR 输入 greetingMessage → Enter可选
└── hooks.afterChatStarted(candidate, chatResult)
└── sleepWithRandomDelay(delayBetweenChats)
chatResult.success → chatCount++, hooks.onProgress({ phase:'recommend', current, max })
chatResult.reason === DAILY_LIMIT_REACHED / RISK_CONTROL → break mainLoop
d. scrollAndLoadMore(recommendFrame) ← 在 iframe frame 内滚动
· page.mouse.wheel 小步随机滚动3 轮 × 4 步,每步 25-40px间隔 80-160ms
· 检测页面文本 /没有更多|已经到底/ 或 Vue hasMore===false → return false
· false → break mainLoop已加载全部
10. hooks.onComplete
11. returnBrowser ? return { browser, page } : browser.close()
```
---
## 5. 候选人简历弹窗(动态弹出的详情)
点击 `li.card-item` 后,页面弹出一个动态 dialog内含 iframe 渲染的完整简历:
```html
<!-- 弹窗容器 -->
<div class="boss-popup__wrapper dialog-lib-resume recommendV2">
<div class="lib-resume-recommend wasm-resume-layout">
<!-- 简历内容区:通过 iframe 渲染 -->
<iframe src="/web/frame/c-resume/?source=recommend" frameborder="0">
<!-- iframe 内部WASM 解密后渲染到 Canvas -->
<canvas id="resume" width="1448" height="1478" style="width:724px;height:739px"></canvas>
</iframe>
<!-- 右侧操作区(非 iframe -->
<div class="resume-simple-box">
<!-- 经历概览:仅包含摘要,非完整简历 -->
<div class="resume-summary"> ... </div>
<!-- 打招呼按钮popup 内也有,与列表项内 btn-greet 同效) -->
<button class="btn-v2 btn-sure-v2 btn-greet">打招呼</button>
</div>
</div>
</div>
```
**Canvas 简历提取setupCanvasTextHook**
- WASMBase64+AES-256 解密)在 iframe 内逐字调用 `fillText` 绘制到 `#resume`
- 主页面的 `evaluateOnNewDocument` 不影响 iframe因此注入方式为
```
主页面注入 MutationObserver
→ 监听 iframe[src*="c-resume"] 创建
→ iframe.addEventListener('load', ...)
→ 在 iframe.contentWindow.CanvasRenderingContext2D.prototype 上用 Proxy 包装 fillText
→ 收集 { text, x, y } 到主页面 window.__canvasCapturedText
```
- 提取后用 `extractResumeText()` 按 y 坐标分行、x 排序拼字 → 返回 `string[]`
- 双层 Canvas 导致每字绘制两次,`extractResumeText` 通过 `Y_THRESHOLD=5px` 分组已自动处理
---
## 6. 打招呼完成弹窗
打招呼成功后,页面弹出"已向牛人发送招呼"
```html
<div class="dialog-wrap active" data-type="boss-dialog">
<div class="boss-popup__wrapper dialog-default-v2">
<div class="boss-dialog__body">
<div class="tip-title"><span class="title">已向牛人发送招呼</span></div>
<div class="tip-con">你好,我们公司正在招聘研究员,请问考虑吗</div>
</div>
<div class="buttons">
<label class="checkbox"><input type="checkbox" name="notip"> 不再显示</label>
<button type="button" class="btn-v2 btn-sure-v2">知道了</button> <!-- GREETING_SENT_KNOW_BTN_SELECTOR -->
</div>
</div>
</div>
```
`GREETING_SENT_KNOW_BTN_SELECTOR = 'div.dialog-wrap button.btn-sure-v2'`
---
## 6.5 不感兴趣与原因弹窗
对未通过筛选的候选人点击卡片上的"不感兴趣"`div.tooltip-wrap.suitable`)后,**会先弹出原因选择弹窗**(在 iframe 内),须选一项后弹窗才关闭、该候选人才从列表移除。
**弹窗结构iframe 内,与列表同属 recommendFrame**
```html
<div class="card-reason-f1 show" showcandidatecard="true">
<div class="bg"></div>
<div class="reason-group">
<dl>
<dt>选择不喜欢的原因,为您优化推荐</dt>
<dd class="first-reason-list-warp">
<span class="first-reason-item">牛人距离远</span>
<span class="first-reason-item">不考虑本科</span>
<span class="first-reason-item">期望薪资偏高</span>
<!-- ... 更多选项 ... -->
<span class="first-reason-item">其他原因</span>
</dd>
</dl>
</div>
<div class="close-icon">...</div>
</div>
```
**流程:**
1. 悬停到候选人卡片 → 点击"不感兴趣"`NOT_INTERESTED_IN_ITEM_SELECTOR`
2. 等待原因弹窗出现:`NOT_INTERESTED_REASON_POPUP_SELECTOR = 'div.card-reason-f1.show'`iframe 内)
3. 按**筛选原因**在弹窗内选对应选项(见下表),以便 BOSS 优化推荐;未匹配时选"其他原因"
4. 弹窗关闭,该条从列表移除
**筛选原因 → 弹窗选项constant.mjs `NOT_INTERESTED_REASON_MAP`**
| 筛选 reasoncandidate-processor | 弹窗选项文案 |
|-----------------------------------|--------------|
| `city` | 牛人距离远 |
| `education` | 不考虑本科 |
| `salary` | 期望薪资偏高 |
| `workExp` | 工作经历和制剂研发无关 |
| `viewed` | 重复推荐 |
| `skills` / `blockName` | 优先选文案包含"与职位不符"的选项(如"期望xxx与职位不符"),否则"其他原因" |
| 其他/未知 | 其他原因 |
**选择器与常量constant.mjs**
- `NOT_INTERESTED_IN_ITEM_SELECTOR`:卡片内"不感兴趣"区域
- `NOT_INTERESTED_REASON_POPUP_SELECTOR`:原因弹窗容器
- `NOT_INTERESTED_REASON_ITEMS_SELECTOR`:弹窗内所有 `span.first-reason-item`,按文案匹配
- `NOT_INTERESTED_REASON_MAP`reason → 弹窗文案
- `NOT_INTERESTED_REASON_POSITION_MISMATCH`:用于 skills/blockName 的包含匹配("与职位不符"
- `NOT_INTERESTED_REASON_FALLBACK`:无匹配时使用("其他原因"
**扩展(后续可做):** 可将 `filterResult.reasonDetail` 或候选人信息交给 LLM由 LLM 返回更贴切的弹窗选项文案,再在 `span.first-reason-item` 中做模糊匹配点击,进一步优化 BOSS 推荐效果。
---
## 7. 每日限额检测checkDailyLimit
通过 `document.body.innerText` 匹配文字特征判断,无独立 API
```
/今日已沟通\s*(\d+)\s*\/\s*(\d+)/ → count/max 数字解析
/今日沟通人数已达上限|明天再来|今日.*已达上限/ → 直接判定 limitReached=true
```
风控检测(在 `startChatWithCandidate` 点击打招呼后立即检测):
```
/风控|存在风险|请稍后再试|操作过于频繁/ → reason: RISK_CONTROL
```
---
## 8. 滚动加载scrollAndLoadMore
推荐牛人页为**无限滚动**,无分页按钮。滚动使用 `page.mouse.wheel()` 模拟人工:
```
maxScrolls = 3, stepsPerScroll = 4
每步deltaY = 25~40px间隔 80~160ms
```
到底判断:
1. `document.body.innerText` 含 `/没有更多|已经到底|加载完毕|暂无更多/`
2. Vue `el.__vue__?.hasMore === false`
两者均为"没有更多"时返回 `false`,主循环退出。
---
## 9. 反检测机制
- **puppeteer-extra-plugin-stealth** — 抹除 headless 特征
- **@geekgeekrun/puppeteer-extra-plugin-laodeng** — 自定义指纹混淆
- **puppeteer-extra-plugin-anonymize-ua** — 随机 UserAgent`makeWindows: false`
- **ghost-cursor** — `createHumanCursor(page)` 所有点击走人类轨迹,不用 `page.click()`
- **sleepWithRandomDelay** — 操作间随机延迟
- Canvas hook 用 **Proxy**(不直接替换 prototype`fillText.toString()` 仍返回 `[native code]`
---
## 10. 配置文件
**boss-recruiter.json**`~/.geekgeekrun/config/boss-recruiter.json`
```json
{
"logLevel": "info",
"targetJobId": "",
"recommendPage": {
"clickNotInterestedForFiltered": true,
"skipViewedCandidates": false,
"runOnceAfterComplete": false
},
"autoChat": {
"greetingMessage": "你好,...",
"maxChatPerRun": 50,
"delayBetweenChats": [3000, 8000]
}
}
```
- `logLevel`:日志级别 `"debug"` | `"info"` | `"warn"` | `"error"`;设为 `"debug"` 时每次自动操作(解析、悬停、点击不感兴趣、选原因等)都会打印日志,便于排查。
- `recommendPage.clickNotInterestedForFiltered`:对未通过筛选的候选人点击"不感兴趣"(卡片内 `div.tooltip-wrap.suitable`),并自动在原因弹窗中选择一项(默认"其他原因")后关闭,避免重复扫描;设为 `false` 可关闭便于调试。
- `recommendPage.skipViewedCandidates`:是否跳过已读候选人(卡片带 `has-viewed` 的项);与 candidate-filter 的 `skipViewedCandidates` 一致,以 boss-recruiter 为准。
- `recommendPage.runOnceAfterComplete`:单次运行结束后是否停止(不再次启动);`true` 时 worker 只跑一轮后退出。
- `recommendPage.delayBetweenNotInterestedMs`:每次点击"不感兴趣"(含原因选择)之间的延迟 [min, max] 毫秒,在此区间随机,用于反检测;默认 `[800, 2500]`。
- `recommendPage.keepBrowserOpenAfterRun`:单轮结束后是否保持浏览器打开(仅招聘端推荐页,不影响应聘端;需同时开启 runOnceAfterComplete开启时关闭浏览器窗口后 worker 再退出。
**candidate-filter.json**`~/.geekgeekrun/config/candidate-filter.json`
```json
{
"expectCityList": ["北京", "上海"],
"expectEducationList": ["本科", "硕士"],
"expectWorkExpRange": [1, 5],
"expectSalaryRange": [15, 50],
"expectSalaryWhenNegotiable": "exclude",
"expectSkillKeywords": ["Vue", "React"],
"blockCandidateNameRegExpStr": "测试|内推",
"skipViewedCandidates": false
}
```
筛选未通过时日志会打印原因,`reason` 可能为:`city` / `education` / `workExp` / `salary` / `skills` / `blockName` / `viewed`。
`expectSalaryRange` 单位:千/月K。配置可填 K 或元:若数值 ≥100 会按元自动折成 K如 8000→8K。薪资解析`parseSalaryRange()` 支持 `3-5K`、`10-15K`、`20-30K`(万自动 ×10
`expectSalaryWhenNegotiable`:候选人薪资为"面议"或无法解析时 — `exclude`(默认)= 不通过,`include` = 通过(不因薪资排除)。
---
## 11. Hooks推荐页用到的
| Hook | 参数 | 触发时机 |
|------|------|---------|
| `beforeBrowserLaunch` | — | 浏览器启动前 |
| `afterBrowserLaunch` | — | 浏览器启动后、导航前 |
| `beforeNavigateToRecommend` | — | Tab1/Tab2 导航前 |
| `onCandidateListLoaded` | — | 登录成功、列表可用时(触发 GUI progress fulfilled |
| `onCandidateFiltered` | `(candidates, filterResult)` | 每轮筛选完成waterfall可修改结果 |
| `beforeStartChat` | `(candidate)` | 开始处理单个候选人前 |
| `afterChatStarted` | `(candidate, chatResult)` | 打招呼完成后(成功或失败) |
| `onProgress` | `{ phase:'recommend', current, max }` | 每次打招呼成功后 |
| `onError` | `(error)` | 主循环抛出异常 |
| `onComplete` | — | 主循环正常结束 |

View File

@@ -0,0 +1,423 @@
# 招聘端Recruiter/BOSS架构总览
> **定位**:供 AI Agent 快速理解招聘端全貌,用于协作开发时减少 token 消耗。
> 最后更新2026-03-16
---
## 1. 整体架构一览
```
UI 层 (Electron Renderer)
└── 四个配置/启动页面
├── BossAutoBrowseAndChat → 推荐牛人:配置 + 启动
├── BossChatPage → 沟通页:配置 + 启动
├── BossAutoSequence → 顺序执行(无独立配置,复用上两者)
└── WebhookIntegration → Webhook / 外部集成(配置 + 测试发送)
IPC 层 (Electron Main)
└── ipc/index.ts → run / stop / save / fetch-config 处理器
Worker 层 (独立 Electron 子进程,通过 daemon 管理)
├── bossRecommendMain → flow/BOSS_RECOMMEND_MAIN/index.ts
├── bossChatPageMain → flow/BOSS_CHAT_PAGE_MAIN/index.ts
└── bossAutoBrowseAndChatMain → flow/BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts串联上两者
自动化核心 (packages/boss-auto-browse-and-chat/)
├── index.mjs → startBossAutoBrowse推荐牛人主入口
├── chat-page-processor.mjs → startBossChatPageProcess沟通页主入口
├── candidate-processor.mjs → 解析列表、筛选候选人、滚动加载
├── chat-handler.mjs → 点击打招呼、发送招呼语、检查日限
├── resume-extractor.mjs → 网络拦截 + Canvas hook 提取简历文本
├── chat-page-resume.mjs → 在线简历、附件简历、下载 PDF
├── data-manager.mjs → 查重、保存候选人信息、写联系日志
├── humanMouse.mjs → ghost-cursor 人类鼠标模拟
├── constant.mjs → URL 常量、DOM 选择器
└── runtime-file-utils.mjs → 读写 config / storage 文件工具
持久化 (@geekgeekrun/sqlite-plugin)
└── SQLite: ~/.geekgeekrun/storage/public.db
├── CandidateInfo → 候选人基础信息
└── CandidateContactLog → 联系记录日志
外部集成 (packages/ui/src/main/features/webhook/)
└── index.ts → sendWebhook / buildMockPayload
配置文件:~/.geekgeekrun/config/webhook.jsonboss-auto-browse 路径)
```
---
## 2. 三个 Worker 说明
| Worker ID | Flow 文件 | 调用的核心函数 | 对应页面 |
|-----------|-----------|--------------|--------|
| `bossRecommendMain` | `BOSS_RECOMMEND_MAIN/index.ts` | `startBossAutoBrowse(hooks)` | BossAutoBrowseAndChat |
| `bossChatPageMain` | `BOSS_CHAT_PAGE_MAIN/index.ts` | `startBossChatPageProcess(hooks)` | BossChatPage |
| `bossAutoBrowseAndChatMain` | `BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts` | 两者串联先推荐后沟通browser 在两阶段间复用 | BossAutoSequence |
**Worker 生命周期(通用模式):**
1. `initPuppeteer()` 注册 stealth/laodeng/anonymize-ua 插件
2. 构建 `hooks` 对象AsyncSeriesHook / AsyncSeriesWaterfallHook
3. `new SqlitePlugin(dbPath).apply(hooks)` 挂载 DB 操作
4. 无限循环:执行主函数 → 出错则等待 `rerunInterval`(默认 3000ms重试
5. 特定错误LOGIN_STATUS_INVALID / ERR_INTERNET_DISCONNECTED 等)直接 `process.exit(exitCode)`
6. 通过 `sendToDaemon()` 向 GUI 发送进度消息
---
## 3. 推荐牛人Recommend Page主循环
```
startBossAutoBrowse(hooks, { returnBrowser? })
1. hooks.beforeBrowserLaunch
2. 启动 Puppeteer 浏览器
3. Tab1 (pageChat) → BOSS_CHAT_INDEX_URL沟通页当前仅作展示
4. Tab2 (page) → BOSS_RECOMMEND_PAGE_URL推荐牛人主循环在此
5. 注入 Cookie + localStorageboss-cookies.json / boss-local-storage.json
6. 登录检测URL 未在推荐页则等待用户手动登录后持久化存储
7. hooks.onCandidateListLoaded触发 GUI 登录状态 fulfilled
8. 主循环:
while (hasMore) {
candidates = parseCandidateList(page) // DOM/Vue 解析
filtered = filterCandidates(candidates, cfg) // 城市/学历/工作年限/薪资/技能/屏蔽名
hooks.onCandidateFiltered(filtered)
for each matched:
checkDailyLimit(page) // 今日已达上限则 break
if count >= maxChatPerRun: break
processCandidate(page, candidate, ...) // 含打招呼延迟
hooks.onProgress({ phase:'recommend', current, max })
hasMore = scrollAndLoadMore(page)
}
9. returnBrowser ? return { browser, page } : browser.close()
```
---
## 4. 沟通页Chat Page主流程
```
startBossChatPageProcess(hooks, { browser?, page? })
1. 若有传入 browser/page 则复用(来自顺序执行),否则自行启动
2. 导航到 BOSS_CHAT_PAGE_URL
3. 解析左侧会话列表,筛选 unread === true 的会话
4. 每条会话(最多 maxProcessPerRun
a. checkIfAlreadyContacted(hooks) → 已接触则 skip
b. 点击会话human cursor
c. 若存在附件简历消息 → openPreviewAndDownloadPdf()
d. 否则若候选人已发招呼 →
openOnlineResume() → getOnlineResumeText()Canvas hook
mode=keywords: 关键词 substring 匹配
mode=llm: screenCandidateWithLlm(text, rule) → JSON { pass, reason }
pass → requestAttachmentResume()
e. saveCandidateInfo() + logContact()
f. hooks.onProgress({ phase:'chatPage', current, max })
```
---
## 5. 配置文件结构
**路径:** `~/.geekgeekrun/config/`
### webhook.jsonboss-auto-browse 配置路径)
```json
{
"enabled": true,
"url": "https://your-paperless.example.com/api/documents/post_document/",
"method": "POST",
"headers": {
"Authorization": "Token YOUR_TOKEN"
},
"payloadOptions": {
"includeBasicInfo": true,
"includeFilterReason": true,
"includeLlmConclusion": true,
"includeResume": "path"
}
}
```
`includeResume` 取值:`"none"` | `"path"` | `"base64"`
**Payload 结构(发出的 JSON**
```json
{
"runId": "run-<runRecordId>",
"timestamp": "ISO8601",
"summary": { "total": 10, "matched": 7, "skipped": 3 },
"candidates": [
{
"basicInfo": { "name": "...", "education": "...", "workExpYears": 3, "city": "...", "salary": "...", "skills": [] },
"filterReport": { "matched": true, "matchedRules": [], "score": 85 },
"llmConclusion": "...",
"resumeFile": { "path": "/abs/path/to/resume.pdf", "filename": "张三.pdf" }
}
]
}
```
**自动触发时机:** `bossAutoBrowseAndChatMain` worker 中每轮(推荐页 + 沟通页)执行完毕后,读取 webhook.json`enabled=true` 则发送汇总报告;失败不影响主流程,清空 `sessionCandidates` 后等待下轮。
**候选人数据来源:** `afterChatStarted` hook每次打招呼后触发收集 `candidate` 对象中的 `info``matchedRules``score``llmConclusion``resumeFilePath``resumeFileName` 字段。
---
### boss-recruiter.json
```json
{
"targetJobId": "",
"autoChat": {
"greetingMessage": "你好,...",
"maxChatPerRun": 50,
"delayBetweenChats": [3000, 8000]
},
"chatPage": {
"maxProcessPerRun": 20,
"filter": {
"mode": "keywords",
"keywordList": ["Python", "机器学习"],
"llmRule": ""
}
}
}
```
### candidate-filter.json
```json
{
"expectCityList": ["北京", "上海"],
"expectEducationList": ["本科", "硕士"],
"expectWorkExpRange": [1, 5],
"expectSalaryRange": [15, 50],
"expectSkillKeywords": ["Vue", "React"],
"blockCandidateNameRegExpStr": "测试|内推"
}
```
**路径:** `~/.geekgeekrun/storage/`
- `boss-cookies.json` — 招聘端 Cookie
- `boss-local-storage.json` — 域名 localStorage
- `public.db` — SQLite 数据库
---
## 6. 关键常量constant.mjs
```js
// URL
BOSS_RECOMMEND_PAGE_URL = 'https://www.zhipin.com/web/chat/recommend'
BOSS_CHAT_INDEX_URL = 'https://www.zhipin.com/web/chat/index'
BOSS_CHAT_PAGE_URL = 'https://www.zhipin.com/web/chat/index'
// 推荐牛人页选择器
CANDIDATE_LIST_SELECTOR = '#recommend-list'
CANDIDATE_ITEM_SELECTOR = '#recommend-list ul.card-list > li.card-item'
CANDIDATE_NAME_SELECTOR = 'span.name'
CHAT_START_BUTTON_SELECTOR = 'div.operate-side button.btn-greet'
GREETING_SENT_KNOW_BTN_SELECTOR = 'div.dialog-wrap button.btn-sure-v2'
CONTINUE_CHAT_BUTTON_SELECTOR = 'div.operate-side > div > span > div > div'
CHAT_INPUT_SELECTOR = '#boss-chat-global-input'
// 沟通页选择器CHAT_PAGE_* 前缀)
CHAT_PAGE_ITEM_SELECTOR = 'div.user-container > div > div:nth-child(2) > div:nth-child(1) > div'
CHAT_PAGE_NAME_SELECTOR = 'span.geek-name'
CHAT_PAGE_JOB_SELECTOR = 'span.source-job'
CHAT_PAGE_ONLINE_RESUME_SELECTOR = 'div.resume-btn-content > a > svg'
CHAT_PAGE_ATTACH_RESUME_BTN_SELECTOR = 'div.resume-btn-content > div > div.btn.resume-btn-file'
CHAT_PAGE_ASK_RESUME_CONFIRM_BTN_SELECTOR = 'div.ask-for-resume-confirm .content button.boss-btn-primary'
CHAT_PAGE_MESSAGE_ITEM_SELECTOR = 'div.chat-message-list.is-to-top > div > div:nth-child(2) > div'
CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR = 'div.message-card-buttons > span'
CHAT_PAGE_DOWNLOAD_PDF_BTN_SELECTOR = 'div.resume-common-dialog div.resume-footer-wrap > div > div > div:nth-child(3)'
```
---
## 7. IPC 通信接口main ↔ renderer
### 招聘端专用 IPC
| Channel | 方向 | 说明 |
|---------|------|------|
| `run-boss-recommend` | invoke | 启动推荐牛人 worker返回 `{ runRecordId }` |
| `stop-boss-recommend` | invoke | 停止推荐牛人 worker等待退出 |
| `run-boss-chat-page` | invoke | 启动沟通页 worker返回 `{ runRecordId }` |
| `stop-boss-chat-page` | invoke | 停止沟通页 worker |
| `run-boss-auto-browse-and-chat` | invoke | 启动顺序执行 worker返回 `{ runRecordId }` |
| `stop-boss-auto-browse-and-chat` | invoke | 停止顺序执行 worker |
| `save-boss-recruiter-config` | invoke | 保存招聘端配置JSON 字符串),同时写 boss-recruiter.json 和 candidate-filter.json |
| `fetch-boss-recruiter-config-file-content` | invoke | 读取配置,返回 `{ config: { 'boss-recruiter.json': {}, 'candidate-filter.json': {} } }` |
| `check-boss-recruiter-cookie-file` | invoke | 检查 Cookie 格式是否合法 |
| `fetch-webhook-config` | invoke | 读取 webhook.json返回配置对象或 null |
| `save-webhook-config` | invoke | 写入 webhook.json接收 JSON 字符串) |
| `test-webhook` | invoke | 发送 mock payload 到已配置的 URL返回 `{ status, body }` |
| `trigger-webhook-manually` | invoke | 用 mock payload标记为 manual手动触发一次返回 `{ status, body }` |
### Worker → GUI 消息(通过 daemon
消息类型(`data.type` 字段):
| type | 说明 |
|------|------|
| `worker-log` | 普通日志文本 |
| `prerequisite-step-by-step-checkstep-by-step-check` | 前置步骤状态更新,含 `{ step: { id, status } }` |
| `boss-auto-browse-progress` | 进度更新,含 `{ phase, current, max }` |
| `worker-exited` | Worker 进程退出 |
---
## 8. 前置步骤RunningOverlay 进度条)
```
getBossAutoBrowseSteps() → [
{ id: 'worker-launch', describe: '启动子进程' },
{ id: 'puppeteer-executable-check', describe: 'Puppeteer 可执行程序检查' },
{ id: 'login-status-check', describe: '登录状态检查(若浏览器弹出请以招聘者身份登录)' }
]
```
步骤状态:`'todo' | 'pending' | 'fulfilled' | 'rejected'`
---
## 9. Hooks 体系
所有 worker 都构造相同结构的 hooks 传给核心模块SqlitePlugin 在上面挂载 DB 操作:
```ts
const hooks = {
beforeBrowserLaunch: AsyncSeriesHook,
afterBrowserLaunch: AsyncSeriesHook,
beforeNavigateToRecommend: AsyncSeriesHook,
onCandidateListLoaded: AsyncSeriesHook,
onCandidateFiltered: AsyncSeriesWaterfallHook, // ['candidates', 'filterResult']
beforeStartChat: AsyncSeriesHook, // ['candidate']
afterChatStarted: AsyncSeriesHook, // ['candidate', 'result']
onError: AsyncSeriesHook, // ['error']
onComplete: AsyncSeriesHook,
onProgress: AsyncSeriesHook, // ['payload']
// SqlitePlugin 额外挂载:
createOrUpdateCandidateInfo: AsyncSeriesHook
insertCandidateContactLog: AsyncSeriesHook
queryCandidateByEncryptId: AsyncSeriesWaterfallHook
}
```
---
## 10. 反检测机制
- **puppeteer-extra-plugin-stealth** — 抹除 headless 特征
- **@geekgeekrun/puppeteer-extra-plugin-laodeng** — 自定义反检测
- **puppeteer-extra-plugin-anonymize-ua** — 随机 UserAgent`makeWindows: false`
- **ghost-cursor** — `createHumanCursor(page)` 模拟人类鼠标轨迹,所有点击走 cursor 而非 `page.click()`
- **sleepWithRandomDelay(base, range)** — 操作间随机延迟
- 滚动:`page.mouse.wheel({ deltaY })` 分步骤随机延迟
---
## 11. 路由与导航
| 路由 Name | 路径 | 页面组件 |
|----------|------|--------|
| `BossAutoBrowseAndChat` | `/main-layout/BossAutoBrowseAndChat` | 推荐牛人 - 自动开聊 |
| `BossChatPage` | `/main-layout/BossChatPage` | 沟通页 |
| `BossAutoSequence` | `/main-layout/BossAutoSequence` | 自动顺序执行 |
| `WebhookIntegration` | `/main-layout/WebhookIntegration` | Webhook / 外部集成 |
切换到招聘端身份时默认重定向到 `BossAutoBrowseAndChat`
`RECRUITER_ROUTES = ['BossAutoBrowseAndChat', 'BossChatPage', 'BossAutoSequence']` 用于判断当前身份模式。
> 注:`WebhookIntegration` 是纯配置页,无需加入 `RECRUITER_ROUTES`(无 RunningOverlay
---
## 12. 如何新增招聘端页面
1. **新建页面组件**
```
packages/ui/src/renderer/src/page/MainLayout/Boss<NewPage>/index.vue
```
- 若需要启动任务:参考 `BossAutoBrowseAndChat/index.vue` 或 `BossChatPage/index.vue`
- 使用 `RunningOverlay` 组件显示进度:
```vue
<RunningOverlay worker-id="<workerId>" :run-record-id="runRecordId" :get-steps="getBossAutoBrowseSteps" />
```
2. **注册路由** — 在 `packages/ui/src/renderer/src/router/index.ts` 的 `/main-layout` children 中添加:
```ts
{ name: 'Boss<NewPage>', path: 'Boss<NewPage>', component: () => import(...), meta: { title: '...' } }
```
3. **更新 RECRUITER_ROUTES** — 在 `packages/ui/src/renderer/src/page/MainLayout/index.vue` 中:
```ts
const RECRUITER_ROUTES = ['BossAutoBrowseAndChat', 'BossChatPage', 'BossAutoSequence', 'Boss<NewPage>']
```
4. **添加导航入口** — 在 `packages/ui/src/renderer/src/page/MainLayout/LeftNavBar/RecruiterPart.vue`
```vue
<RouterLink :to="{ name: 'Boss<NewPage>' }">页面名称</RouterLink>
```
5. **(如需新 Worker新建 flow 文件**
```
packages/ui/src/main/flow/BOSS_<NEW>_MAIN/index.ts
```
- 复制 `BOSS_RECOMMEND_MAIN/index.ts` 为模板
- 修改 `workerId`、调用的核心函数、日志前缀
6. **注册新 mode** — 在 `packages/ui/src/main/index.ts` 的 switch 中:
```ts
case 'boss<New>Main': {
const { waitForProcessHandShakeAndRunAutoChat } = await import('./flow/BOSS_<NEW>_MAIN/index')
waitForProcessHandShakeAndRunAutoChat()
break
}
```
7. **添加 IPC 处理器** — 在 `packages/ui/src/main/flow/OPEN_SETTING_WINDOW/ipc/index.ts`
```ts
ipcMain.handle('run-boss-<new>', async () => {
const mode = 'boss<New>Main'
const { runRecordId } = await runCommon({ mode })
daemonEE.on('message', function handler(message) {
if (message.workerId !== mode) return
if (message.type === 'worker-exited') mainWindow?.webContents.send('worker-exited', message)
})
return { runRecordId }
})
ipcMain.handle('stop-boss-<new>', async () => {
// 参考 stop-boss-recommend 的模式
})
```
8. **(如需新配置字段)更新 save-boss-recruiter-config 处理器**(同文件)
---
## 13. 如何在核心模块新增功能
### 新增候选人筛选条件
- 在 `candidate-processor.mjs` 的 `filterCandidates()` 添加判断逻辑
- 在 `candidate-filter.json` 对应结构增加字段
- 在 `BossAutoBrowseAndChat/index.vue` 增加 UI 表单项
- 在 IPC `save-boss-recruiter-config` 中处理新字段的保存/读取
### 新增 Hook 钩子点
- 在 worker flow 文件的 `hooks` 对象中添加 `new AsyncSeriesHook(['...'])`
- 在核心 .mjs 文件对应位置调用 `await hooks.newHook?.promise?.(payload)`
### 新增联系日志类型
- 在 `data-manager.mjs` 的 `logContact()` 调用时传入新的 `contactType` 字符串
- SQLite Plugin 会自动持久化
---
## 14. 相关文档
- [boss_auto_browse_tabs.md](boss_auto_browse_tabs.md) — 双 Tab 设计(沟通 vs 推荐牛人)
- [chat_page_resume_flow.md](chat_page_resume_flow.md) — 沟通页简历流程详述
- [cv_canvas_solution.md](cv_canvas_solution.md) — Canvas/WASM 简历解密方案
- [recruiter_mouse_trajectory.md](recruiter_mouse_trajectory.md) — 反检测鼠标轨迹方案
- [webhook_integration.md](webhook_integration.md) — Webhook / 外部集成详述Paperless-ngx 对接)

View File

@@ -0,0 +1,214 @@
# 招聘端调试工具
> **定位**:用于在沟通页手动调试简历相关操作(在线简历、附件简历、下载 PDF 等),以及 LLM 简历筛选Rubric 评估、Rubric 生成)。与正式运行的 `bossChatPageMain` 使用完全一致的技术栈Puppeteer + ghost-cursor + Canvas hook
> 最后更新2026-03-18
---
## 1. 概述
招聘端调试工具是一套**独立于自动化主流程**的调试环境,可单独启动浏览器到 BOSS 沟通页,然后通过 GUI 按钮逐条执行调试命令,验证:
- 在线简历的打开、关闭、Canvas hook 提取
- 附件简历的请求、同意、预览与下载
- 各类弹窗的关闭(意向沟通、在线简历等)
**与正式运行的一致性**:所有页面操作均通过 `createHumanCursor`ghost-cursor拟人轨迹点击`chat-page-processor.mjs` / `chat-page-resume.mjs` 的实现完全一致,便于在无自动化干扰下排查 DOM 选择器、时序问题。
---
## 2. 架构
```
┌─────────────────────────────────────────────────────────────────────┐
│ Renderer: BossDebugTool/index.vue │
│ - 启动浏览器 / 关闭浏览器 按钮 │
│ - Tab A「简历操作」9 个调试命令按钮(获取姓名、在线简历、附件简历等) │
│ - Tab B「LLM 筛选」:提取简历文本 / 运行 Rubric / 生成 Rubric 按钮 │
│ - 统一操作日志区域 │
└─────────────────────────────────────────────────────────────────────┘
│ IPC: open-boss-chat-debug / boss-debug-command / close-boss-chat-debug
┌─────────────────────────────────────────────────────────────────────┐
│ Main: ipc/index.ts │
│ - 管理 bossChatDebugProcess子进程
│ - 通过 stdio fd3/fd4 与 worker 通信JSON 行协议) │
└─────────────────────────────────────────────────────────────────────┘
│ spawn(..., --mode=bossChatDebugMain, stdio[3,4]=pipe)
┌─────────────────────────────────────────────────────────────────────┐
│ Worker: flow/BOSS_CHAT_DEBUG_MAIN/index.ts │
│ - Electron 子进程app.dock?.hide()
│ - initPuppeteer + launch 非无头浏览器 │
│ - 注入 Cookie + localStorage打开 BOSS_CHAT_PAGE_URL │
│ - 监听 fd3 读取命令,执行后通过 fd4 写回响应 │
└─────────────────────────────────────────────────────────────────────┘
```
**进程路由**`src/main/index.ts` 根据 `--mode=bossChatDebugMain` 加载 `flow/BOSS_CHAT_DEBUG_MAIN/index.ts`,调用 `waitForProcessHandShakeAndRunDebug()`
---
## 3. 通信协议
### 3.1 进程间 stdio
主进程 spawn 时 `stdio: ['inherit','inherit','inherit','pipe','pipe']`
| fd | 方向 | 用途 |
|----|------|------|
| fd3 | 主进程 → 子进程 | 主进程写入命令 |
| fd4 | 子进程 → 主进程 | 子进程写入 READY/响应 |
每条消息为 **一行 JSON**,以 `\n` 结尾。
### 3.2 命令格式(主 → 子)
```json
{ "type": "ping", "id": "abc123" }
{ "type": "open-online-resume", "id": "xyz789" }
```
- `type`(必填):命令类型
- `id`(必填):由主进程生成,用于匹配响应
### 3.3 响应格式(子 → 主)
**就绪通知**(子进程启动完成后发一次):
```json
{ "type": "READY", "ok": true }
{ "type": "READY", "ok": false, "error": "NO_BROWSER" }
```
**命令响应**
```json
{ "id": "abc123", "ok": true, "result": { "name": "张三" } }
{ "id": "xyz789", "ok": false, "error": "未找到在线简历按钮" }
```
### 3.4 命令超时
主进程发命令后,若 30 秒内未收到响应,视为超时,返回 `{ ok: false, error: 'timeout' }`
---
## 4. 支持的调试命令
### 4.1 Tab A — 简历操作命令
| type | 说明 | 成功时 result |
|------|------|---------------|
| `ping` | 探活 | `'pong'` |
| `get-panel-name` | 获取右侧面板当前候选人姓名 | `{ name: string }` |
| `dismiss-intent-dialog` | 关闭「意向沟通」弹窗 | `{ found: boolean }` |
| `close-online-resume` | 关闭在线简历弹窗 | `{ found: boolean }` |
| `open-online-resume` | 打开当前会话的在线简历 | `{ opened: boolean }` |
| `check-attach-resume` | 检查当前会话是否有「点击预览附件简历」按钮 | `{ hasAttachment: boolean }` |
| `request-attach-resume` | 请求附件简历(点击按钮 + 确认弹窗) | `{ requested: boolean, ... }` |
| `download-attach-resume` | 预览并下载当前会话已有的附件简历 | `{ clickedDownload: boolean, ... }` |
| `accept-incoming-attach-resume` | 同意对方发来的附件简历请求(仅当出现「是否同意」弹窗时) | `{ found: boolean, accepted: boolean }` |
**附件简历流程说明**(与 `plan/chat_page_resume_flow.md` 一致):
- **看在线简历**`open-online-resume`,无需对方同意,点开即可。
- **下载 PDF**:需先 `request-attach-resume` → 对方同意 → PDF 作为新消息出现在聊天里 → `download-attach-resume`
- **同意请求**:若对方先发起附件简历请求,会出现「对方想发送附件简历给您,您是否同意」弹窗,用 `accept-incoming-attach-resume` 同意。
### 4.2 Tab B — LLM 筛选命令
| type | 说明 | payload | 成功时 result |
|------|------|---------|---------------|
| `extract-resume-text` | 打开当前会话的在线简历并用 Canvas hook 提取纯文本 | 无 | `{ resumeText: string, charCount: number }` |
| `llm-screen-resume` | 对指定简历文本运行 Rubric 评估(使用当前职位的 `resumeLlmConfig` | `{ resumeText: string, jobId?: string }` | `{ isPassed: boolean, totalScore: number, reason: string, rawResponse: string }` |
| `llm-generate-rubric` | 根据 JD 文本生成 Rubric 结构(不依赖浏览器,仅调 LLM API | `{ sourceJd: string }` | `{ rubric: { knockouts: string[], dimensions: [...] } }` |
**命令使用说明:**
- `extract-resume-text`在子进程Worker侧执行需要浏览器已就绪且已手动选中一条会话。内部调用 `openOnlineResume` + Canvas hook 提取文本,提取完后**关闭简历弹窗**,将文本返回给主进程。
- `llm-screen-resume`:在**主进程**侧执行(不经过子进程),直接调用 `evaluateResumeByRubric``jobId` 用于从 `boss-jobs-config.json` 载入对应的 `resumeLlmConfig`;若不传 `jobId`,则 UI 侧提供手动填写 Rubric JSON 的区域,将其序列化为 payload 传入。
- `llm-generate-rubric`:同样在**主进程**侧执行,调用 `generateRubricFromJd`,读取 `boss-llm.json``rubric_generation` 用途的模型(未配置时会回退到默认/第一个启用模型)。
---
## 5. 文件与入口
| 路径 | 说明 |
|------|------|
| `packages/ui/src/main/flow/BOSS_CHAT_DEBUG_MAIN/index.ts` | Worker 入口,启动浏览器并监听 stdio 命令 |
| `packages/ui/src/renderer/src/page/MainLayout/BossDebugTool/index.vue` | GUI 页面(含 Tab A 简历操作、Tab B LLM 筛选) |
| `packages/ui/src/main/flow/OPEN_SETTING_WINDOW/ipc/index.ts` | IPC 处理器:`open-boss-chat-debug``boss-debug-command``close-boss-chat-debug` |
| `packages/ui/src/renderer/src/router/index.ts` | 路由 `BossDebugTool` |
| `packages/ui/src/renderer/src/page/MainLayout/LeftNavBar/RecruiterPart.vue` | 左侧导航「招聘端调试工具」入口 |
**依赖的核心包**(与 `bossChatPageMain` 相同):
- `@geekgeekrun/boss-auto-browse-and-chat/index.mjs``initPuppeteer`
- `@geekgeekrun/boss-auto-browse-and-chat/chat-page-resume.mjs``openOnlineResume``requestAttachmentResume``acceptIncomingAttachResume``openPreviewAndDownloadPdf`
- `@geekgeekrun/boss-auto-browse-and-chat/resume-extractor.mjs``setupCanvasTextHook`
- `@geekgeekrun/boss-auto-browse-and-chat/constant.mjs`:选择器常量
- `@geekgeekrun/boss-auto-browse-and-chat/humanMouse.mjs``createHumanCursor`
- `@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs``readStorageFile``ensureStorageFileExist`
- `@geekgeekrun/boss-auto-browse-and-chat/llm-rubric.mjs``evaluateResumeByRubric``generateRubricFromJd`Tab B LLM 命令,**主进程侧调用**
**存储**:与正式流程共用 `~/.geekgeekrun/storage/boss-cookies.json``boss-local-storage.json`
---
## 6. 使用流程
### 6.1 Tab A — 简历操作
1. 打开设置窗口 → 左侧导航选择「招聘端调试工具」
2. 点击「启动浏览器」→ 主进程 spawn `bossChatDebugMain` 子进程,子进程启动 Puppeteer、注入 Cookie/localStorage、打开沟通页、发 READY
3. 在 BOSS 沟通页**手动选择一条会话**(左侧会话列表点击,右侧展示该候选人)
4. 点击调试按钮执行命令,如「打开在线简历」「检查附件简历」等
5. 查看操作日志和命令返回值
6. 测试完成后点击「关闭浏览器」,主进程 kill 子进程
**注意事项**
- 启动前需已配置浏览器路径(与推荐页/沟通页一致,若未配置会弹窗引导)
- 子进程退出(用户关闭浏览器)时,主进程会发送 `boss-chat-debug-exited` 到 RendererGUI 自动将状态置为「未就绪」
### 6.2 Tab B — LLM 筛选调试
Tab B 分为三个子功能区域:
**区域 1提取简历文本**
> 依赖浏览器已就绪,且已在 BOSS 沟通页手动选中一条会话。
1. 确认浏览器已就绪,沟通页已选中目标会话
2. 点击「📄 提取当前简历文本」
3. 主进程发送 `extract-resume-text` 命令到子进程,子进程打开在线简历 → Canvas hook 提取 → 关闭弹窗 → 返回文本
4. 提取到的文本显示在下方只读 Textarea可手动选中复制
5. 文本自动填入「区域 2」的输入框供后续 LLM 评估使用
**区域 2运行 Rubric 评估**
> 不依赖浏览器,直接在主进程调用 LLM API。
- **职位选择器**:下拉选择 `boss-jobs-config.json` 中已启用 `resumeLlmEnabled` 的职位,用于自动加载其 `resumeLlmConfig`
- **或**:展开「手动填写 Rubric JSON」折叠区直接粘贴 rubric 对象(`{ knockouts, dimensions, passThreshold }`
- **简历文本输入框**(来自区域 1 自动填充或手动粘贴)
- 点击「🤖 运行 LLM 评估」→ 主进程调用 `llm-screen-resume` → 返回结果展示:
- **通过 / 未通过** 状态标签
- 总分(如 `78 / 100`
- 判断理由
- 展开「原始 LLM 响应」折叠区查看 raw JSON
**区域 3生成 Rubric**
> 不依赖浏览器,直接在主进程调用 LLM API。需已配置 `boss-llm.json`。
- **JD 输入框**`el-input` textarea粘贴岗位描述
- 点击「✨ 生成 Rubric」→ 主进程调用 `llm-generate-rubric` → 将生成的 Rubric JSON 格式化展示在只读文本框
- 提供「📋 复制 JSON」按钮方便直接粘贴到 `boss-jobs-config.json` 或 BossJobConfig UI
---
## 7. 与 recruiter_architecture 的关系
招聘端调试工具**不参与** daemon/worker 调度,是主进程直接 spawn 的独立子进程,用于本地开发与问题排查。架构总览(`recruiter_architecture.md`)中未单独列出,可在此文档中查阅其设计与用法。

View File

@@ -0,0 +1,263 @@
# 招聘端 LLM 集成计划(整合版)
本文档由原 LLM 简历 Rubric 集成计划与招聘端 LLM 配置设计整合而来,并反映当前实现状态。
---
## 1. 设计目标与原则
1. **独立于应聘端**:招聘端使用 `~/.geekgeekrun/config/boss-llm.json`,与应聘端 `llm.json` 完全独立
2. **多模型多用途**:支持为不同场景(简历筛选、招呼语生成、消息续写等)指定不同模型
3. **简历 Rubric 模式**:由 `resumeLlmRule`(单一字符串)升级为可视化的、由大模型辅助生成的多维度评分表
4. **支持推理模型**:支持 DeepSeek-R1、Qwen3、GLM 推理系列等 thinking model待实现
5. **测试 API 连通性**:配置页提供「测试连接」按钮(待实现)
---
## 2. 配置文件
### 2.1 `boss-llm.json` — 招聘端 LLM 配置
**路径:** `~/.geekgeekrun/config/boss-llm.json`
```json
{
"providers": [
{
"id": "uuid-provider",
"name": "SiliconFlow",
"baseURL": "https://api.siliconflow.cn/v1",
"apiKey": "sk-xxx",
"models": [
{
"id": "uuid-model",
"name": "DeepSeek-R1",
"model": "Pro/deepseek-ai/DeepSeek-R1",
"enabled": true,
"thinking": {
"enabled": true,
"budget": 2048
}
}
]
}
],
"purposeDefaultModelId": {
"resume_screening": "uuid-model",
"greeting_generation": "uuid-model-2",
"default": "uuid-model"
}
}
```
**设计说明:** 模型本身不再携带 `purposes` 标签。用途与模型的绑定通过 `purposeDefaultModelId` 统一管理,在配置 UI 中直接为每个用途选择一个模型即可。所有 `enabled: true` 的模型均可被分配到任意用途。
**用途purposeDefaultModelId 的 key**
| 用途 key | 说明 |
|----------|------|
| `resume_screening` | 简历 LLM 筛选Rubric 评分 / 规则判断) |
| `rubric_generation` | 自动生成评分标准(根据 JD 生成 Rubric |
| `greeting_generation` | 招呼语生成 |
| `message_rewrite` | 消息续写 / 已读不回提醒等 |
| `default` | 未指定用途时的默认模型 |
**实现状态:**
- [x] `llm-rubric.mjs``getEnabledLlmClient` 已正确读取 `boss-llm.json` 并按 purpose 选模型
- [x] 默认文件与自动创建逻辑:`readBossLlmConfig()` 在文件不存在时返回 `{ providers: [], purposeDefaultModelId: {} }` 安全默认值
- [x] BossLlmConfig 配置页及 IPC`boss-fetch-llm-config``boss-save-llm-config``boss-test-llm-endpoint``boss-llm-config` 窗口)
### 2.2 `boss-jobs-config.json` — 每职位 `resumeLlmConfig` 扩展
```json
{
"resumeLlmEnabled": true,
"resumeLlmConfig": {
"sourceJd": "用户粘贴的岗位描述...",
"passThreshold": 75,
"rubric": {
"knockouts": ["必须统招本科及以上", "必须有3年以上经验"],
"dimensions": [
{
"name": "硬件开发经验",
"weight": 40,
"criteria": { "1": "无相关经验", "3": "参与过项目", "5": "主导过复杂设计" }
},
{
"name": "编程能力",
"weight": 60,
"criteria": { "1": "无编程经验", "3": "中等模块开发", "5": "大型项目架构" }
}
]
}
}
}
```
**字段说明:**
- `knockouts`:一票否决项
- `dimensions`1/3/5 分制,按权重加权得总分(满 100
- `passThreshold`及格线0100
- **向后兼容**:若无 `resumeLlmConfig` 但有 `resumeLlmRule`,沿用旧字符串规则逻辑
**实现状态:** [x] 已完整实现BossJobConfig UI + runtime-file-utils + chat-page-processor
---
## 3. 自动化层
### 3.1 `llm-rubric.mjs`
| 函数 | 作用 | 状态 |
|------|------|------|
| `getEnabledLlmClient(purpose)` | 从 boss-llm.json 按 purpose 选取模型 | [x] 已实现 |
| `evaluateResumeByRubric(resumeText, rubricConfig)` | 根据 Rubric 评估简历 | [x] 已实现 |
| `generateRubricFromJd(sourceJd)` | 根据 JD 生成 Rubric | [x] 已实现 |
### 3.2 `runtime-file-utils.mjs`
- `jobFilterToChatPageFilter`:正确处理 `resumeLlmConfig.rubric` 优先级 [x] 已实现
### 3.3 `chat-page-processor.mjs`
- rubric 分支:当 `llmConfig.rubric` 存在时调用 `evaluateResumeByRubric` [x] 已实现
### 3.4 `gpt-request.mjs` — 推理模型支持
**状态:** [x] 已实现
需扩展 `completes` 支持 `thinking` 参数,并**避免与推理预算冲突**
- **max_tokens 约束**:推理模型的输出长度通常包含「思考内容 + 最终 content」若开启 `thinking_budget`,则 `max_tokens` 需要明显大于预算(常见至少 2x 以上),否则会因长度上限导致 `finish_reason=length` 被截断。
- **建议做法**:将 `max_tokens` 由“固定默认值”改为“按用途/按模型配置”,并为 `thinking.enabled=true` 的请求提供更大的默认值(例如 4096/8192避免 Rubric JSON 输出不完整。
- **temperature**:对结构化 JSON 输出建议低温(如 0.1);推理模型在启用 thinking 时可考虑允许更高 temperature如 0.6),但应可配置(不建议硬编码一个值覆盖所有模型与用途)。
```javascript
// 新增可选参数
export async function completes(
{ baseURL, apiKey, model, max_tokens, temperature, thinking },
messages
) {
// 建议:调用方显式传 max_tokens若未传按是否启用 thinking 给一个更安全的默认值
const resolvedMaxTokens =
typeof max_tokens === 'number' ? max_tokens : thinking?.enabled ? 8192 : 1200
const body = {
messages,
model,
max_tokens: resolvedMaxTokens,
temperature: typeof temperature === 'number' ? temperature : thinking?.enabled ? 0.6 : 0.1
}
if (thinking?.enabled && thinking?.budget) {
body.enable_thinking = true
body.thinking_budget = thinking.budget
}
// ...
}
```
---
## 4. IPC 与 UI
### 4.1 已实现的 IPC
| IPC | 作用 | 状态 |
|-----|------|------|
| `generate-llm-rubric` | 根据 sourceJd 调用 generateRubricFromJd | [x] 已实现 |
| `llm-screen-resume` | 调试工具用,主进程直接 evaluateResumeByRubric | [x] 已实现 |
### 4.2 待实现的 IPCBossLlmConfig 配置页)
| IPC | 作用 |
|-----|------|
| `boss-fetch-llm-config` | 读取 boss-llm.json |
| `boss-save-llm-config` | 保存 boss-llm.json |
| `boss-test-llm-endpoint` | 测试 API 连通性 |
### 4.3 已实现的 UI
- **BossJobConfig**AI Rubric BuilderStep 1+ Visual Rubric EditorStep 2[x]
- **BossDebugTool**LLM 简历筛选、Rubric 生成测试 [x]
### 4.4 待实现的 UI
- **BossLlmConfig** 配置页模型列表增删改、purposes 多选、thinking 参数、测试连接、预设模板SiliconFlow、DeepSeek、阿里云百炼、火山方舟、Ollama 等)
- 路由 `#/bossLlmConfig``createBossLlmConfigWindow`
- RecruiterPart 导航增加「配置大语言模型」入口
**测试 API 逻辑**`boss-test-llm-endpoint``POST {baseURL}/chat/completions`Body: `{ model, messages: [{ role: "user", content: "Hi" }], max_tokens: 10 }`。成功显示「连接成功」,失败显示错误信息。
---
## 5. 实现检查清单(更新后)
### 简历 Rubric 流程
- [x] `llm-rubric.mjs`getEnabledLlmClient 读 boss-llm.json
- [x] `llm-rubric.mjs`evaluateResumeByRubric、generateRubricFromJd
- [x] `runtime-file-utils.mjs`jobFilterToChatPageFilter 支持 resumeLlmConfig
- [x] `chat-page-processor.mjs`:接入 rubric 分支
- [x] IPCgenerate-llm-rubric
- [x] BossJobConfigresumeLlmConfig 数据结构 + AI Rubric Builder + Visual Rubric Editor
### boss-llm.json 配置页
- [x] boss-llm.json 默认文件与 ensure 逻辑(`readBossLlmConfig` / `writeBossLlmConfig` in `runtime-file-utils.mjs`
- [x] BossLlmConfig/index.vue模型 CRUD、purposes 多选、thinking 参数、测试连接、用途默认模型选择器)
- [x] createBossLlmConfigWindow 及路由 #/bossLlmConfig
- [x] IPCboss-fetch-llm-config、boss-save-llm-config、boss-test-llm-endpoint、boss-llm-config打开窗口
- [x] RecruiterPart 增加「配置大语言模型」链接
- [x] 预设模板SiliconFlow DeepSeek-R1/V3、DeepSeek 官方、阿里云百炼、Ollama
### 推理模型
- [x] gpt-request.mjs支持 `thinking` 参数(`enable_thinking` / `thinking_budget`)、`reasoning_content` 日志输出、按是否启用 thinking 自动调整 `max_tokens``temperature` 默认值
---
## 6. 标准 API 参考(推理模型扩展)
推理模型在标准 Chat Completions 请求上增加可选参数:
| 参数 | 类型 | 说明 |
|------|------|------|
| `enable_thinking` | boolean | 是否启用思维链 |
| `thinking_budget` | integer | 思维链最大 token 数 |
响应中 `message` 可能包含 `reasoning_content`,业务**仅使用 `content`**。
**Provider 适配**:部分厂商通过 `extra_body` 传入推理参数,实现时需兼容顶层参数或 extra_body 透传。
**参考文献:**
- [SiliconFlow - Chat Completions](https://docs.siliconflow.cn/cn/api-reference/chat-completions/chat-completions.md)
- [SiliconFlow - 推理模型](https://docs.siliconflow.cn/cn/userguide/capabilities/reasoning.md)
---
## 7. 注意事项
- **boss-llm.json 必须先配置**generate-llm-rubric 与 evaluateResumeByRubric 均依赖 boss-llm.json 中至少一个启用的 resume_screening 模型。若未配置UI 应给出明确提示并引导用户到「大语言模型配置」页。
- **Token 控制**:简历文本截断至 3500 字knockouts 优先判断可减少 token。
- **维度上限**:建议最多 5 个generateRubricFromJd 已做截断。
- **权重归一化**generateRubricFromJd 返回前已归一化UI 可对总和不等于 100 时做警告提示。
- **Rubric 总分计算(实现已按 100 分归一化)**:维度分是 1/3/5需先映射到 01 再乘权重,最终总分落在 0100。推荐公式
\[
\text{Total} = 100 \times \frac{\sum_i \left(W_i \times \frac{Score_i}{5}\right)}{\sum_i W_i}
\]
这样当权重和为 100 且所有维度满分 5 时,总分为 100最低分 1 时总分约为 20若权重和为 100
---
## 8. 与应聘端对比
| 项目 | 应聘端 | 招聘端 |
|------|--------|--------|
| 配置文件 | llm.json | boss-llm.json |
| 配置入口 | LlmConfig (#/llmConfig) | BossLlmConfig待实现 #/bossLlmConfig |
| 用途 | 职位匹配、已读不回提醒等 | 简历筛选、招呼语、消息续写等 |

View File

@@ -0,0 +1,76 @@
# 招聘端拟人鼠标轨迹(反人机)
## 一、原作者提醒(必须重视)
> **鼠标轨迹**BOSS 会对招聘端**鼠标移动轨迹**进行埋点,这个可能是判断人机的特征之一。可以试试看能不能借助一些库生成**拟人的鼠标轨迹**。
含义:招聘端所有在页面上发生的**点击、移动**,若以「瞬移」方式执行(如直接 `element.click()``page.mouse.click(x, y)`),容易被埋点识别为脚本。**各 Phase 中凡涉及浏览器内点击/移动的操作,都应使用拟人轨迹**,而不能忽略此问题。
---
## 二、适用范围(各 Phase
| Phase | 是否涉及页面点击/移动 | 说明 |
|-------|----------------------|------|
| **Phase 0** | 否(仅脚手架与入口) | 无浏览器操作,可不考虑。 |
| **Phase 1** | **是** | 1B 导航、Cookie1C 若在 page 上操作。凡在招聘端页面上的 click/move 都需拟人。 |
| **Phase 2** | **是** | 2A 列表解析可能有点击/滚动2B 开聊、点击按钮、输入框。必须拟人。 |
| **Phase 3** | **是** | 3A 主流程串联,所有点击/滚动/输入均需拟人。 |
| **Phase 4** | **是** | 4A/4B/4C 若有页面交互,同样需拟人。 |
**原则**:只要代码在**招聘端 BOSS 页面**上执行 `page.click``page.mouse.click``element.click()`、或先 `page.mouse.move` 再点击,都应改为「沿拟人轨迹移动 + 再点击」,而不是坐标瞬移。
---
## 三、推荐实现方式:拟人轨迹库
### 3.1 库选型
- **ghost-cursor**(推荐)
- npm: `ghost-cursor`
- 用途:用贝塞尔曲线生成两点间拟人移动路径,支持 Puppeteer可 overshoot 再回弹、按距离/元素大小调速。
- 用法:对 `page` 创建 `GhostCursor`,用 `cursor.move(selector)` / `cursor.click(selector)` 替代直接 `page.click(selector)`
- **@extra/humanize**puppeteer-extra 插件)
- npm: `@extra/humanize`
- 用途:基于 ghost-cursor 的贝塞尔技术,对 puppeteer-extra 的 `page.click()` 等做拟人增强,与现有 stealth 等插件可叠加使用。
### 3.2 集成思路
1. **招聘端专用封装**
`packages/boss-auto-browse-and-chat/` 下提供统一入口,例如:
- `humanMouse.mjs`:封装 `createHumanCursor(page)`,返回的 cursor 提供 `move(selector|{x,y})``click(selector|{x,y})`,内部用 ghost-cursor@extra/humanize)沿轨迹移动再点击。
2. **替换所有「裸」点击**
- 凡在招聘端页面上的点击/移动,不直接调用 `page.click()``page.mouse.click(x,y)``element.click()`,改为通过上述封装执行(先沿轨迹移动再点击)。
3. **Phase 14 的 Agent 实现约束**
- 在 parallel_execution_plan 中已注明:涉及页面操作的 Phase实现时**必须**使用拟人鼠标(本 plan避免遗漏。
### 3.3 示例ghost-cursor
```js
import { createCursor } from 'ghost-cursor';
// 在 page 创建后
const cursor = createCursor(page);
// 替代 page.click(selector)
await cursor.click(selector);
// 或先移动到坐标再点击
await cursor.move({ x: 100, y: 200 });
await cursor.click();
```
若使用 **puppeteer-extra**,可挂载 **@extra/humanize** 插件,让所有 `page.click()` 自动带拟人移动(需确认与当前 puppeteer-extra 版本兼容)。
---
## 四、与 plan 的对应关系
- **简历 Canvas 破解**:见 **plan/cv_canvas_solution.md**Claude Code 已实现并验证)。
- **招聘端流程与 Phase 划分**:见 **plan/parallel_execution_plan.md.resolved**;各 Phase 中涉及页面操作的,均需遵守本拟人鼠标方案。
- **沟通页简历流程**:见 **plan/chat_page_resume_flow.md**;其中在沟通页内的点击(如打开在线简历、求简历、下载 PDF也需拟人轨迹。
---
*文档维护:招聘端任何新增的页面点击/移动,都应默认使用拟人轨迹并在此 doc 或实现处注明。*

204
plan/webhook_integration.md Normal file
View File

@@ -0,0 +1,204 @@
# Webhook / 外部集成
本文档描述招聘端 Webhook 功能的设计、配置结构、数据流与扩展方式,适用于对接 Paperless-ngx、自定义 API 等外部系统。
---
## 1. 功能概述
每轮自动化任务(推荐牛人 + 沟通页)结束后,系统将本轮处理的所有候选人数据汇总成一条 JSON Payload通过 HTTP 请求发送到用户配置的 URL。
支持:
- 开关控制(关闭后任务结束时跳过发送)
- 「保存并测试发送」(用 mock 数据验证接口通不通)
- 「手动触发」(用 mock 数据模拟一次 manual 发送)
- 自定义 Headers用于 Token 认证等)
- Payload 内容裁剪(可关闭某些字段)
- 简历以本地路径或 Base64 附带
---
## 2. 相关文件
| 文件 | 说明 |
|------|------|
| `packages/ui/src/renderer/src/page/MainLayout/WebhookIntegration/index.vue` | 设置页 UI |
| `packages/ui/src/main/features/webhook/index.ts` | 发送逻辑、类型定义、mock 数据生成 |
| `packages/ui/src/main/flow/BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts` | 任务完成后自动触发(`afterChatStarted` 收集 + 轮次结束后发送) |
| `packages/ui/src/main/flow/OPEN_SETTING_WINDOW/ipc/index.ts` | IPC handlersfetch/save/test/trigger |
| `packages/ui/src/renderer/src/router/index.ts` | 路由注册 `WebhookIntegration` |
| `packages/ui/src/renderer/src/page/MainLayout/LeftNavBar/RecruiterPart.vue` | 左侧导航入口 |
配置文件路径:`~/.geekgeekrun/config/webhook.json`(通过 boss-auto-browse 的 `runtime-file-utils.mjs` 读写)
---
## 3. 配置结构webhook.json
```json
{
"enabled": true,
"url": "https://your-paperless.example.com/api/documents/post_document/",
"method": "POST",
"sendMode": "batch",
"contentType": "application/json",
"headers": {
"Authorization": "Token YOUR_TOKEN",
"X-Custom-Header": "value"
},
"payloadOptions": {
"includeBasicInfo": true,
"includeFilterReason": true,
"includeLlmConclusion": true,
"includeResume": "path"
},
"retryTimes": 3,
"retryDelayMs": 1000,
"queueFileOnFailure": false
}
```
**字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `enabled` | boolean | 是否启用自动触发 |
| `url` | string | 目标 URL须以 http:// 或 https:// 开头) |
| `method` | `"POST"` \| `"PUT"` \| `"PATCH"` | HTTP 方法 |
| `sendMode` | `"batch"` \| `"realtime"` | 轮次结束汇总发送 / 每打招呼后立即发送一条 |
| `contentType` | `"application/json"` \| `"multipart/form-data"` | 请求体格式multipart 时每条候选人一个请求(直传 Paperless 等) |
| `headers` | object | 自定义请求头 key-value 对 |
| `payloadOptions.includeBasicInfo` | boolean | 是否包含候选人基本信息 |
| `payloadOptions.includeFilterReason` | boolean | 是否包含筛选理由/评分 |
| `payloadOptions.includeLlmConclusion` | boolean | 是否包含 LLM 评估结论 |
| `payloadOptions.includeResume` | `"none"` \| `"path"` \| `"base64"` | 简历文件携带方式;**若沟通页开启了 `chatPage.attachmentResume.skipDownload`(见下),`resumeFile` 在 Payload 中将始终为空,与此选项无关** |
| `retryTimes` | number | 失败重试次数0 不重试 |
| `retryDelayMs` | number | 首次重试延迟(毫秒),之后指数退避 |
| `queueFileOnFailure` | boolean | 最终失败时是否写入本地队列文件webhook-failed-queue.jsonl |
---
## 4. Payload 结构
```json
{
"runId": "run-<runRecordId 或时间戳>",
"timestamp": "2026-03-16T10:00:00.000Z",
"summary": {
"total": 10,
"matched": 7,
"skipped": 3
},
"candidates": [
{
"basicInfo": {
"name": "张三",
"education": "本科",
"workExpYears": 3,
"city": "北京",
"salary": "15-25K",
"skills": ["Vue", "React", "TypeScript"]
},
"filterReport": {
"matched": true,
"matchedRules": ["education", "workExp", "skills"],
"score": 85
},
"llmConclusion": "候选人技能与岗位匹配度较高,建议优先沟通。",
"resumeFile": {
"path": "/Users/xxx/.geekgeekrun/storage/resumes/张三.pdf",
"filename": "张三.pdf"
}
}
]
}
```
- `resumeFile.base64` 仅在 `includeResume = "base64"` 时出现
- 若某字段对应的 `payloadOptions` 为 false则该字段在所有候选人对象中省略
- **`chatPage.attachmentResume.skipDownload` 的影响**:若 BOSS 直聘已配置「接收附件简历自动发到邮箱」,可在 `boss-recruiter.json` 中将 `chatPage.attachmentResume.skipDownload` 设为 `true`。此时系统仅发出索取请求、不下载 PDF`resumeFile` 字段在 Payload 中将始终缺失(无论 `payloadOptions.includeResume` 取何值)。若下游系统依赖 `resumeFile`,请保持 `skipDownload: false`(默认值)。
---
## 5. 数据流
```
afterChatStarted hook每次打招呼后
BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts
收集到 sessionCandidates[]candidate.info / matchedRules / score / llmConclusion / resumeFilePath
startBossAutoBrowse + startBossChatPageProcess 均完成
读取 webhook.json
├── enabled=false → 跳过sessionCandidates.length = 0
└── enabled=true, url 非空
features/webhook/index.ts: sendWebhook(config, payload)
├── 按 payloadOptions 过滤字段
├── includeResume="base64" → fs.readFileSync(path).toString('base64')
└── fetch(url, { method, headers, body: JSON.stringify(payload) })
返回 { status, body },记录日志
sessionCandidates.length = 0等待下轮
```
---
## 6. IPC 接口
| Channel | 说明 |
|---------|------|
| `fetch-webhook-config` | 读取 webhook.json不存在时返回 null |
| `save-webhook-config` | payload 为 JSON 字符串,与 existing 合并后写入 |
| `test-webhook` | 用 `buildMockPayload()` 发送到已配置 URL返回 `{ status, body }` |
| `trigger-webhook-manually` | 同上runId 前缀为 `manual-` |
---
## 7. UI 页面说明
**入口:** 「招聘BOSS」左侧导航 → Webhook / 外部集成
**布局:**
1. **基础设置卡片** — 启用开关 / URL 输入 / 请求方法选择
2. **请求头卡片** — 动态 key/value 列表 + 「Authorization Token」「X-API-Key」快速模板按钮
3. **Payload 选项卡片** — 基本信息/筛选报告/LLM 结论 checkbox + 简历携带方式 radio
4. **操作栏** — 仅保存 / 保存并测试发送 / 手动触发
5. **测试结果卡片** — 显示 HTTP 状态码(带颜色 tag+ 格式化响应体
---
## 8. 与 Paperless-ngx 对接示例
Paperless-ngx 的文档上传 API`POST /api/documents/post_document/`
配置示例:
```json
{
"enabled": true,
"url": "http://paperless.local/api/documents/post_document/",
"method": "POST",
"headers": {
"Authorization": "Token <your-paperless-token>"
},
"payloadOptions": {
"includeBasicInfo": true,
"includeFilterReason": true,
"includeLlmConclusion": true,
"includeResume": "base64"
}
}
```
> **注意:** Paperless 上传 API 期望 `multipart/form-data` 格式,而当前实现发送的是 JSON。若需直接上传到 Paperless建议在外部用一个中间服务如 n8n、自定义脚本接收本 webhook JSON再转发给 Paperless。这也是「允许调用自定义 API」的典型用法。
---
## 9. 扩展方向(已实现)
- **逐条实时触发**:配置项 `sendMode: 'realtime'`,在 `afterChatStarted` hook 中每打招呼后立即调用 `sendWebhook` 发送单条候选人;`sendMode: 'batch'` 保持轮次结束汇总发送。
- **支持 multipart/form-data**:配置项 `contentType: 'multipart/form-data'`每条候选人单独一个请求FormData 含 runId、timestamp、summary、candidateJSON、document简历文件支持直传 Paperless 等。
- **重试机制**:配置项 `retryTimes``retryDelayMs`,失败时指数退避重试;`queueFileOnFailure: true` 时最终失败写入 `~/.geekgeekrun/storage/webhook-failed-queue.jsonl`
- **手动触发使用真实数据**`trigger-webhook-manually` 支持第二参数 `useRealData`;为 true 时从 SQLite `CandidateContactLog` + `CandidateInfo` 查最近 50 条联系人组装 payload无数据时回退为 Mock。

73
pnpm-lock.yaml generated
View File

@@ -54,6 +54,39 @@ importers:
specifier: ^7.6.0
version: 7.6.0
packages/boss-auto-browse-and-chat:
dependencies:
'@geekgeekrun/puppeteer-extra-plugin-laodeng':
specifier: workspace:*
version: link:../laodeng
'@geekgeekrun/sqlite-plugin':
specifier: workspace:*
version: link:../sqlite-plugin
'@geekgeekrun/utils':
specifier: workspace:*
version: link:../utils
ghost-cursor:
specifier: ^1.3.1
version: 1.4.2
json5:
specifier: ^2.2.3
version: 2.2.3
puppeteer:
specifier: 24.19.0
version: 24.19.0(typescript@5.3.3)
puppeteer-extra:
specifier: 3.3.6
version: 3.3.6(puppeteer@24.19.0)
puppeteer-extra-plugin-anonymize-ua:
specifier: 2.4.6
version: 2.4.6(patch_hash=5bafoxpvwneq2ywcyjznkii37m)(puppeteer-extra@3.3.6)
puppeteer-extra-plugin-stealth:
specifier: 2.11.2
version: 2.11.2(puppeteer-extra@3.3.6)
tapable:
specifier: ^2.2.1
version: 2.2.1
packages/dingtalk-plugin: {}
packages/geek-auto-start-chat-with-boss:
@@ -105,6 +138,18 @@ importers:
specifier: 28.2.0
version: 28.2.0
packages/run-core-of-boss-auto-browse:
dependencies:
'@geekgeekrun/boss-auto-browse-and-chat':
specifier: workspace:*
version: link:../boss-auto-browse-and-chat
'@geekgeekrun/sqlite-plugin':
specifier: workspace:*
version: link:../sqlite-plugin
'@geekgeekrun/utils':
specifier: workspace:*
version: link:../utils
packages/run-core-of-geek-auto-start-chat-with-boss:
dependencies:
'@geekgeekrun/dingtalk-plugin':
@@ -159,6 +204,9 @@ importers:
'@electron-toolkit/utils':
specifier: ^3.0.0
version: 3.0.0(electron@39.2.7)
'@geekgeekrun/boss-auto-browse-and-chat':
specifier: workspace:*
version: link:../boss-auto-browse-and-chat
'@geekgeekrun/launch-bosszhipin-login-page-with-preload-extension':
specifier: workspace:*
version: link:../launch-bosszhipin-login-page-with-preload-extension
@@ -1687,6 +1735,10 @@ packages:
/@tsconfig/node16@1.0.4:
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
/@types/bezier-js@4.1.3:
resolution: {integrity: sha512-FNVVCu5mx/rJCWBxLTcL7oOajmGtWtBTDjq6DSUWUI12GeePivrZZXz+UgE0D6VYsLEjvExRO03z4hVtu3pTEQ==}
dev: false
/@types/cacheable-request@6.0.3:
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
dependencies:
@@ -2704,6 +2756,10 @@ packages:
prebuild-install: 7.1.3
dev: false
/bezier-js@6.1.4:
resolution: {integrity: sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==}
dev: false
/binary-extensions@2.2.0:
resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
engines: {node: '>=8'}
@@ -3223,13 +3279,8 @@ packages:
resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==}
dev: false
/dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
dev: true
/dayjs@1.11.19:
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
dev: false
/de-indent@1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
@@ -3595,7 +3646,7 @@ packages:
'@types/lodash-es': 4.17.12
'@vueuse/core': 9.13.0(vue@3.4.15)
async-validator: 4.2.5
dayjs: 1.11.13
dayjs: 1.11.19
escape-html: 1.0.3
lodash: 4.17.21
lodash-es: 4.17.21
@@ -4255,6 +4306,16 @@ packages:
transitivePeerDependencies:
- supports-color
/ghost-cursor@1.4.2:
resolution: {integrity: sha512-NPMuH05Ik9h99zrAb4fvAzhB4T6MqKwdPoI+Me0IJWN3676j4UoAsjKN/cJq5AG4zyZwAEPPggZXHVU0P57/5g==}
dependencies:
'@types/bezier-js': 4.1.3
bezier-js: 6.1.4
debug: 4.4.3
transitivePeerDependencies:
- supports-color
dev: false
/github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
dev: false