Merge pull request #79 from rqi14/master

增加招聘端功能
This commit is contained in:
geekgeekrun-maintainer
2026-05-12 09:12:41 +08:00
committed by GitHub
86 changed files with 23100 additions and 4726 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.worktrees

121
CLAUDE.md Normal file
View File

@@ -0,0 +1,121 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Package Manager
Use bare `pnpm` (v8 or v10+ both work). The `engines.pnpm` constraint is `>=8.15.9`.
```bash
pnpm install
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)
pnpm -F geekgeekrun-ui dev # development mode
pnpm -F geekgeekrun-ui build # production build
pnpm -F geekgeekrun-ui build:win # Windows installer
# Lint & format (run from packages/ui)
pnpm -F geekgeekrun-ui lint # eslint --fix
pnpm -F geekgeekrun-ui format # prettier --write
# Type checking (run from packages/ui)
pnpm -F geekgeekrun-ui typecheck # both node + web
# SQLite plugin (must build before UI if changed)
pnpm -F @geekgeekrun/sqlite-plugin build
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`.
**Known post-login popups** — all must be auto-dismissed or automation will hang:
- **Governance notice** (`dialog-uninstall-extension`) — appears every login; handled by `dismissGovernanceNoticeDialog(page)` in `index.mjs`, called after login in both `launchBrowserAndNavigateToChat` and `startBossAutoBrowse`. Confirm button is `div.confirm-btn` (a `<div>` styled with a background image, not a `<button>`). See `plan/recruiter_architecture.md §14.1` and `examples/BOSS直聘-治理公告*.html`.
- **Intent dialog** (`.op-btn.rightbar-item div.dialog-container`) — per-session, per-conversation; handled in `chat-page-processor.mjs`.
- When selectors break, update `constant.mjs` first, then follow the checklist in `plan/recruiter_architecture.md §14.5`.
## 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

@@ -30,12 +30,19 @@
"node": "20.16.0"
},
"engines": {
"pnpm": ">=8.15.9 <9.0.0"
"pnpm": ">=8.15.9"
},
"pnpm": {
"patchedDependencies": {
"find-chrome-bin@2.0.4": "patches/find-chrome-bin@2.0.4.patch",
"puppeteer-extra-plugin-anonymize-ua@2.4.6": "patches/puppeteer-extra-plugin-anonymize-ua@2.4.6.patch"
}
},
"onlyBuiltDependencies": [
"@parcel/watcher",
"better-sqlite3",
"electron",
"esbuild",
"puppeteer"
]
}
}

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

View File

@@ -0,0 +1,856 @@
/**
* 沟通页自动化:处理未读会话 — 对方发来附件简历则下载,对方打招呼则看在线简历后关键词/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 { dismissBlockingOverlays } from './dialog-dismisser.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_ITEM_SELECTOR,
CHAT_PAGE_ITEM_UNREAD_SELECTOR,
CHAT_PAGE_ALL_FILTER_SELECTOR,
CHAT_PAGE_UNREAD_FILTER_SELECTOR,
CHAT_PAGE_TAB_NEW_GREET_SELECTOR,
CHAT_PAGE_NAME_SELECTOR,
CHAT_PAGE_JOB_SELECTOR,
CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR,
CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR,
CHAT_PAGE_JOB_DROPDOWN_SELECTOR,
CHAT_PAGE_JOB_ITEM_SELECTOR
} from './constant.mjs'
const LOG = '[chat-page-processor]'
/**
* 在沟通页切换到指定职位。
* @param {import('puppeteer').Page} page
* @param {string} jobId
*/
async function switchChatPageJobId (page, jobId) {
try {
const cursor = await createHumanCursor(page)
// 用拟人轨迹点击下拉触发按钮
const dropdownBtn = await page.$(CHAT_PAGE_JOB_DROPDOWN_SELECTOR)
if (dropdownBtn) {
const box = await dropdownBtn.boundingBox().catch(() => null)
if (box) {
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
} else {
await dropdownBtn.click()
}
} else {
await page.click(CHAT_PAGE_JOB_DROPDOWN_SELECTOR)
}
await page.waitForSelector(CHAT_PAGE_JOB_ITEM_SELECTOR, { timeout: 5000 })
await sleepWithRandomDelay(150, 300)
// 用拟人轨迹点击目标职位项
const items = await page.$$(CHAT_PAGE_JOB_ITEM_SELECTOR)
let found = false
for (const item of items) {
const val = await item.evaluate(el => el.getAttribute('value')).catch(() => null)
if (val === jobId) {
const itemBox = await item.boundingBox().catch(() => null)
if (itemBox) {
await cursor.click({ x: itemBox.x + itemBox.width / 2, y: itemBox.y + itemBox.height / 2 })
} else {
await item.click()
}
found = true
break
}
}
if (!found) {
logWarn(`${LOG} 职位 ${jobId} 未在沟通页下拉列表中找到,将使用默认职位继续`)
await page.keyboard.press('Escape')
return
}
// 等待左侧会话列表刷新
await sleepWithRandomDelay(400, 700)
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,
* getCapturedText?: Function,
* clearCapturedText?: Function,
* jobId?: string | null,
* retryCandidate?: { encryptGeekId: string, geekName: string, jobTitle: string } | null,
* processContext?: { currentCandidate: object | null } | null
* }} [options]
* - retryCandidate: 验证中断后需优先重试的候选人(此前已被点击成"已读"需在「全部」tab 找回)
* - processContext: 调用方传入的可变对象,本函数在处理每条会话前将 currentCandidate 写入,
* 供调用方在捕获错误时读取"是哪位候选人被中断"
*/
export default async function startBossChatPageProcess (hooksFromCaller, options = {}) {
const hooks = hooksFromCaller || {}
const {
page: existingPage,
getCapturedText,
clearCapturedText,
peekCapturedText,
jobId = null,
retryCandidate = null,
processContext = 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)
}
const cursor = await createHumanCursor(page)
// ────────────────────────────────────────────────────────────────────────────
// 内部辅助:切换到指定 tab封闭 page、cursor
// ────────────────────────────────────────────────────────────────────────────
const switchToTab = async (selector, tabName, opts = {}) => {
if (!opts.force) {
const isActive = await page.evaluate(
(sel) => document.querySelector(sel)?.classList.contains('active') ?? false,
selector
)
if (isActive) {
logDebug(`${LOG} 已在「${tabName}」tab`)
return
}
}
logInfo(`${LOG} 切换到「${tabName}」tab...`)
const tabEl = await page.$(selector)
if (!tabEl) {
logWarn(`${LOG} 未找到「${tabName}」tab 元素selector: ${selector}`)
return
}
const box = await tabEl.boundingBox().catch(() => null)
if (box) {
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
await sleepWithRandomDelay(400, 600)
try {
await page.waitForSelector(CHAT_PAGE_ITEM_SELECTOR, { timeout: 5000 })
logDebug(`${LOG}${tabName}」tab 切换后列表已刷新`)
} catch {
logDebug(`${LOG}${tabName}」tab 切换后列表为空(无会话)`)
}
}
}
// ────────────────────────────────────────────────────────────────────────────
// 内部核心:处理单条会话的完整逻辑(闭包,封闭所有外层状态变量)
// 返回 { processed: boolean, skipped: boolean }
// ────────────────────────────────────────────────────────────────────────────
const processOneCandidateConversation = async (item) => {
const { encryptGeekId, geekName, jobTitle } = item
// 向调用方暴露当前正在处理的候选人(发生错误时可读取)
if (processContext) processContext.currentCandidate = item
const { contacted } = await checkIfAlreadyContacted(encryptGeekId, hooks)
if (contacted) {
logInfo(`${LOG} → 已在数据库中联系过,跳过`)
if (processContext) processContext.currentCandidate = null
return { processed: false, skipped: true }
}
logDebug(`${LOG} → 数据库未记录,继续处理`)
// 切换会话前必须确保在线简历弹窗已关闭。
// 弹窗遮挡会导致下方会话列表的点击被拦截,使会话无法切换(右侧面板仍显示上一个人),
// 进而导致打开的在线简历是上一个候选人的数据。
{
const resumeCloseBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null)
if (resumeCloseBtn) {
logDebug(`${LOG} → 检测到在线简历弹窗未关闭,点击关闭...`)
const closeBox = await resumeCloseBtn.boundingBox().catch(() => null)
if (closeBox) {
await cursor.click({ x: closeBox.x + closeBox.width / 2, y: closeBox.y + closeBox.height / 2 })
} else {
await resumeCloseBtn.click().catch(() => {})
}
try {
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 4000 })
logDebug(`${LOG} → 在线简历弹窗已关闭`)
} catch {
const stillOpen = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null)
if (stillOpen) {
logWarn(`${LOG} → 在线简历弹窗关闭失败4s 超时),继续尝试切换会话,但可能影响会话切换成功率`)
}
}
}
}
// 点击前先清空拦截数据,确保拿到的是本次点击触发的 geek/infoBOSS 有缓存时可能不重新请求)
getInterceptedData()
logDebug(`${LOG} → 点击会话...`)
const selected = await selectConversationById(page, encryptGeekId, { cursor })
if (!selected) {
logWarn(`${LOG} → 无法在 DOM 中找到该会话(可能已被标为已读或滚出虚拟滚动视口),跳过`)
if (processContext) processContext.currentCandidate = null
return { processed: false, skipped: true }
}
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)
if (processContext) processContext.currentCandidate = null
return { processed: false, skipped: true }
}
if (panelName) {
logDebug(`${LOG} → 右侧面板验证:「${panelName}」✓`)
}
}
// 关闭「意向沟通」提示弹窗BOSS 每次新浏览器会话打开某些会话时会弹出,遮挡右侧操作按钮)
// 优先用已知 selector 快速路径;失败则启发式扫描兜底(应对 BOSS 弹窗结构变动)
{
const intentKnowBtn = await page.$(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR).catch(() => null)
if (intentKnowBtn) {
logInfo(`${LOG} → 检测到「意向沟通」提示弹窗,点击「我知道了」关闭...`)
try {
const knowBox = await intentKnowBtn.boundingBox().catch(() => null)
if (knowBox) {
await cursor.click({ x: knowBox.x + knowBox.width / 2, y: knowBox.y + knowBox.height / 2 })
} else {
await intentKnowBtn.click().catch(() => {})
}
} catch {
// 留给启发式兜底
}
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)
}
// 启发式兜底:扫一遍主页面剩余浮层(治理公告补刀 / 未知新弹窗)
const closed = await dismissBlockingOverlays(page, { maxRounds: 2 }).catch(() => 0)
if (closed > 0) {
logInfo(`${LOG} → 启发式额外关闭了 ${closed} 个浮层`)
await sleepWithRandomDelay(200, 400)
}
}
// 阶段一:初步信息筛选(点击会话后 geek/info 已触发,从拦截数据取结构化字段)
// 注意:使用 peekInterceptedData不清空而非 getInterceptedData清空避免数据被消费后简历筛选阶段拿不到
if (hasPreFilter) {
logDebug(`${LOG} → 等待 geek/info 数据(初步筛选,最长 5s...`)
let geekInfoSnap = await waitForGeekInfo(peekInterceptedData, { timeoutMs: 5000 })
if (!geekInfoSnap) {
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)
if (processContext) processContext.currentCandidate = null
return { processed: false, skipped: true }
}
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)
getInterceptedData()
await sleepWithRandomDelay(2000, 4000)
if (processContext) processContext.currentCandidate = null
return { processed: true, skipped: false }
}
// 无附件简历 → 说明对方只是打招呼,需要我方先筛选再决定是否索取
logInfo(`${LOG} → 对方打招呼,点击查看在线简历...`)
if (typeof clearCapturedText === 'function') {
await clearCapturedText(page)
}
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)
if (processContext) processContext.currentCandidate = null
return { processed: false, skipped: true }
}
logInfo(`${LOG} → 在线简历 iframe 已出现,等待 Canvas 渲染完成...`)
let resumeText = ''
if (typeof getCapturedText === 'function') {
const { extractResumeText } = await import('./resume-extractor.mjs')
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 = typeof peekCapturedText === 'function'
? await peekCapturedText(page)
: 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
}
const captured = await getCapturedText(page)
const rawLines = extractResumeText(captured)
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 {
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)
if (processContext) processContext.currentCandidate = null
return { processed: false, skipped: true }
}
}
} 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} 字)`)
}
await sleepWithRandomDelay(2000, 4500)
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.rubric?.passThreshold ?? llmConfig.passThreshold ?? 75,
_scoring_note: llmConfig.rubric?._scoring_note
})
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} → 先关闭在线简历弹窗,避免遮挡附件简历按钮...`)
const closeBox2 = await openResumeCloseBtn.boundingBox().catch(() => null)
if (closeBox2) {
await cursor.click({ x: closeBox2.x + closeBox2.width / 2, y: closeBox2.y + closeBox2.height / 2 })
} else {
await openResumeCloseBtn.click().catch(() => {})
}
try {
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 3000 })
logDebug(`${LOG} → 在线简历弹窗已关闭`)
} catch {
logWarn(`${LOG} → 在线简历弹窗关闭超时,继续尝试(可能影响附件简历按钮点击)`)
}
await sleepWithRandomDelay(500, 1000)
}
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
)
getInterceptedData()
await sleepWithRandomDelay(2000, 4500)
if (processContext) processContext.currentCandidate = null
return { processed: true, skipped: false }
}
// ────────────────────────────────────────────────────────────────────────────
// ── 职位 tab 初始化:切换到「新招呼」分类,再强制点击「未读」触发列表刷新 ────────
// 必须先进入「新招呼」分类,才能只扫描当前职位下候选人主动发来的招呼,避免遍历其他类型会话。
// 「未读」tab 只有被实际点击时 BOSS 才会刷新列表若上次运行后页面已停在「未读」tab
// 不点击则不会刷新已处理过的会话仍会出现导致重复操作。因此此处强制点击force: true
await switchToTab(CHAT_PAGE_TAB_NEW_GREET_SELECTOR, '新招呼', { force: true })
await sleepWithRandomDelay(300, 500)
await switchToTab(CHAT_PAGE_UNREAD_FILTER_SELECTOR, '未读', { force: true })
await sleepWithRandomDelay(400, 600)
// ────────────────────────────────────────────────────────────────────────────
// ── 验证恢复:若上次被验证中断,优先重试被中断的候选人 ────────────────────────
if (retryCandidate) {
logInfo(`${LOG} ── 验证恢复:重试被中断候选人 ${retryCandidate.geekName}${retryCandidate.encryptGeekId})──`)
// 候选人此前已被点击,状态变为"已读"需切换到「全部」tab 才能找到
await switchToTab(CHAT_PAGE_ALL_FILTER_SELECTOR, '全部')
await sleepWithRandomDelay(300)
const retrySel = await selectConversationById(page, retryCandidate.encryptGeekId, { cursor })
if (retrySel) {
logInfo(`${LOG} 重试候选人会话已找到,开始处理...`)
await sleepWithRandomDelay(600, 1200)
await processOneCandidateConversation(retryCandidate)
} else {
logWarn(`${LOG} 未在「全部」会话中找到重试候选人 ${retryCandidate.geekName}(可能已被处理或不可见),跳过`)
}
// 切回「未读」tab 进行正常扫描
await switchToTab(CHAT_PAGE_UNREAD_FILTER_SELECTOR, '未读')
await sleepWithRandomDelay(300)
}
// ── 正常扫描:处理「新招呼」分类下的未读会话 ─────────────────────────────────
// 「新招呼」分类与「未读」tab 已在上方初始化阶段完成切换force: true此处直接解析列表。
// 若经过 retryCandidate 流程retry 结束时已切回「未读」tab状态同样正确。
// ── 批次循环:每处理 BATCH_REFRESH_SIZE 条后重新点击「未读」刷新列表步骤4-5────
const BATCH_REFRESH_SIZE = 10
let totalAttempted = 0
let totalProcessed = 0
const seenIds = new Set()
while (totalAttempted < maxProcessPerRun) {
const conversations = await parseConversationList(page)
logDebug(`${LOG} DOM 解析到 ${conversations.length} 条会话`)
const unreadItems = conversations.filter((c) => c.encryptGeekId && !seenIds.has(c.encryptGeekId))
if (unreadItems.length === 0) {
logInfo(`${LOG} 「未读」列表为空,全部处理完毕`)
break
}
const batchSize = Math.min(BATCH_REFRESH_SIZE, maxProcessPerRun - totalAttempted)
const batch = unreadItems.slice(0, batchSize)
logInfo(`${LOG} 当前未读 ${unreadItems.length} 条,本批次处理 ${batch.length} 条(已尝试 ${totalAttempted}/${maxProcessPerRun}`)
await hooks.onProgress?.promise?.({ phase: 'chatPage', current: totalProcessed, max: maxProcessPerRun }).catch(() => {})
for (const item of batch) {
seenIds.add(item.encryptGeekId)
logInfo(`${LOG} ── [${totalAttempted + 1}/${maxProcessPerRun}] 开始处理 ${item.geekName}${item.encryptGeekId})──`)
const result = await processOneCandidateConversation(item)
totalAttempted++
if (result.processed) {
totalProcessed++
await hooks.onProgress?.promise?.({ phase: 'chatPage', current: totalProcessed, max: maxProcessPerRun }).catch(() => {})
}
if (totalAttempted >= maxProcessPerRun) break
}
if (totalAttempted >= maxProcessPerRun) {
logInfo(`${LOG} 已达本次最大处理数 ${maxProcessPerRun},停止`)
break
}
// 步骤4每批次结束后重新点击「未读」标签刷新列表
logInfo(`${LOG} 本批次结束,重新点击「未读」标签刷新列表...`)
await switchToTab(CHAT_PAGE_UNREAD_FILTER_SELECTOR, '未读', { force: true })
await sleepWithRandomDelay(400, 600)
}
logInfo(`${LOG} 本次共处理 ${totalProcessed} 条未读会话(尝试 ${totalAttempted} 条)`)
} catch (err) {
await hooks.onError?.promise?.(err)
throw err
}
}

View File

@@ -0,0 +1,394 @@
/**
* 沟通页:先看在线简历 → 提取文字供关键词/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 { dismissBlockingOverlays } from './dialog-dismisser.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
} 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] 检测到旧简历弹窗,点击关闭按钮...')
const closeBox = await closeBtn.boundingBox().catch(() => null)
if (closeBox) {
await cursor.click({ x: closeBox.x + closeBox.width / 2, y: closeBox.y + closeBox.height / 2 })
} else {
await closeBtn.click().catch(() => {})
}
// 等关闭按钮从 DOM 消失(即弹窗完全关闭),比等 iframe 消失更可靠
try {
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/意向沟通弹窗,避免遮挡附件简历按钮或误点
// 已知 selector 优先 → 启发式扫描兜底(应对未知新弹窗)
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 {
// 留给启发式兜底
}
await sleepWithRandomDelay(300, 600)
}
const extraClosed = await dismissBlockingOverlays(page, { maxRounds: 2 }).catch(() => 0)
if (extraClosed > 0) {
console.log('[requestAttachmentResume] 启发式额外关闭了', extraClosed, '个浮层')
await sleepWithRandomDelay(200, 400)
}
// 检查是否有残留的确认弹窗上一个候选人流程遗留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(300, 600)
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,242 @@
// 招聘端 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 = '.dropmenu-label.chat-select-job'
/** 沟通页:职位下拉展开后的列表项(过滤 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(1) 在 .chat-message-filter-left 内) */
export const CHAT_PAGE_ALL_FILTER_SELECTOR = '.chat-message-filter-left span:nth-child(1)'
/** 沟通页:顶部"未读"筛选 tabspan:nth-child(2) 在 .chat-message-filter-left 内active 时有 class="active" */
export const CHAT_PAGE_UNREAD_FILTER_SELECTOR = '.chat-message-filter-left span:nth-child(2)'
/** 左侧会话列表容器 */
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="全部"]'
// =============================================================================
// 三、治理公告弹窗(登录后出现,须点击「我已知晓」才能继续操作)
// =============================================================================
/**
* 治理公告弹窗dialog-uninstall-extension容器选择器。
* BOSS 每次登录后会弹出此公告,告知平台禁止使用第三方自动化工具。
* HTML: div.boss-popup__wrapper.boss-dialog.boss-dialog__wrapper.dialog-uninstall-extension
*/
export const GOVERNANCE_NOTICE_DIALOG_SELECTOR = '.dialog-uninstall-extension'
/**
* 治理公告弹窗内的「我已知晓」确认按钮div.confirm-btn背景图模拟按钮样式
* HTML: div.dialog-uninstall-extension div.uninstall-extension div.content div.confirm-btn
*/
export const GOVERNANCE_NOTICE_DIALOG_CONFIRM_BTN_SELECTOR = '.dialog-uninstall-extension .confirm-btn'
/**
* 沟通页:左侧会话列表分类 tab——「新招呼」候选人主动发来招呼的会话
* 每次开始处理前须先点击此 tab确保只扫描新招呼消息避免遍历其他类型会话。
* HTML: div.chat-label-item[title="新招呼"],选中态有 class selected。
*/
export const CHAT_PAGE_TAB_NEW_GREET_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,337 @@
/**
* 通用弹窗 / 遮挡层自动识别与关闭。
*
* 设计目标:减少手动维护「治理公告」「意向沟通」之类一次性弹窗 selector 的成本。
* 思路:
* 1) 启发式扫描页面顶层 fixed / 高 z-index 浮层;
* 2) 在浮层内按文本(我已知晓/我知道了/知道了/确定/好的/关闭/取消/跳过/×+
* aria-label / classclose|dismiss|confirm-btn|btn-sure匹配关闭按钮
* 3) safeClickAt点击前用 elementFromPoint 检测目标坐标是否被遮挡,被挡则先尝试关闭遮挡再重试。
*
* 注意:所有浏览器侧逻辑都在一次 evaluate 内做完,避免多次 round-trip返回结果含被关闭浮层的 outerHTML 摘要供日志审计。
*/
import { sleep } from '@geekgeekrun/utils/sleep.mjs'
import { debug as logDebug, info as logInfo } from './logger.mjs'
/** 关闭按钮文本(按优先级) */
const CLOSE_TEXTS = [
'我已知晓', '我知道啦', '我知道了', '知道了', '我知道',
'好的', '确定', '确认',
'关闭', '取消',
'跳过', '稍后再说', '稍后', '不再提示',
'我已阅读并同意', '同意并继续'
]
/** 单字关闭符号 */
const CLOSE_GLYPHS = ['×', '✕', '✖', '', '']
/**
* 浏览器侧的弹窗识别 / 关闭脚本。
* 一次 evaluate 内完成扫描 + 点击,避免多次 round-trip。
*
* @returns {{
* dismissed: boolean,
* reason?: 'TEXT' | 'CLASS' | 'ARIA' | 'GLYPH',
* text?: string,
* outerHtml?: string,
* overlaySignature?: string
* }}
*/
function dismissInPageBody (closeTexts, closeGlyphs) {
const isVisible = (el) => {
if (!el || !el.getBoundingClientRect) return false
const cs = getComputedStyle(el)
if (cs.visibility === 'hidden' || cs.display === 'none') return false
// opacity 既可能是 '0' 也可能是 '0.0'/'0.00' 等,用 parseFloat 兜底
if (parseFloat(cs.opacity) <= 0) return false
const r = el.getBoundingClientRect()
return r.width > 1 && r.height > 1
}
const vw = window.innerWidth
const vh = window.innerHeight
// 1) 扫描候选浮层。先用窄选择器CSS class/role 显式标记 dialog/popup 等的元素);
// 命中即可避免对整页 querySelectorAll('*') 做样式计算 —— 在大型 SPA 上能省下 O(N) 的 getComputedStyle。
// 若窄查询过滤后仍为空,再回退到全量扫描(带元素数上限),覆盖无标记的 hand-rolled 浮层。
const NARROW_SEL = '[class*="dialog"],[class*="popup"],[class*="modal"],[class*="mask"],[class*="overlay"],[class*="drawer"],[role="dialog"],[role="alertdialog"]'
const FULL_SCAN_CAP = 5000
const collectFrom = (nodes) => {
const out = []
let scanned = 0
for (const el of nodes) {
if (++scanned > FULL_SCAN_CAP) break
if (!isVisible(el)) continue
const cs = getComputedStyle(el)
if (cs.position !== 'fixed' && cs.position !== 'absolute') continue
const r = el.getBoundingClientRect()
const area = r.width * r.height
if (area < vw * vh * 0.05) continue
if (r.right < 0 || r.left > vw || r.bottom < 0 || r.top > vh) continue
const z = parseInt(cs.zIndex || '0', 10) || 0
const cls = (el.className && typeof el.className === 'string') ? el.className : (el.getAttribute && el.getAttribute('class')) || ''
const looksLikeDialog = /dialog|popup|modal|mask|overlay|drawer/i.test(cls)
if (z < 100 && !looksLikeDialog) continue
// 排除:业务流程主动打开、不该被自动关闭的 dialog在线/附件简历预览、索取简历确认)
if (/resume-common-dialog|ask-for-resume-confirm|c-resume/i.test(cls)) continue
out.push({ el, z, area, looksLikeDialog })
}
return out
}
let overlays = document.body ? collectFrom(document.body.querySelectorAll(NARROW_SEL)) : []
if (overlays.length === 0 && document.body) {
overlays = collectFrom(document.body.querySelectorAll('*'))
}
// 优先级:明显是 dialog 的 + z-index 高 + 面积大
overlays.sort((a, b) => {
if (a.looksLikeDialog !== b.looksLikeDialog) return a.looksLikeDialog ? -1 : 1
if (b.z !== a.z) return b.z - a.z
return b.area - a.area
})
const findClose = (root) => {
const candidates = root.querySelectorAll('button, [role="button"], a, span, div, i')
let best = null
for (const c of candidates) {
if (!isVisible(c)) continue
// 只考虑可点击 / 有 cursor pointer 的元素
const cs = getComputedStyle(c)
const looksClickable = c.tagName === 'BUTTON' || c.getAttribute('role') === 'button' ||
cs.cursor === 'pointer' ||
/btn|button|close|confirm|sure|know|agree/i.test(c.className || '')
if (!looksClickable) continue
const text = (c.innerText || c.textContent || '').trim()
const aria = (c.getAttribute('aria-label') || '') + ' ' + (c.getAttribute('title') || '')
const cls = c.className || ''
// 1) 文本严格匹配(短文本,整段就是按钮文案)
if (text.length > 0 && text.length <= 12) {
for (const t of closeTexts) {
if (text === t || text.includes(t)) {
return { btn: c, reason: 'TEXT', text }
}
}
for (const g of closeGlyphs) {
if (text === g) return { btn: c, reason: 'GLYPH', text }
}
}
// 2) class / aria 匹配关闭语义
if (/close|dismiss|confirm-btn|btn-sure/i.test(cls)) {
if (!best) best = { btn: c, reason: 'CLASS', text: text.slice(0, 30) }
}
if (/close|dismiss/i.test(aria)) {
if (!best) best = { btn: c, reason: 'ARIA', text: aria.trim().slice(0, 30) }
}
}
return best
}
for (const ov of overlays) {
const hit = findClose(ov.el)
if (!hit) continue
const r = hit.btn.getBoundingClientRect()
if (r.width < 1 || r.height < 1) continue
// 触发真实 clickHTMLElement.click() 同时通知 Vue/React 监听)
try {
hit.btn.click()
} catch (_) {
// ignore
}
const outerHtml = (ov.el.outerHTML || '').slice(0, 400)
const sigParts = []
if (ov.el.id) sigParts.push('#' + ov.el.id)
if (ov.el.className) sigParts.push('.' + String(ov.el.className).split(/\s+/).slice(0, 2).join('.'))
return {
dismissed: true,
reason: hit.reason,
text: hit.text,
outerHtml,
overlaySignature: sigParts.join('') || ov.el.tagName.toLowerCase()
}
}
return { dismissed: false }
}
/**
* 在 page / frame 上扫描并关闭一个挡住操作的浮层。
* 调用方可循环调用直到 false最多 N 次)以处理叠加弹窗。
* @param {import('puppeteer').Page | import('puppeteer').Frame} ctx
* @returns {Promise<{dismissed: boolean, reason?: string, text?: string, overlaySignature?: string, outerHtml?: string}>}
*/
export async function tryDismissOneOverlay (ctx) {
try {
const result = await ctx.evaluate(dismissInPageBody, CLOSE_TEXTS, CLOSE_GLYPHS)
if (result?.dismissed) {
logInfo('[dialog-dismisser] 自动关闭浮层:', result.overlaySignature, '匹配=', result.reason, '文案=', result.text)
logDebug('[dialog-dismisser] outerHTML 摘要:', result.outerHtml)
}
return result || { dismissed: false }
} catch (e) {
logDebug('[dialog-dismisser] evaluate 失败:', e?.message)
return { dismissed: false }
}
}
/**
* 多次循环尝试关闭浮层(应对叠加 / 关一个出一个的情况)。
*
* 每次 click 后等 gapMs 再重新扫描若下一轮扫到的是同一个浮层signature 相同),
* 说明 click 没有触发关闭(可能是 disabled 按钮或事件被吞),记为一次"无进展"
* 连续 2 次无进展则提前终止,避免无限点击失效按钮。
*
* @param {import('puppeteer').Page | import('puppeteer').Frame} ctx
* @param {{ maxRounds?: number, gapMs?: number }} [opts]
* @returns {Promise<number>} 成功关闭的浮层数量
*/
export async function dismissBlockingOverlays (ctx, opts = {}) {
const maxRounds = opts.maxRounds ?? 3
const gapMs = opts.gapMs ?? 350
let count = 0
let staleSig = null // 上一轮被点击但可能未关掉的浮层 signature
let staleCount = 0 // 连续无进展次数
for (let i = 0; i < maxRounds; i++) {
const r = await tryDismissOneOverlay(ctx)
if (!r.dismissed) break
await sleep(gapMs)
// 检查是否真正消失:若 signature 与上轮相同,视为 click 无效
if (r.overlaySignature && r.overlaySignature === staleSig) {
staleCount++
logDebug('[dialog-dismisser] 浮层', r.overlaySignature, '点击后仍存在staleCount=', staleCount, '')
if (staleCount >= 2) {
logInfo('[dialog-dismisser] 浮层', r.overlaySignature, '连续 2 次点击无效,终止重试')
break
}
} else {
// 新出现的浮层(或浮层已关且另一个冒出),重置计数
staleSig = r.overlaySignature
staleCount = 0
count++
}
}
return count
}
/**
* 检查 (x, y) 处的最顶层元素是否是 expectedEl 或其后代。被其他元素遮挡返回 blocked=true。
*
* 坐标系x/y 必须是 **viewport / client 坐标**(即 `document.elementFromPoint` 期望的坐标系,
* 与 `getBoundingClientRect()` 返回值一致。Puppeteer 的 `ElementHandle.boundingBox()`
* 返回的也是 viewport-relative 坐标(与 `page.mouse.click` 接受的坐标系相同),所以可直接传入。
* 若调用方手头是 document/page 坐标(含 scroll 偏移),需先减去 `window.scrollX/scrollY`。
*
* @param {import('puppeteer').Page | import('puppeteer').Frame} ctx
* @param {import('puppeteer').ElementHandle} expectedEl
* @param {number} x viewport x 坐标
* @param {number} y viewport y 坐标
* @returns {Promise<{blocked: boolean, topTag?: string, topClass?: string}>}
*/
export async function checkBlockedAt (ctx, expectedEl, x, y) {
try {
const result = await ctx.evaluate((targetEl, px, py) => {
const top = document.elementFromPoint(px, py)
if (!top) return { blocked: false }
// 只有 top === target 或 top 是 target 的后代时才认为未被遮挡。
// top 是 target 的祖先意味着 target 上方有元素拦截了点击事件pointer-events 转发到祖先)。
if (top === targetEl || (targetEl && targetEl.contains && targetEl.contains(top))) {
return { blocked: false }
}
return {
blocked: true,
topTag: top.tagName?.toLowerCase(),
topClass: (typeof top.className === 'string' ? top.className : '').slice(0, 60)
}
}, expectedEl, x, y)
return result || { blocked: false }
} catch (_) {
return { blocked: false }
}
}
/**
* 安全点击:先校验目标是否被遮挡,被挡则尝试关闭浮层后重试。
*
* 兼容 humanMouse 的 cursor.click({x, y})。当 ctx 是 FrameelementFromPoint
* 是 frame 内部坐标系,但 boundingBox() 是 page 坐标——所以这里 ctx 应当与 expectedEl
* 来自同一上下文Frame 元素就传 Frame主页面元素就传 Page
*
* @param {{
* ctx: import('puppeteer').Page | import('puppeteer').Frame,
* page: import('puppeteer').Page,
* element: import('puppeteer').ElementHandle,
* cursor: { click: (p:{x:number,y:number}) => Promise<void> },
* maxRetries?: number,
* logPrefix?: string
* }} args
* @returns {Promise<{ clicked: boolean, dismissedCount: number, error?: string }>}
* - clicked: 是否真正发出过一次成功的点击调用cursor.click 或 element.click 未抛错)
* - dismissedCount: 期间被启发式关掉的浮层数
* - error: 失败原因NO_BOUNDING_BOX_AND_CLICK_FAILED / BLOCKED_AND_DISMISS_FAILED / RETRY_EXHAUSTED 等)
*/
export async function safeClickElement (args) {
const { ctx, page, element, cursor, maxRetries = 3, logPrefix = '[safe-click]' } = args
let dismissedCount = 0
for (let attempt = 0; attempt < maxRetries; attempt++) {
const box = await element.boundingBox().catch(() => null)
if (!box) {
logDebug(logPrefix, '元素无 boundingBox回退到 element.click()')
let ok = true
await element.click().catch((e) => {
ok = false
logDebug(logPrefix, 'element.click() 抛错:', e?.message)
})
return ok
? { clicked: true, dismissedCount }
: { clicked: false, dismissedCount, error: 'NO_BOUNDING_BOX_AND_CLICK_FAILED' }
}
const cx = box.x + box.width / 2
const cy = box.y + box.height / 2
// ctx 上的 elementFromPoint 用的是 ctx 自己的 viewport 坐标。
// 当 ctx === pagebox 已是主页面 viewport 坐标,可直接传给 elementFromPoint。
// 当 ctx 是 frameboundingBox 返回的是主页面 viewport 坐标但 frame 内 elementFromPoint
// 期待 frame 自己的坐标系——保守跳过遮挡检测iframe 内罕见全局弹窗,主页面才是高发区)。
const sameAsPage = ctx === page
let blocked = { blocked: false }
if (sameAsPage) {
blocked = await checkBlockedAt(ctx, element, cx, cy)
}
if (blocked.blocked) {
logInfo(logPrefix, `点击目标被遮挡top=${blocked.topTag}.${blocked.topClass}),尝试自动关闭浮层…`)
const n = await dismissBlockingOverlays(page)
dismissedCount += n
if (n === 0) {
logDebug(logPrefix, '未识别到可关闭的浮层,强制点击一次后返回(成功率不保证)')
let ok = true
await cursor.click({ x: cx, y: cy }).catch((e) => {
ok = false
logDebug(logPrefix, 'cursor.click 抛错:', e?.message)
})
return ok
? { clicked: true, dismissedCount, error: 'CLICKED_WHILE_BLOCKED' }
: { clicked: false, dismissedCount, error: 'BLOCKED_AND_DISMISS_FAILED' }
}
// 关闭后重试
continue
}
let ok = true
await cursor.click({ x: cx, y: cy }).catch((e) => {
ok = false
logDebug(logPrefix, 'cursor.click 抛错:', e?.message)
})
if (ok) return { clicked: true, dismissedCount }
// cursor 点击失败也用 retry 兜底
}
// 重试用尽
const box = await element.boundingBox().catch(() => null)
let ok = false
if (box) {
ok = true
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 }).catch(() => { ok = false })
}
return ok
? { clicked: true, dismissedCount, error: 'RETRY_EXHAUSTED_BUT_FINAL_CLICK_OK' }
: { clicked: false, dismissedCount, error: 'RETRY_EXHAUSTED' }
}

View File

@@ -0,0 +1,150 @@
/**
* 拟人鼠标轨迹封装(招聘端专用)
*
* BOSS 对招聘端鼠标移动轨迹进行埋点,直接 page.click() 或 page.mouse.click(x,y)
* 的"瞬移"方式容易被识别为脚本。本模块封装 ghost-cursor以贝塞尔曲线生成拟人
* 移动路径,替换所有在招聘端页面上的点击操作。
*
* 用法:
* import { createHumanCursor, randomizeInitialCursorPosition } from './humanMouse.mjs'
* const cursor = await createHumanCursor(page)
* await randomizeInitialCursorPosition(page)
* await cursor.click(selector) // 先沿轨迹移动,再点击
* await cursor.move(selector) // 仅移动,不点击
*/
// 模块级缓存:首次成功 preflight 后避免重复 import
let cachedGhostCursorCreate = null
/**
* 预检查 ghost-cursor 是否可用,返回其 createCursor 函数。
* 失败时抛出明确错误,避免静默退化为 page.click() 这种"以为隐身实则裸奔"的最坏情况。
*
* @returns {Promise<Function>} ghost-cursor 的 createCursor 函数
*/
export async function preflightGhostCursor () {
if (cachedGhostCursorCreate) return cachedGhostCursorCreate
let createCursor
try {
const mod = await import('ghost-cursor')
// ghost-cursor 同时支持 ESM default export 和命名 export
createCursor = mod.createCursor ?? mod.default?.createCursor
} catch (e) {
throw new Error(
'GHOST_CURSOR_UNAVAILABLE: ghost-cursor failed to load — refusing to run with bot-like clicks. Reinstall dependencies (pnpm -F @geekgeekrun/boss-auto-browse-and-chat install).'
)
}
if (typeof createCursor !== 'function') {
throw new Error(
'GHOST_CURSOR_UNAVAILABLE: ghost-cursor failed to load — refusing to run with bot-like clicks. Reinstall dependencies (pnpm -F @geekgeekrun/boss-auto-browse-and-chat install).'
)
}
cachedGhostCursorCreate = createCursor
return createCursor
}
/**
* 在 box 的中心 60% 区域内随机一个落点(默认 centerBiasFraction=0.3,即中心 ±30%)。
* 避免每次点击都落在精确几何中心,这本身就是脚本特征。
*
* @param {{x: number, y: number, width: number, height: number}} box
* @param {number} [centerBiasFraction=0.3]
* @returns {{x: number, y: number}}
*/
function randomizePointInBox (box, centerBiasFraction = 0.3) {
const minXFrac = 0.5 - centerBiasFraction
const maxXFrac = 0.5 + centerBiasFraction
const minYFrac = 0.5 - centerBiasFraction
const maxYFrac = 0.5 + centerBiasFraction
const xFrac = minXFrac + Math.random() * (maxXFrac - minXFrac)
const yFrac = minYFrac + Math.random() * (maxYFrac - minYFrac)
return {
x: box.x + box.width * xFrac,
y: box.y + box.height * yFrac
}
}
/**
* 为给定 Puppeteer page 创建拟人鼠标 cursor。
* 内部强依赖 ghost-cursor若不可用直接抛错fail-fast不再静默退化。
*
* @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) {
const ghostCursorCreate = await preflightGhostCursor()
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) 执行。
* 对 selector / ElementHandle 输入会在中心 60% 范围内随机落点;
* 对显式 {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 randomizePointInBox(box)
}
// 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 randomizePointInBox(box)
}
// 已是 {x, y} 坐标对象,调用方已选定精确坐标,不再随机化
return selectorOrPos
}
return {
/**
* 沿拟人轨迹移动到目标后点击。使用 moveTo({x,y}) + page.mouse.click(x,y)
* 规避 ghost-cursor 传坐标/ElementHandle 时调 element.evaluate 的崩溃问题。
* 以约 0.5 概率先做一次轻微 overshoot 移动,模拟真实用户在按钮附近犹豫/减速。
* @param {string | {x: number, y: number} | import('puppeteer').ElementHandle} selectorOrPos
*/
async click (selectorOrPos) {
const pos = await resolvePos(selectorOrPos)
if (Math.random() < 0.5) {
const vp = page.viewport() || { width: 1280, height: 720 }
const overshoot = {
x: Math.max(1, Math.min(vp.width - 1, pos.x + (Math.random() * 60 - 30))),
y: Math.max(1, Math.min(vp.height - 1, pos.y + (Math.random() * 30 - 15)))
}
await cursor.moveTo(overshoot)
}
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)
}
}
}
/**
* 将鼠标移动到 viewport 内一个随机位置,避免每次会话都从 (0,0) 起步这一明显特征。
* 由集成方在合适时机如打开页面后显式调用createHumanCursor 不会自动调用它。
*
* @param {import('puppeteer').Page} page
*/
export async function randomizeInitialCursorPosition (page) {
// Move cursor to a random viewport position (avoids the (0,0) start signature)
const viewport = page.viewport() || { width: 1280, height: 720 }
const x = 200 + Math.floor(Math.random() * (viewport.width - 400))
const y = 100 + Math.floor(Math.random() * (viewport.height - 200))
await page.mouse.move(x, y, { steps: 5 + Math.floor(Math.random() * 10) })
}

View File

@@ -0,0 +1,616 @@
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,
GOVERNANCE_NOTICE_DIALOG_SELECTOR,
GOVERNANCE_NOTICE_DIALOG_CONFIRM_BTN_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 { dismissBlockingOverlays } from './dialog-dismisser.mjs'
import { setLevel, debug as logDebug, info as logInfo, warn as logWarn, error as logError } from './logger.mjs'
import { preflightGhostCursor, randomizeInitialCursorPosition } from './humanMouse.mjs'
import { buildRecruiterLaunchOptions } from './launch-options.mjs'
import { checkpointRiskControl } from './risk-detector.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 }))
// ghost-cursor preflightfail-fast避免后续静默退化为裸 page.click()
await preflightGhostCursor()
logDebug('[boss-auto-browse] initPuppeteer: 插件已注册(含 ghost-cursor preflight')
return {
puppeteer,
StealthPlugin,
LaodengPlugin,
AnonymizeUaPlugin
}
}
/**
* 关闭登录后弹出的「治理公告」等任何挡住操作的浮层。
*
* 历史上这里只针对 GOVERNANCE_NOTICE_DIALOG_CONFIRM_BTN_SELECTOR 硬编码点击,
* 现改为:先等待已知治理公告 selector 出现(提示性),再调用通用的 dismissBlockingOverlays
* 启发式扫描——这样新冒出的弹窗也能被自动关掉,不必每次手工加 selector。
* @param {import('puppeteer').Page} page
*/
export async function dismissGovernanceNoticeDialog (page) {
// 给已知的治理公告一点时间冒出来;超时也没关系——通用扫描兜底
await page.waitForSelector(GOVERNANCE_NOTICE_DIALOG_SELECTOR, { timeout: 10000 }).catch(() => null)
const closed = await dismissBlockingOverlays(page, { maxRounds: 3 }).catch(() => 0)
if (closed > 0) {
logInfo(`[boss-auto-browse] 自动关闭了 ${closed} 个登录后浮层(含治理公告)`)
await sleep(300)
}
// 兜底:若启发式没识别到治理公告(例如关闭按钮文案变了),再尝试硬编码 selector
const stillThere = await page.$(GOVERNANCE_NOTICE_DIALOG_SELECTOR).catch(() => null)
if (stillThere) {
logWarn('[boss-auto-browse] 启发式未关闭治理公告,回退到硬编码 selector')
const confirmBtn = await page.$(GOVERNANCE_NOTICE_DIALOG_CONFIRM_BTN_SELECTOR).catch(() => null)
if (confirmBtn) {
const box = await confirmBtn.boundingBox().catch(() => null)
if (box) {
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2).catch(() => {})
} else {
await confirmBtn.click().catch(() => {})
}
await page.waitForSelector(GOVERNANCE_NOTICE_DIALOG_SELECTOR, { hidden: true, timeout: 5000 }).catch(() => {})
await sleep(300)
}
}
}
/** 招聘端 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 launchOpts = await buildRecruiterLaunchOptions()
const browser = await puppeteer.launch(launchOpts)
const page = (await browser.pages())[0]
await randomizeInitialCursorPosition(page).catch(() => {})
const bossCookies = readStorageFile('boss-cookies.json')
const bossLocalStorage = readStorageFile('boss-local-storage.json')
// persistProfile=true 时 profile 已持久化 cookies跳过注入避免用过期文件覆盖有效 session
if (!launchOpts.userDataDir && 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))
await dismissGovernanceNoticeDialog(page)
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 {
const { createHumanCursor } = await import('./humanMouse.mjs')
const cursor = await createHumanCursor(page)
// 用拟人轨迹点击下拉触发按钮
const dropdownBtn = await page.$(RECOMMEND_JOB_DROPDOWN_LABEL_SELECTOR)
if (dropdownBtn) {
const box = await dropdownBtn.boundingBox().catch(() => null)
if (box) {
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
} else {
await dropdownBtn.click()
}
} else {
await page.click(RECOMMEND_JOB_DROPDOWN_LABEL_SELECTOR)
}
await page.waitForSelector(RECOMMEND_JOB_ITEM_SELECTOR, { timeout: 5000 })
await sleepWithRandomDelay(150, 300)
// 用拟人轨迹点击目标职位项
const items = await page.$$(RECOMMEND_JOB_ITEM_SELECTOR)
let found = false
for (const item of items) {
const val = await item.evaluate(el => el.getAttribute('value')).catch(() => null)
if (val === jobId) {
const itemBox = await item.boundingBox().catch(() => null)
if (itemBox) {
await cursor.click({ x: itemBox.x + itemBox.width / 2, y: itemBox.y + itemBox.height / 2 })
} else {
await item.click()
}
found = true
break
}
}
if (!found) {
logWarn(`[boss-auto-browse] 职位 ${jobId} 未在下拉列表中找到,将使用默认职位继续`)
await page.keyboard.press('Escape')
return
}
// 等候选人列表重新加载
await sleepWithRandomDelay(400, 800)
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 launchOpts = await buildRecruiterLaunchOptions()
logDebug('[boss-auto-browse] 即将启动浏览器', { headless: launchOpts.headless, persistProfile: !!launchOpts.userDataDir })
browser = await puppeteer.launch(launchOpts)
page = (await browser.pages())[0]
await randomizeInitialCursorPosition(page).catch(() => {})
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?.()
// persistProfile=true 时 profile 已持久化 cookies跳过注入避免用过期文件覆盖有效 session
const persistProfile = (readConfigFile('boss-recruiter.json') || {})?.advanced?.persistProfile === true
if (!persistProfile && 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(() => {})
}
// 关闭登录后弹出的「治理公告」弹窗(每次登录必现,不处理会阻塞后续操作)
await dismissGovernanceNoticeDialog(page)
// 切换职位(若指定了 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') {
break mainLoop
}
// 'RISK_CONTROL' 落到下面统一 checkpoint 处理
}
// 每位候选人处理完都做一次 checkpoint检测到验证则在循环内等待用户完成避免崩出 catch + 3s 重试导致连环触发
// 不传 expectedUrlPrefix仅依赖 !detectRiskControl 判断完成,避免 URL query-params 导致误判超时
const cpStatus = await checkpointRiskControl(page, { log: logWarn })
if (cpStatus === 'timed-out') 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,69 @@
/**
* boss-recruiter.json `advanced` section schema:
* {
* "advanced": {
* "persistProfile": false // opt-in: persist Chromium profile across launches (better anti-detection;
* // BUT cannot run BOSS in system Chrome simultaneously)
* }
* }
*/
import path from 'node:path'
import fs from 'node:fs'
import crypto from 'node:crypto'
import { readConfigFile, storageFilePath } from './runtime-file-utils.mjs'
const VIEWPORT_POOL = [
{ w: 1366, h: 768 },
{ w: 1440, h: 900 - 140 },
{ w: 1536, h: 864 },
{ w: 1600, h: 900 },
{ w: 1680, h: 1050 - 150 }
]
const DEFAULT_VIEWPORT = { width: 1440, height: 760 }
function pickViewportForPath(seed) {
const digest = crypto.createHash('md5').update(seed).digest()
const intVal = digest.readInt32BE(0)
const idx = Math.abs(intVal) % VIEWPORT_POOL.length
const picked = VIEWPORT_POOL[idx]
return { width: picked.w, height: picked.h }
}
/**
* Build the puppeteer.launch() options object for the recruiter side.
* Reads boss-recruiter.json's `advanced` section for opt-in features.
*
* @param {object} [overrides] - shallow-merged onto the result (e.g. { headless: false } for force)
* @returns {Promise<import('puppeteer').LaunchOptions>}
*/
export async function buildRecruiterLaunchOptions(overrides = {}) {
const cfg = readConfigFile('boss-recruiter.json') || {}
const advanced = cfg.advanced || {}
const persistProfile = advanced.persistProfile === true
const headless = process.env.HEADLESS === '1'
let userDataDir
let viewport
if (persistProfile) {
userDataDir = path.join(storageFilePath, 'boss-chrome-profile')
fs.mkdirSync(userDataDir, { recursive: true })
viewport = pickViewportForPath(userDataDir)
} else {
viewport = { ...DEFAULT_VIEWPORT }
}
const args = ['--lang=zh-CN', '--disable-blink-features=AutomationControlled']
const opts = {
headless,
ignoreHTTPSErrors: true,
protocolTimeout: 120000,
defaultViewport: viewport,
args: [...args]
}
if (userDataDir) opts.userDataDir = userDataDir
return { ...opts, ...overrides }
}

View File

@@ -0,0 +1,315 @@
/**
* 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, _scoring_note?: string }} 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
const scoringNote = typeof rubricConfig?._scoring_note === 'string' ? rubricConfig._scoring_note : null
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": "简要判断理由"
}`
if (scoringNote) {
systemContent += `\n\n【评分说明】\n${scoringNote}`
}
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,392 @@
/**
* 招聘端简历数据提取工具:网络请求拦截 + 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 拦截
// ---------------------------------------------------------------------------
const CANVAS_HOOK_DEBUG = process.env.GEEKGEEKRUN_CANVAS_HOOK_DEBUG === '1'
/**
* 在页面上通过 evaluateOnNewDocument 注入 Canvas fillText hook将绘制文字收集到主页面随机命名的 marker 属性上。
*
* 实现原理:
* - evaluateOnNewDocument 会在主页面和每一个 iframe 中各执行一次。
* - 在线简历 iframe 带有 sandbox 属性且不含 allow-same-origin主页面无法访问其 contentWindow
* 因此必须在 iframe 自身的执行上下文内直接 hook CanvasRenderingContext2D.prototype.fillText。
* - iframe 内 hook 到的文字通过 window.top.postMessage 批量发回主页面(同 origin 或跨 origin 均可用)。
* - 主页面监听 message 事件并累积到随机命名的 window 属性。
*
* 反检测marker 属性名capturedTextProp / messageKey / hookedFlag每次调用本函数时随机生成
* 不同 session 不同;同时通过 laodeng.registerFakeNativeSource 让 fillText 包装函数的 toString 返回原生外观。
*
* @param {import('puppeteer').Page} page - Puppeteer 页面实例(必须在 page.goto 之前调用)
* @returns {Promise<{ getCapturedText: (page: import('puppeteer').Page) => Promise<Array<{text: string, x: number, y: number}>>, clearCapturedText: (page: import('puppeteer').Page) => Promise<void>, peekCapturedText: (page: import('puppeteer').Page) => Promise<number> }>}
*/
export async function setupCanvasTextHook (page) {
const markerSuffix = Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4)
const capturedTextProp = '__cct_' + markerSuffix
const messageKey = '__mk_' + markerSuffix
const hookedFlag = '_h_' + markerSuffix
// 转发浏览器内部 [canvasHook] 日志到 Node 侧(仅 debug 模式)
if (CANVAS_HOOK_DEBUG) {
page.on('console', (msg) => {
const text = msg.text()
if (text.startsWith('[canvasHook]')) {
console.log('[canvasHook-browser]', text)
}
})
}
// 注册 fillText 包装的伪原生 toString依赖 laodeng 已被 puppeteer.use 装载)
try {
const laodengMod = await import('@geekgeekrun/puppeteer-extra-plugin-laodeng')
const registerFakeNativeSource =
laodengMod.registerFakeNativeSource ?? laodengMod.default?.registerFakeNativeSource
if (typeof registerFakeNativeSource === 'function') {
await registerFakeNativeSource(
page,
'CanvasRenderingContext2D.prototype.fillText',
'function fillText() { [native code] }'
)
}
} catch (e) {
// non-fatal: hook still works, just one more detectable surface
}
await page.evaluateOnNewDocument(
(capturedTextProp, messageKey, hookedFlag, DEBUG) => {
// 此脚本在每个 frame主页面 + 所有 iframe中各执行一次。
const isTopFrame = (window === window.top)
if (isTopFrame) {
window[capturedTextProp] = []
window.addEventListener('message', (evt) => {
if (evt.data && evt.data[messageKey] && Array.isArray(evt.data[messageKey])) {
if (!window[capturedTextProp]) window[capturedTextProp] = []
for (const item of evt.data[messageKey]) {
window[capturedTextProp].push(item)
}
if (DEBUG) console.log('[canvasHook] main received ' + evt.data[messageKey].length + ' items, total ' + window[capturedTextProp].length)
}
})
if (DEBUG) console.log('[canvasHook] main: message listener set')
}
// 在当前 window无论是主页面还是 iframe上 hook fillText
try {
const proto = window.CanvasRenderingContext2D?.prototype
if (!proto) { if (DEBUG) console.log('[canvasHook] CanvasRenderingContext2D.prototype not found'); return }
if (proto[hookedFlag]) { if (DEBUG) console.log('[canvasHook] already hooked, skip'); return }
proto[hookedFlag] = true
const origFillText = proto.fillText
if (typeof origFillText !== 'function') { if (DEBUG) console.log('[canvasHook] fillText is not a function'); return }
const captured = []
let flushScheduled = false
const flush = () => {
flushScheduled = false
if (captured.length === 0) return
const items = captured.splice(0)
if (isTopFrame) {
if (!window[capturedTextProp]) window[capturedTextProp] = []
for (const item of items) window[capturedTextProp].push(item)
if (DEBUG) console.log('[canvasHook] main fillText wrote ' + items.length + ' items')
} else {
try {
const payload = {}
payload[messageKey] = items
window.top.postMessage(payload, '*')
if (DEBUG) console.log('[canvasHook] iframe postMessage sent ' + items.length + ' items')
} catch (e) {
if (DEBUG) 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
})
if (DEBUG) console.log('[canvasHook] fillText hook installed, isTopFrame=' + isTopFrame + ' href=' + window.location.href)
} catch (e) {
if (DEBUG) console.log('[canvasHook] hook install error: ' + e.message)
}
},
capturedTextProp,
messageKey,
hookedFlag,
CANVAS_HOOK_DEBUG
)
/**
* 从主页面读取当前收集的 Canvas 文字并清空。
* @param {import('puppeteer').Page} p - 同一页面实例
* @returns {Promise<Array<{text: string, x: number, y: number}>>}
*/
async function getCapturedText (p) {
await p.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)))
const result = await p.evaluate((prop) => {
const arr = window[prop] || []
const copy = arr.map(({ text, x, y }) => ({ text, x, y }))
window[prop] = []
return copy
}, capturedTextProp)
return result
}
/**
* 清空主页面收集数组(不返回数据),用于切换候选人前丢弃上一个 iframe 的残留数据。
* @param {import('puppeteer').Page} p - 同一页面实例
*/
async function clearCapturedText (p) {
await p.evaluate((prop) => { window[prop] = [] }, capturedTextProp)
}
/**
* Peek at how many canvas text items have been captured so far, without consuming them.
* Used for "stable count" polling to detect when Canvas rendering has finished.
* @param {import('puppeteer').Page} p
* @returns {Promise<number>}
*/
async function peekCapturedText (p) {
return p.evaluate((prop) => (window[prop] || []).length, capturedTextProp)
}
return { getCapturedText, clearCapturedText, peekCapturedText }
}
// ---------------------------------------------------------------------------
// 文字按行整理
// ---------------------------------------------------------------------------
/**
* 将 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 数据中取简历,若无则从页面 Canvas hook 中提取(需先调用 setupCanvasTextHook
*
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
* @param {Map<string, unknown>} interceptedData - setupNetworkInterceptor 返回的 getInterceptedData() 的结果
* @param {{ getCapturedText?: (page: import('puppeteer').Page) => Promise<Array<{text: string, x: number, y: number}>> }} [opts]
* opts.getCapturedText — setupCanvasTextHook 返回的同名函数(支持随机 marker 名);不传时降级读 window.__canvasCapturedText旧行为仅向后兼容
* @returns {Promise<{ source: 'api' | 'canvas', data: unknown }>} source 为 'api' 时 data 为 API 响应对象;为 'canvas' 时为 extractResumeText 的结果(字符串数组)
*/
export async function getResumeData (page, interceptedData, opts = {}) {
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 }) } }
}
}
// Canvas fallback: use getCapturedText closure if provided (supports randomized marker names)
// Fall back to legacy window.__canvasCapturedText for callers that don't yet pass it
const getCapturedTextFn = opts.getCapturedText
let captured
if (typeof getCapturedTextFn === 'function') {
captured = await getCapturedTextFn(page)
} else {
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,108 @@
import { sleep } from '@geekgeekrun/utils/sleep.mjs'
/**
* Detect whether the page is currently showing BOSS security verification
* (CAPTCHA / slider / 安全验证 / etc).
*
* Multi-signal: URL match + DOM elements + body text fallback.
* Element-first (more robust than text, which can false-positive on candidate
* descriptions that mention 验证).
*
* @param {import('puppeteer').Page} page
* @returns {Promise<boolean>}
*/
export async function detectRiskControl(page) {
try {
const url = page.url()
if (/verify|captcha|security.?check|safe\b|\/safe\/|安全验证/.test(url)) return true
return await page.evaluate(() => {
const hasVerifyEl = !!(
document.querySelector('#nc_mask') ||
document.querySelector('.verify-container') ||
document.querySelector('.captcha-wrap') ||
document.querySelector('.nc-container') ||
document.querySelector('[class*="verify"][class*="wrap"]') ||
document.querySelector('[class*="captcha"]') ||
document.querySelector('.geetest_panel') ||
document.querySelector('.geetest_box') ||
document.querySelector('[id^="__yidun"]') ||
document.querySelector('iframe[src*="captcha"]') ||
document.querySelector('iframe[src*="verify"]') ||
document.querySelector('.boss-popup__wrapper.dialog-verify')
)
if (hasVerifyEl) return true
const bodyText = document.body?.innerText || ''
const hasVerifyText =
/请完成.{0,10}验证|安全验证|滑动.{0,6}滑块|人机验证|完成验证后继续|异常.{0,6}操作|操作过于频繁|请稍后再试.*继续|存在风险.*操作/.test(
bodyText
)
return hasVerifyText
})
} catch {
return false
}
}
/**
* Block until user manually completes verification, OR timeout.
* Polls every 2s. Sends a desktop notification once on entry.
*
* @param {import('puppeteer').Page} page
* @param {object} [opts]
* @param {string} [opts.expectedUrlPrefix] - if provided, only consider verification done when url returns to this prefix
* @param {number} [opts.timeoutMs=300000] - default 5 min
* @param {(msg: string) => void} [opts.log] - optional logger
* @returns {Promise<boolean>} true if completed, false if timed out
*/
export async function waitForRiskControlCompletion(page, opts = {}) {
const { expectedUrlPrefix, timeoutMs = 300000, log } = opts
const logFn = typeof log === 'function' ? log : () => {}
logFn('⚠️ 检测到 BOSS 安全验证...')
try {
const { Notification } = await import('electron')
new Notification({
title: 'GeekGeekRun - 需要人工验证',
body: '检测到 BOSS 直聘安全验证,请在浏览器窗口中完成验证,完成后程序将自动继续。'
}).show()
} catch {
/* Notification 不可用时静默忽略 */
}
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
await sleep(2000)
try {
const isStillVerify = await detectRiskControl(page)
if (expectedUrlPrefix) {
const url = page.url()
if (url.startsWith(expectedUrlPrefix) && !isStillVerify) {
logFn('✅ 安全验证已完成')
return true
}
} else if (!isStillVerify) {
logFn('✅ 安全验证已完成')
return true
}
} catch {
/* 页面可能正在跳转,继续等待 */
}
}
logFn('验证等待超时5 分钟)')
return false
}
/**
* Convenience: detect and, if positive, wait for completion.
*
* @param {import('puppeteer').Page} page
* @param {object} [opts] - same as waitForRiskControlCompletion
* @returns {Promise<'no-risk'|'completed'|'timed-out'>}
*/
export async function checkpointRiskControl(page, opts = {}) {
const detected = await detectRiskControl(page)
if (!detected) return 'no-risk'
const completed = await waitForRiskControlCompletion(page, opts)
return completed ? 'completed' : 'timed-out'
}

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

@@ -34,6 +34,21 @@ const stealthScript = () => {
if (nativeSourceMap.has(this)) {
return nativeSourceMap.get(this);
}
// Path-based extra registrations
try {
const extras = window.__laodengExtraNativeSources;
if (extras && extras.size) {
for (const [path, src] of extras) {
const parts = path.split(".");
let obj = window;
for (let i = 0; i < parts.length; i++) {
if (obj == null) break;
obj = obj[parts[i]];
}
if (obj === this) return src;
}
}
} catch (_) {}
return nativeFunctionToString.call(this);
},
});
@@ -152,6 +167,31 @@ class Plugin extends PuppeteerExtraPlugin {
}
}
module.exports = function (pluginConfig) {
/**
* Register a fake native source for a function in the target page.
* Must be called AFTER the laodeng plugin has been applied to the browser.
* The wrapped function should already exist (or be created shortly after) — this
* adds a deferred registration that runs on every new document.
*
* @param {import('puppeteer').Page} page
* @param {string} accessorPath - dotted path to the wrapped function in window scope, e.g. "CanvasRenderingContext2D.prototype.fillText"
* @param {string} fakeNativeSource - what `.toString()` should return, e.g. "function fillText() { [native code] }"
*/
async function registerFakeNativeSource(page, accessorPath, fakeNativeSource) {
await page.evaluateOnNewDocument(
function (path, src) {
try {
if (!window.__laodengExtraNativeSources) window.__laodengExtraNativeSources = new Map();
window.__laodengExtraNativeSources.set(path, src);
} catch (_) {}
},
accessorPath,
fakeNativeSource
);
}
const pluginFactory = function (pluginConfig) {
return new Plugin(pluginConfig);
};
pluginFactory.registerFakeNativeSource = registerFakeNativeSource;
module.exports = pluginFactory;

View File

@@ -31,7 +31,9 @@ export async function main() {
const browser = await puppeteer.launch({
headless: false,
pipe: true,
enableExtensions: [editThisCookieExtensionPath]
enableExtensions: [editThisCookieExtensionPath],
defaultViewport: null,
args: ['--window-size=1440,900']
})
const closeAttachedSet = new WeakSet()

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

@@ -1,3 +1,4 @@
import { resolve } from 'path'
import { AUTO_CHAT_ERROR_EXIT_CODE } from '../../common/enums/auto-start-chat'
import { daemonEE, sendToDaemon } from '../flow/OPEN_SETTING_WINDOW/connect-to-daemon'
import { saveAndGetCurrentRunRecord } from '../flow/OPEN_SETTING_WINDOW/utils/db'
@@ -43,7 +44,7 @@ export async function runCommon({ mode }) {
}
const args =
process.env.NODE_ENV === 'development'
? [process.argv[1], `--mode=${mode}`, `--run-record-id=${currentRunRecord?.id || 0}`]
? [resolve(process.argv[1]), `--mode=${mode}`, `--run-record-id=${currentRunRecord?.id || 0}`]
: [`--mode=${mode}`, `--run-record-id=${currentRunRecord?.id || 0}`]
await sendToDaemon(
{

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

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,456 @@
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}`)
}
/**
* 检测当前页面是否为 BOSS 安全验证页URL 特征 + 页面文字 + 常见验证组件选择器)。
* 没有具体截图样本,使用多重信号:命中任意一条即判定为验证页。
*/
const checkForBossVerification = async (page: any): Promise<boolean> => {
try {
const url: string = page.url()
if (/verify|captcha|security.?check|safe\b|\/safe\/|安全验证/.test(url)) return true
return await page.evaluate(() => {
const text = (document.body?.innerText || '').toLowerCase()
const hasVerifyText = /请完成.{0,10}验证|安全验证|滑动.{0,6}滑块|人机验证|完成验证后继续|异常.{0,6}操作|验证码/.test(
document.body?.innerText || ''
)
const hasVerifyEl = !!(
document.querySelector('#nc_mask') ||
document.querySelector('.verify-container') ||
document.querySelector('.captcha-wrap') ||
document.querySelector('.nc-container') ||
document.querySelector('[class*="verify"][class*="wrap"]') ||
document.querySelector('[class*="captcha"]')
)
return hasVerifyText || hasVerifyEl
})
} catch {
return false
}
}
/**
* 等待用户完成验证(最长 5 分钟)。
* 期间每 2s 轮询页面状态;完成后返回 true超时返回 false。
*/
const waitForBossVerificationCompletion = async (page: any, expectedUrlPrefix: string, logFn: (msg: string) => void): Promise<boolean> => {
logFn('⚠️ 检测到 BOSS 安全验证,请在浏览器窗口中手动完成验证,完成后将自动继续...')
try {
const { Notification } = await import('electron')
new Notification({
title: 'GeekGeekRun - 需要人工验证',
body: '检测到 BOSS 直聘安全验证,请在打开的浏览器窗口中完成验证,完成后程序将自动继续。'
}).show()
} catch { /* Notification 不可用时静默忽略 */ }
const deadline = Date.now() + 5 * 60 * 1000
while (Date.now() < deadline) {
await sleep(2000)
try {
const url: string = page.url()
const isStillVerify = await checkForBossVerification(page)
if (url.startsWith(expectedUrlPrefix) && !isStillVerify) {
logFn('✅ 安全验证已完成,继续处理...')
return true
}
} catch { /* 页面可能正在跳转,继续等待 */ }
}
logFn('验证等待超时5 分钟),将重启浏览器重试')
return false
}
const runChatPage = async () => {
app.dock?.hide()
log('runChatPage 开始')
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; peekCapturedText?: any;
jobId?: string | null;
retryCandidate?: { encryptGeekId: string; geekName: string; jobTitle: string } | null;
processContext?: { currentCandidate: any } | null;
}) => Promise<void>
initPuppeteer: () => Promise<{ puppeteer: any }>
dismissGovernanceNoticeDialog: (page: any) => Promise<void>
}
const {
startBossChatPageProcess,
initPuppeteer,
dismissGovernanceNoticeDialog
} = (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/'
// browser/page/canvas hooks 提升到循环外,验证完成后可复用
let browser: any = null
let page: any = null
let getCapturedText: any = null
let clearCapturedText: any = null
let peekCapturedText: any = null
// processContext 提升到循环外catch 块中可读取被中断的候选人
const processContext: { currentCandidate: any } = { currentCandidate: null }
while (true) {
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
// 仅在没有复用浏览器时才重新启动
if (!browser) {
log('启动浏览器...')
await hooks.beforeBrowserLaunch?.promise?.()
const { buildRecruiterLaunchOptions } = (await import(
'@geekgeekrun/boss-auto-browse-and-chat/launch-options.mjs'
)) as any
const launchOpts = await buildRecruiterLaunchOptions()
log(`使用 launch optionspersistProfile=${!!launchOpts.userDataDir}`)
browser = await puppeteer.launch(launchOpts)
await hooks.afterBrowserLaunch?.promise?.()
const bossCookies = readStorageFile('boss-cookies.json')
const bossLocalStorage = readStorageFile('boss-local-storage.json')
page = (await browser.pages())[0]
// 注入 Canvas fillText hook必须在页面导航前注入evaluateOnNewDocument
const canvasHooks = await setupCanvasTextHook(page)
getCapturedText = canvasHooks.getCapturedText
clearCapturedText = canvasHooks.clearCapturedText
peekCapturedText = canvasHooks.peekCapturedText
const { randomizeInitialCursorPosition } = (await import(
'@geekgeekrun/boss-auto-browse-and-chat/humanMouse.mjs'
)) as any
await randomizeInitialCursorPosition(page).catch(() => {})
// persistProfile=true 时 profile 已持久化 cookies跳过注入避免用过期文件覆盖有效 session
if (!launchOpts.userDataDir && 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))
await dismissGovernanceNoticeDialog(page)
sendToDaemon({
type: 'worker-to-gui-message',
data: {
type: 'prerequisite-step-by-step-checkstep-by-step-check',
step: { id: 'login-status-check', status: 'fulfilled' },
runRecordId
}
})
} else {
log('复用已有浏览器实例,直接开始处理...')
}
log('读取职位队列配置...')
const { readBossJobsConfig } = await import(
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
) as any
const jobsConfig = readBossJobsConfig()
const allJobs = jobsConfig?.jobs || []
if (allJobs.length > 0) {
const chatJobs = allJobs.filter(
(j: any) => j.sequence?.enabled === true && j.sequence?.runChat !== false
)
if (chatJobs.length > 0) {
log(`检测到 ${chatJobs.length} 个职位纳入沟通处理,依次执行...`)
for (const job of chatJobs) {
const jid = job.jobId ?? job.id
const jname = job.jobName ?? job.name
log(`开始处理职位 ${jid}${jname})的沟通页...`)
processContext.currentCandidate = null
await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, peekCapturedText, jobId: jid, processContext })
log(`职位 ${jid} 沟通页处理完成`)
}
} else {
log('当前没有勾选"纳入处理"的职位,跳过本轮沟通页扫描')
}
} else {
log('未配置职位队列,开始执行 startBossChatPageProcess处理所有未读...')
processContext.currentCandidate = null
await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, peekCapturedText, processContext })
}
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
page = null
getCapturedText = null
clearCapturedText = null
peekCapturedText = null
const rerunMs = cfg?.chatPage?.rerunIntervalMs ?? rerunInterval
log(`下次运行将在 ${rerunMs}ms 后开始`)
await sleep(rerunMs)
} catch (err) {
// ── 优先检测安全验证,命中则等待完成后复用浏览器继续,而非重启 ──
if (page) {
try {
const isVerify = await checkForBossVerification(page)
if (isVerify) {
// 保存被中断的候选人,验证完成后通过 retryCandidate 重试
const interruptedCandidate = processContext.currentCandidate ?? null
if (interruptedCandidate) {
log(`⚠️ 验证中断时正在处理候选人:${interruptedCandidate.geekName}${interruptedCandidate.encryptGeekId}),验证后将优先重试`)
}
const completed = await waitForBossVerificationCompletion(page, BOSS_CHAT_PAGE_URL, log)
if (completed) {
// 验证完成:导航回沟通页
try {
await page.goto(BOSS_CHAT_PAGE_URL, { timeout: 60 * 1000 })
await page.waitForFunction(() => document.readyState === 'complete', { timeout: 60 * 1000 })
} catch { /* 导航失败则让下一轮处理 */ }
// 若有被中断的候选人,立即单独重试(不依赖 jobId在「全部」tab 中找回)
if (interruptedCandidate) {
log(`🔄 正在重试被验证中断的候选人:${interruptedCandidate.geekName}...`)
try {
await startBossChatPageProcess(hooks, {
browser, page, getCapturedText, clearCapturedText, peekCapturedText,
retryCandidate: interruptedCandidate,
processContext: { currentCandidate: null }
})
log(`重试候选人 ${interruptedCandidate.geekName} 完成`)
} catch (retryErr) {
log(`重试候选人时发生错误:${retryErr instanceof Error ? retryErr.message : String(retryErr)}`)
}
}
continue // 重新进入循环,进行正常扫描
}
}
} catch { /* 检测本身出错,走正常错误处理 */ }
}
// ── 正常错误处理:关闭浏览器、识别错误类型 ──
if (browser) {
try { await browser.close() } catch (e) { void e }
browser = null
page = null
getCapturedText = null
clearCapturedText = null
peekCapturedText = 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,687 @@ 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
}
}
if (payload.advanced && typeof payload.advanced === 'object') {
bossRecruiterConfig.advanced = bossRecruiterConfig.advanced || {}
if (typeof payload.advanced.persistProfile === 'boolean') {
bossRecruiterConfig.advanced.persistProfile = payload.advanced.persistProfile
}
}
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 {
//
}
}
})
}

View File

@@ -1,4 +1,5 @@
import { spawn } from 'child_process'
import { resolve } from 'path'
import {
ensureStorageFileExist,
writeStorageFile,
@@ -29,7 +30,7 @@ export async function launchDaemon() {
daemonProcess = spawn(
process.argv[0],
process.env.NODE_ENV === 'development'
? [process.argv[1], `--mode=launchDaemon`]
? [resolve(process.argv[1]), `--mode=launchDaemon`]
: [`--mode=launchDaemon`],
{
stdio: ['ignore', 'pipe', 'pipe', 'pipe'],

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 runningTaskInfo = computed(() => {
})
})
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 } }
}
function applyRuntimeStepStatus() {
@@ -155,6 +192,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-check' || data.runRecordId !== props.runRecordId) {
return
}
@@ -258,6 +311,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,336 @@
<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>
<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.advancedPersistProfile">
持久化浏览器 profile更难被识别为新设备
</el-checkbox>
<div class="form-tip">
启用后 BOSS 看到的是老设备而非每次都是新设备能显著降低人工验证触发率<br>
副作用bot 运行期间不能在系统 Chrome 同时登录 BOSS会被挤掉profile 文件夹长期会占用 1-2GB 磁盘空间<br>
路径<code>~/.geekgeekrun/storage/boss-chrome-profile/</code>
</div>
</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,
advancedPersistProfile: 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
const advanced = recruiterConfig.advanced ?? {}
formContent.advancedPersistProfile = advanced.persistProfile ?? 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
},
advanced: {
persistProfile: formContent.advancedPersistProfile
}
}
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,243 @@
<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="preflight-links">
<RouterLink :to="{ name: 'BossAutoBrowseAndChat' }"> 推荐牛人 - 自动开聊运行策略配置</RouterLink>
<RouterLink :to="{ name: 'BossChatPage' }"> 沟通运行策略配置</RouterLink>
</div>
<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, onActivated } from 'vue'
import { RouterLink } from 'vue-router'
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)
const loadJobsList = async () => {
try {
const result = await ipcRenderer.invoke('fetch-boss-jobs-config')
jobsList.value = (result?.jobs ?? []).map((j: any) => ({
...j,
sequence: {
enabled: j.sequence?.enabled ?? true,
runRecommend: j.sequence?.runRecommend ?? true,
runChat: j.sequence?.runChat ?? true
}
}))
} catch (err) {
console.error(err)
}
}
onMounted(loadJobsList)
onActivated(loadJobsList)
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 0.5em;
font-size: 14px;
color: #606266;
line-height: 1.6;
}
.preflight-links {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 1em;
a {
font-size: 13px;
color: #2faa9e;
text-decoration: none;
&:hover { text-decoration: underline; }
}
}
.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,283 @@
<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">
<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 style="margin-top: 8px; font-size: 12px; color: #909399;">
勾选的职位将在处理沟通页时被依次扫描若全部不勾选则不处理任何职位执行推荐牛人执行沟通页列在自动顺序执行模式下生效
</div>
</template>
</el-card>
<el-card class="config-section">
<el-form-item mb0>
<div class="section-title">沟通页运行策略</div>
</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, onActivated } from 'vue'
import { ElMessage } from 'element-plus'
import RunningOverlay from '@renderer/features/RunningOverlay/index.vue'
import { RUNNING_STATUS_ENUM } from '../../../../../common/enums/auto-start-chat'
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)
interface JobSequenceItem {
jobId: string
jobName: string
sequence: { enabled: boolean; runRecommend: boolean; runChat: boolean }
[key: string]: unknown
}
const jobsList = ref<JobSequenceItem[]>([])
const formContent = reactive({
chatPage: {
maxProcessPerRun: 20,
runOnceAfterComplete: false,
keepBrowserOpenAfterRun: false,
rerunIntervalMs: 3000
}
})
const loadData = async () => {
try {
const [recruiterResult, jobsResult] = await Promise.all([
ipcRenderer.invoke('fetch-boss-recruiter-config-file-content'),
ipcRenderer.invoke('fetch-boss-jobs-config')
])
const recruiterConfig = recruiterResult?.config?.['boss-recruiter.json'] || {}
const chatPage = recruiterConfig.chatPage ?? {}
formContent.chatPage.maxProcessPerRun = chatPage.maxProcessPerRun ?? 20
formContent.chatPage.runOnceAfterComplete = chatPage.runOnceAfterComplete ?? false
formContent.chatPage.keepBrowserOpenAfterRun = chatPage.keepBrowserOpenAfterRun ?? false
formContent.chatPage.rerunIntervalMs = chatPage.rerunIntervalMs ?? 3000
jobsList.value = (jobsResult?.jobs ?? []).map((j: any) => ({
...j,
sequence: {
enabled: j.sequence?.enabled ?? true,
runRecommend: j.sequence?.runRecommend ?? true,
runChat: j.sequence?.runChat ?? true
}
}))
} catch (err) {
console.error(err)
}
}
onMounted(loadData)
onActivated(loadData)
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))
if (jobsList.value.length > 0) {
await ipcRenderer.invoke('save-boss-jobs-config', JSON.stringify({ jobs: jobsList.value }))
}
}
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,850 @@
<template>
<div class="debug-tool__wrap">
<div class="main__wrap">
<!-- Tab 切换 -->
<el-tabs v-model="activeTab" class="debug-tabs">
<!-- Tab A: 简历操作 -->
<el-tab-pane label="简历操作(需要浏览器)" name="resume">
<!-- 浏览器控制栏仅在 Tab A 显示 -->
<el-card class="section">
<div class="section-title">浏览器控制</div>
<div class="section-desc">
启动浏览器并打开沟通页在右侧手动选中一条会话再用下方按钮测试各项功能
</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>
<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 筛选测试区域1/3 无需浏览器)" name="llm">
<!-- 区域 1生成 Rubric工作流起点 -->
<el-card class="section">
<div class="section-title">区域 1生成 Rubric</div>
<div class="section-desc">
输入 JD 自动生成评分标准生成后可直接编辑 JSON再点用于评估传到区域 3<br />
<strong>注意</strong>此处生成的 Rubric 仅用于测试不会自动保存如需持久保存请在
<RouterLink :to="{ name: 'BossJobConfig' }" style="color: #2faa9e;">职位配置</RouterLink>
页面为具体职位生成并保存
</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 { RouterLink } from 'vue-router'
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,539 @@
<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>
<!-- 各用途默认模型前置显示方便快速查看当前生效模型 -->
<el-card class="section" style="margin-bottom: 0">
<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-alert
v-if="!isLoading && allEnabledModels.length === 0"
type="info"
show-icon
:closable="false"
title="尚未添加任何启用的模型,请在下方添加服务商和模型后再设置默认模型"
style="margin-top: 4px"
/>
</el-card>
<!-- 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>
<!-- 操作栏 -->
<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 isLoading = ref(true)
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)
} finally {
isLoading.value = false
}
})
// ── 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,82 @@
<template>
<div class="group-item">
<div class="group-title">招聘BOSS</div>
<div flex flex-col class="link-list">
<div class="nav-sub-label">账号配置</div>
<a href="javascript:void(0)" @click="handleClickRecruiterLogin">
编辑登录凭据<TopRight w-1em h-1em mr10px />
</a>
<RouterLink :to="{ name: 'BossLlmConfig' }">
配置大语言模型
</RouterLink>
<a href="javascript:void(0)" @click="handleLaunchRecruiterBossSite">
手动逛逛<TopRight w-1em h-1em mr10px />
</a>
<div class="nav-sub-label">职位配置</div>
<RouterLink :to="{ name: 'BossJobConfig' }">
职位配置
</RouterLink>
<div class="nav-sub-label">自动化执行</div>
<RouterLink :to="{ name: 'BossAutoBrowseAndChat' }">
推荐牛人 - 自动开聊
</RouterLink>
<RouterLink :to="{ name: 'BossChatPage' }">
沟通
</RouterLink>
<RouterLink :to="{ name: 'BossAutoSequence' }">
自动顺序执行
</RouterLink>
<div class="nav-sub-label">集成与工具</div>
<RouterLink :to="{ name: 'WebhookIntegration' }">
Webhook / 外部集成
</RouterLink>
<RouterLink :to="{ name: 'BossDebugTool' }">
调试与测试工具
</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

@@ -5,6 +5,19 @@
padding: 0.25em 0;
}
.link-list {
.nav-sub-label {
color: #b0bcba;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0.6em 0 0.15em 1em;
margin-top: 0.2em;
&:first-of-type {
margin-top: 0;
padding-top: 0.15em;
}
}
a {
display: flex;
align-items: center;

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

86
plan/README.md Normal file
View File

@@ -0,0 +1,86 @@
# plan/ 文档索引
本目录存放招聘端BOSS相关的设计、流程说明与阶段性记录。单篇篇幅较长时**先读本索引再点进对应文件**。
---
## 从这里开始
| 文档 | 说明 |
|------|------|
| [recruiter_architecture.md](recruiter_architecture.md) | **招聘端架构总览**UI / IPC / Worker、`boss-auto-browse-and-chat` 模块分工、主循环要点。协作开发时优先读这篇。 |
| [recruiter_chat_page_hr_guide.md](recruiter_chat_page_hr_guide.md) | **给 HR 的操作说明**登录、职位筛选、LLM、沟通页启动非开发者入口。 |
---
## 架构与页面结构
| 文档 | 说明 |
|------|------|
| [boss_auto_browse_tabs.md](boss_auto_browse_tabs.md) | 自动化里「沟通 Tab + 推荐牛人 Tab」双 Tab 设计、URL、主流程顺序。 |
| [chat_page_tab_navigation.md](chat_page_tab_navigation.md) | 沟通页左侧「会话类型 Tab」与「已读/未读」两套控件、导航顺序与实现注意点。 |
| [recommend_page_flow.md](recommend_page_flow.md) | 推荐牛人页完整逻辑iframe 结构、选择器、各 `.mjs` 分工。 |
---
## 沟通页 · 简历与筛选
| 文档 | 说明 |
|------|------|
| [chat_page_resume_flow.md](chat_page_resume_flow.md) | 在线简历 → 关键词/LLM → 附件简历 → PDF 的流程与数据链路说明。 |
| [cv_canvas_solution.md](cv_canvas_solution.md) | 在线简历 Canvas/WASM 加密与提取思路fillText Hook、`get_export_geek_detail_info` 等)。 |
| [recruiter_llm_integration.md](recruiter_llm_integration.md) | 招聘端 LLM`boss-llm.json`、多用途模型、Rubric、与实现状态。 |
| [recruiter_debug_tool.md](recruiter_debug_tool.md) | 招聘端调试工具进程架构、IPC、与正式流程一致的操作栈。 |
---
## 集成与扩展规划
| 文档 | 说明 |
|------|------|
| [webhook_integration.md](webhook_integration.md) | Webhook配置、Payload、相关源码路径、与任务结束时的发送时机。 |
| [multi-job-switching.md](multi-job_switching.md) | 多职位同步、`boss-jobs-config.json`、按职位配置与顺序执行的设计草案。 |
---
## 工程细节
| 文档 | 说明 |
|------|------|
| [logger_usage.md](logger_usage.md) | `logger.mjs` 级别、API、`boss-recruiter.json``logLevel` 的用法。 |
| [recruiter_mouse_trajectory.md](recruiter_mouse_trajectory.md) | 拟人鼠标ghost-cursor 等)要求与适用范围。 |
---
## 状态与里程碑(可能部分重叠)
| 文档 | 说明 |
|------|------|
| [current_status_2026_03_18.md](current_status_2026_03_18.md) | 截至 2026-03-18已实现能力、已知问题尤其推荐页状态/去重)、下一步计划。 |
| [STATUS_2026-03-18.md](STATUS_2026-03-18.md) | 同日另一份阶段性现状(沟通可用、推荐页状态判定待加强等),可与上一篇对照阅读。 |
---
## 历史方案与并行开发稿(篇幅大、偏「当时怎么拆任务」)
以下文件多为早期规划或已 resolve 的长文,**需要考古或对齐旧 Prompt 时再打开**。
| 文档 | 说明 |
|------|------|
| [implementation_plan.md.resolved](implementation_plan.md.resolved) | 招聘端功能扩展总体方案可行性、Phase、工时等。 |
| [parallel_execution_plan.md.resolved](parallel_execution_plan.md.resolved) | 分 Phase 并行执行的 Agent Prompt 集合;文中引用 `recruiter_mouse_trajectory.md`。 |
> 说明:`.gitignore` 中忽略了 `*.resolved` 等类型;若仓库中未跟踪这些文件,以你本地是否存在为准。
---
## 未列入索引的文件
| 类型 | 说明 |
|------|------|
| `log.txt` / `log_recommend.txt` | 运行日志样例,非设计文档。 |
| `.gitignore` | 忽略规则。 |
---
*索引维护:新增或拆分大文档时,请在本 README 中补一行条目,避免目录再次难以浏览。*

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。*

View File

@@ -0,0 +1,222 @@
# 沟通页 Tab 导航行为与「新招呼 + 未读」初始化设计
## 背景
本文档记录 **招聘端沟通页**`/web/chat/index`)左侧会话列表的 Tab 结构、
正确的导航顺序、已知的 BOSS 前端刷新特性,以及对应的自动化实现设计。
相关代码:
- `packages/boss-auto-browse-and-chat/chat-page-processor.mjs``startBossChatPageProcess`
- `packages/boss-auto-browse-and-chat/constant.mjs` — 所有选择器
---
## 1. 沟通页左侧面板的 UI 层次
沟通页左侧面板存在**两套独立的过滤控件**,功能和 DOM 结构完全不同,容易混淆:
### 1-A. 会话类型 Tab`.chat-label-item`
位于左侧列表的顶部,按**消息来源类型**分类:
| Tab 名称 | DOM 选择器 | 选中态 class | 含义 |
|---------|-----------|------------|------|
| 全部 | `.chat-label-item[title="全部"]` | `selected` | 不限类型 |
| **新招呼** | `.chat-label-item[title="新招呼"]` | `selected` | 候选人主动发来的第一条招呼 |
| 沟通中 | `.chat-label-item[title="沟通中"]` | `selected` | 已有来回消息的会话 |
| 已获取简历 | `.chat-label-item[title="已获取简历"]` | `selected` | 简历已获取 |
| 已交换微信 | `.chat-label-item[title="已交换微信"]` | `selected` | 微信已交换 |
> **注意**:选中态 class 是 `selected`,不是 `active`。`switchToTab` 的默认 active
> 检测用的是 `active`,因此对 `.chat-label-item` tab 不要依赖 active 检测,
> 应使用 `force: true` 强制点击。
### 1-B. 已读/未读状态 Tab`.chat-message-filter-left span`
位于 1-A 之下,按**已读/未读状态**过滤当前类型内的会话:
| Tab 名称 | DOM 选择器 | 选中态 class | 含义 |
|---------|-----------|------------|------|
| 全部 | `.chat-message-filter-left span:nth-child(1)` | `active` | 不限已读/未读 |
| **未读** | `.chat-message-filter-left span:nth-child(2)` | `active` | 只显示未读会话 |
> 这里的"全部"与 1-A 的"全部"是**不同的控件**选择器、class 名、语义均不同,
> 不要混淆。
---
## 2. 正确的手动操作顺序(同事记录的复现路径)
```
1. 点击「全部职位」(展开顶部职位下拉框)
2. 点击目标职位(如「实验室技术员」)→ 会话列表切换为该职位
3. 点击「新招呼」1-A tab→ 只显示候选人主动打招呼的会话
4. 点击「未读」1-B tab→ BOSS 刷新未读列表
5. 开始逐条处理
```
步骤 3新招呼和步骤 4未读缺一不可
- **省略步骤 3** → 在「全部类型」下,工具会看到「沟通中」「已获取简历」等其他类型的候选人,
处理范围远超预期(遍历全部职位的全部类型消息)。
- **省略步骤 4 或不强制点击** → BOSS 不刷新列表,上次已处理(已读)的候选人会继续出现,
导致重复操作(详见第 3 节)。
---
## 3. BOSS 「未读」列表不自动刷新的特性
BOSS 直聘沟通页的**未读会话列表不会自动轮询刷新**。
以下两种操作能使其刷新:
1. **整页 reload**`page.reload()` 或 F5
2. **手动点击「未读」tab**
如果程序已在「未读」tab 停留,且直接解析 DOM解析到的是**上次点击时的快照**
而非当前真实未读状态。已处理(点击后已读)的会话不会从列表消失。
### 旧代码的 bug
`switchToTab` 的实现含有如下提前返回逻辑:
```js
if (isActive) {
logDebug(`已在「${tabName}」tab`)
return // ← 跳过了点击BOSS 不会刷新列表
}
```
如果上次运行结束时页面停留在「未读」tab下次运行到达这段代码时
`isActive === true`,点击被跳过 → 列表未刷新 → 已处理的候选人被重复遍历。
### 修复方案
在每次 `startBossChatPageProcess` 进入处理循环前,用 `force: true` 强制点击
「新招呼」和「未读」,绕过 active 检测:
```js
await switchToTab(CHAT_PAGE_TAB_NEW_GREET_SELECTOR, '新招呼', { force: true })
await sleepWithRandomDelay(300, 500)
await switchToTab(CHAT_PAGE_UNREAD_FILTER_SELECTOR, '未读', { force: true })
await sleepWithRandomDelay(400, 600)
```
`switchToTab` 签名改为:
```js
const switchToTab = async (selector, tabName, opts = {}) => {
if (!opts.force) {
// 检测 active class已激活则跳过用于非刷新场景
}
// ... 拟人点击 ...
}
```
---
## 4. 职位下拉框(`.chat-top-job`)的切换逻辑
沟通页顶部有职位筛选下拉框,切换后左侧列表只显示该职位的会话。
| DOM 元素 | 常量名 | 说明 |
|---------|-------|------|
| 触发按钮 | `CHAT_PAGE_JOB_DROPDOWN_SELECTOR` | `.chat-top-job .ui-dropmenu-label` |
| 展开后列表项 | `CHAT_PAGE_JOB_ITEM_SELECTOR` | `.chat-top-job .ui-dropmenu-list li` |
切换函数:`switchChatPageJobId(page, jobId)`(同文件内部函数)。
- `jobId === '-1'``jobId == null` → 不切换(使用当前选中的全部职位)
- 切换后等待 400700 ms 让列表刷新
- 若下拉列表中未找到目标 jobId会打印 warning 并跳过(不抛异常)
> **调试提示**:如果切换职位后列表仍显示所有职位的消息,
> 先确认 `boss-jobs-config.json` 中的 `jobId` 字段值与 BOSS 页面
> 下拉框 `li[value]` 的值一致(通过 sync-boss-job-list IPC 同步可以保证这一点)。
---
## 5. 完整的 Tab 初始化序列(当前实现)
每次调用 `startBossChatPageProcess` 时,按以下顺序执行:
```
1. 确认当前在沟通页 URL否则 goto
2. setupNetworkInterceptor
3. waitForSelector(CHAT_PAGE_ITEM_SELECTOR, timeout=15s)
4. switchChatPageJobId若 jobId 有效)
5. 【新招呼 force】switchToTab(CHAT_PAGE_TAB_NEW_GREET_SELECTOR, { force: true })
6. 【未读 force】switchToTab(CHAT_PAGE_UNREAD_FILTER_SELECTOR, { force: true })
7. [可选] retryCandidate
switchToTab(ALL_FILTER, '全部') ← 找已读候选人
processOneCandidateConversation(...)
switchToTab(UNREAD_FILTER, '未读') ← 切回(不 force正常切换即可
8. parseConversationList → process loop
```
步骤 56 的「强制点击」保证无论上次运行的终止状态如何,都能进入正确的筛选视图,
且触发 BOSS 的未读列表数据刷新。
---
## 6. 验证 Tab 初始化是否生效的方法
### 日志关键字
成功路径(`logLevel: 'info'` 或更详细)应出现:
```
[chat-page-processor] 切换到「新招呼」tab...
[chat-page-processor] 「新招呼」tab 切换后列表已刷新
[chat-page-processor] 切换到「未读」tab...
[chat-page-processor] 「未读」tab 切换后列表已刷新
```
失败路径(元素未找到):
```
[chat-page-processor] 未找到「新招呼」tab 元素selector: .chat-label-item[title="新招呼"]
```
### 如果「新招呼」tab 找不到
可能原因:
1. **BOSS 更新了 DOM**:登录后手动打开沟通页,检查是否存在 `.chat-label-item[title="新招呼"]`
2. **账号下没有「新招呼」分类**:部分账号/状态下该 tab 不显示(如没有招聘职位),
此时 `switchToTab` 会打印 warning 并继续,不影响后续流程(降级为当前 tab
3. **中文 title 属性编码差异**:用浏览器控制台 `document.querySelector('.chat-label-item[title="新招呼"]')` 确认
### 如果处理后候选人仍然重复出现
检查步骤:
1. 确认日志中「未读」tab 的点击确实发生(不是 skip 返回)
2. 确认 `CHAT_PAGE_UNREAD_FILTER_SELECTOR` 指向的是 `span:nth-child(2)` 而不是第 1 个
3. 候选人可能已在数据库 `encryptGeekId` 记录中但未标记为 `contacted`
此时会被 `checkIfAlreadyContacted` 放过 → 检查数据库记录
---
## 7. retryCandidate 流程与 Tab 状态
`retryCandidate` 是验证中断恢复流程(被 BOSS 安全验证打断时),此阶段 Tab 状态:
| 步骤 | 1-A 类型 tab | 1-B 状态 tab | 说明 |
|------|------------|------------|------|
| 进入 retry 前 | 新招呼force 切入) | 未读force 切入) | 初始化阶段已设置 |
| retry 内切换 | 新招呼(保持) | **全部** | 候选人已读,需看全部 |
| retry 结束 | 新招呼(保持) | **未读** | 切回,准备正常扫描 |
| 正常扫描 | 新招呼 | 未读 | 初始化状态,直接 parseConversationList |
retry 结束时的 `switchToTab(UNREAD, '未读')` 不需要 `force: true`
因为这只是从「全部」切回「未读」的正常操作BOSS 会正常刷新列表。
---
## 8. 相关文件
| 文件 | 作用 |
|------|------|
| `packages/boss-auto-browse-and-chat/chat-page-processor.mjs` | `startBossChatPageProcess``switchChatPageJobId``switchToTab` |
| `packages/boss-auto-browse-and-chat/constant.mjs` | 所有 tab/选择器常量(`CHAT_PAGE_TAB_NEW_GREET_SELECTOR` 等) |
| `packages/ui/src/main/flow/BOSS_CHAT_PAGE_MAIN/index.ts` | Worker 入口,读取 `boss-jobs-config.json` 并按职位循环调用 `startBossChatPageProcess` |
| `plan/multi-job-switching.md` | 多职位配置文件结构、`sync-boss-job-list` IPC 实现 |
| `plan/boss_auto_browse_tabs.md` | 推荐牛人页与沟通页双 Tab 架构总览 |

View File

@@ -0,0 +1,101 @@
# 当前状况2026-03-18
本文档用于记录 **招聘端BOSS当前已实现能力、已知问题、以及下一步修复/测试计划**,方便在打包测试前统一对齐。
> 结论先行:**沟通页流程总体正常**;主要欠账在「推荐牛人」的状态判定与流程闭环(已读/已打招呼/已处理/去重不够精确),以及 Webhook 功能尚未做端到端验证与“功能安排”完善。
---
## 1. 已实现(可用能力)
### 1.1 Worker / UI 入口
- **BossAutoBrowseAndChat / BossAutoSequence**:推荐牛人 + 沟通页串联(复用同一 browser
- **BossRecommend / BossChatPage**:两段可分别单独跑(用于调试)
- **WebhookIntegration**Webhook 配置页启用、URL、Headers、Payload options、测试发送/手动触发)
### 1.2 数据持久化SQLite
- `CandidateInfo`:候选人基础信息(用于去重与状态追踪的基础)
- `CandidateContactLog`:联系记录(用于判断“是否已联系/已处理”的依据)
- 迁移:`AddCandidateTables`
### 1.3 自动化核心boss-auto-browse-and-chat
- **推荐牛人页**:解析候选人列表、筛选、点击打招呼、处理弹窗、主循环翻页/滚动加载
- **沟通页**解析会话列表unread 优先、打开在线简历Canvas hook、可请求附件简历/下载 PDF按配置
- **反爬/拟人**ghost-cursor 人类轨迹、随机 delay、stealth/laodeng 等插件栈
---
## 2. 已知问题(需要修复/补齐)
### 2.1 推荐牛人:状态判定不够精确(优先级最高)
你提到的核心痛点是 **“哪些已读、哪些已打招呼、哪些已经处理过”** 没有被准确识别和闭环,导致:
- **重复处理**:同一候选人被多次进入流程(浪费配额/触发风控风险)
- **漏处理**:本应处理的候选人因为状态误判被跳过
- **UI 展示与实际行为不一致**:页面上看似“已读/已沟通”,但本地状态未落库或落库不一致
#### 需要补齐的“状态模型”(建议落库维度)
- **viewed已读/已查看详情)**:是否点击过候选人卡片进入详情弹窗
- **greeted已打招呼**:是否成功触发并完成“打招呼”链路(含弹窗确认)
- **processed已处理**:本轮流程是否已经对该候选人做完“筛选 →(可选)打招呼/不感兴趣 → 记录”闭环
- **skippedReason跳过原因**:如重复推荐/命中屏蔽名/学历不符/工作年限不符/薪资不符/技能不符/日限已满等
> 备注:上述状态不一定都需要新表字段;也可以通过 `CandidateContactLog` 的 action/type + timestamp 来推断。但目前推断链路不够稳,建议明确化并保证写入时机一致。
#### 可能的根因方向(便于定位修复点)
- **DOM 层状态标识不稳定**:推荐列表/卡片上的“已读/已沟通/已打招呼”可能是 class/图标变化,当前解析没有覆盖或覆盖不全
- **打招呼成功条件定义不一致**:点击按钮 ≠ 实际送达需要用弹窗、toast、按钮状态变化、或网络请求成功作为确认信号
- **去重 key 不统一**`geekId` / `encryptGeekId` / DOM data 属性在不同阶段使用不一致,导致写库与判断对不上
- **写库时机缺口**:只在某些节点写 `CandidateInfo/ContactLog`,导致“已读但未落库/已打招呼但未落库”的灰状态
### 2.2 Webhook尚未端到端测试 + 功能安排不完善
当前实现已经包含配置、mock 测试与触发入口,但缺少:
- **真实 run 的联动验证**:推荐/沟通真实跑一轮后,是否能正确汇总候选人数据并发送
- **错误与重试体验**:网络失败/4xx/5xx 时的日志、重试、以及失败队列(若开启)是否可用
- **payload 与下游契合**JSON / multipart 两种模式的边界以及简历字段path/base64在不同配置下是否符合预期
- **“功能安排”**:比如 realtime/batch 两种 sendMode 与 UI/日志/存储的配套是否完善、默认值是否合理
### 2.3 沟通页:目前认为正常(仅保留回归点)
你反馈沟通页正常,这里只建议在打包测试时做最小回归:
- unread 会话能按预期被识别与逐条处理
- 在线简历 Canvas 提取正常
- 附件简历下载/跳过下载配置(`skipDownload`)行为符合预期
---
## 3. 打包前的测试计划(本次目标)
### 3.1 推荐牛人(重点验证)
- **状态一致性**:同一候选人连续运行两次,不应重复“已打招呼”的人
- **去重有效**:刷新/翻页/滚动加载后,已处理候选人不会再次进入队列
- **打招呼确认**:出现弹窗/按钮变灰/提示信息时均能稳定判定成功或失败,并落库
### 3.2 Webhook最小可行验证
- 在本地起一个临时接收端(或用现成 request bin接收 webhook
- 用 UI 的 **保存并测试发送** 验证接口可达
- 真实跑一轮推荐/沟通后,验证自动触发 payloadbatch 模式)确实发送且内容合理
### 3.3 沟通页(回归)
- 选 3-5 个 unread 会话跑一轮,确认不崩、不重复、能落库
---
## 4. 下一步修复建议(按优先级)
1. **先把推荐牛人的“状态模型”落地**:明确 viewed/greeted/processed/skippedReason 的来源与写入点
2. **统一去重 key**:全链路统一使用同一种 `geekId`(或明确映射),避免 DB 与 DOM key 不一致
3. **把 Webhook 做一次真实跑通**:补齐失败重试/日志,并把 payload 与配置默认值调整到“开箱可用”

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,535 @@
# 招聘端Recruiter/BOSS架构总览
> **定位**:供 AI Agent 快速理解招聘端全貌,用于协作开发时减少 token 消耗。
> 最后更新2026-03-26
---
## 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
> 以下为主要常量摘录完整列表以源文件为准。BOSS 站点改版时常量可能失效,参见 §14.5 排查流程。
```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 = 'ul.card-list'
CANDIDATE_ITEM_SELECTOR = 'ul.card-list > li.card-item'
CANDIDATE_NAME_SELECTOR = 'span.name'
CHAT_START_BUTTON_SELECTOR = 'button.btn-greet'
GREETING_SENT_KNOW_BTN_SELECTOR = 'div.dialog-wrap button.btn-sure-v2'
CONTINUE_CHAT_BUTTON_SELECTOR = 'div.operate-side div.button-chat'
CHAT_INPUT_SELECTOR = '#boss-chat-global-input'
// 沟通页选择器CHAT_PAGE_* 前缀)
CHAT_PAGE_ITEM_SELECTOR = '.user-container .geek-item'
CHAT_PAGE_NAME_SELECTOR = 'span.geek-name'
CHAT_PAGE_JOB_SELECTOR = 'span.source-job'
CHAT_PAGE_ONLINE_RESUME_SELECTOR = 'a.resume-btn-online'
CHAT_PAGE_ATTACH_RESUME_BTN_SELECTOR = 'div.resume-btn-content .resume-btn-file'
CHAT_PAGE_ASK_RESUME_CONFIRM_BTN_SELECTOR = 'div.ask-for-resume-confirm > div.content > button.boss-btn-primary'
CHAT_PAGE_MESSAGE_ITEM_SELECTOR = '.chat-message-list .message-item'
CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR = 'div.message-card-buttons > span.card-btn:only-child'
CHAT_PAGE_DOWNLOAD_PDF_BTN_SELECTOR = '.resume-common-dialog .attachment-resume-btns > .popover:nth-child(3)'
CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR = '.op-btn.rightbar-item div.dialog-container div.button span'
// 治理公告弹窗登录后必现§14.1 详述)
GOVERNANCE_NOTICE_DIALOG_SELECTOR = '.dialog-uninstall-extension'
GOVERNANCE_NOTICE_DIALOG_CONFIRM_BTN_SELECTOR = '.dialog-uninstall-extension .confirm-btn'
```
---
## 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直聘在各页面会弹出各类提示/公告弹窗,均需自动点击关闭,否则会遮挡操作区域或导致自动化卡死。以下列出所有已纳入代码处理的弹窗。
---
### 14.1 治理公告弹窗dialog-uninstall-extension
**何时出现:** 每次登录后包含首次加载、cookie 失效重新登录),浏览器导航到 BOSS 站点后必现。BOSS 借此告知平台禁止使用第三方自动化工具。
**外观:** 全屏遮罩,正中宽 580px 卡片,含平台公告文字;底部有一枚背景图模拟的「我已知晓」按钮(`div.confirm-btn`,非 `<button>`,以图片代替文字)。
**HTML 骨架(来自 `examples/BOSS直聘-治理公告 (2026_3_26 154151).html`**
```html
<!-- 挂载在 #boss-dynamic-dialog-<id>id 随机 -->
<div class="boss-popup__wrapper boss-dialog boss-dialog__wrapper dialog-uninstall-extension"
style="animation-duration:0s; width:580px; z-index:2002">
<div class="boss-popup__content">
<div class="boss-dialog__body">
<div data-v-4a24c2ed class="uninstall-extension">
<div data-v-4a24c2ed class="top"></div> <!-- 顶部装饰图 -->
<div data-v-4a24c2ed class="content">
<div data-v-4a24c2ed class="notice">...公告标题...</div>
<div data-v-4a24c2ed class="tips mb-24">...禁止使用第三方工具说明...</div>
<div data-v-4a24c2ed class="confirm-btn"></div> <!-- 「我已知晓」按钮(背景图) -->
</div>
</div>
</div>
</div>
<div ka class="boss-popup__close"><i class="icon-close"></i></div>
</div>
```
**关键选择器(在 `constant.mjs` 中定义):**
| 常量 | 选择器 | 用途 |
|------|--------|------|
| `GOVERNANCE_NOTICE_DIALOG_SELECTOR` | `.dialog-uninstall-extension` | 检测弹窗是否存在 |
| `GOVERNANCE_NOTICE_DIALOG_CONFIRM_BTN_SELECTOR` | `.dialog-uninstall-extension .confirm-btn` | 点击「我已知晓」 |
> **注意:** `confirm-btn` 是 `<div>` 而非 `<button>`,文字由背景图渲染,`page.$eval(selector, el => el.textContent)` 返回空字符串。
**处理函数:** `dismissGovernanceNoticeDialog(page)` — 在 `boss-auto-browse-and-chat/index.mjs` 中定义。
**调用位置:**
- `launchBrowserAndNavigateToChat()` — 导航到沟通页并等待 `readyState=complete` 之后
- `startBossAutoBrowse()` — 登录检查/等待登录块结束之后、切换职位之前
**Debug 提示:**
- 若弹窗出现但 `confirm-btn` 不可点击(`boundingBox()` 返回 null说明容器被 `overflow:hidden` 裁剪或弹窗尚未完成动画,应先等待 500ms 再重试。
- 弹窗 `z-index:2002`,会遮挡推荐牛人 iframe若候选人列表未能渲染优先排查此弹窗是否已关闭。
- 若选择器失效BOSS 改了 class打开 `examples/` 文件夹中最新保存的治理公告 HTML 快照,搜索 `confirm-btn` 或 `dialog-uninstall-extension` 重新确认。
---
### 14.2 意向沟通提示弹窗dialog-container
**何时出现:** 沟通页每次新浏览器会话切到某个会话时BOSS 视为新用户会弹出此提示(遮挡右侧附件简历等操作按钮)。
**关键选择器:**
| 常量 | 选择器 |
|------|--------|
| `CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR` | `.op-btn.rightbar-item div.dialog-container div.button span` |
| `CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR` | `.op-btn.rightbar-item div.dialog-container div.iboss-close.close` |
**处理位置:** `chat-page-processor.mjs`,每次切换会话后(点击左侧会话项、等待右侧面板更新之后)立即检测并关闭。
---
### 14.3 已向牛人发送招呼弹窗
**何时出现:** 推荐牛人页,点击「打招呼」后弹出确认。
**关键选择器:** `GREETING_SENT_KNOW_BTN_SELECTOR = 'div.dialog-wrap button.btn-sure-v2'`
**处理位置:** `chat-handler.mjs` — `processCandidate()` 内打招呼流程。
---
### 14.4 「不感兴趣」原因弹窗
**何时出现:** 推荐牛人页 iframe 内,点击「不感兴趣」后弹出原因选择框。
**关键选择器:**
| 常量 | 选择器 |
|------|--------|
| `NOT_INTERESTED_REASON_POPUP_SELECTOR` | `div.card-reason-f1.show` |
| `NOT_INTERESTED_REASON_POPUP_CLOSE_SELECTOR` | `div.card-reason-f1.show div.close-icon` |
**处理位置:** `chat-handler.mjs` — `clickNotInterested()` 内。
---
### 14.5 维护历史 & 选择器失效排查流程
1. 在 Chrome DevTools 中打开对应 BOSS 页面,手动触发弹窗。
2. 右键元素 → Copy → Copy selector与 `constant.mjs` 中对应常量对比。
3. 更新 `constant.mjs` 中的常量值。
4. 在 `examples/` 目录下保存最新 HTML 快照(文件名含日期),以备后续对比。
5. 若弹窗结构变化较大同步更新本文档对应小节的「HTML 骨架」。
---
## 15. 相关文档
- [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,423 @@
# 招聘端(给 HR沟通页使用说明含 LLM Rubric 详细教程)
> 适用范围只使用「招聘BOSS → 沟通」相关功能(以及它依赖的「职位配置」「配置大语言模型」「招聘端调试工具」)。
>
> 你只需要按本文档把三件事配置好:**登录凭据**、**职位筛选规则**、**LLM 模型**。然后在「沟通」页启动即可。
>
> 本软件会把所有配置保存到你电脑的 `~/.geekgeekrun/` 目录下(重装软件通常不影响配置)。
---
## 0. 你会用到的页面入口(左侧导航)
- **沟通**:只配置“怎么跑”(处理多少未读、是否单轮、两轮间隔),并启动任务
- **职位配置**:配置“筛什么人”(城市/学历/年限/薪资 + 简历全文关键词/正则/LLM Rubric
- **配置大语言模型**:配置 LLM 服务商baseURL、API Key、模型、用途默认模型、测试连接
- **编辑登录凭据**:登录 BOSS 直聘(招聘者身份),保存 Cookie
- **招聘端调试工具**Rubric 生成/评估/测试专用(强烈建议你用它把 Rubric 调顺再去正式跑)
---
## 1) 第一次使用:先配置“登录凭据”
### 1.1 登录凭据是什么
软件需要你 BOSS 直聘**招聘者身份**的登录态Cookie + localStorage才能打开沟通页并读取职位列表。
会用到两份本地文件(一般不需要你手动打开它们):
- `~/.geekgeekrun/storage/boss-cookies.json`:招聘端 Cookie最关键
- `~/.geekgeekrun/storage/boss-local-storage.json`:招聘端 localStorage
### 1.2 一键登录(推荐做法)
1. 打开:**招聘BOSS → 编辑登录凭据**
2. 确认提示里写的是「招聘者身份登录」(很重要)
3. 在弹出的“BOSS 登录助手”窗口里,点击 **启动浏览器**
4. 用你常用的方式登录(短信/二维码/微信小程序等)
5. 登录完成后等待 510 秒Cookie 会自动出现在输入框
6. 点击右下角 **确定** 保存
7. 看到“Cookie 保存成功”的提示即可
### 1.3 常见问题
- **我登录了但 Cookie 没自动出现**
- 在登录助手窗口里按它的说明安装/打开 `EditThisCookie` 扩展,导出 Cookie 后粘贴到输入框保存
- **我确认登录了但运行时提示未登录/403/拒绝访问**
- 先重新走一遍“编辑登录凭据”
- 确保账号确实是招聘者身份(能看到招聘端功能)
---
## 2) 配置 LLM Provider + Modelboss-llm.json
> 只要你想用「LLM Rubric 筛选」或「自动生成 Rubric」就必须配置这一步。
配置入口:**招聘BOSS → 配置大语言模型**
配置保存到:`~/.geekgeekrun/config/boss-llm.json`
### 2.1 你需要填什么
#### A. 服务商Provider
- **API Base URL**:常见例子
- SiliconFlow`https://api.siliconflow.cn/v1`
- DeepSeek 官方:`https://api.deepseek.com/v1`
- 阿里云百炼(兼容模式):`https://dashscope.aliyuncs.com/compatible-mode/v1`
- Ollama 本地:`http://localhost:11434/v1`
- **API Key**:服务商提供的 Key通常以 `sk-` 开头)
#### B. 模型Model
每个模型一行(同一服务商下多个模型共享一个 API Key
- **启用开关**:关闭后该模型不会被使用
- **模型别名name**给你自己看的名字例如“DeepSeek-R1简历筛选
- **Model IDmodel**:服务商要求的模型标识,例如 `deepseek-reasoner``deepseek-chat`
- **推理模型**(可选):如果你用的是推理模型(例如 R1/Qwen3 推理系列),可以勾选启用并设置预算(常用 2048 起)
#### C. 测试连接(非常重要)
每个模型旁边点 **测试连接**
- 显示“连接成功”才算配置完成
- 如果失败:优先检查 Base URL 是否正确(很多服务商必须带 `/v1`、API Key 是否正确、网络是否可达
### 2.2 “各用途默认模型”应该怎么选
页面下方的“各用途默认模型”,建议至少设置:
- **简历筛选**:选择你用于 Rubric 评估的模型(最常用)
- 其他用途(招呼语生成、消息续写、默认)可以先不设置或按需选择
> 留空时:系统会“跟随第一个启用的模型”。为了可控,建议明确选一下“简历筛选”。
---
## 3) 职位配置(岗位设置):筛选条件都在这里
配置入口:**招聘BOSS → 职位配置**
保存到:`~/.geekgeekrun/config/boss-jobs-config.json`
### 3.1 第一步:同步职位列表
进入「职位配置」页面后,先点顶部 **同步职位列表**
- 如果提示 `NEED_LOGIN`:说明登录凭据不对或已过期,回到“编辑登录凭据”重新登录保存 Cookie
同步完成后会显示职位列表,点职位名展开配置。
### 3.2 你要理解的关键点
- **“沟通”页不配置筛选规则**
沟通页只管“怎么跑”;筛选规则按职位配置来执行。
- **一职位一套筛选规则**
你可以给不同岗位设置不同城市/学历/薪资/简历筛选 Rubric。
---
## 4) “沟通”页:只配置运行策略,然后启动
入口:**招聘BOSS → 沟通**
这页会保存到 `boss-recruiter.json``chatPage` 配置,但你不需要关心文件。
### 4.1 每个字段含义与建议值
- **每次最多处理未读会话数**
- 含义:本轮最多处理多少条“未读对话”
- 建议:先用 1020 做小流量测试,稳定再加
- **单轮运行完成后停止(不再自动重启)**
- 含义:跑完这一轮就结束,不会过一会儿再跑
- 建议:你手动跑的时候先勾上,便于观察效果
- **单轮结束后保持浏览器打开(需同时勾选「单轮运行完成后停止」)**
- 含义:本轮跑完不关浏览器,方便你检查页面;你手动把浏览器关掉后任务才算结束
- 建议:排错时勾选
- **两轮之间的等待间隔(毫秒)**
- 含义:不勾选“单轮停止”时,每轮跑完等多久再跑下一轮
- 默认30003 秒)
- 建议:一般保持默认;如果你想更稳、减少触发风控的概率,可以调大(例如 10000
### 4.2 两个按钮怎么用
- **仅保存配置**:只保存,不启动
- **保存配置,并开始处理沟通页!**:保存 + 立刻启动任务
启动后如果要中止,在运行遮罩层里点 **结束任务**
---
## 5) LLM RubricAI 简历筛选)详细教程
> 目标:把“复杂的 criteria”变成一套可重复、可验证、可迭代的配置。
>
> 你会做 4 件事:
> 1) 准备 JD输入给 LLM
> 2) 生成 Rubricknockouts + dimensions
> 3) 用真实简历文本测试 Rubric通过/不通过是否符合预期)
> 4) 调整 Rubric 再测试,最后应用到职位配置
### 5.1 Rubric 的结构是什么(你需要看懂)
Rubric 是一个 JSON对应三部分
- **knockouts**:一票否决项(命中任意一条,直接淘汰)
- **dimensions**:评分维度列表(每个维度 1/3/5 分标准 + 权重 weight
- **passThreshold**通过分数线0100例如 75 分以上通过
手动 Rubric JSON 的标准格式示例:
```json
{
"knockouts": [
"必须统招本科及以上",
"必须有 3 年以上前端经验"
],
"dimensions": [
{
"name": "前端工程化与质量",
"weight": 35,
"criteria": {
"1": "缺乏工程化经验:无构建/规范/CI/CD/测试实践描述",
"3": "有基础工程化实践使用过打包工具、Lint、简单 CI但缺少质量指标或规模化经验",
"5": "工程化成熟能落地规范、CI/CD、监控与质量体系有大型项目治理经验"
}
},
{
"name": "业务交付与协作",
"weight": 65,
"criteria": {
"1": "主要做简单页面或边缘需求,缺少端到端交付与跨团队协作证据",
"3": "能独立交付模块:有业务拆解、排期、联调与上线经验",
"5": "能主导复杂交付:推动关键项目落地,跨团队协作强,能复盘与优化"
}
}
],
"passThreshold": 75
}
```
> 提醒:`weight` 建议总和为 100更直观。维度分数是 1/3/5系统会按权重换算成 0100 总分。
### 5.2 JD 怎么给写得好Rubric 才会准)
#### 5.2.1 最推荐的 JD 输入模板(直接粘贴给 LLM
把你手里的 JD 改成“能评估”的结构,尽量包含:
- **岗位目标**这个岗位最核心的产出是什么13 句话)
- **必须项(硬门槛)**:学历/年限/城市/必须技能/必须行业等(这些应当转成 knockouts
- **加分项(软偏好)**:有更好,没有也能考虑(这些适合做 dimensions 或 3/5 分差异)
- **禁止项(明确不考虑)**:例如“必须坐班/不接受远程/不接受频繁跳槽”等(也是 knockouts 候选)
- **评估方式倾向**:你希望 LLM 重点看什么证据(项目、成果指标、技术栈、论文、专利、带队等)
示例(你可以复制改):
```text
岗位前端工程师ToB
岗位目标:
1) 负责核心业务模块交付,提升交付质量与稳定性
2) 推进工程化建设规范、CI、监控、性能优化
必须项(硬门槛):
- 本科及以上
- 3 年以上前端经验
- 熟悉 Vue 或 React 至少一种,并有线上项目经验
加分项(软偏好):
- 有工程化/组件库/低代码平台经验
- 有性能优化、监控告警、可观测性经验
- 有带新人/小组协作经验
不考虑:
- 频繁跳槽(平均在职 < 10 个月)
评估重点:
- 项目经历是否体现端到端交付(需求拆解、联调、上线、复盘)
- 是否有工程化/质量体系的证据规范、测试、CI/CD、监控
```
#### 5.2.2 常见写法错误(会让 Rubric 变得很玄学)
- 只有“岗位职责”没有“硬门槛/加分项/不考虑”
- 只写“熟悉/精通/良好沟通”这种形容词,没有“证据/行为/产出”
- 把“加分项”当“必须项”写死,导致 knockouts 太多、候选人几乎全被淘汰
### 5.3 如何生成 Rubric两种方式
你可以在两处生成 Rubric
- **方式 A推荐**:在「职位配置」页面里生成(生成后就属于该职位)
- **方式 B更适合调试**:在「招聘端调试工具 → LLM 筛选」里生成(更适合反复试错)
#### 5.3.1 在“职位配置”里生成(最常用)
1. 打开:**职位配置** → 展开某个职位
2. 在“简历全文筛选”里勾选:**大模型筛选AI Rubric**
3. 在 Step 1 的 “岗位描述JD” 粘贴你的 JD
4.**自动生成评分标准**
5. 生成后出现:
- 一票否决项knockouts
- 评分维度dimensions
- 通过分数线passThreshold
6. 你可以直接微调(见 5.5),然后点该职位底部 **保存**
> 如果提示“未检测到可用模型”:先去「配置大语言模型」添加并启用至少一个模型,并测试连接成功。
#### 5.3.2 在“招聘端调试工具”里生成(强烈建议先用它把规则跑通)
1. 打开:**招聘端调试工具 → Tab BLLM 筛选**
2. 在“区域 1生成 Rubric”输入 JD
3.**生成 Rubric**
4. 生成后你可以:
- **复制 JSON**
- **用于评估**(把 JSON 直接带到区域 3
- **应用到职位配置**(选目标职位 → 一键写入职位配置)
### 5.4 Rubric 怎么测试(这是最关键的一步)
测试的目标是:让“通过/不通过”的结果**符合你的直觉**,并且“理由”能解释清楚。
推荐用:**招聘端调试工具 → Tab BLLM 筛选**
#### 5.4.1 准备一份真实简历文本(区域 2
1. 在调试工具顶部点 **启动浏览器**
2. 浏览器打开到 BOSS 沟通页后,你在左侧会话列表**手动点击一条会话**(让右侧候选人信息显示出来)
3. 回到调试工具,点 **📄 提取当前简历文本**
4. 成功后会显示简历文本长度(多少字),并自动填到区域 3 的“简历文本”里
> 说明:它会自动打开“在线简历”,用 Canvas hook 把文字提取出来,所以能得到较完整的简历文本。
#### 5.4.2 运行 Rubric 评估(区域 3
区域 3 有两种 Rubric 来源:
- **从职位配置读取**:适合验证“某个职位”现在的配置是否好用
- **手动填写 JSON**:适合快速试错(比如刚生成的 Rubric
操作流程(推荐手动 JSON
1. 在区域 1 生成 Rubric 后点 **用于评估**(会把 JSON 填入区域 3
2. 确认区域 3 里“简历文本”有内容(来自区域 2或你手动粘贴
3.**🤖 运行 LLM 评估**
4. 查看结果:
- ✅/❌ 是否通过
- 总分(如 78/100
- 各维度得分15 分)
- 原因reason
#### 5.4.3 你应该怎么判断“Rubric 好不好”
用 3 个候选人做最小验证:
- **明显不合格**:应当稳定不通过(最好被 knockouts 或低分维度卡住)
- **明显合格**:应当稳定通过(总分高,关键维度 45 分)
- **边界候选人**:通过与否取决于你希望的标准(用它来调 passThreshold/权重)
如果 3 个样本的结果都符合预期,再开始正式跑沟通页。
### 5.5 Rubric 怎么修改(让它更像“你的判断标准”)
你主要会改 4 类东西:
#### A. knockouts硬门槛
适合放“真的不能谈”的条件:
- 学历硬门槛(如必须本科)
- 年限硬门槛(如必须 3 年以上)
- 必须证书/必须行业/必须到岗方式等
不建议放:
- 软偏好(如“更偏好大厂”“最好做过组件库”)——放到维度更合理
#### B. dimensions 的 name维度名称
维度名称要“可评估”,不要写空话。好例子:
- “工程化与质量体系”
- “业务交付与跨团队协作”
- “算法建模与实验设计”
不好的例子:
- “综合匹配度”“整体优秀”“符合岗位需求”
#### C. criteria1/3/5 分标准)
这是你觉得“复杂”的地方,但你只要记住一句话:
> **criteria 不是形容词,是证据。**
写法建议:
- **1 分**:缺少关键证据(没做过/没体现过/只有名词无案例)
- **3 分**:有证据但不够强(参与过、做过模块、缺少规模/指标/主导)
- **5 分**:证据很强(主导过、规模化、可量化成果、影响面大)
典型可用“证据关键词”:
- “主导/负责/推动/落地/设计”
- “从 0 到 1 / 从 1 到 N”
- “指标:耗时降低 X%、崩溃率降低 X、性能提升 X”
- “规模DAU、QPS、组件数量、团队人数”
#### D. weight权重与 passThreshold分数线
建议调参顺序:
1. 先把维度写清楚criteria 能拉开差异)
2. 再调权重:让你最关心的能力占更大比重
3. 最后调通过线:
- 你觉得“通过的人太多” → 提高 passThreshold
- 你觉得“好的人被误杀” → 降低 passThreshold 或放宽 knockouts
### 5.6 生成出来的 Rubric 不靠谱怎么办(最常见原因)
- **原因 1JD 太泛**
- 解决:按 5.2 的模板补齐“必须项/加分项/不考虑/评估重点”
- **原因 2knockouts 太多导致全淘汰**
- 解决:只保留真正的硬门槛;软偏好放维度
- **原因 3criteria 写成“部分符合/完全符合”这种废话**
- 解决:改成“证据标准”(项目/指标/产出/职责范围)
- **原因 4模型不稳定/经常超时**
- 解决:换一个更稳定的 provider/model先用“测试连接”确认连通
---
## 6) 最推荐的落地流程(照着做就行)
1. **编辑登录凭据**:确保招聘者身份 Cookie 保存成功
2. **配置大语言模型**:至少 1 个可用模型,测试连接成功;用途默认模型设置“简历筛选”
3. **职位配置**
- 同步职位列表
- 给目标职位开启“LLM Rubric”
4. **招聘端调试工具**
- 生成 Rubric → 提取 3 份真实简历文本 → 运行评估 → 调整 Rubric → 直到结果符合预期
- 一键“应用到职位配置”
5. **沟通**
- 设置每轮处理未读数(先 10
- 勾选“单轮运行完成后停止”先跑一轮观察
- 点击“保存配置,并开始处理沟通页!”
---
## 7) 常见错误提示对照表
- **同步职位列表失败NEED_LOGIN**
- 去“编辑登录凭据”重新登录保存 Cookie招聘者身份
- **运行沟通页后提示登录无效**
- Cookie 过期了,重新登录保存即可
- **LLM 生成/评估失败:请检查 LLM 配置**
- 去“配置大语言模型”测试连接
- 检查 baseURL 是否正确(尤其是否包含 `/v1`
- 检查该模型是否启用

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。

10497
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff