mirror of
https://github.com/geekgeekrun/geekgeekrun.git
synced 2026-05-26 18:50:10 +08:00
recruiter: add boss auto browse/chat flows, webhook, and candidate tables
- Add recruiter-side automation core and run-core entry - Extend sqlite-plugin with candidate info + contact logs - Add UI routes/pages, IPC handlers, progress + log panel - Document current status and plans under plan/ Made-with: Cursor
This commit is contained in:
28
.gitignore
vendored
28
.gitignore
vendored
@@ -1,4 +1,26 @@
|
||||
node_modules
|
||||
database.sqlite
|
||||
.env.local
|
||||
**/node_modules/
|
||||
|
||||
# build outputs
|
||||
dist/
|
||||
out/
|
||||
packages/**/dist/
|
||||
packages/**/out/
|
||||
packages/**/.vite/
|
||||
packages/**/.turbo/
|
||||
packages/**/.cache/
|
||||
|
||||
# OS / logs
|
||||
.DS_Store
|
||||
*.log
|
||||
*.log.*
|
||||
|
||||
# local examples (often large/volatile)
|
||||
examples/
|
||||
|
||||
# runtime data / secrets
|
||||
database.sqlite
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
*.db-journal
|
||||
.env.local
|
||||
116
CLAUDE.md
Normal file
116
CLAUDE.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Package Manager
|
||||
|
||||
**Always use `proto run pnpm -- <args>`** instead of bare `pnpm`. The project requires pnpm `>=8.15.9 <9.0.0`; the system pnpm (10.x) is incompatible and will cause errors.
|
||||
|
||||
```bash
|
||||
proto run pnpm -- install
|
||||
proto run pnpm -- -F geekgeekrun-ui dev
|
||||
```
|
||||
|
||||
## Key Commands
|
||||
|
||||
All UI development happens in `packages/ui`. The `dev`/`build`/`start` scripts automatically rebuild `sqlite-plugin` first.
|
||||
|
||||
```bash
|
||||
# Electron app (main entry point for users)
|
||||
proto run pnpm -- -F geekgeekrun-ui dev # development mode
|
||||
proto run pnpm -- -F geekgeekrun-ui build # production build
|
||||
proto run pnpm -- -F geekgeekrun-ui build:win # Windows installer
|
||||
|
||||
# Lint & format (run from packages/ui)
|
||||
proto run pnpm -- -F geekgeekrun-ui lint # eslint --fix
|
||||
proto run pnpm -- -F geekgeekrun-ui format # prettier --write
|
||||
|
||||
# Type checking (run from packages/ui)
|
||||
proto run pnpm -- -F geekgeekrun-ui typecheck # both node + web
|
||||
|
||||
# SQLite plugin (must build before UI if changed)
|
||||
proto run pnpm -- -F @geekgeekrun/sqlite-plugin build
|
||||
proto run pnpm -- -F @geekgeekrun/sqlite-plugin dev # watch mode
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a **pnpm monorepo** (`packages/*`) — a desktop automation tool for BOSS Zhipin (job platform) built on Electron + Puppeteer.
|
||||
|
||||
### Two Sides
|
||||
|
||||
**Job-seeker side** (older, more complete):
|
||||
- `packages/geek-auto-start-chat-with-boss` — core automation: LLM-based resume matching, auto-chat
|
||||
- `packages/run-core-of-geek-auto-start-chat-with-boss` — headless daemon entry point
|
||||
|
||||
**Recruiter side** (newer, under active development):
|
||||
- `packages/boss-auto-browse-and-chat` — core automation: browse candidates, filter, send greetings, extract resumes via Canvas hook
|
||||
- `packages/run-core-of-boss-auto-browse` — headless daemon entry point
|
||||
|
||||
### Electron App (`packages/ui`)
|
||||
|
||||
The app uses **mode-based process routing**: every worker subprocess is actually the same Electron binary launched with a `--mode` flag. `src/main/index.ts` switches on `runMode`:
|
||||
|
||||
- No `--mode` (default) → opens the settings GUI window (`OPEN_SETTING_WINDOW`)
|
||||
- `bossRecommendMain` / `bossChatPageMain` / `bossAutoBrowseAndChatMain` → recruiter workers
|
||||
- `geekAutoStartWithBossMain` → job-seeker worker
|
||||
- `launchDaemon` → background daemon process (manages worker subprocesses via `@geekgeekrun/pm`)
|
||||
|
||||
The GUI renderer is **Vue 3 + Pinia + Vue Router** served by electron-vite. IPC handlers live in `src/main/flow/OPEN_SETTING_WINDOW/ipc/index.ts`.
|
||||
|
||||
### Plugin/Hook System
|
||||
|
||||
All automation cores (both sides) use **tapable** (`AsyncSeriesHook`, `AsyncSeriesWaterfallHook`) for extensibility. The sqlite-plugin and webhook features attach to these hooks:
|
||||
|
||||
```
|
||||
Worker flow file
|
||||
→ constructs hooks object
|
||||
→ new SqlitePlugin(dbPath).apply(hooks)
|
||||
→ calls core function (startBossAutoBrowse / startBossChatPageProcess / startGeekAutoChat)
|
||||
```
|
||||
|
||||
### Shared Packages
|
||||
|
||||
- `packages/sqlite-plugin` — TypeORM + better-sqlite3, compiled TypeScript (`dist/`). Entities: `CandidateInfo`, `CandidateContactLog`, `ChatStartupLog`. **Must be built before UI.**
|
||||
- `packages/utils` — ESM utilities: sleep, OpenAI/GPT requests, Puppeteer helpers
|
||||
- `packages/pm` — Electron multi-process daemon/worker management
|
||||
- `packages/laodeng` / `packages/puppeteer-extra-plugin-laodeng` — anti-bot-detection Puppeteer plugin
|
||||
|
||||
### Storage Layout
|
||||
|
||||
```
|
||||
~/.geekgeekrun/
|
||||
config/
|
||||
boss-recruiter.json # recruiter automation config
|
||||
candidate-filter.json # candidate filter criteria
|
||||
webhook.json # webhook integration config
|
||||
storage/
|
||||
boss-cookies.json # persisted BOSS Zhipin cookies
|
||||
boss-local-storage.json # persisted localStorage
|
||||
public.db # SQLite database
|
||||
```
|
||||
|
||||
### Recruiter Automation Stack (boss-auto-browse-and-chat)
|
||||
|
||||
Key files and their roles:
|
||||
- `index.mjs` — `startBossAutoBrowse()`: browser launch, login, main loop
|
||||
- `candidate-processor.mjs` — DOM parsing (`#recommend-list > div > ul > li`), candidate filtering
|
||||
- `chat-handler.mjs` — clicking 打招呼 (`button.btn-greet`), handling popup (`button.btn-sure-v2`), processCandidate
|
||||
- `resume-extractor.mjs` — network intercept + iframe Canvas fillText hook (MutationObserver pattern, see `plan/cv_canvas_solution.md`)
|
||||
- `constant.mjs` — all CSS selectors and URLs; **update here first when BOSS site HTML changes**
|
||||
|
||||
Anti-detection: stealth + laodeng + anonymize-ua plugins; all clicks via `ghost-cursor` (`createHumanCursor`); random delays via `sleepWithRandomDelay`.
|
||||
|
||||
## Code Style
|
||||
|
||||
Enforced by eslint + prettier in `packages/ui`:
|
||||
- **No semicolons**, **single quotes**, `printWidth: 100`, no trailing commas
|
||||
- Vue 3 `<script setup>` SFC style
|
||||
- `.mjs` files (automation core) are plain ESM, no TypeScript, no build step
|
||||
|
||||
## Plan Documents
|
||||
|
||||
`plan/` contains architecture decision documents intended for AI-assisted development:
|
||||
- `recruiter_architecture.md` — high-level overview of the recruiter side
|
||||
- `recommend_page_flow.md` — detailed DOM structure, selectors, and flow for the recommend page
|
||||
- `cv_canvas_solution.md` — how BOSS Zhipin's WASM+Canvas resume protection works and how it's bypassed
|
||||
411
packages/boss-auto-browse-and-chat/candidate-processor.mjs
Normal file
411
packages/boss-auto-browse-and-chat/candidate-processor.mjs
Normal 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
|
||||
}
|
||||
}
|
||||
486
packages/boss-auto-browse-and-chat/chat-handler.mjs
Normal file
486
packages/boss-auto-browse-and-chat/chat-handler.mjs
Normal file
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* 招聘端自动开聊与对话处理
|
||||
* 提供:查看候选人详情、发起沟通、单候选人处理流程、每日限额检测
|
||||
*
|
||||
* 注意:凡在招聘端页面上的点击/移动,均通过 humanMouse.mjs 的拟人轨迹执行,
|
||||
* 不直接调用 page.click() / page.mouse.click(),以规避 BOSS 鼠标埋点检测。
|
||||
*/
|
||||
|
||||
import { sleep, sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs'
|
||||
import { getResumeData, extractResumeText } from './resume-extractor.mjs'
|
||||
import { createHumanCursor } from './humanMouse.mjs'
|
||||
import { debug as logDebug, info as logInfo, warn as logWarn } from './logger.mjs'
|
||||
import {
|
||||
CANDIDATE_ITEM_SELECTOR,
|
||||
CHAT_START_BUTTON_SELECTOR,
|
||||
GREETING_SENT_KNOW_BTN_SELECTOR,
|
||||
RESUME_POPUP_CLOSE_SELECTOR,
|
||||
NOT_INTERESTED_IN_ITEM_SELECTOR,
|
||||
NOT_INTERESTED_REASON_POPUP_SELECTOR,
|
||||
NOT_INTERESTED_REASON_ITEMS_SELECTOR,
|
||||
NOT_INTERESTED_REASON_MAP,
|
||||
NOT_INTERESTED_REASON_POSITION_MISMATCH,
|
||||
NOT_INTERESTED_REASON_FALLBACK,
|
||||
NOT_INTERESTED_REASON_POPUP_CLOSE_SELECTOR
|
||||
} from './constant.mjs'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 查看候选人详情
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 点击候选人条目进入详情,等待详情面板加载,提取详情信息;若详情含 canvas 简历则通过 resume-extractor 获取文字。
|
||||
* 选择器为 TODO 占位符,需登录招聘端后通过 DevTools 确认。
|
||||
*
|
||||
* 点击使用 humanMouse 拟人轨迹,规避 BOSS 鼠标埋点。
|
||||
*
|
||||
* @param {import('puppeteer').Frame} frame - iframe Frame 实例(候选人列表在此 frame 内)
|
||||
* @param {object} candidateItem - 列表中的候选人项,至少含 encryptGeekId、geekName 等
|
||||
* @param {{
|
||||
* cursor?: object,
|
||||
* mainPage?: import('puppeteer').Page,
|
||||
* getInterceptedData?: () => Map<string, unknown>,
|
||||
* getCapturedText?: (p: import('puppeteer').Page) => Promise<Array<{text: string, x: number, y: number}>>,
|
||||
* candidateIndex?: number
|
||||
* }} [options] - 可选:cursor、mainPage(主页面,用于 Canvas 提取与关闭弹窗);candidateIndex 为列表中的索引
|
||||
* @returns {Promise<object>} 详情数据对象
|
||||
*/
|
||||
export async function viewCandidateDetail (frame, candidateItem, options = {}) {
|
||||
const { mainPage, getInterceptedData, getCapturedText } = options
|
||||
// 推荐牛人页无独立详情面板(CANDIDATE_DETAIL_SELECTOR 为空),不点击卡片(点击会弹出简历弹窗,遮挡列表影响后续打招呼)
|
||||
|
||||
const detail = {
|
||||
encryptGeekId: candidateItem.encryptGeekId,
|
||||
geekName: candidateItem.geekName,
|
||||
education: candidateItem.education,
|
||||
workExp: candidateItem.workExp,
|
||||
city: candidateItem.city,
|
||||
jobTitle: candidateItem.jobTitle,
|
||||
salaryExpect: candidateItem.salaryExpect ?? candidateItem.salary,
|
||||
skills: candidateItem.skills
|
||||
}
|
||||
|
||||
let resumeText = null
|
||||
let resumeSource = null
|
||||
|
||||
if (getInterceptedData) {
|
||||
const intercepted = getInterceptedData()
|
||||
const resumeResult = await getResumeData(frame, intercepted)
|
||||
if (resumeResult.source === 'api' && resumeResult.data) {
|
||||
resumeSource = 'api'
|
||||
resumeText = typeof resumeResult.data === 'string' ? resumeResult.data : JSON.stringify(resumeResult.data)
|
||||
} else if (resumeResult.source === 'canvas' && Array.isArray(resumeResult.data)) {
|
||||
resumeSource = 'canvas'
|
||||
resumeText = resumeResult.data.join('\n')
|
||||
}
|
||||
} else if (getCapturedText && mainPage) {
|
||||
const captured = await getCapturedText(mainPage)
|
||||
const lines = extractResumeText(captured)
|
||||
if (lines.length > 0) {
|
||||
resumeSource = 'canvas'
|
||||
resumeText = lines.join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
if (resumeText) {
|
||||
detail.resumeText = resumeText
|
||||
detail.resumeSource = resumeSource
|
||||
}
|
||||
|
||||
// 关闭简历弹窗(主页面上的 dialog),避免列表一直灰显且便于下一项操作
|
||||
if (mainPage && RESUME_POPUP_CLOSE_SELECTOR) {
|
||||
try {
|
||||
await closeResumePopup(mainPage)
|
||||
} catch (e) {
|
||||
logWarn('[chat-handler] 关闭简历弹窗失败:', e?.message)
|
||||
}
|
||||
}
|
||||
|
||||
return detail
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击简历详情弹窗的关闭按钮(主页面)
|
||||
* @param {import('puppeteer').Page} page - 主页面
|
||||
*/
|
||||
export async function closeResumePopup (page) {
|
||||
if (!page || !RESUME_POPUP_CLOSE_SELECTOR) return
|
||||
const closeBtn = await page.$(RESUME_POPUP_CLOSE_SELECTOR)
|
||||
if (!closeBtn) return
|
||||
const cursor = await createHumanCursor(page)
|
||||
const box = await closeBtn.boundingBox()
|
||||
if (box) {
|
||||
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
|
||||
} else {
|
||||
await closeBtn.click()
|
||||
}
|
||||
await sleepWithRandomDelay(300)
|
||||
}
|
||||
|
||||
/**
|
||||
* 在推荐页 iframe 内点击第 itemIndex 条候选人的"不感兴趣",使该条从列表移除,避免重复扫描。
|
||||
* 会先悬停到该卡片再查找按钮;点击后弹出原因弹窗,按筛选原因(filterResult.reason)选对应选项以优化 BOSS 推荐。
|
||||
* 后续可接入 LLM:根据 opts.filterResult.reasonDetail 或候选人信息选择更贴切的原因项。
|
||||
* @param {import('puppeteer').Frame} frame - recommendFrame
|
||||
* @param {number} itemIndex - 在 ul.card-list > li.card-item 中的索引
|
||||
* @param {object} [cursor] - 可选拟人 cursor(须为 frame 所在 page 的 cursor,坐标在 page 坐标系)
|
||||
* @param {{ logPrefix?: string, filterResult?: { reason: string, reasonDetail?: string } }} [opts] - logPrefix 日志前缀;filterResult 为本条被跳过的筛选结果,用于选对应原因项
|
||||
*/
|
||||
export async function clickNotInterested (frame, itemIndex, cursor, opts = {}) {
|
||||
const logPrefix = opts.logPrefix || '[chat-handler]'
|
||||
const filterResult = opts.filterResult
|
||||
const reason = filterResult?.reason || 'unknown'
|
||||
if (!NOT_INTERESTED_IN_ITEM_SELECTOR) return
|
||||
const items = await frame.$$(CANDIDATE_ITEM_SELECTOR)
|
||||
const item = items[itemIndex]
|
||||
if (!item) {
|
||||
logInfo(logPrefix, '未找到候选人条目(index=', itemIndex, '),跳过点击不感兴趣')
|
||||
return
|
||||
}
|
||||
const page = frame.page?.() || frame
|
||||
const c = cursor || await createHumanCursor(page)
|
||||
// Puppeteer 24.x boundingBox() 已自动叠加 iframe 偏移,返回 page 绝对坐标,直接使用
|
||||
|
||||
// 第一步:将卡片滚动进视口(卡片可能在视口外,page.mouse 只对可视区域内坐标有效)
|
||||
await item.scrollIntoView().catch(() => {})
|
||||
await sleepWithRandomDelay(300)
|
||||
|
||||
// 第二步:悬停到卡片中央,触发卡片 hover 样式(使操作区出现)
|
||||
const itemBox = await item.boundingBox()
|
||||
logDebug(logPrefix, '卡片 boundingBox index=', itemIndex, ':', itemBox)
|
||||
if (itemBox) {
|
||||
logDebug(logPrefix, '悬停到卡片 index=', itemIndex)
|
||||
await c.move({ x: itemBox.x + itemBox.width / 2, y: itemBox.y + itemBox.height / 2 })
|
||||
await sleepWithRandomDelay(400)
|
||||
}
|
||||
|
||||
// 第三步:找到 tooltip-wrap 区域并移动过去,触发该区域 hover("不感兴趣"在此区域内)
|
||||
let wrap = await item.$(NOT_INTERESTED_IN_ITEM_SELECTOR)
|
||||
let box = wrap ? await wrap.boundingBox() : null
|
||||
logDebug(logPrefix, 'tooltip-wrap boundingBox index=', itemIndex, ':', box)
|
||||
if (!wrap || !box) {
|
||||
logInfo(logPrefix, '未找到不感兴趣按钮或不可见(index=', itemIndex, '),选择器:', NOT_INTERESTED_IN_ITEM_SELECTOR)
|
||||
return
|
||||
}
|
||||
await c.move({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
|
||||
await sleepWithRandomDelay(400)
|
||||
|
||||
// 第四步:再次获取 box 后点击
|
||||
box = await wrap.boundingBox()
|
||||
if (!box) {
|
||||
logInfo(logPrefix, 'tooltip-wrap hover 后仍不可见(index=', itemIndex, '),跳过')
|
||||
return
|
||||
}
|
||||
// 点击前在 frame(iframe)和主页面同时注入 MutationObserver 监听 toast 插入
|
||||
// toast 是临时元素(几秒后从 DOM 移除),必须在插入瞬间捕获;toast 可能在 iframe 内或主页面
|
||||
const injectToastObserver = (ctx) => ctx.evaluate(() => {
|
||||
window.__notInterestedLimitReached = false
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
for (const node of m.addedNodes) {
|
||||
if (node.nodeType === 1 && node.classList?.contains('toast')) {
|
||||
if (node.innerText?.includes('已达上限')) {
|
||||
window.__notInterestedLimitReached = true
|
||||
}
|
||||
observer.disconnect()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
setTimeout(() => observer.disconnect(), 3000)
|
||||
}).catch(() => {})
|
||||
await Promise.all([injectToastObserver(frame), injectToastObserver(page)])
|
||||
|
||||
logDebug(logPrefix, '点击不感兴趣按钮 index=', itemIndex, 'box:', box)
|
||||
await c.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
|
||||
await sleep(800)
|
||||
|
||||
// 读取 MutationObserver 结果(frame 或 page 任一检测到即算)
|
||||
const limitReachedInFrame = await frame.evaluate(() => window.__notInterestedLimitReached === true).catch(() => false)
|
||||
const limitReachedInPage = await page.evaluate(() => window.__notInterestedLimitReached === true).catch(() => false)
|
||||
if (limitReachedInFrame || limitReachedInPage) {
|
||||
logInfo(logPrefix, '当天标记不合适的牛人数已达上限,停止点击不感兴趣')
|
||||
return 'NOT_INTERESTED_LIMIT_REACHED'
|
||||
}
|
||||
|
||||
// 点击"不感兴趣"后弹出原因弹窗,按筛选原因选对应选项;弹窗在 iframe 内(与列表同属 recommendFrame)
|
||||
if (NOT_INTERESTED_REASON_POPUP_SELECTOR && NOT_INTERESTED_REASON_ITEMS_SELECTOR) {
|
||||
const reasonPopupTimeoutMs = 5000
|
||||
const runReasonPopup = async () => {
|
||||
const popupInFrame = await frame.waitForSelector(NOT_INTERESTED_REASON_POPUP_SELECTOR, { timeout: 2000 }).catch(() => null)
|
||||
const popupInPage = !popupInFrame && page !== frame ? await page.waitForSelector(NOT_INTERESTED_REASON_POPUP_SELECTOR, { timeout: 2000 }).catch(() => null) : null
|
||||
const popup = popupInFrame || popupInPage
|
||||
const ctx = popupInFrame ? frame : page
|
||||
if (!popup) {
|
||||
if (logPrefix) logInfo(logPrefix, '原因弹窗未出现(index=', itemIndex, '),跳过')
|
||||
return
|
||||
}
|
||||
if (logPrefix) logDebug(logPrefix, '原因弹窗已出现,正在选择原因(', reason, ')…')
|
||||
const reasonItems = await ctx.$$(NOT_INTERESTED_REASON_ITEMS_SELECTOR)
|
||||
const targetText = NOT_INTERESTED_REASON_MAP[reason] || NOT_INTERESTED_REASON_FALLBACK
|
||||
const preferPositionMismatch = (reason === 'skills' || reason === 'blockName') && NOT_INTERESTED_REASON_POSITION_MISMATCH
|
||||
let toClick = null
|
||||
let matchedText = null
|
||||
for (const el of reasonItems) {
|
||||
const text = await ctx.evaluate(e => e.textContent.trim(), el).catch(() => '')
|
||||
if (preferPositionMismatch && text.includes(NOT_INTERESTED_REASON_POSITION_MISMATCH)) {
|
||||
toClick = el
|
||||
matchedText = text
|
||||
break
|
||||
}
|
||||
if (text === targetText) {
|
||||
toClick = el
|
||||
matchedText = text
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!toClick && targetText !== NOT_INTERESTED_REASON_FALLBACK) {
|
||||
for (const el of reasonItems) {
|
||||
const text = await ctx.evaluate(e => e.textContent.trim(), el).catch(() => '')
|
||||
if (text === NOT_INTERESTED_REASON_FALLBACK) {
|
||||
toClick = el
|
||||
matchedText = text
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (toClick) {
|
||||
const reasonBox = await toClick.boundingBox()
|
||||
if (reasonBox) {
|
||||
await c.click({ x: reasonBox.x + reasonBox.width / 2, y: reasonBox.y + reasonBox.height / 2 })
|
||||
} else {
|
||||
await toClick.click()
|
||||
}
|
||||
if (logPrefix && matchedText) {
|
||||
logInfo(logPrefix, '不感兴趣原因已选:', matchedText, '(筛选原因:', reason, ')')
|
||||
}
|
||||
} else {
|
||||
// 未匹配到任何选项时点击关闭图标,避免弹窗一直挡住后续操作
|
||||
if (NOT_INTERESTED_REASON_POPUP_CLOSE_SELECTOR) {
|
||||
const closeBtn = await ctx.$(NOT_INTERESTED_REASON_POPUP_CLOSE_SELECTOR)
|
||||
if (closeBtn) {
|
||||
const closeBox = await closeBtn.boundingBox()
|
||||
if (closeBox) await c.click({ x: closeBox.x + closeBox.width / 2, y: closeBox.y + closeBox.height / 2 })
|
||||
else await closeBtn.click()
|
||||
if (logPrefix) logDebug(logPrefix, '未匹配到原因选项,已点击关闭弹窗')
|
||||
}
|
||||
}
|
||||
}
|
||||
await sleepWithRandomDelay(300)
|
||||
}
|
||||
try {
|
||||
await Promise.race([
|
||||
runReasonPopup(),
|
||||
sleep(reasonPopupTimeoutMs).then(() => { throw new Error('原因弹窗处理超时') })
|
||||
])
|
||||
} catch (e) {
|
||||
logWarn(logPrefix, '不感兴趣原因弹窗处理失败:', e?.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 发起沟通(打招呼 → 知道了 → 继续沟通 → 发消息)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 招聘端打招呼完整流程:
|
||||
* 1. 找到并以拟人轨迹点击"打招呼"按钮(CHAT_START_BUTTON_SELECTOR,在当前候选人 item 内)
|
||||
* 2. 等待"已向牛人发送招呼"弹窗出现,点击"知道了"(GREETING_SENT_KNOW_BTN_SELECTOR)
|
||||
* 3. 等待"继续沟通"按钮出现,在当前候选人 item 内点击(CONTINUE_CHAT_BUTTON_SELECTOR)
|
||||
* 4. 等待全局聊天输入框出现(CHAT_INPUT_SELECTOR),输入后续消息(若配置了 greetingMessage)并回车发送
|
||||
*
|
||||
* 选择器为实际值(已从 HTML 示例中确认)。
|
||||
* 所有点击通过 humanMouse 拟人轨迹执行,规避 BOSS 鼠标埋点。
|
||||
* 当 options.candidateIndex 存在时,仅在对应 CANDIDATE_ITEM_SELECTOR 的该条内查找按钮,避免点到别的候选人。
|
||||
*
|
||||
* @param {import('puppeteer').Frame} frame - iframe Frame 实例(候选人列表在此 frame 内)
|
||||
* @param {object} candidate - 候选人对象,至少含 encryptGeekId、geekName
|
||||
* @param {string} [greetingMessage] - 打招呼后在聊天框发送的后续消息(可选)
|
||||
* @param {{ cursor?: object, candidateIndex?: number, mainPage?: import('puppeteer').Page }} [options] - mainPage 为主页面,用于处理弹窗和风控检测
|
||||
* @returns {Promise<{ success: boolean, reason: string }>}
|
||||
*/
|
||||
export async function startChatWithCandidate (frame, _candidate, _greetingMessage, options = {}) {
|
||||
const cursor = options.cursor || await createHumanCursor(frame)
|
||||
const candidateIndex = options.candidateIndex
|
||||
// mainPage 用于处理主页面弹窗("知道了" dialog 在主页面,不在 iframe 内)
|
||||
const mainPage = options.mainPage || frame
|
||||
|
||||
if (!CHAT_START_BUTTON_SELECTOR) {
|
||||
return { success: false, reason: 'CHAT_START_BUTTON_SELECTOR_NOT_CONFIGURED' }
|
||||
}
|
||||
|
||||
// 1. 点击"打招呼"按钮(拟人轨迹);在 iframe frame 内按 candidateIndex 找到对应 item
|
||||
// Puppeteer 24.x boundingBox() 已自动叠加 iframe 偏移,返回 page 绝对坐标,直接使用
|
||||
try {
|
||||
if (typeof candidateIndex === 'number' && CANDIDATE_ITEM_SELECTOR) {
|
||||
const items = await frame.$$(CANDIDATE_ITEM_SELECTOR)
|
||||
const item = items[candidateIndex]
|
||||
if (!item) {
|
||||
return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
|
||||
}
|
||||
const startBtn = await item.$(CHAT_START_BUTTON_SELECTOR)
|
||||
if (!startBtn) {
|
||||
return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
|
||||
}
|
||||
const box = await startBtn.boundingBox()
|
||||
if (box) {
|
||||
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
|
||||
} else {
|
||||
await startBtn.click()
|
||||
}
|
||||
} else {
|
||||
const startBtn = await frame.waitForSelector(CHAT_START_BUTTON_SELECTOR, { timeout: 8000 })
|
||||
if (!startBtn) {
|
||||
return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
|
||||
}
|
||||
const box = await startBtn.boundingBox()
|
||||
if (box) {
|
||||
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
|
||||
} else {
|
||||
await startBtn.click()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return { success: false, reason: 'CHAT_BUTTON_NOT_FOUND' }
|
||||
}
|
||||
|
||||
await sleepWithRandomDelay(800)
|
||||
|
||||
// 检测风控或每日限额(在主页面检测,iframe 内不会显示这些提示)
|
||||
const immediateCheck = await mainPage.evaluate(() => {
|
||||
const bodyText = document.body?.innerText || ''
|
||||
if (/今日沟通人数已达上限|明天再来|今日.*已达上限/.test(bodyText)) {
|
||||
return 'DAILY_LIMIT_REACHED'
|
||||
}
|
||||
if (/风控|存在风险|请稍后再试|操作过于频繁/.test(bodyText)) {
|
||||
return 'RISK_CONTROL'
|
||||
}
|
||||
return null
|
||||
})
|
||||
if (immediateCheck) {
|
||||
return { success: false, reason: immediateCheck }
|
||||
}
|
||||
|
||||
// 2. 等待"已向牛人发送招呼"弹窗并点击"知道了"(弹窗在主页面,不在 iframe 内)
|
||||
if (GREETING_SENT_KNOW_BTN_SELECTOR) {
|
||||
try {
|
||||
const knowBtn = await mainPage.waitForSelector(GREETING_SENT_KNOW_BTN_SELECTOR, { timeout: 6000 })
|
||||
if (knowBtn) {
|
||||
const box = await knowBtn.boundingBox()
|
||||
if (box) {
|
||||
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
|
||||
} else {
|
||||
await knowBtn.click()
|
||||
}
|
||||
}
|
||||
await sleepWithRandomDelay(500)
|
||||
} catch {
|
||||
// 弹窗未出现不是致命错误,继续
|
||||
logWarn('[chat-handler] "知道了"弹窗未出现,继续尝试后续步骤')
|
||||
}
|
||||
}
|
||||
|
||||
// 打招呼已通过点击 button.btn-greet 自动发送,无需继续沟通或发后续消息
|
||||
|
||||
// 最终结果判断:招呼已发成功视为 OK;在 iframe 和主页面同时检测风控/限额文字
|
||||
const checkBodyText = (bodyText) => {
|
||||
if (/今日沟通人数已达上限|明天再来|今日.*已达上限/.test(bodyText)) {
|
||||
return { success: false, reason: 'DAILY_LIMIT_REACHED' }
|
||||
}
|
||||
if (/风控|存在风险|请稍后再试|操作过于频繁/.test(bodyText)) {
|
||||
return { success: false, reason: 'RISK_CONTROL' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
const [frameText, pageText] = await Promise.all([
|
||||
frame.evaluate(() => document.body?.innerText || '').catch(() => ''),
|
||||
mainPage.evaluate(() => document.body?.innerText || '').catch(() => '')
|
||||
])
|
||||
const result = checkBodyText(frameText) || checkBodyText(pageText) || { success: true, reason: 'OK' }
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 单候选人完整流程(详情 + 开聊 + hooks + 延迟)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 整合查看详情与发起沟通的完整流程:beforeStartChat → 开聊 → afterChatStarted,并在开聊后随机延迟。
|
||||
* 所有页面点击通过共享的 humanMouse cursor 执行,规避 BOSS 鼠标埋点。
|
||||
*
|
||||
* @param {import('puppeteer').Frame} frame - iframe Frame 实例(候选人列表在此 frame 内)
|
||||
* @param {object} candidate - 候选人对象
|
||||
* @param {object} config - 配置,含 autoChat.greetingMessage、autoChat.delayBetweenChats [min, max]
|
||||
* @param {object} hooks - tapable hooks:beforeStartChat、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
|
||||
}
|
||||
720
packages/boss-auto-browse-and-chat/chat-page-processor.mjs
Normal file
720
packages/boss-auto-browse-and-chat/chat-page-processor.mjs
Normal file
@@ -0,0 +1,720 @@
|
||||
/**
|
||||
* 沟通页自动化:处理未读会话 — 对方发来附件简历则下载,对方打招呼则看在线简历后关键词/LLM 筛选,通过则请求附件简历。
|
||||
* 所有页面点击均通过 createHumanCursor,使用 sleepWithRandomDelay 做随机延迟。
|
||||
*/
|
||||
|
||||
import { sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs'
|
||||
import { readConfigFile, getMergedJobConfig } from './runtime-file-utils.mjs'
|
||||
import { setupNetworkInterceptor, parseGeekInfoFromIntercepted } from './resume-extractor.mjs'
|
||||
import { createHumanCursor } from './humanMouse.mjs'
|
||||
import {
|
||||
openOnlineResume,
|
||||
getOnlineResumeText,
|
||||
requestAttachmentResume,
|
||||
openPreviewAndDownloadPdf,
|
||||
hasIncomingAttachResumeRequest,
|
||||
acceptIncomingAttachResume
|
||||
} from './chat-page-resume.mjs'
|
||||
import { filterCandidates } from './candidate-processor.mjs'
|
||||
import { checkIfAlreadyContacted, saveCandidateInfo, logContact } from './data-manager.mjs'
|
||||
import { setLevel, debug as logDebug, info as logInfo, warn as logWarn } from './logger.mjs'
|
||||
import {
|
||||
BOSS_CHAT_PAGE_URL,
|
||||
CHAT_PAGE_ACTIVE_NAME_SELECTOR,
|
||||
CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR,
|
||||
CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR,
|
||||
CHAT_PAGE_ITEM_SELECTOR,
|
||||
CHAT_PAGE_ITEM_UNREAD_SELECTOR,
|
||||
CHAT_PAGE_UNREAD_FILTER_SELECTOR,
|
||||
CHAT_PAGE_NAME_SELECTOR,
|
||||
CHAT_PAGE_JOB_SELECTOR,
|
||||
CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR,
|
||||
CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR
|
||||
} from './constant.mjs'
|
||||
|
||||
const LOG = '[chat-page-processor]'
|
||||
|
||||
/**
|
||||
* 在沟通页切换到指定职位。
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {string} jobId
|
||||
*/
|
||||
async function switchChatPageJobId (page, jobId) {
|
||||
try {
|
||||
await page.click('.ui-dropmenu.chat-top-job .ui-dropmenu-label')
|
||||
await page.waitForSelector('.ui-dropmenu.chat-top-job .ui-dropmenu-list', { timeout: 5000 })
|
||||
const found = await page.evaluate((jid) => {
|
||||
const item = document.querySelector(`.ui-dropmenu.chat-top-job .ui-dropmenu-list li[value="${jid}"]`)
|
||||
if (!item) return false
|
||||
item.click()
|
||||
return true
|
||||
}, jobId)
|
||||
if (!found) {
|
||||
logWarn(`${LOG} 职位 ${jobId} 未在沟通页下拉列表中找到,将使用默认职位继续`)
|
||||
await page.keyboard.press('Escape')
|
||||
return
|
||||
}
|
||||
// 等待左侧会话列表刷新
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
logInfo(`${LOG} 已切换到职位 ${jobId}`)
|
||||
} catch (e) {
|
||||
logWarn(`${LOG} 切换沟通页职位失败(${e.message}),将使用默认职位继续`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从完整简历文本中提取候选人姓名。
|
||||
* BOSS 在线简历开头格式:[活跃状态] [姓名] [年龄/简历...]
|
||||
* 活跃状态和姓名可能在不同行(y坐标不同),所以必须对全文按任意空白分割,
|
||||
* 而不能逐行匹配。第 0 个 token = 活跃状态,第 1 个 token = 姓名。
|
||||
* @param {string} text - 完整简历文本(含 \n)
|
||||
* @returns {string|null} 识别到的姓名,或 null
|
||||
*/
|
||||
function extractNameFromResumeText (text) {
|
||||
const tokens = text.trim().split(/\s+/).filter(t => t.length > 0)
|
||||
if (tokens.length < 1) return null
|
||||
// 情况一:tokens[0] 是活跃状态(含"活跃"),tokens[1] 是姓名
|
||||
if (tokens.length >= 2 && /活跃/.test(tokens[0])) {
|
||||
return tokens[1]
|
||||
}
|
||||
// 情况二:无活跃状态前缀,第一个 token 本身是姓名(2-4个汉字,无数字)
|
||||
if (/^[\u4e00-\u9fff]{2,4}$/.test(tokens[0])) {
|
||||
return tokens[0]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除 canvas 简历文本开头的字体渲染预热数据。
|
||||
* BOSS 在 iframe 首次加载时会把所有 ASCII 可打印字符逐一 fillText 做字形预热("bzl|abcde..."),
|
||||
* 这些数据在 extractResumeText 排序后会出现在真正简历内容之前。
|
||||
* 定位到首个含 "活跃" 的行,之前的行全部丢弃。
|
||||
* @param {string[]} lines - extractResumeText 返回的行数组
|
||||
* @returns {string[]} 去除预热数据后的行数组
|
||||
*/
|
||||
function filterFontTestLines (lines) {
|
||||
const idx = lines.findIndex(line => line.includes('活跃'))
|
||||
if (idx > 0) return lines.slice(idx)
|
||||
// 兜底:丢弃不含任何汉字的行
|
||||
return lines.filter(line => /[\u4e00-\u9fff]/.test(line))
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 LLM 根据规则筛选简历。
|
||||
* 兼容 llm.json 为数组或 { configList } 格式,以及 providerCompleteApiUrl/providerApiSecret 字段名。
|
||||
* @param {string} resumeText - 简历全文
|
||||
* @param {string} llmRule - 筛选规则描述
|
||||
* @returns {Promise<{ pass: boolean, reason: string }>} 出错时默认 pass: true
|
||||
*/
|
||||
export async function screenCandidateWithLlm (resumeText, llmRule) {
|
||||
const defaultResult = { pass: true, reason: 'LLM 调用失败,默认通过' }
|
||||
try {
|
||||
const { getEnabledLlmClient } = await import('./llm-rubric.mjs')
|
||||
const client = getEnabledLlmClient()
|
||||
if (!client) return defaultResult
|
||||
|
||||
const { completes } = await import('@geekgeekrun/utils/gpt-request.mjs')
|
||||
const systemContent = `你是一个招聘筛选助手。根据以下筛选规则,判断候选人简历是否符合要求。
|
||||
筛选规则:${llmRule || '无'}
|
||||
请仅以JSON格式回复,不要包含其他内容。格式:{"pass": true或false, "reason": "判断理由"}`
|
||||
const completion = await completes(
|
||||
{
|
||||
baseURL: client.baseURL,
|
||||
apiKey: client.apiKey,
|
||||
model: client.model,
|
||||
max_tokens: 200
|
||||
},
|
||||
[
|
||||
{ role: 'system', content: systemContent },
|
||||
{ role: 'user', content: (resumeText || '(无简历内容)').slice(0, 3500) }
|
||||
]
|
||||
)
|
||||
const raw = completion?.choices?.[0]?.message?.content?.trim()
|
||||
if (!raw) return defaultResult
|
||||
const jsonStr = raw.replace(/^[\s\S]*?(\{[\s\S]*\})[\s\S]*$/, '$1')
|
||||
const parsed = JSON.parse(jsonStr)
|
||||
const pass = !!parsed.pass
|
||||
const reason = typeof parsed.reason === 'string' ? parsed.reason : ''
|
||||
return { pass, reason }
|
||||
} catch (err) {
|
||||
logWarn(`${LOG} screenCandidateWithLlm 失败:`, err.message)
|
||||
return defaultResult
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析沟通页左侧会话列表(仅当前 DOM 可见的项)。
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @returns {Promise<Array<{ encryptGeekId: string, geekName: string, jobTitle: string, unread: boolean, hasAttachmentResumeInChat: boolean }>>}
|
||||
*/
|
||||
async function parseConversationList (page) {
|
||||
const list = await page.evaluate(
|
||||
({ itemSel, nameSel, jobSel, unreadSel }) => {
|
||||
const items = document.querySelectorAll(itemSel)
|
||||
const result = []
|
||||
items.forEach((node) => {
|
||||
const nameEl = node.querySelector(nameSel)
|
||||
const jobEl = node.querySelector(jobSel)
|
||||
const geekName = nameEl?.textContent?.trim() ?? ''
|
||||
const jobTitle = jobEl?.textContent?.trim() ?? ''
|
||||
// encryptGeekId is in data-id="<geekId>-0" on the .geek-item element itself
|
||||
const dataId = node.getAttribute('data-id') ?? ''
|
||||
const encryptGeekId = dataId.replace(/-\d+$/, '')
|
||||
// unread: span.badge-count is present only when there are unread messages
|
||||
const unread = !!node.querySelector(unreadSel)
|
||||
result.push({
|
||||
encryptGeekId,
|
||||
geekName,
|
||||
jobTitle,
|
||||
unread,
|
||||
hasAttachmentResumeInChat: false
|
||||
})
|
||||
})
|
||||
return result
|
||||
},
|
||||
{
|
||||
itemSel: CHAT_PAGE_ITEM_SELECTOR,
|
||||
nameSel: CHAT_PAGE_NAME_SELECTOR,
|
||||
jobSel: CHAT_PAGE_JOB_SELECTOR,
|
||||
unreadSel: CHAT_PAGE_ITEM_UNREAD_SELECTOR
|
||||
}
|
||||
)
|
||||
return list
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击左侧某条会话(通过 encryptGeekId),使右侧显示该会话。使用拟人点击。
|
||||
* 若 data-id 找不到(虚拟滚动已卸载),等待短暂后重试一次。
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {string} encryptGeekId - 候选人 ID(对应 .geek-item[data-id="<id>-0"])
|
||||
* @param {{ cursor?: object }} [options]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function selectConversationById (page, encryptGeekId, options = {}) {
|
||||
const cursor = options.cursor ?? await createHumanCursor(page)
|
||||
const selector = `${CHAT_PAGE_ITEM_SELECTOR}[data-id="${encryptGeekId}-0"]`
|
||||
|
||||
let el = await page.$(selector)
|
||||
if (!el) {
|
||||
// 虚拟滚动可能已将该 item 滚出视口并卸载,尝试滚动列表顶部后重查
|
||||
logDebug(`${LOG} → data-id 未找到,尝试滚回列表顶部后重查...`)
|
||||
await page.evaluate((listSel) => {
|
||||
const listEl = document.querySelector(listSel)
|
||||
if (listEl) listEl.scrollTop = 0
|
||||
}, CHAT_PAGE_ITEM_SELECTOR.split(' ')[0])
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
el = await page.$(selector)
|
||||
}
|
||||
|
||||
if (!el) return false
|
||||
|
||||
// 检查元素是否在会话列表容器的可见区域内;若超出则滚动容器让其进入可见区
|
||||
// 注意:元素有 boundingBox 但 Y 坐标可能超出容器裁剪区,点击会打到容器外的其他元素
|
||||
const scrolled = await page.evaluate((itemSel, targetId) => {
|
||||
const container = document.querySelector('.user-list.b-scroll-stable')
|
||||
if (!container) return false
|
||||
const item = document.querySelector(`${itemSel}[data-id="${targetId}-0"]`)
|
||||
if (!item) return false
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const itemRect = item.getBoundingClientRect()
|
||||
const isVisible = itemRect.top >= containerRect.top && itemRect.bottom <= containerRect.bottom
|
||||
if (!isVisible) {
|
||||
item.scrollIntoView({ block: 'nearest' })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, CHAT_PAGE_ITEM_SELECTOR, encryptGeekId)
|
||||
|
||||
if (scrolled) {
|
||||
logDebug(`${LOG} → 元素超出列表可见区,已滚动至可见`)
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
// 重新获取元素引用(scrollIntoView 后 DOM 引用仍有效,但坐标已变)
|
||||
el = await page.$(selector)
|
||||
if (!el) return false
|
||||
}
|
||||
|
||||
const box = await el.boundingBox()
|
||||
if (!box) {
|
||||
logWarn(`${LOG} → 滚动后仍无法获取坐标,跳过`)
|
||||
return false
|
||||
}
|
||||
logDebug(`${LOG} → 找到会话元素,坐标 (${Math.round(box.x + box.width / 2)}, ${Math.round(box.y + box.height / 2)}),执行拟人点击`)
|
||||
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前右侧会话中,是否存在含"点击预览附件简历"按钮的消息(对方已发来附件)。
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function hasAttachmentResumeInCurrentChat (page) {
|
||||
const btn = await page.$(CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR).catch(() => null)
|
||||
return !!btn
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 等待 geek/info 数据出现在拦截 Map 中(点击会话后异步到达)
|
||||
* @param {() => Map<string, unknown>} peekFn - 不清空的 peek 函数
|
||||
* @param {{ timeoutMs?: number, pollMs?: number }} [opts]
|
||||
* @returns {Promise<Map<string, unknown> | null>} 超时返回 null
|
||||
*/
|
||||
async function waitForGeekInfo (peekFn, opts = {}) {
|
||||
const timeoutMs = opts.timeoutMs ?? 5000
|
||||
const pollMs = opts.pollMs ?? 200
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
const snap = peekFn()
|
||||
for (const path of snap.keys()) {
|
||||
if (path.includes('geek/info')) return snap
|
||||
}
|
||||
await new Promise(r => setTimeout(r, pollMs))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 沟通页自动化主入口。
|
||||
* @param {object} hooksFromCaller - 与 startBossAutoBrowse 相同的 hooks(onError, insertCandidateContactLog, createOrUpdateCandidateInfo, queryCandidateByEncryptId 等)
|
||||
* @param {{ browser?: import('puppeteer').Browser, page?: import('puppeteer').Page }} [options] - 若传入则复用已有 browser/page,否则内部不启动浏览器(调用方需先导航到沟通页或由推荐页流程传入)
|
||||
*/
|
||||
export default async function startBossChatPageProcess (hooksFromCaller, options = {}) {
|
||||
const hooks = hooksFromCaller || {}
|
||||
const { page: existingPage, getCapturedText, clearCapturedText, jobId = null } = options
|
||||
|
||||
/** @type {import('puppeteer').Page} */
|
||||
let page = existingPage
|
||||
if (!page) {
|
||||
logInfo(`${LOG} 未传入 page,跳过沟通页处理。`)
|
||||
return
|
||||
}
|
||||
|
||||
const baseConfig = readConfigFile('boss-recruiter.json') || {}
|
||||
const config = jobId ? getMergedJobConfig(jobId) : { ...baseConfig, chatPage: baseConfig.chatPage }
|
||||
setLevel(config.logLevel || 'info')
|
||||
const chatPageConfig = config.chatPage || {}
|
||||
if (chatPageConfig.enabled === false) {
|
||||
logInfo(`${LOG} 沟通页处理已关闭,跳过。`)
|
||||
return
|
||||
}
|
||||
|
||||
const maxProcessPerRun = chatPageConfig.maxProcessPerRun ?? 20
|
||||
const preFilterConf = chatPageConfig.preFilter || {}
|
||||
const filterConf = chatPageConfig.filter || {}
|
||||
// 当 BOSS 直聘已配置「接收附件简历自动发到邮箱」时,可将此开关设为 true,
|
||||
// 系统将仅发出索取请求,不再打开预览弹窗下载 PDF(webhook 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} 网络拦截器已设置,等待会话列表渲染...`)
|
||||
|
||||
// 等待虚拟滚动列表渲染出至少一条会话 item(readyState='complete' 不够,Vue 组件需额外时间)
|
||||
try {
|
||||
await page.waitForSelector(CHAT_PAGE_ITEM_SELECTOR, { timeout: 15000 })
|
||||
logDebug(`${LOG} 会话列表元素已出现`)
|
||||
} catch {
|
||||
logWarn(`${LOG} 等待会话列表超时(15s),列表可能为空`)
|
||||
}
|
||||
|
||||
// 切换职位(若指定了 jobId 且非全部职位标志)
|
||||
if (jobId && jobId !== '-1') {
|
||||
await switchChatPageJobId(page, jobId)
|
||||
}
|
||||
|
||||
// 切换到"未读"tab,确保虚拟滚动列表只包含未读会话(避免全部模式下未读项在视口外未渲染)
|
||||
const cursor = await createHumanCursor(page)
|
||||
const unreadTabActive = await page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel)
|
||||
return el ? el.classList.contains('active') : false
|
||||
}, CHAT_PAGE_UNREAD_FILTER_SELECTOR)
|
||||
if (!unreadTabActive) {
|
||||
logInfo(`${LOG} 切换到「未读」tab...`)
|
||||
const tabEl = await page.$(CHAT_PAGE_UNREAD_FILTER_SELECTOR)
|
||||
if (tabEl) {
|
||||
const box = await tabEl.boundingBox()
|
||||
if (box) {
|
||||
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
|
||||
await sleepWithRandomDelay(400, 600)
|
||||
// 等列表刷新
|
||||
try {
|
||||
await page.waitForSelector(CHAT_PAGE_ITEM_SELECTOR, { timeout: 5000 })
|
||||
logDebug(`${LOG} 「未读」tab 切换后列表已刷新`)
|
||||
} catch {
|
||||
logDebug(`${LOG} 「未读」tab 切换后列表为空(无未读会话)`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logWarn(`${LOG} 未找到「未读」tab 元素(selector: ${CHAT_PAGE_UNREAD_FILTER_SELECTOR})`)
|
||||
}
|
||||
} else {
|
||||
logDebug(`${LOG} 已在「未读」tab`)
|
||||
}
|
||||
await sleepWithRandomDelay(300)
|
||||
|
||||
const conversations = await parseConversationList(page)
|
||||
logDebug(`${LOG} DOM 解析到 ${conversations.length} 条会话`)
|
||||
|
||||
// 已切到"未读"tab,列表中所有 item 均为未读会话,无需再用 badge-count 二次过滤
|
||||
const unreadItems = conversations.filter((c) => c.encryptGeekId)
|
||||
const toProcess = unreadItems.slice(0, maxProcessPerRun)
|
||||
logInfo(`${LOG} 未读会话 ${unreadItems.length} 条,本次最多处理 ${toProcess.length} 条`)
|
||||
if (toProcess.length > 0) {
|
||||
logDebug(`${LOG} 候选人列表:${toProcess.map((c, i) => `[${i}] ${c.geekName}(${c.encryptGeekId})`).join(', ')}`)
|
||||
}
|
||||
|
||||
await hooks.onProgress?.promise?.({ phase: 'chatPage', current: 0, max: toProcess.length }).catch(() => {})
|
||||
|
||||
let processed = 0
|
||||
|
||||
for (let i = 0; i < toProcess.length; i++) {
|
||||
const item = toProcess[i]
|
||||
const { encryptGeekId, geekName, jobTitle } = item
|
||||
logInfo(`${LOG} ── [${i + 1}/${toProcess.length}] 开始处理 ${geekName}(${encryptGeekId})──`)
|
||||
|
||||
const { contacted } = await checkIfAlreadyContacted(encryptGeekId, hooks)
|
||||
if (contacted) {
|
||||
logInfo(`${LOG} → 已在数据库中联系过,跳过`)
|
||||
continue
|
||||
}
|
||||
logDebug(`${LOG} → 数据库未记录,继续处理`)
|
||||
|
||||
// 切换会话前必须确保在线简历弹窗已关闭。
|
||||
// 弹窗遮挡会导致下方会话列表的点击被拦截,使会话无法切换(右侧面板仍显示上一个人),
|
||||
// 进而导致打开的在线简历是上一个候选人的数据。
|
||||
{
|
||||
const resumeCloseBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null)
|
||||
if (resumeCloseBtn) {
|
||||
logDebug(`${LOG} → 检测到在线简历弹窗未关闭,点击关闭...`)
|
||||
await page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => {})
|
||||
try {
|
||||
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 4000 })
|
||||
logDebug(`${LOG} → 在线简历弹窗已关闭`)
|
||||
} catch {
|
||||
// 仍未消失则强制刷新检查
|
||||
const stillOpen = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null)
|
||||
if (stillOpen) {
|
||||
logWarn(`${LOG} → 在线简历弹窗关闭失败(4s 超时),继续尝试切换会话,但可能影响会话切换成功率`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 点击前先清空拦截数据,确保拿到的是本次点击触发的 geek/info(BOSS 有缓存时可能不重新请求)
|
||||
getInterceptedData()
|
||||
logDebug(`${LOG} → 点击会话...`)
|
||||
const selected = await selectConversationById(page, encryptGeekId, { cursor })
|
||||
if (!selected) {
|
||||
logWarn(`${LOG} → 无法在 DOM 中找到该会话(可能已被标为已读或滚出虚拟滚动视口),跳过`)
|
||||
continue
|
||||
}
|
||||
logInfo(`${LOG} → 会话已选中,等待页面加载...`)
|
||||
await sleepWithRandomDelay(600, 1200)
|
||||
|
||||
// 验证右侧面板已切换到目标候选人(防止会话点击未生效、面板仍停留在上一人)
|
||||
{
|
||||
const panelName = await page.$eval(CHAT_PAGE_ACTIVE_NAME_SELECTOR, el => el.textContent?.trim() ?? '').catch(() => '')
|
||||
if (panelName && !geekName.includes(panelName) && !panelName.includes(geekName)) {
|
||||
logWarn(`${LOG} → 右侧面板姓名「${panelName}」与期望「${geekName}」不符,会话切换未生效,跳过`)
|
||||
await sleepWithRandomDelay(300, 600)
|
||||
continue
|
||||
}
|
||||
if (panelName) {
|
||||
logDebug(`${LOG} → 右侧面板验证:「${panelName}」✓`)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭「意向沟通」提示弹窗(BOSS 每次新浏览器会话打开某些会话时会弹出,遮挡右侧操作按钮)
|
||||
{
|
||||
const intentKnowBtn = await page.$(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR).catch(() => null)
|
||||
if (intentKnowBtn) {
|
||||
logInfo(`${LOG} → 检测到「意向沟通」提示弹窗,点击「我知道了」关闭...`)
|
||||
try {
|
||||
await page.click(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR)
|
||||
} catch {
|
||||
// 备用:点关闭图标
|
||||
await page.click(CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR).catch(() => {})
|
||||
}
|
||||
// 等弹窗消失(点击后 Vue 会移除 DOM)
|
||||
try {
|
||||
await page.waitForSelector(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, { hidden: true, timeout: 3000 })
|
||||
logDebug(`${LOG} → 「意向沟通」弹窗已关闭`)
|
||||
} catch {
|
||||
logWarn(`${LOG} → 「意向沟通」弹窗 3s 内未消失,继续执行(可能影响按钮点击)`)
|
||||
}
|
||||
await sleepWithRandomDelay(200, 400)
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段一:初步信息筛选(点击会话后 geek/info 已触发,从拦截数据取结构化字段)
|
||||
// 注意:使用 peekInterceptedData(不清空)而非 getInterceptedData(清空),避免数据被消费后简历筛选阶段拿不到
|
||||
if (hasPreFilter) {
|
||||
logDebug(`${LOG} → 等待 geek/info 数据(初步筛选,最长 5s)...`)
|
||||
let geekInfoSnap = await waitForGeekInfo(peekInterceptedData, { timeoutMs: 5000 })
|
||||
if (!geekInfoSnap) {
|
||||
// geek/info 未到达,可能是 BOSS 缓存导致未重新请求;重新点击一次会话触发请求
|
||||
logWarn(`${LOG} → geek/info 未到达,重试点击会话...`)
|
||||
getInterceptedData()
|
||||
const retrySelected = await selectConversationById(page, encryptGeekId, { cursor })
|
||||
if (retrySelected) {
|
||||
await sleepWithRandomDelay(400, 800)
|
||||
geekInfoSnap = await waitForGeekInfo(peekInterceptedData, { timeoutMs: 5000 })
|
||||
}
|
||||
}
|
||||
if (geekInfoSnap) {
|
||||
logDebug(`${LOG} → geek/info 已到达,解析中...`)
|
||||
const { data: geekInfoData } = parseGeekInfoFromIntercepted(geekInfoSnap)
|
||||
if (geekInfoData) {
|
||||
logInfo(`${LOG} → geek/info 摘要:学历=${geekInfoData.edu} 工龄=${geekInfoData.workYear} 城市=${geekInfoData.city} 薪资=${geekInfoData.salaryDesc ?? geekInfoData.price}`)
|
||||
const candidateForFilter = {
|
||||
encryptGeekId,
|
||||
geekName,
|
||||
education: geekInfoData.edu ?? null,
|
||||
workExp: geekInfoData.workYear ?? null,
|
||||
city: geekInfoData.city ?? null,
|
||||
salary: geekInfoData.salaryDesc ?? geekInfoData.price ?? null
|
||||
}
|
||||
const { skipped } = filterCandidates([candidateForFilter], preFilterConf)
|
||||
if (skipped.length > 0) {
|
||||
const reason = skipped[0].filterResult.reasonDetail || skipped[0].filterResult.reason
|
||||
logInfo(`${LOG} → 初步信息筛选不通过:${reason},跳过`)
|
||||
await logContact(encryptGeekId, 'resume_screened_out', null, `preFilter:${reason}`, hooks)
|
||||
await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'screened_out' }, hooks)
|
||||
// 消费并清空拦截数据,准备下一个候选人
|
||||
getInterceptedData()
|
||||
await sleepWithRandomDelay(300, 600)
|
||||
continue
|
||||
}
|
||||
logInfo(`${LOG} → 初步信息筛选通过`)
|
||||
} else {
|
||||
logDebug(`${LOG} → geek/info 响应无结构化数据(zpData.data 为空),跳过初步筛选`)
|
||||
}
|
||||
} else {
|
||||
logWarn(`${LOG} → 等待 geek/info 超时(重试后仍未到达),跳过初步筛选`)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查候选人是否主动发来了附件简历请求("同意/拒绝"提示),若有则自动同意
|
||||
const hasIncoming = await hasIncomingAttachResumeRequest(page)
|
||||
if (hasIncoming) {
|
||||
logInfo(`${LOG} → 检测到对方主动发送附件简历请求,自动点击"同意"...`)
|
||||
const accepted = await acceptIncomingAttachResume(page, { cursor })
|
||||
if (accepted) {
|
||||
logInfo(`${LOG} → 已同意对方发送附件简历`)
|
||||
await logContact(encryptGeekId, 'attachment_resume_accepted_incoming', null, 'success', hooks)
|
||||
} else {
|
||||
logWarn(`${LOG} → 点击"同意"失败(按钮未找到或不可见)`)
|
||||
}
|
||||
}
|
||||
|
||||
// 先检查:对方是否已发来附件简历消息(我方此前请求已被对方同意,或上方同意后出现预览按钮)
|
||||
const hasAttachment = await hasAttachmentResumeInCurrentChat(page)
|
||||
logInfo(`${LOG} → 附件简历检查:${hasAttachment ? '已有(对方已发来附件)' : '无'}`)
|
||||
|
||||
if (hasAttachment) {
|
||||
if (skipAttachmentResumeDownload) {
|
||||
logInfo(`${LOG} → 已有附件简历,但 skipDownload=true(已配置自动发邮箱),跳过 PDF 下载`)
|
||||
} else {
|
||||
logInfo(`${LOG} → 下载附件简历...`)
|
||||
const { clickedDownload } = await openPreviewAndDownloadPdf(page, null, { cursor })
|
||||
if (clickedDownload) {
|
||||
logInfo(`${LOG} → 附件简历下载成功`)
|
||||
await logContact(encryptGeekId, 'attachment_resume_downloaded', null, 'success', hooks)
|
||||
} else {
|
||||
logWarn(`${LOG} → 附件简历下载失败(未找到下载按钮)`)
|
||||
}
|
||||
}
|
||||
await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'contacted' }, hooks)
|
||||
processed++
|
||||
await hooks.onProgress?.promise?.({ phase: 'chatPage', current: processed, max: toProcess.length }).catch(() => {})
|
||||
getInterceptedData()
|
||||
await sleepWithRandomDelay(1000, 2500)
|
||||
continue
|
||||
}
|
||||
|
||||
// 无附件简历 → 说明对方只是打招呼,需要我方先筛选再决定是否索取
|
||||
// 点击「查看在线简历」(等 iframe 出现即视为打开成功,geek/info 数据点击会话时已触发,复用之)
|
||||
logInfo(`${LOG} → 对方打招呼,点击查看在线简历...`)
|
||||
// 打开新简历前清空残留数据(主要清除 geek/info 等待阶段积累的旧 iframe postMessage)
|
||||
if (typeof clearCapturedText === 'function') {
|
||||
await clearCapturedText(page)
|
||||
}
|
||||
// 传入 clearCapturedText,在旧弹窗关闭后、新简历点击前再清一次(关闭过程中可能有新一批旧数据到达)
|
||||
const openedResume = await openOnlineResume(page, { cursor, clearCapturedText: clearCapturedText || undefined })
|
||||
if (!openedResume) {
|
||||
logWarn(`${LOG} → 未找到「查看在线简历」按钮或 iframe 未出现,跳过`)
|
||||
await saveCandidateInfo({ encryptGeekId, geekName, jobTitle, status: 'viewed' }, hooks)
|
||||
getInterceptedData()
|
||||
await sleepWithRandomDelay(500, 1000)
|
||||
continue
|
||||
}
|
||||
logInfo(`${LOG} → 在线简历 iframe 已出现,等待 Canvas 渲染完成...`)
|
||||
let resumeText = ''
|
||||
if (typeof getCapturedText === 'function') {
|
||||
const { extractResumeText } = await import('./resume-extractor.mjs')
|
||||
|
||||
// 稳定轮询:连续两次 peek 到相同数量(且 > 0)视为渲染完成
|
||||
// WASM 通常在 iframe 出现后 1~2s 内完成全部渲染
|
||||
const POLL_INTERVAL_MS = 400
|
||||
const STABLE_POLLS_NEEDED = 2
|
||||
const CANVAS_POLL_TIMEOUT = 8000
|
||||
const canvasDeadline = Date.now() + CANVAS_POLL_TIMEOUT
|
||||
let lastCount = -1
|
||||
let stableCount = 0
|
||||
while (Date.now() < canvasDeadline) {
|
||||
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS))
|
||||
const currentCount = await page.evaluate(() => (window.__canvasCapturedText || []).length)
|
||||
if (currentCount > 0 && currentCount === lastCount) {
|
||||
stableCount++
|
||||
if (stableCount >= STABLE_POLLS_NEEDED) break
|
||||
} else {
|
||||
stableCount = currentCount > 0 ? 1 : 0
|
||||
}
|
||||
lastCount = currentCount
|
||||
}
|
||||
|
||||
// 最终读取并清空(getCapturedText 内部再等 150ms 确保最后一批 postMessage 已到)
|
||||
const captured = await getCapturedText(page)
|
||||
const rawLines = extractResumeText(captured)
|
||||
// 去除 BOSS 字体预热数据(首次 iframe 加载时 "bzl|abcdef..." 类的 ASCII 行)
|
||||
const lines = filterFontTestLines(rawLines)
|
||||
resumeText = lines.join('\n')
|
||||
logInfo(`${LOG} → Canvas 抓取完成,共 ${captured.length} 次 fillText 调用,文本 ${resumeText.length} 字(原始行 ${rawLines.length},过滤后 ${lines.length})`)
|
||||
|
||||
if (captured.length === 0) {
|
||||
logWarn(`${LOG} → Canvas 为空(等待 ${CANVAS_POLL_TIMEOUT}ms 超时),降级使用 geek/info 摘要...`)
|
||||
const out = await getOnlineResumeText(page, { getInterceptedData })
|
||||
resumeText = out.text
|
||||
logInfo(`${LOG} → geek/info 摘要文本 ${resumeText.length} 字`)
|
||||
} else {
|
||||
// 验证简历内容是否属于当前候选人。
|
||||
// geekName 已知,直接用 includes 最可靠;extractNameFromResumeText 仅用于 warn 消息。
|
||||
const detectedName = extractNameFromResumeText(resumeText)
|
||||
logDebug(`${LOG} → 简历姓名识别:${detectedName || '(未识别)'}(期望:${geekName})`)
|
||||
if (!resumeText.includes(geekName)) {
|
||||
logWarn(`${LOG} → [简历不匹配] 期望: ${geekName},简历检测到: ${detectedName || '(未识别)'}`)
|
||||
logWarn(`${LOG} → 右侧面板未切换到本会话(geek/info 超时或被安全验证打断),跳过,下次运行时重试`)
|
||||
getInterceptedData()
|
||||
await sleepWithRandomDelay(300, 600)
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logWarn(`${LOG} → 无 Canvas hook,从 geek/info 摘要提取...`)
|
||||
const out = await getOnlineResumeText(page, { getInterceptedData })
|
||||
resumeText = out.text
|
||||
logInfo(`${LOG} → geek/info 摘要文本 ${resumeText.length} 字`)
|
||||
}
|
||||
|
||||
if (!resumeText) {
|
||||
logWarn(`${LOG} → 简历文本为空(geek/info 未到达或 zpData.data 无内容)`)
|
||||
} else {
|
||||
logDebug(`${LOG} → 简历文本(前200字):${resumeText.slice(0, 200).replace(/\n/g, ' ')}`)
|
||||
logInfo(`${LOG} → 简历文本获取成功(共 ${resumeText.length} 字)`)
|
||||
}
|
||||
|
||||
let pass = true
|
||||
let filterReason = ''
|
||||
|
||||
if (mode === 'keywords') {
|
||||
const normalized = (resumeText || '').toLowerCase()
|
||||
const hasKeyword = keywordList.length === 0 || keywordList.some((kw) => normalized.includes((kw || '').toLowerCase()))
|
||||
pass = hasKeyword
|
||||
filterReason = hasKeyword ? '' : `关键词未匹配(列表:${keywordList.join('、')})`
|
||||
logInfo(`${LOG} → 关键词筛选:${pass ? '通过' : filterReason}`)
|
||||
} else if (mode === 'llm') {
|
||||
logInfo(`${LOG} → LLM 筛选中...`)
|
||||
let result
|
||||
if (llmConfig?.rubric) {
|
||||
const { evaluateResumeByRubric } = await import('./llm-rubric.mjs')
|
||||
result = await evaluateResumeByRubric(resumeText, {
|
||||
knockouts: llmConfig.rubric?.knockouts,
|
||||
dimensions: llmConfig.rubric?.dimensions,
|
||||
passThreshold: llmConfig.passThreshold ?? 75
|
||||
})
|
||||
pass = result.isPassed
|
||||
filterReason = result.reason || ''
|
||||
logInfo(`${LOG} → LLM Rubric 筛选:${pass ? '通过' : '不通过'},得分:${result.totalScore},原因:${filterReason}`)
|
||||
} else {
|
||||
result = await screenCandidateWithLlm(resumeText, llmRule)
|
||||
pass = result.pass
|
||||
filterReason = result.reason || ''
|
||||
logInfo(`${LOG} → LLM 筛选:${pass ? '通过' : '不通过'},原因:${filterReason}`)
|
||||
}
|
||||
} else {
|
||||
logDebug(`${LOG} → 无筛选模式(mode=${mode}),默认通过`)
|
||||
}
|
||||
|
||||
if (pass) {
|
||||
logInfo(`${LOG} → 筛选通过,发送索取附件简历请求...`)
|
||||
// 在线简历弹窗若仍开着,会遮挡附件简历按钮导致点击失效,必须先完全关闭(含动画)
|
||||
const openResumeCloseBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null)
|
||||
if (openResumeCloseBtn) {
|
||||
logDebug(`${LOG} → 先关闭在线简历弹窗,避免遮挡附件简历按钮...`)
|
||||
await page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => {})
|
||||
try {
|
||||
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 3000 })
|
||||
logDebug(`${LOG} → 在线简历弹窗已关闭`)
|
||||
} catch {
|
||||
logWarn(`${LOG} → 在线简历弹窗关闭超时,继续尝试(可能影响附件简历按钮点击)`)
|
||||
}
|
||||
// 等待弹窗 CSS 动画完全结束,确保不再遮挡下方按钮
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
}
|
||||
const { requested, error } = await requestAttachmentResume(page, { cursor })
|
||||
if (requested) {
|
||||
logInfo(`${LOG} → 附件简历索取请求已发送`)
|
||||
await logContact(encryptGeekId, 'attachment_resume_requested', null, 'success', hooks)
|
||||
} else {
|
||||
logWarn(`${LOG} → 附件简历索取失败:${error}`)
|
||||
await logContact(encryptGeekId, 'attachment_resume_requested', null, error || 'failed', hooks)
|
||||
}
|
||||
} else {
|
||||
logInfo(`${LOG} → 筛选不通过(${filterReason}),跳过`)
|
||||
await logContact(encryptGeekId, 'resume_screened_out', null, filterReason || 'screened_out', hooks)
|
||||
}
|
||||
|
||||
await saveCandidateInfo(
|
||||
{
|
||||
encryptGeekId,
|
||||
geekName,
|
||||
jobTitle,
|
||||
status: pass ? 'contacted' : 'screened_out',
|
||||
rawData: { resumeText: (resumeText || '').slice(0, 2000) }
|
||||
},
|
||||
hooks
|
||||
)
|
||||
processed++
|
||||
await hooks.onProgress?.promise?.({ phase: 'chatPage', current: processed, max: toProcess.length }).catch(() => {})
|
||||
getInterceptedData()
|
||||
await sleepWithRandomDelay(1000, 2500)
|
||||
}
|
||||
|
||||
logInfo(`${LOG} 本次共处理 ${processed} 条未读会话`)
|
||||
} catch (err) {
|
||||
await hooks.onError?.promise?.(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
393
packages/boss-auto-browse-and-chat/chat-page-resume.mjs
Normal file
393
packages/boss-auto-browse-and-chat/chat-page-resume.mjs
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* 沟通页:先看在线简历 → 提取文字供关键词/LLM 筛选 → 再请求附件简历
|
||||
*
|
||||
* 在线简历有两套数据(详见 plan/chat_page_resume_flow.md):
|
||||
* - 简单摘要:geek/info、historyMsg body.resume,仅工作单位/学校等,可拦截 API 得到。
|
||||
* - 完整版(图片里那种):加密 → WASM 解密 → 仅画到 #resume Canvas,无明文 API;要完整版需 Canvas hook 或逆向 WASM。
|
||||
*
|
||||
* 看在线简历无需对方同意;下载 PDF 需先请求附件简历,等对方同意后在新消息里点预览→下载。
|
||||
*
|
||||
* 所有在招聘端页面上的点击均通过 humanMouse 拟人轨迹执行,规避 BOSS 鼠标埋点。
|
||||
*/
|
||||
|
||||
import { extractResumeText, parseGeekInfoFromIntercepted } from './resume-extractor.mjs'
|
||||
import { sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs'
|
||||
import { createHumanCursor } from './humanMouse.mjs'
|
||||
import {
|
||||
CHAT_PAGE_ONLINE_RESUME_SELECTOR,
|
||||
CHAT_PAGE_ONLINE_RESUME_IFRAME_SELECTOR,
|
||||
CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR,
|
||||
CHAT_PAGE_ATTACH_RESUME_BTN_SELECTOR,
|
||||
CHAT_PAGE_ASK_RESUME_CONFIRM_BTN_SELECTOR,
|
||||
CHAT_PAGE_MESSAGE_ITEM_SELECTOR,
|
||||
CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR,
|
||||
CHAT_PAGE_ACCEPT_ATTACH_RESUME_BTN_SELECTOR,
|
||||
CHAT_PAGE_DOWNLOAD_PDF_BTN_SELECTOR,
|
||||
CHAT_PAGE_ATTACH_RESUME_DIALOG_CLOSE_SELECTOR,
|
||||
CHAT_PAGE_TAB_ALL_SELECTOR,
|
||||
CHAT_PAGE_ITEM_SELECTOR,
|
||||
CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR,
|
||||
CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR
|
||||
} from './constant.mjs'
|
||||
|
||||
/**
|
||||
* 点击"查看在线简历",等待简历内容区域(#resume)出现。
|
||||
* 调用前需已选中某条会话(右侧已为该候选人)。
|
||||
* 使用拟人轨迹点击,规避 BOSS 鼠标埋点。
|
||||
*
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
|
||||
* @param {{ timeout?: number, cursor?: object }} [options] - timeout 毫秒;cursor 可选,拟人光标(不传则内部创建)
|
||||
* @returns {Promise<boolean>} 是否成功打开并看到 #resume
|
||||
*/
|
||||
export async function openOnlineResume (page, options = {}) {
|
||||
const timeout = options.timeout ?? 10000
|
||||
const cursor = options.cursor ?? await createHumanCursor(page)
|
||||
// clearCapturedText 可选传入:关闭旧弹窗后调用,清空旧 iframe 在关闭过程中产生的残留 postMessage
|
||||
const clearCapturedText = typeof options.clearCapturedText === 'function' ? options.clearCapturedText : null
|
||||
|
||||
const btn = await page.$(CHAT_PAGE_ONLINE_RESUME_SELECTOR)
|
||||
if (!btn) {
|
||||
console.log('[openOnlineResume] 未找到在线简历按钮 (selector:', CHAT_PAGE_ONLINE_RESUME_SELECTOR, ')')
|
||||
return false
|
||||
}
|
||||
|
||||
// 若在线简历弹窗已打开,先关闭它(弹窗不会随切换候选人自动关闭)
|
||||
const closeBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR)
|
||||
if (closeBtn) {
|
||||
console.log('[openOnlineResume] 检测到旧简历弹窗,点击关闭按钮...')
|
||||
try {
|
||||
// 直接用 page.click,比坐标点击更可靠(不受 ghost-cursor 偏移影响)
|
||||
await page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR)
|
||||
} catch (e) {
|
||||
// 备用:坐标点击
|
||||
const closeBox = await closeBtn.boundingBox().catch(() => null)
|
||||
if (closeBox) {
|
||||
await cursor.click({ x: closeBox.x + closeBox.width / 2, y: closeBox.y + closeBox.height / 2 })
|
||||
}
|
||||
}
|
||||
// 等关闭按钮从 DOM 消失(即弹窗完全关闭),比等 iframe 消失更可靠
|
||||
try {
|
||||
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 4000 })
|
||||
console.log('[openOnlineResume] 旧简历弹窗已关闭')
|
||||
} catch {
|
||||
console.log('[openOnlineResume] 关闭按钮 4s 内未消失,继续执行')
|
||||
}
|
||||
// 关闭期间旧 iframe 可能还会发来残留 postMessage,在点开新简历前清掉
|
||||
if (clearCapturedText) {
|
||||
await clearCapturedText(page)
|
||||
console.log('[openOnlineResume] 旧弹窗关闭后残留 Canvas 数据已清空')
|
||||
}
|
||||
}
|
||||
|
||||
// 用坐标点击,更可靠
|
||||
const box = await btn.boundingBox()
|
||||
if (!box) {
|
||||
console.log('[openOnlineResume] 在线简历按钮无法获取坐标')
|
||||
return false
|
||||
}
|
||||
console.log('[openOnlineResume] 点击在线简历按钮,坐标:', Math.round(box.x + box.width / 2), Math.round(box.y + box.height / 2))
|
||||
await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 })
|
||||
|
||||
try {
|
||||
// #resume 在 iframe 内部,主页面 waitForSelector 找不到;改为等待 iframe 本身出现
|
||||
await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_IFRAME_SELECTOR, { timeout })
|
||||
console.log('[openOnlineResume] 在线简历 iframe 已出现')
|
||||
return true
|
||||
} catch {
|
||||
console.log('[openOnlineResume] 等待 iframe 超时 (', timeout, 'ms )')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从已拦截的网络数据中取沟通页在线简历(推荐)。
|
||||
* 在 openOnlineResume 之前调用 setupNetworkInterceptor(page),点开在线简历后页面会请求 geek/info,
|
||||
* 调用 getInterceptedData() 并传入本函数即可得到与 #resume 上简历内容一致的结构化数据与全文,无需 Canvas hook。
|
||||
*
|
||||
* @param {() => Map<string, unknown>} getInterceptedData - setupNetworkInterceptor 返回的 getInterceptedData
|
||||
* @returns {{ text: string, lines: string[], data: object | null }} data 为 zpData.data,text 为拼接全文,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] - 优先 getInterceptedData(API),否则 getCapturedText(Canvas)
|
||||
* @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 默认 5000;cursor 可选
|
||||
* @returns {Promise<{ requested: boolean, error?: string }>}
|
||||
*/
|
||||
export async function requestAttachmentResume (page, options = {}) {
|
||||
const confirmTimeout = options.confirmTimeout ?? 8000
|
||||
const cursor = options.cursor ?? await createHumanCursor(page)
|
||||
|
||||
// 请求前先检测并关闭 tutorial/意向沟通弹窗,避免遮挡附件简历按钮或误点
|
||||
const intentKnowBtn = await page.$(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR).catch(() => null)
|
||||
if (intentKnowBtn) {
|
||||
console.log('[requestAttachmentResume] 检测到意向沟通/tutorial 弹窗,先关闭...')
|
||||
try {
|
||||
await intentKnowBtn.click()
|
||||
await page.waitForSelector(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, { hidden: true, timeout: 3000 })
|
||||
console.log('[requestAttachmentResume] tutorial 弹窗已关闭')
|
||||
} catch {
|
||||
const closeIcon = await page.$(CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR).catch(() => null)
|
||||
if (closeIcon) await closeIcon.click().catch(() => {})
|
||||
}
|
||||
await sleepWithRandomDelay(300, 600)
|
||||
}
|
||||
|
||||
// 检查是否有残留的确认弹窗(上一个候选人流程遗留,v-if 未关闭)
|
||||
// 若存在则先等它消失;若等不到则视为卡死,直接报错,不继续执行
|
||||
const staleConfirm = await page.$(CHAT_PAGE_ASK_RESUME_CONFIRM_BTN_SELECTOR).catch(() => null)
|
||||
if (staleConfirm) {
|
||||
console.log('[requestAttachmentResume] 检测到残留的确认弹窗(上一次遗留),等待其消失(最长 3s)...')
|
||||
try {
|
||||
await page.waitForSelector(CHAT_PAGE_ASK_RESUME_CONFIRM_BTN_SELECTOR, { hidden: true, timeout: 3000 })
|
||||
console.log('[requestAttachmentResume] 残留弹窗已消失,继续')
|
||||
} catch {
|
||||
console.log('[requestAttachmentResume] 残留弹窗 3s 内未消失,跳过本次请求以避免误操作')
|
||||
return { requested: false, error: 'STALE_CONFIRM_DIALOG' }
|
||||
}
|
||||
}
|
||||
|
||||
// 找到附件简历按钮并用坐标点击
|
||||
const attachBtn = await page.$(CHAT_PAGE_ATTACH_RESUME_BTN_SELECTOR)
|
||||
if (!attachBtn) {
|
||||
console.log('[requestAttachmentResume] 未找到附件简历按钮 (selector:', CHAT_PAGE_ATTACH_RESUME_BTN_SELECTOR, ')')
|
||||
return { requested: false, error: 'ATTACH_RESUME_BUTTON_NOT_FOUND' }
|
||||
}
|
||||
const attachBox = await attachBtn.boundingBox()
|
||||
if (!attachBox) {
|
||||
console.log('[requestAttachmentResume] 附件简历按钮 boundingBox 为空(不在视口或不可见)')
|
||||
return { requested: false, error: 'ATTACH_RESUME_BUTTON_NOT_VISIBLE' }
|
||||
}
|
||||
console.log('[requestAttachmentResume] 点击附件简历按钮,坐标:', Math.round(attachBox.x + attachBox.width / 2), Math.round(attachBox.y + attachBox.height / 2))
|
||||
await cursor.click({ x: attachBox.x + attachBox.width / 2, y: attachBox.y + attachBox.height / 2 })
|
||||
// 等 Vue 响应点击事件并插入确认弹窗 DOM(v-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 总超时(默认 120000),pollIntervalMs 轮询间隔(默认 2000)
|
||||
* @returns {Promise<{ found: boolean, element?: import('puppeteer').ElementHandle }>} 若 found 为 true,element 为包含"点击预览附件简历"的那条消息的容器(可在此容器内点预览、再点下载)
|
||||
*/
|
||||
export async function waitForAttachmentResumeMessage (page, options = {}) {
|
||||
const timeout = options.timeout ?? 120000
|
||||
const pollIntervalMs = options.pollIntervalMs ?? 2000
|
||||
const deadline = Date.now() + timeout
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const msgItems = await page.$$(CHAT_PAGE_MESSAGE_ITEM_SELECTOR)
|
||||
for (const el of msgItems) {
|
||||
const hasPreview = await el.$(CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR).then(b => !!b).catch(() => false)
|
||||
if (hasPreview) {
|
||||
return { found: true, element: el }
|
||||
}
|
||||
}
|
||||
await new Promise(r => setTimeout(r, pollIntervalMs))
|
||||
}
|
||||
return { found: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* 在已拿到"带附件简历"的消息容器后,点击"点击预览附件简历"并等待预览弹窗出现,再点击"下载 PDF"。
|
||||
* 若需指定下载目录,可在调用前用 page._client.send('Page.setDownloadBehavior', ...) 等设置。
|
||||
* 使用拟人轨迹点击(预览按钮在消息容器内,用坐标点击;下载按钮用选择器)。
|
||||
*
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
|
||||
* @param {import('puppeteer').ElementHandle} [messageElement] - 包含预览按钮的那条消息容器;若不传则在当前对话里找第一条带预览按钮的消息
|
||||
* @param {{ previewTimeout?: number, downloadTimeout?: number, cursor?: object }} [options] - cursor 可选
|
||||
* @returns {Promise<{ clickedPreview: boolean, clickedDownload: boolean }>}
|
||||
*/
|
||||
export async function openPreviewAndDownloadPdf (page, messageElement, options = {}) {
|
||||
const previewTimeout = options.previewTimeout ?? 10000
|
||||
const cursor = options.cursor ?? await createHumanCursor(page)
|
||||
|
||||
let el = messageElement
|
||||
if (!el) {
|
||||
const { found, element } = await waitForAttachmentResumeMessage(page, { timeout: 5000 })
|
||||
if (!found || !element) return { clickedPreview: false, clickedDownload: false }
|
||||
el = element
|
||||
}
|
||||
|
||||
const previewBtn = await el.$(CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR)
|
||||
if (!previewBtn) return { clickedPreview: false, clickedDownload: false }
|
||||
// 预览按钮在消息少时会紧贴 tab 栏,拟人轨迹从别处移过来会经过「已交换微信」等 tab,一点就切到空白。
|
||||
// 此处直接用 Puppeteer 的 element.click():无移动轨迹,先 scrollIntoView 再点,避免误触 tab。
|
||||
await previewBtn.evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' }))
|
||||
await sleepWithRandomDelay(150, 300)
|
||||
console.log('[openPreviewAndDownloadPdf] 点击「点击预览附件简历」按钮(原生 click,避免轨迹误触 tab)')
|
||||
await previewBtn.click()
|
||||
|
||||
// 等待简历预览弹窗内的下载按钮出现(PDF 加载可能较慢,默认 10s)
|
||||
let downloadBtn
|
||||
try {
|
||||
await page.waitForSelector(CHAT_PAGE_DOWNLOAD_PDF_BTN_SELECTOR, { visible: true, timeout: previewTimeout })
|
||||
downloadBtn = await page.$(CHAT_PAGE_DOWNLOAD_PDF_BTN_SELECTOR)
|
||||
} catch {
|
||||
console.log('[openPreviewAndDownloadPdf] 等待下载按钮超时 (', previewTimeout, 'ms),预览弹窗未出现')
|
||||
return { clickedPreview: true, clickedDownload: false }
|
||||
}
|
||||
|
||||
if (!downloadBtn) return { clickedPreview: true, clickedDownload: false }
|
||||
const downloadBox = await downloadBtn.boundingBox()
|
||||
if (!downloadBox) {
|
||||
console.log('[openPreviewAndDownloadPdf] 下载按钮 boundingBox 为空')
|
||||
return { clickedPreview: true, clickedDownload: false }
|
||||
}
|
||||
console.log('[openPreviewAndDownloadPdf] 点击下载按钮,坐标:', Math.round(downloadBox.x + downloadBox.width / 2), Math.round(downloadBox.y + downloadBox.height / 2))
|
||||
await cursor.click({ x: downloadBox.x + downloadBox.width / 2, y: downloadBox.y + downloadBox.height / 2 })
|
||||
// 等待下载开始(给浏览器一点时间触发下载)
|
||||
await sleepWithRandomDelay(600, 1000)
|
||||
|
||||
// 关闭简历预览弹窗:优先用 Escape,避免点击关闭时误触下方「已交换微信」等 tab 导致列表切到空分组(暂无牛人)
|
||||
const dialogVisible = await page.$(CHAT_PAGE_ATTACH_RESUME_DIALOG_CLOSE_SELECTOR).then(() => true).catch(() => false)
|
||||
if (dialogVisible) {
|
||||
console.log('[openPreviewAndDownloadPdf] 关闭附件简历预览弹窗(优先 Escape)...')
|
||||
await page.keyboard.press('Escape')
|
||||
await sleepWithRandomDelay(200, 400)
|
||||
const stillVisible = await page.$(CHAT_PAGE_ATTACH_RESUME_DIALOG_CLOSE_SELECTOR).then(() => true).catch(() => false)
|
||||
if (stillVisible) {
|
||||
const dialogs = await page.$$('.resume-common-dialog').catch(() => [])
|
||||
for (const dialog of dialogs) {
|
||||
const visible = await dialog.boundingBox().catch(() => null)
|
||||
if (!visible) continue
|
||||
const closeBtn = await dialog.$('.boss-popup__close').catch(() => null)
|
||||
if (closeBtn) {
|
||||
await closeBtn.click().catch(() => {})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.waitForSelector(CHAT_PAGE_ATTACH_RESUME_DIALOG_CLOSE_SELECTOR, { hidden: true, timeout: 3000 }).catch(() => {})
|
||||
}
|
||||
|
||||
// 若列表被清空(误触到「已交换微信」等 tab),切回「全部」恢复会话列表
|
||||
const listCount = await page.$$(CHAT_PAGE_ITEM_SELECTOR).then(arr => arr.length).catch(() => 0)
|
||||
if (listCount === 0) {
|
||||
const tabAll = await page.$(CHAT_PAGE_TAB_ALL_SELECTOR).catch(() => null)
|
||||
if (tabAll) {
|
||||
console.log('[openPreviewAndDownloadPdf] 检测到会话列表为空,切回「全部」tab 恢复列表')
|
||||
await tabAll.click().catch(() => {})
|
||||
await sleepWithRandomDelay(300, 600)
|
||||
}
|
||||
}
|
||||
|
||||
return { clickedPreview: true, clickedDownload: true }
|
||||
}
|
||||
216
packages/boss-auto-browse-and-chat/constant.mjs
Normal file
216
packages/boss-auto-browse-and-chat/constant.mjs
Normal file
@@ -0,0 +1,216 @@
|
||||
// 招聘端 CSS 选择器常量(参考 packages/geek-auto-start-chat-with-boss/constant.mjs 风格)
|
||||
// 结构参考:examples/BOSS直聘-推荐牛人.html、推荐牛人-候选人详情页、推荐牛人-打招呼、推荐牛人-继续沟通
|
||||
// 沟通页参考:examples/BOSS直聘-沟通-聊天框.html、BOSS直聘-沟通-附件简历.html
|
||||
|
||||
// =============================================================================
|
||||
// 一、推荐牛人页(/web/boss/recommend)— 主动打招呼流程
|
||||
// =============================================================================
|
||||
// 流程:点"打招呼"→ 招呼自动发送 → 弹窗"已向牛人发送招呼"→ 点"知道了"→ 点该条"继续沟通"→ 在 #boss-chat-global-input 输入并回车发送(无发送按钮)
|
||||
//
|
||||
// 1. CANDIDATE_LIST_SELECTOR:列表容器
|
||||
// 2. CANDIDATE_ITEM_SELECTOR:单条候选人(li)
|
||||
// 3. CANDIDATE_NAME_SELECTOR:在 item 内姓名
|
||||
// 4. CANDIDATE_DETAIL_SELECTOR:无独立详情面板,留空
|
||||
// 5. CHAT_START_BUTTON_SELECTOR:打招呼按钮(在 item 内)
|
||||
// 6. 弹窗"知道了"、继续沟通、聊天输入框见下方
|
||||
|
||||
/** 1. 候选人列表容器(在 iframe[name="recommendFrame"] 内) */
|
||||
export const CANDIDATE_LIST_SELECTOR = 'ul.card-list'
|
||||
|
||||
/** 2. 单个候选人条目(取多个用 querySelectorAll,在 iframe 内) */
|
||||
export const CANDIDATE_ITEM_SELECTOR = 'ul.card-list > li.card-item'
|
||||
|
||||
/** 3. 候选人姓名(在单条 item 内:item.querySelector(CANDIDATE_NAME_SELECTOR)) */
|
||||
export const CANDIDATE_NAME_SELECTOR = 'span.name'
|
||||
|
||||
/** 4. 详情面板(推荐牛人页无独立详情面板,留空) */
|
||||
export const CANDIDATE_DETAIL_SELECTOR = ''
|
||||
|
||||
/** 5. 打招呼按钮(在单条 item 的 div.operate-side 内) */
|
||||
export const CHAT_START_BUTTON_SELECTOR = 'button.btn-greet'
|
||||
|
||||
/** "已向牛人发送招呼"弹窗内的"知道了"按钮(弹窗在主页面,不在 iframe 内) */
|
||||
export const GREETING_SENT_KNOW_BTN_SELECTOR = 'div.dialog-wrap button.btn-sure-v2'
|
||||
|
||||
/** 继续沟通按钮(在单条 item 内;点完"知道了"后再点此项) */
|
||||
export const CONTINUE_CHAT_BUTTON_SELECTOR = 'div.operate-side div.button-chat'
|
||||
|
||||
/** 聊天输入框(点"继续沟通"后出现,无发送按钮,用回车发送) */
|
||||
export const CHAT_INPUT_SELECTOR = '#boss-chat-global-input'
|
||||
|
||||
/** 列表/分页:下一页按钮(多种样式兼容) */
|
||||
export const NEXT_PAGE_BUTTON_SELECTOR = '.options-pages a.next, .pagination .next, [ka*="next"], [class*="next-page"]'
|
||||
|
||||
/** 推荐页:单条候选人卡片内的"不感兴趣"区域(点击后弹出原因选择,需再选原因才关闭) */
|
||||
export const NOT_INTERESTED_IN_ITEM_SELECTOR = 'div.tooltip-wrap.suitable'
|
||||
|
||||
/** 推荐页(iframe 内):点击"不感兴趣"后弹出的原因选择弹窗(选择不喜欢的原因,为您优化推荐) */
|
||||
export const NOT_INTERESTED_REASON_POPUP_SELECTOR = 'div.card-reason-f1.show'
|
||||
|
||||
/** 推荐页:原因弹窗内所有可选项(span.first-reason-item),按筛选原因选对应一项以优化推荐 */
|
||||
export const NOT_INTERESTED_REASON_ITEMS_SELECTOR = 'div.card-reason-f1.show span.first-reason-item'
|
||||
|
||||
/** 筛选原因 → 弹窗选项文案(精确匹配)。与 candidate-processor 的 reason 一致,便于 BOSS 优化推荐;后续可接 LLM 根据 reasonDetail 选更贴切项 */
|
||||
export const NOT_INTERESTED_REASON_MAP = {
|
||||
city: '牛人距离远',
|
||||
education: '不考虑本科',
|
||||
salary: '期望薪资偏高',
|
||||
workExp: '工作经历和制剂研发无关',
|
||||
viewed: '重复推荐',
|
||||
skills: '其他原因',
|
||||
blockName: '其他原因'
|
||||
}
|
||||
|
||||
/** 弹窗中用于"与职位不符"的选项匹配:若选项文案包含此字符串则视为职位/技能不符(skills、blockName 可优先选此项) */
|
||||
export const NOT_INTERESTED_REASON_POSITION_MISMATCH = '与职位不符'
|
||||
|
||||
/** 无匹配或未知原因时使用的弹窗选项 */
|
||||
export const NOT_INTERESTED_REASON_FALLBACK = '其他原因'
|
||||
|
||||
/** 原因弹窗的关闭图标(未匹配到原因时点击以关闭弹窗,避免卡住后续操作) */
|
||||
export const NOT_INTERESTED_REASON_POPUP_CLOSE_SELECTOR = 'div.card-reason-f1.show div.close-icon'
|
||||
|
||||
/** 推荐页:简历详情弹窗的关闭按钮(主页面,非 iframe 内) */
|
||||
export const RESUME_POPUP_CLOSE_SELECTOR = 'div.boss-popup__close'
|
||||
|
||||
/** @deprecated 招呼为自动发送,无需弹窗输入框;若需在弹窗内编辑招呼语可再用 */
|
||||
export const GREETING_DIALOG_SELECTOR = 'body > div.dialog-wrap.dialog-chat-greeting.v-transfer-dom > div.dialog-container'
|
||||
|
||||
/** @deprecated 招呼自动发送,留空;发后续消息用 CHAT_INPUT_SELECTOR + Enter */
|
||||
export const GREETING_INPUT_SELECTOR = ''
|
||||
|
||||
// 招聘端 URL 常量
|
||||
/** 推荐牛人页(招聘端入口可能是 /web/boss/recommend 或 /web/chat/recommend,需与当前站点一致) */
|
||||
export const BOSS_RECOMMEND_PAGE_URL = 'https://www.zhipin.com/web/chat/recommend'
|
||||
/** 沟通页(默认入口,登录后可能落在此页,需点击"推荐牛人"切到推荐页) */
|
||||
export const BOSS_CHAT_INDEX_URL = 'https://www.zhipin.com/web/chat/index'
|
||||
/** 沟通页 URL 别名(/web/boss/chat) */
|
||||
export const BOSS_CHAT_PAGE_URL = 'https://www.zhipin.com/web/chat/index'
|
||||
|
||||
/** 侧栏"推荐牛人"入口(在沟通页时点击可切到推荐牛人页) */
|
||||
export const RECOMMEND_MENU_BUTTON_SELECTOR = '#wrap > div.side-wrap.side-wrap-v2 > div > dl.menu-recommend > dt > a'
|
||||
|
||||
/** 推荐页:顶部职位下拉触发按钮(主页面 #headerWrap 内,点击后展开职位列表) */
|
||||
export const RECOMMEND_JOB_DROPDOWN_LABEL_SELECTOR = '#headerWrap .ui-dropmenu-label'
|
||||
/** 推荐页:职位下拉列表容器 */
|
||||
export const RECOMMEND_JOB_LIST_SELECTOR = '#headerWrap ul.job-list'
|
||||
/** 推荐页:职位下拉内单条职位项(li.job-item,value 为 jobId,文本为职位名) */
|
||||
export const RECOMMEND_JOB_ITEM_SELECTOR = '#headerWrap ul.job-list li.job-item'
|
||||
|
||||
/** 沟通页:顶部职位筛选下拉触发按钮(点击展开职位列表) */
|
||||
export const CHAT_PAGE_JOB_DROPDOWN_SELECTOR = '.chat-top-job .ui-dropmenu-label'
|
||||
/** 沟通页:职位下拉展开后的列表项(过滤 value="-1" 的"全部职位") */
|
||||
export const CHAT_PAGE_JOB_ITEM_SELECTOR = '.chat-top-job .ui-dropmenu-list li'
|
||||
|
||||
// =============================================================================
|
||||
// 二、沟通页(/web/chat/index)— 会话列表、要简历、预览附件、下载 PDF
|
||||
// =============================================================================
|
||||
// 流程简述:
|
||||
// - 看在线简历:无需对方同意,点"查看在线简历"即可,内容在 #resume(Canvas),用 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"按钮
|
||||
|
||||
/** 沟通页:顶部"未读"筛选 tab(span:nth-child(2) 在 .chat-message-filter-left 内,active 时有 class="active") */
|
||||
export const CHAT_PAGE_UNREAD_FILTER_SELECTOR = '.chat-message-filter-left span:nth-child(2)'
|
||||
|
||||
/** 左侧会话列表容器 */
|
||||
export const CHAT_PAGE_USER_LIST_SELECTOR = '.user-container'
|
||||
|
||||
/** 左侧单个会话 item(.geek-item,id=_<geekId>-0,data-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,无 href,Vue 点击事件) */
|
||||
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="全部"]'
|
||||
181
packages/boss-auto-browse-and-chat/data-manager.mjs
Normal file
181
packages/boss-auto-browse-and-chat/data-manager.mjs
Normal 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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"expectCityList": [],
|
||||
"expectEducationRegExpStr": "",
|
||||
"expectWorkExpRange": [0, 99],
|
||||
"expectSalaryRange": [0, 0],
|
||||
"expectSalaryWhenNegotiable": "exclude",
|
||||
"expectSkillKeywords": [],
|
||||
"blockCandidateNameRegExpStr": "",
|
||||
"skipViewedCandidates": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
111
packages/boss-auto-browse-and-chat/humanMouse.mjs
Normal file
111
packages/boss-auto-browse-and-chat/humanMouse.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 拟人鼠标轨迹封装(招聘端专用)
|
||||
*
|
||||
* BOSS 对招聘端鼠标移动轨迹进行埋点,直接 page.click() 或 page.mouse.click(x,y)
|
||||
* 的"瞬移"方式容易被识别为脚本。本模块封装 ghost-cursor,以贝塞尔曲线生成拟人
|
||||
* 移动路径,替换所有在招聘端页面上的点击操作。
|
||||
*
|
||||
* 用法:
|
||||
* import { createHumanCursor } from './humanMouse.mjs'
|
||||
* const cursor = await createHumanCursor(page)
|
||||
* await cursor.click(selector) // 先沿轨迹移动,再点击
|
||||
* await cursor.move(selector) // 仅移动,不点击
|
||||
*/
|
||||
|
||||
/**
|
||||
* 为给定 Puppeteer page 创建拟人鼠标 cursor。
|
||||
* 内部使用 ghost-cursor;若 ghost-cursor 不可用(如包未安装),
|
||||
* 则 fallback 到普通 page.click(),并打印警告。
|
||||
*
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
|
||||
* @returns {Promise<{
|
||||
* click: (selectorOrPos: string | {x: number, y: number}) => Promise<void>,
|
||||
* move: (selectorOrPos: string | {x: number, y: number}) => Promise<void>
|
||||
* }>}
|
||||
*/
|
||||
export async function createHumanCursor (page) {
|
||||
let ghostCursorCreate
|
||||
try {
|
||||
const mod = await import('ghost-cursor')
|
||||
// ghost-cursor 同时支持 ESM default export 和命名 export
|
||||
ghostCursorCreate = mod.createCursor ?? mod.default?.createCursor
|
||||
} catch {
|
||||
ghostCursorCreate = null
|
||||
}
|
||||
|
||||
if (ghostCursorCreate) {
|
||||
const cursor = ghostCursorCreate(page)
|
||||
|
||||
/**
|
||||
* 将 selector 字符串或 ElementHandle 解析成 {x, y} 坐标。
|
||||
* ghost-cursor 的 click/move 只接受 string selector 或 ElementHandle,
|
||||
* 传 {x,y} 坐标对象会被误当 ElementHandle 调 element.remoteObject() 崩溃。
|
||||
* 统一在封装层解析成坐标,再用 moveTo({x,y}) + page.mouse.click(x,y) 执行。
|
||||
*/
|
||||
const resolvePos = async (selectorOrPos) => {
|
||||
if (typeof selectorOrPos === 'string') {
|
||||
const el = await page.$(selectorOrPos)
|
||||
if (!el) throw new Error(`[humanMouse] element not found: ${selectorOrPos}`)
|
||||
const box = await el.boundingBox()
|
||||
if (!box) throw new Error(`[humanMouse] element has no bounding box: ${selectorOrPos}`)
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||
}
|
||||
// ElementHandle(有 boundingBox 方法)
|
||||
if (selectorOrPos && typeof selectorOrPos.boundingBox === 'function') {
|
||||
const box = await selectorOrPos.boundingBox()
|
||||
if (!box) throw new Error('[humanMouse] ElementHandle has no bounding box')
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||
}
|
||||
// 已是 {x, y} 坐标对象
|
||||
return selectorOrPos
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* 沿拟人轨迹移动到目标后点击。使用 moveTo({x,y}) + page.mouse.click(x,y)
|
||||
* 规避 ghost-cursor 传坐标/ElementHandle 时调 element.evaluate 的崩溃问题。
|
||||
* @param {string | {x: number, y: number} | import('puppeteer').ElementHandle} selectorOrPos
|
||||
*/
|
||||
async click (selectorOrPos) {
|
||||
const pos = await resolvePos(selectorOrPos)
|
||||
await cursor.moveTo(pos)
|
||||
await page.mouse.click(pos.x, pos.y)
|
||||
},
|
||||
/**
|
||||
* 沿拟人轨迹移动到目标(不点击)
|
||||
* @param {string | {x: number, y: number} | import('puppeteer').ElementHandle} selectorOrPos
|
||||
*/
|
||||
async move (selectorOrPos) {
|
||||
const pos = await resolvePos(selectorOrPos)
|
||||
await cursor.moveTo(pos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: ghost-cursor 未安装时退化为普通点击(打印警告)
|
||||
console.warn('[humanMouse] ghost-cursor 未安装,退化为普通 page.click()。建议安装 ghost-cursor 以规避 BOSS 鼠标轨迹埋点检测。')
|
||||
return {
|
||||
async click (selectorOrPos) {
|
||||
if (typeof selectorOrPos === 'string') {
|
||||
await page.click(selectorOrPos)
|
||||
} else if (selectorOrPos && typeof selectorOrPos.x === 'number') {
|
||||
await page.mouse.click(selectorOrPos.x, selectorOrPos.y)
|
||||
}
|
||||
},
|
||||
async move (selectorOrPos) {
|
||||
if (typeof selectorOrPos === 'string') {
|
||||
try {
|
||||
const el = await page.$(selectorOrPos)
|
||||
if (el) {
|
||||
const box = await el.boundingBox()
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||
}
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
} else if (selectorOrPos && typeof selectorOrPos.x === 'number') {
|
||||
await page.mouse.move(selectorOrPos.x, selectorOrPos.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
550
packages/boss-auto-browse-and-chat/index.mjs
Normal file
550
packages/boss-auto-browse-and-chat/index.mjs
Normal file
@@ -0,0 +1,550 @@
|
||||
import { sleep, sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { setDomainLocalStorage } from '@geekgeekrun/utils/puppeteer/local-storage.mjs'
|
||||
import { readConfigFile, readStorageFile, writeStorageFile, ensureConfigFileExist, ensureStorageFileExist, getMergedJobConfig } from './runtime-file-utils.mjs'
|
||||
import {
|
||||
BOSS_RECOMMEND_PAGE_URL,
|
||||
BOSS_CHAT_PAGE_URL,
|
||||
RECOMMEND_JOB_DROPDOWN_LABEL_SELECTOR,
|
||||
RECOMMEND_JOB_ITEM_SELECTOR
|
||||
} from './constant.mjs'
|
||||
import { setupNetworkInterceptor, setupCanvasTextHook } from './resume-extractor.mjs'
|
||||
import { parseCandidateList, filterCandidates, scrollAndLoadMore } from './candidate-processor.mjs'
|
||||
import { processCandidate, checkDailyLimit, clickNotInterested } from './chat-handler.mjs'
|
||||
import { setLevel, debug as logDebug, info as logInfo, warn as logWarn, error as logError } from './logger.mjs'
|
||||
|
||||
export { default as startBossChatPageProcess } from './chat-page-processor.mjs'
|
||||
|
||||
ensureConfigFileExist()
|
||||
ensureStorageFileExist()
|
||||
|
||||
/** 招聘端自动化事件总线(参照 autoStartChatEventBus 模式) */
|
||||
export const bossAutoBrowseEventBus = new EventEmitter()
|
||||
|
||||
/**
|
||||
* @type { import('puppeteer') }
|
||||
*/
|
||||
let puppeteer
|
||||
let StealthPlugin
|
||||
let LaodengPlugin
|
||||
let AnonymizeUaPlugin
|
||||
|
||||
/**
|
||||
* 初始化 Puppeteer(puppeteer-extra + stealth + laodeng + anonymize-ua)
|
||||
* @returns {{ puppeteer: import('puppeteer'), StealthPlugin: unknown, LaodengPlugin: unknown, AnonymizeUaPlugin: unknown }}
|
||||
*/
|
||||
export async function initPuppeteer () {
|
||||
logDebug('[boss-auto-browse] initPuppeteer: 开始动态加载插件')
|
||||
const importResult = await Promise.all([
|
||||
import('puppeteer-extra'),
|
||||
import('puppeteer-extra-plugin-stealth'),
|
||||
import('@geekgeekrun/puppeteer-extra-plugin-laodeng'),
|
||||
import('puppeteer-extra-plugin-anonymize-ua')
|
||||
])
|
||||
puppeteer = importResult[0].default
|
||||
StealthPlugin = importResult[1].default
|
||||
LaodengPlugin = importResult[2].default
|
||||
AnonymizeUaPlugin = importResult[3].default
|
||||
puppeteer.use(StealthPlugin())
|
||||
puppeteer.use(LaodengPlugin())
|
||||
puppeteer.use(AnonymizeUaPlugin({ makeWindows: false }))
|
||||
logDebug('[boss-auto-browse] initPuppeteer: 插件已注册')
|
||||
return {
|
||||
puppeteer,
|
||||
StealthPlugin,
|
||||
LaodengPlugin,
|
||||
AnonymizeUaPlugin
|
||||
}
|
||||
}
|
||||
|
||||
/** 招聘端 localStorage 生效的页面 URL(与 geek 端一致使用 desktop) */
|
||||
const localStoragePageUrl = 'https://www.zhipin.com/desktop/'
|
||||
|
||||
/**
|
||||
* 启动浏览器并导航到沟通页(供多职位队列中「仅沟通页」场景使用)
|
||||
* @returns {{ browser: import('puppeteer').Browser, page: import('puppeteer').Page }}
|
||||
*/
|
||||
export async function launchBrowserAndNavigateToChat () {
|
||||
if (!puppeteer) await initPuppeteer()
|
||||
const headless = process.env.HEADLESS === '1'
|
||||
const browser = await puppeteer.launch({
|
||||
headless,
|
||||
ignoreHTTPSErrors: true,
|
||||
protocolTimeout: 120000,
|
||||
defaultViewport: { width: 1440, height: 900 - 140 }
|
||||
})
|
||||
const page = (await browser.pages())[0]
|
||||
const bossCookies = readStorageFile('boss-cookies.json')
|
||||
const bossLocalStorage = readStorageFile('boss-local-storage.json')
|
||||
if (Array.isArray(bossCookies) && bossCookies.length > 0) {
|
||||
await page.setCookie(...bossCookies)
|
||||
}
|
||||
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
|
||||
await page.goto(BOSS_CHAT_PAGE_URL, { timeout: 60 * 1000 })
|
||||
await page.waitForFunction(() => document.readyState === 'complete', { timeout: 120 * 1000 })
|
||||
await new Promise(r => setTimeout(r, 1500))
|
||||
return { browser, page }
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前 page 的 cookie 和 localStorage 持久化到本地
|
||||
* @param { import('puppeteer').Page } page
|
||||
*/
|
||||
async function storeStorage (page) {
|
||||
const [cookies, localStorage] = await Promise.all([
|
||||
page.cookies(),
|
||||
page.evaluate(() => {
|
||||
return JSON.stringify(window.localStorage)
|
||||
}).then(res => JSON.parse(res))
|
||||
])
|
||||
return Promise.all([
|
||||
writeStorageFile('boss-cookies.json', cookies),
|
||||
writeStorageFile('boss-local-storage.json', localStorage)
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* 招聘端 hooks 类型(由调用方传入,此处仅作文档说明)
|
||||
* - beforeBrowserLaunch: AsyncSeriesHook
|
||||
* - afterBrowserLaunch: AsyncSeriesHook
|
||||
* - beforeNavigateToRecommend: AsyncSeriesHook
|
||||
* - onCandidateListLoaded: AsyncSeriesHook
|
||||
* - onCandidateFiltered: AsyncSeriesWaterfallHook
|
||||
* - beforeStartChat: AsyncSeriesHook
|
||||
* - afterChatStarted: AsyncSeriesHook
|
||||
* - onError: AsyncSeriesHook
|
||||
* - onComplete: AsyncSeriesHook
|
||||
*/
|
||||
|
||||
/**
|
||||
* 招聘端自动浏览与开聊主入口
|
||||
* @param {{
|
||||
* beforeBrowserLaunch?: import('tapable').AsyncSeriesHook<[]>,
|
||||
* afterBrowserLaunch?: import('tapable').AsyncSeriesHook<[]>,
|
||||
* beforeNavigateToRecommend?: import('tapable').AsyncSeriesHook<[]>,
|
||||
* onCandidateListLoaded?: import('tapable').AsyncSeriesHook<[]>,
|
||||
* onCandidateFiltered?: import('tapable').AsyncSeriesWaterfallHook<[unknown, unknown]>,
|
||||
* beforeStartChat?: import('tapable').AsyncSeriesHook<[unknown]>,
|
||||
* afterChatStarted?: import('tapable').AsyncSeriesHook<[unknown, unknown]>,
|
||||
* onError?: import('tapable').AsyncSeriesHook<[unknown]>,
|
||||
* onComplete?: import('tapable').AsyncSeriesHook<[]>
|
||||
* }} hooksFromCaller
|
||||
* @param {{ returnBrowser?: boolean }} [opts] - 若 true,结束时不关闭 browser 并返回 { browser, page },由调用方关闭
|
||||
*/
|
||||
/**
|
||||
* 在推荐页切换到指定职位(主页面操作,不在 iframe 内)。
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {string} jobId
|
||||
*/
|
||||
async function switchRecommendJobId (page, jobId) {
|
||||
try {
|
||||
await page.click(RECOMMEND_JOB_DROPDOWN_LABEL_SELECTOR)
|
||||
await page.waitForSelector(RECOMMEND_JOB_ITEM_SELECTOR, { timeout: 5000 })
|
||||
const found = await page.evaluate((jid) => {
|
||||
const item = document.querySelector(`#headerWrap ul.job-list li.job-item[value="${jid}"]`)
|
||||
if (!item) return false
|
||||
item.click()
|
||||
return true
|
||||
}, jobId)
|
||||
if (!found) {
|
||||
logWarn(`[boss-auto-browse] 职位 ${jobId} 未在下拉列表中找到,将使用默认职位继续`)
|
||||
// 关闭下拉
|
||||
await page.keyboard.press('Escape')
|
||||
return
|
||||
}
|
||||
// 等候选人列表重新加载
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
logInfo(`[boss-auto-browse] 已切换到职位 ${jobId}`)
|
||||
} catch (e) {
|
||||
logWarn(`[boss-auto-browse] 切换推荐页职位失败(${e.message}),将使用默认职位继续`)
|
||||
}
|
||||
}
|
||||
|
||||
export default async function startBossAutoBrowse (hooksFromCaller, opts = {}) {
|
||||
const hooks = hooksFromCaller || {}
|
||||
const returnBrowser = opts.returnBrowser === true
|
||||
const jobId = opts.jobId ?? null
|
||||
const existingBrowser = opts.browser ?? null
|
||||
const existingPage = opts.page ?? null
|
||||
const reuseBrowser = !!(existingBrowser && existingPage)
|
||||
setLevel((readConfigFile('boss-recruiter.json') || {}).logLevel || 'info')
|
||||
|
||||
if (!puppeteer) {
|
||||
logDebug('[boss-auto-browse] puppeteer 未初始化,正在 initPuppeteer()')
|
||||
await initPuppeteer()
|
||||
}
|
||||
|
||||
/** @type { import('puppeteer').Browser } */
|
||||
let browser
|
||||
/** @type { import('puppeteer').Page } */
|
||||
let page
|
||||
|
||||
try {
|
||||
if (reuseBrowser) {
|
||||
browser = existingBrowser
|
||||
page = existingPage
|
||||
logDebug('[boss-auto-browse] 复用已有浏览器实例')
|
||||
} else {
|
||||
await hooks.beforeBrowserLaunch?.promise?.()
|
||||
|
||||
const headlessEnv = process.env.HEADLESS
|
||||
const headless = headlessEnv === '1'
|
||||
logDebug('[boss-auto-browse] 即将启动浏览器', { headless, HEADLESS_env: headlessEnv ?? null })
|
||||
browser = await puppeteer.launch({
|
||||
headless,
|
||||
ignoreHTTPSErrors: true,
|
||||
protocolTimeout: 120000,
|
||||
defaultViewport: {
|
||||
width: 1440,
|
||||
height: 900 - 140
|
||||
}
|
||||
})
|
||||
|
||||
page = (await browser.pages())[0]
|
||||
|
||||
await hooks.afterBrowserLaunch?.promise?.()
|
||||
}
|
||||
|
||||
const bossCookies = readStorageFile('boss-cookies.json')
|
||||
const bossLocalStorage = readStorageFile('boss-local-storage.json')
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 直接导航到推荐牛人页(注入 Cookie / localStorage 后 goto;复用浏览器时若已在推荐页可跳过 goto)
|
||||
// -----------------------------------------------------------------------
|
||||
await hooks.beforeNavigateToRecommend?.promise?.()
|
||||
if (Array.isArray(bossCookies) && bossCookies.length > 0) {
|
||||
await page.setCookie(...bossCookies)
|
||||
}
|
||||
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
|
||||
const alreadyOnRecommend = page.url().startsWith(BOSS_RECOMMEND_PAGE_URL)
|
||||
if (!alreadyOnRecommend) {
|
||||
await page.goto(BOSS_RECOMMEND_PAGE_URL, { timeout: 60 * 1000 })
|
||||
}
|
||||
await page.waitForFunction(
|
||||
() => document.readyState === 'complete',
|
||||
{ timeout: 120 * 1000 }
|
||||
)
|
||||
|
||||
// 等待 SPA 路由稳定(readyState=complete 后 SPA 可能还会重定向)
|
||||
await new Promise(r => setTimeout(r, 1500))
|
||||
|
||||
await page.bringToFront()
|
||||
|
||||
if (
|
||||
page.url().startsWith('https://www.zhipin.com/web/common/403.html') ||
|
||||
page.url().startsWith('https://www.zhipin.com/web/common/error.html')
|
||||
) {
|
||||
throw new Error('ACCESS_IS_DENIED')
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否需要登录(包含重定向到首页的情况)
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
const checkNeedLogin = async () => {
|
||||
const url = page.url()
|
||||
// 首页 / 登录页 / 非推荐牛人页都视为需要登录
|
||||
if (url === 'https://www.zhipin.com/' || url === 'https://www.zhipin.com') {
|
||||
return true
|
||||
}
|
||||
return page.evaluate((recommendUrl) => {
|
||||
const href = location.href
|
||||
return !href.startsWith(recommendUrl) || /\/login|\/wapi\/zppassport\//.test(href)
|
||||
}, BOSS_RECOMMEND_PAGE_URL)
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待用户登录并回到推荐牛人页
|
||||
*/
|
||||
const waitForLoginAndRedirect = async () => {
|
||||
logInfo('[boss-auto-browse] 未登录或已过期,请在推荐牛人 Tab 中完成登录,登录成功后将继续执行…')
|
||||
await page.waitForFunction(
|
||||
(recommendUrl) => {
|
||||
const href = location.href
|
||||
return href.startsWith(recommendUrl) && document.readyState === 'complete'
|
||||
},
|
||||
{ timeout: 300 * 1000 },
|
||||
BOSS_RECOMMEND_PAGE_URL
|
||||
)
|
||||
// 再等待 SPA 稳定
|
||||
await new Promise(r => setTimeout(r, 1500))
|
||||
await storeStorage(page).catch(() => {})
|
||||
logInfo('[boss-auto-browse] 登录成功,已保存 Cookie。')
|
||||
}
|
||||
|
||||
if (await checkNeedLogin()) {
|
||||
await waitForLoginAndRedirect()
|
||||
} else {
|
||||
await storeStorage(page).catch(() => {})
|
||||
}
|
||||
|
||||
// 切换职位(若指定了 jobId 且非全部职位标志)
|
||||
if (jobId && jobId !== '-1' && jobId !== '0') {
|
||||
await switchRecommendJobId(page, jobId)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 获取推荐牛人 iframe 的 Frame 对象(候选人列表在 iframe 内渲染)
|
||||
// iframe 在 Vue 异步渲染期间可能多次导航,Frame 对象会被销毁重建,需重试获取
|
||||
// 若在等待过程中发现页面跳转到非推荐牛人页(如首页),则触发登录等待
|
||||
// -----------------------------------------------------------------------
|
||||
logDebug('[boss-auto-browse] 等待候选人列表渲染(iframe)...')
|
||||
|
||||
const getRecommendFrame = () => {
|
||||
return page.frames().find(f => f.name() === 'recommendFrame') ?? null
|
||||
}
|
||||
|
||||
const recommendFrame = await (async () => {
|
||||
const deadline = Date.now() + 60 * 1000
|
||||
while (Date.now() < deadline) {
|
||||
// 若页面被重定向(首页或登录页),等待用户重新登录
|
||||
if (await checkNeedLogin()) {
|
||||
await waitForLoginAndRedirect()
|
||||
// 登录后重置计时器,再给 60s 等待 iframe
|
||||
// (通过 continue 重新进入循环,deadline 已过则会退出,不过登录成功后通常很快出现)
|
||||
}
|
||||
|
||||
try {
|
||||
const f = getRecommendFrame()
|
||||
if (!f) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
continue
|
||||
}
|
||||
// 尝试在 frame 内查找候选人列表
|
||||
const el = await f.waitForSelector('ul.card-list > li.card-item', { timeout: 5000 })
|
||||
if (el) return f
|
||||
} catch (_) {
|
||||
// frame 导航中或 context 销毁,稍等重试
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
}
|
||||
}
|
||||
throw new Error('等待推荐牛人 iframe 候选人列表超时(60s)')
|
||||
})()
|
||||
|
||||
logInfo('[boss-auto-browse] 候选人列表已就绪')
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 设置网络拦截器 + Canvas hook(登录成功后立即启动,仅针对推荐牛人 Tab)
|
||||
// -----------------------------------------------------------------------
|
||||
const { getInterceptedData } = setupNetworkInterceptor(page)
|
||||
await setupCanvasTextHook(page)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 读取配置(若指定 jobId 则使用 per-job 合并配置)
|
||||
// -----------------------------------------------------------------------
|
||||
const baseConfig = readConfigFile('boss-recruiter.json') || {}
|
||||
const config = jobId ? getMergedJobConfig(jobId) : { ...baseConfig, candidateFilter: readConfigFile('candidate-filter.json') || {} }
|
||||
setLevel(config?.logLevel || 'info')
|
||||
let filterConfig = config.candidateFilter || readConfigFile('candidate-filter.json') || {}
|
||||
const recommendPageOpts = config?.recommendPage || baseConfig?.recommendPage || {}
|
||||
const clickNotInterestedForFiltered = recommendPageOpts.clickNotInterestedForFiltered !== false
|
||||
const runOnceAfterComplete = recommendPageOpts.runOnceAfterComplete === true
|
||||
const delayBetweenNotInterestedMs = recommendPageOpts.delayBetweenNotInterestedMs ?? [800, 2500]
|
||||
filterConfig = { ...filterConfig, skipViewedCandidates: recommendPageOpts.skipViewedCandidates ?? filterConfig.skipViewedCandidates }
|
||||
|
||||
const maxChatPerRun = config?.autoChat?.maxChatPerRun ?? 50
|
||||
let chatCount = 0
|
||||
let notInterestedLimitReached = false // 当天"不感兴趣"上限,达到后跳过点击但继续打招呼
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 主循环:解析 → 筛选 → 开聊 → 翻页/滚动
|
||||
// -----------------------------------------------------------------------
|
||||
await hooks.onCandidateListLoaded?.promise?.()
|
||||
|
||||
mainLoop: while (true) {
|
||||
// a. 解析候选人列表(在 iframe 的 frame 内操作)
|
||||
logDebug('[boss-auto-browse] 主循环:开始解析候选人列表...')
|
||||
let candidates = []
|
||||
try {
|
||||
candidates = await parseCandidateList(recommendFrame)
|
||||
logInfo('[boss-auto-browse] 解析完成,共', candidates.length, '人')
|
||||
} catch (parseErr) {
|
||||
logWarn('[boss-auto-browse] parseCandidateList 失败,跳过本轮:', parseErr.message)
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
logDebug('[boss-auto-browse] 候选人列表为空,尝试滚动加载…')
|
||||
const hasMore = await scrollAndLoadMore(recommendFrame).catch(() => false)
|
||||
if (!hasMore) {
|
||||
logInfo('[boss-auto-browse] 没有更多候选人,结束本次运行。')
|
||||
break mainLoop
|
||||
}
|
||||
await sleepWithRandomDelay(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
// b. 筛选候选人(经由 onCandidateFiltered waterfall hook,让外部插件也能参与过滤)
|
||||
const rawFilterResult = filterCandidates(candidates, filterConfig)
|
||||
const skippedListForLog = Array.isArray(rawFilterResult?.skipped)
|
||||
? [...rawFilterResult.skipped]
|
||||
: []
|
||||
let filterResult = rawFilterResult
|
||||
if (hooks.onCandidateFiltered?.promise) {
|
||||
try {
|
||||
const hookResult = await hooks.onCandidateFiltered.promise(candidates, filterResult)
|
||||
if (hookResult != null && (Array.isArray(hookResult.matched) || Array.isArray(hookResult.skipped))) {
|
||||
filterResult = hookResult
|
||||
}
|
||||
} catch (_) { /* hook 出错不影响主流程 */ }
|
||||
}
|
||||
|
||||
// filterResult.matched 的每项是 { candidate, filterResult } 包装对象;无人 tap 时 hook 返回 undefined,用 rawFilterResult
|
||||
const matchedItems = Array.isArray(filterResult?.matched) ? filterResult.matched : rawFilterResult.matched || []
|
||||
// 将每个 matched candidate 映射回 candidates 数组中的原始索引,用于在 iframe li.card-item 列表中定位
|
||||
const matched = matchedItems.map(item => {
|
||||
const c = item?.candidate ?? item
|
||||
const originalIndex = candidates.indexOf(c)
|
||||
return { candidate: c, originalIndex: originalIndex >= 0 ? originalIndex : 0 }
|
||||
})
|
||||
|
||||
for (const item of skippedListForLog) {
|
||||
const candidate = item?.candidate ?? item
|
||||
const fr = item?.filterResult ?? item
|
||||
const name = candidate?.geekName ?? candidate?.encryptGeekId ?? '?'
|
||||
const detail = fr?.reasonDetail ?? `不满足条件 ${fr?.reason ?? 'unknown'}`
|
||||
logInfo(`[boss-auto-browse] 跳过 ${name}:${detail}`)
|
||||
}
|
||||
if (skippedListForLog.length > 0) {
|
||||
const reasonCounts = {}
|
||||
for (const item of skippedListForLog) {
|
||||
const r = item?.filterResult?.reason ?? 'unknown'
|
||||
reasonCounts[r] = (reasonCounts[r] || 0) + 1
|
||||
}
|
||||
logInfo('[boss-auto-browse] 跳过原因统计:', reasonCounts)
|
||||
}
|
||||
if (matched.length > 0) {
|
||||
const passedNames = matched.map(m => m.candidate?.geekName ?? m.candidate?.encryptGeekId ?? '?').join('、')
|
||||
logInfo('[boss-auto-browse] 本轮通过筛选:', passedNames)
|
||||
}
|
||||
logInfo(`[boss-auto-browse] 本轮候选人:共 ${candidates.length} 人,筛选通过 ${matched.length} 人`)
|
||||
|
||||
// 对未通过筛选的候选人点击"不感兴趣",并按筛选原因选对应弹窗选项以优化 BOSS 推荐;每次点击间隔随机延迟(反检测)
|
||||
if (clickNotInterestedForFiltered && !notInterestedLimitReached && skippedListForLog.length > 0) {
|
||||
const cursor = await (await import('./humanMouse.mjs')).createHumanCursor(page)
|
||||
const indexToFilterResult = new Map()
|
||||
for (const item of skippedListForLog) {
|
||||
const idx = candidates.indexOf(item?.candidate ?? item)
|
||||
if (idx >= 0) indexToFilterResult.set(idx, item?.filterResult ?? item)
|
||||
}
|
||||
const sortedIndices = skippedListForLog
|
||||
.map(s => candidates.indexOf(s?.candidate ?? s))
|
||||
.filter(i => i >= 0)
|
||||
.sort((a, b) => b - a)
|
||||
logInfo('[boss-auto-browse] 将对', sortedIndices.length, '人点击"不感兴趣"(原因与筛选一致)')
|
||||
const delayRange = Array.isArray(delayBetweenNotInterestedMs) && delayBetweenNotInterestedMs.length >= 2
|
||||
? delayBetweenNotInterestedMs
|
||||
: [800, 2500]
|
||||
for (let i = 0; i < sortedIndices.length; i++) {
|
||||
const idx = sortedIndices[i]
|
||||
const fr = indexToFilterResult.get(idx)
|
||||
logDebug('[boss-auto-browse] 正在对 index=', idx, ' 点击"不感兴趣"(reason=', fr?.reason ?? 'unknown', ')')
|
||||
try {
|
||||
const niResult = await clickNotInterested(recommendFrame, idx, cursor, {
|
||||
logPrefix: '[boss-auto-browse]',
|
||||
filterResult: fr
|
||||
})
|
||||
if (niResult === 'NOT_INTERESTED_LIMIT_REACHED') {
|
||||
notInterestedLimitReached = true
|
||||
logInfo('[boss-auto-browse] 当天"不感兴趣"上限已达,本次及后续轮次将跳过,继续处理打招呼')
|
||||
break
|
||||
}
|
||||
if (i < sortedIndices.length - 1) {
|
||||
const [minMs, maxMs] = delayRange
|
||||
const delay = minMs + Math.random() * (maxMs - minMs)
|
||||
await sleep(delay)
|
||||
}
|
||||
} catch (e) {
|
||||
logWarn('[boss-auto-browse] 点击不感兴趣失败(index=', idx, '):', e?.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matched.length === 0) {
|
||||
// 全被过滤掉,继续翻页/滚动加载下一批
|
||||
logDebug('[boss-auto-browse] 本轮无匹配候选人,继续滚动加载…')
|
||||
const hasMore = await scrollAndLoadMore(recommendFrame).catch(() => false)
|
||||
if (!hasMore) {
|
||||
logInfo('[boss-auto-browse] 已加载全部候选人,结束本次运行。')
|
||||
break mainLoop
|
||||
}
|
||||
await sleepWithRandomDelay(1500)
|
||||
continue
|
||||
}
|
||||
|
||||
// c. 逐一处理匹配的候选人
|
||||
for (let i = 0; i < matched.length; i++) {
|
||||
const { candidate, originalIndex } = matched[i]
|
||||
|
||||
// 检查每日限额(在主页面检查)
|
||||
const limitStatus = await checkDailyLimit(page).catch(() => ({ limitReached: false }))
|
||||
if (limitStatus.limitReached) {
|
||||
logInfo('[boss-auto-browse] 今日沟通人数已达上限,停止运行。')
|
||||
break mainLoop
|
||||
}
|
||||
|
||||
if (chatCount >= maxChatPerRun) {
|
||||
logInfo(`[boss-auto-browse] 本次运行已开聊 ${chatCount} 人,达到上限,停止运行。`)
|
||||
break mainLoop
|
||||
}
|
||||
|
||||
// d. 开聊(在 iframe frame 内操作,弹窗处理在主页面)
|
||||
logDebug('[boss-auto-browse] 开始处理候选人', candidate.geekName, '(index=', originalIndex, ')')
|
||||
let procesResult
|
||||
try {
|
||||
procesResult = await processCandidate(
|
||||
recommendFrame,
|
||||
candidate,
|
||||
config,
|
||||
hooks,
|
||||
{ getInterceptedData, candidateIndex: originalIndex, mainPage: page }
|
||||
)
|
||||
} catch (procErr) {
|
||||
logError('[boss-auto-browse] processCandidate 异常(', candidate.geekName, '):', procErr.message)
|
||||
continue
|
||||
}
|
||||
|
||||
const { chatResult } = procesResult
|
||||
if (chatResult.success) {
|
||||
chatCount++
|
||||
await hooks.onProgress?.promise?.({ phase: 'recommend', current: chatCount, max: maxChatPerRun }).catch(() => {})
|
||||
logInfo('[boss-auto-browse] ✓ 已向', candidate.geekName, '发送招呼(本次共', chatCount, '人)')
|
||||
} else {
|
||||
logInfo('[boss-auto-browse] ✗', candidate.geekName, '开聊失败:', chatResult.reason)
|
||||
if (chatResult.reason === 'DAILY_LIMIT_REACHED' || chatResult.reason === 'RISK_CONTROL') {
|
||||
break mainLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// e. 滚动加载 / 翻页(在 iframe frame 内操作)
|
||||
logDebug('[boss-auto-browse] 本轮匹配已处理完,滚动加载更多…')
|
||||
const hasMore = await scrollAndLoadMore(recommendFrame).catch(() => false)
|
||||
if (!hasMore) {
|
||||
logInfo('[boss-auto-browse] 已加载全部候选人,结束本次运行。')
|
||||
break mainLoop
|
||||
}
|
||||
await sleepWithRandomDelay(1500)
|
||||
}
|
||||
|
||||
await hooks.onComplete?.promise?.()
|
||||
logInfo('[boss-auto-browse] 本次运行完成,共成功开聊', chatCount, '人。')
|
||||
|
||||
if (returnBrowser && browser && page) {
|
||||
return { browser, page }
|
||||
}
|
||||
} catch (err) {
|
||||
await hooks.onError?.promise?.(err)
|
||||
throw err
|
||||
} finally {
|
||||
if (browser && !returnBrowser) {
|
||||
try {
|
||||
await browser.close()
|
||||
} catch (e) {
|
||||
void e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
310
packages/boss-auto-browse-and-chat/llm-rubric.mjs
Normal file
310
packages/boss-auto-browse-and-chat/llm-rubric.mjs
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* llm-rubric.mjs
|
||||
*
|
||||
* LLM-based resume evaluation using Rubric (knockouts + dimensions).
|
||||
* Used when resumeLlmConfig.rubric is present in job filter.
|
||||
*/
|
||||
|
||||
import { readConfigFile } from './runtime-file-utils.mjs'
|
||||
import { debug as logDebug, info as logInfo, warn as logWarn, error as logError } from './logger.mjs'
|
||||
|
||||
const RESUME_TEXT_MAX_CHARS = 3500
|
||||
const LOG = '[llm-rubric]'
|
||||
|
||||
/**
|
||||
* 将 providers 数组展开为 flat model 列表,每个 model 携带所属 provider 的 baseURL/apiKey。
|
||||
* 同时兼容旧格式(直接含 models 字段的配置)。
|
||||
* @param {object} config
|
||||
* @returns {Array<{ id, baseURL, apiKey, model, enabled, thinking, name }>}
|
||||
*/
|
||||
function flattenModels (config) {
|
||||
if (Array.isArray(config.providers)) {
|
||||
return config.providers.flatMap((p) =>
|
||||
(p.models ?? []).map((m) => ({
|
||||
...m,
|
||||
baseURL: p.baseURL,
|
||||
apiKey: p.apiKey
|
||||
}))
|
||||
)
|
||||
}
|
||||
// 旧格式兜底(迁移前可能在 runtime 里读到)
|
||||
if (Array.isArray(config.models)) {
|
||||
return config.models
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用的招聘端 LLM 配置,从 boss-llm.json 按 purpose 选取模型。
|
||||
* boss-llm.json 格式: { providers: [...], purposeDefaultModelId: { resume_screening: "uuid" } }
|
||||
* @param {string} [purpose='resume_screening'] - 用途 key
|
||||
* @param {string|null} [preferModelId=null] - 指定模型 id(优先)
|
||||
* @returns {{ baseURL: string, apiKey: string, model: string, thinking?: { enabled: boolean, budget: number } } | null}
|
||||
*/
|
||||
export function getEnabledLlmClient (purpose = 'resume_screening', preferModelId = null) {
|
||||
const raw = readConfigFile('boss-llm.json')
|
||||
const models = flattenModels(raw ?? {})
|
||||
if (models.length === 0) return null
|
||||
|
||||
// 指定 modelId:优先使用(需启用)
|
||||
if (preferModelId) {
|
||||
const preferred = models.find((m) => m.id === preferModelId && m.enabled !== false)
|
||||
if (preferred?.baseURL && preferred?.apiKey && preferred?.model) {
|
||||
logDebug(LOG, 'use preferred modelId', preferModelId, preferred.model)
|
||||
return {
|
||||
baseURL: preferred.baseURL,
|
||||
apiKey: preferred.apiKey,
|
||||
model: preferred.model,
|
||||
thinking: preferred.thinking ?? null
|
||||
}
|
||||
}
|
||||
logWarn(LOG, 'preferred modelId not found/enabled', preferModelId)
|
||||
}
|
||||
|
||||
// 优先按 purposeDefaultModelId 精确匹配
|
||||
const defaultId =
|
||||
raw?.purposeDefaultModelId?.[purpose] ?? raw?.purposeDefaultModelId?.['default']
|
||||
let selected = defaultId
|
||||
? models.find((m) => m.id === defaultId && m.enabled !== false)
|
||||
: null
|
||||
|
||||
// 回退: 找第一个启用的模型
|
||||
if (!selected) {
|
||||
selected = models.find((m) => m.enabled !== false)
|
||||
}
|
||||
|
||||
if (!selected || !selected.baseURL || !selected.apiKey || !selected.model) return null
|
||||
|
||||
logDebug(LOG, 'selected model', { purpose, modelId: selected.id, model: selected.model })
|
||||
return {
|
||||
baseURL: selected.baseURL,
|
||||
apiKey: selected.apiKey,
|
||||
model: selected.model,
|
||||
thinking: selected.thinking ?? null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 Rubric 评估简历。
|
||||
* @param {string} resumeText - 简历全文
|
||||
* @param {{ knockouts?: string[], dimensions?: Array<{ name: string, weight: number, criteria: Record<string, string> }>, passThreshold?: number }} rubricConfig
|
||||
* @param {{ modelId?: string | null }} [options]
|
||||
* @returns {Promise<{ isPassed: boolean, totalScore: number, reason: string }>} 失败时默认通过
|
||||
*/
|
||||
export async function evaluateResumeByRubric (resumeText, rubricConfig, options = {}) {
|
||||
const defaultResult = { isPassed: true, totalScore: 0, reason: 'LLM 调用失败,默认通过' }
|
||||
const modelId = typeof options?.modelId === 'string' ? options.modelId : null
|
||||
const client = getEnabledLlmClient('resume_screening', modelId)
|
||||
if (!client) return defaultResult
|
||||
|
||||
const knockouts = Array.isArray(rubricConfig?.knockouts) ? rubricConfig.knockouts : []
|
||||
const dimensions = Array.isArray(rubricConfig?.dimensions) ? rubricConfig.dimensions : []
|
||||
const passThreshold = typeof rubricConfig?.passThreshold === 'number' ? rubricConfig.passThreshold : 75
|
||||
|
||||
if (dimensions.length === 0) {
|
||||
return { isPassed: true, totalScore: 100, reason: '无评分维度,默认通过' }
|
||||
}
|
||||
|
||||
const truncatedResume = (resumeText || '(无简历内容)').slice(0, RESUME_TEXT_MAX_CHARS)
|
||||
|
||||
const dimensionsDesc = dimensions
|
||||
.map((d) => {
|
||||
const criteriaStr = Object.entries(d.criteria || {})
|
||||
.map(([k, v]) => `${k}分: ${v}`)
|
||||
.join(';')
|
||||
return `- ${d.name}(权重${d.weight}%):${criteriaStr}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
let systemContent = `你是一个招聘筛选助手。请根据以下评分标准对候选人简历进行结构化评估。
|
||||
|
||||
【一票否决项】若简历不满足以下任一项,直接返回 knockout_failed: true,无需计算维度分:
|
||||
${knockouts.length > 0 ? knockouts.map((k) => `- ${k}`).join('\n') : '(无)'}
|
||||
|
||||
【评分维度】每个维度打 1/3/5 分,按权重加权得到总分(满100):
|
||||
${dimensionsDesc}
|
||||
|
||||
请仅以 JSON 格式回复,不要包含其他内容。格式:
|
||||
{
|
||||
"knockout_failed": true或false,
|
||||
"knockout_reason": "若不通过则填写原因,否则填空字符串",
|
||||
"dimension_scores": { "维度名": 1或3或5, ... },
|
||||
"reasoning": "简要判断理由"
|
||||
}`
|
||||
|
||||
try {
|
||||
logInfo(LOG, 'evaluateResumeByRubric start', {
|
||||
model: client.model,
|
||||
resumeChars: truncatedResume.length,
|
||||
dims: dimensions.length,
|
||||
knockouts: knockouts.length,
|
||||
passThreshold
|
||||
})
|
||||
const { completes } = await import('@geekgeekrun/utils/gpt-request.mjs')
|
||||
const completion = await completes(
|
||||
{
|
||||
baseURL: client.baseURL,
|
||||
apiKey: client.apiKey,
|
||||
model: client.model,
|
||||
max_tokens: 500,
|
||||
response_format: { type: 'json_object' }
|
||||
},
|
||||
[
|
||||
{ role: 'system', content: systemContent },
|
||||
{ role: 'user', content: truncatedResume }
|
||||
]
|
||||
)
|
||||
|
||||
const raw = completion?.choices?.[0]?.message?.content?.trim()
|
||||
logDebug(LOG, 'evaluateResumeByRubric raw length', raw?.length ?? 0)
|
||||
if (!raw) return defaultResult
|
||||
|
||||
const jsonStr = raw.replace(/^[\s\S]*?(\{[\s\S]*\})[\s\S]*$/, '$1')
|
||||
const parsed = JSON.parse(jsonStr)
|
||||
|
||||
if (parsed.knockout_failed === true) {
|
||||
return {
|
||||
isPassed: false,
|
||||
totalScore: 0,
|
||||
reason: String(parsed.knockout_reason || parsed.reasoning || '一票否决')
|
||||
}
|
||||
}
|
||||
|
||||
const scores = parsed.dimension_scores || {}
|
||||
let weightedSum = 0
|
||||
let totalWeight = 0
|
||||
const dimensionResults = []
|
||||
for (const d of dimensions) {
|
||||
const score = scores[d.name]
|
||||
const num = typeof score === 'number' ? Math.min(5, Math.max(1, score)) : 3
|
||||
const weight = typeof d.weight === 'number' ? d.weight : 100 / dimensions.length
|
||||
weightedSum += (num / 5) * weight
|
||||
totalWeight += weight
|
||||
dimensionResults.push({ name: d.name, score: num, weight })
|
||||
}
|
||||
const totalScore = totalWeight > 0 ? Math.round((weightedSum / totalWeight) * 100) : 0
|
||||
const isPassed = totalScore >= passThreshold
|
||||
|
||||
return {
|
||||
isPassed,
|
||||
totalScore,
|
||||
reason: String(parsed.reasoning || ''),
|
||||
dimensionResults
|
||||
}
|
||||
} catch (err) {
|
||||
logError(LOG, 'evaluateResumeByRubric error', err?.message || err)
|
||||
return { ...defaultResult, reason: `评估异常: ${err?.message || err}` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据岗位描述(JD)生成 Rubric 结构。
|
||||
* @param {string} sourceJd - 岗位描述或招聘要求
|
||||
* @param {{ modelId?: string | null }} [options]
|
||||
* @returns {Promise<{ rubric: { knockouts: string[], dimensions: Array<{ name: string, weight: number, criteria: Record<string, string> }> } }>}
|
||||
*/
|
||||
export async function generateRubricFromJd (sourceJd, options = {}) {
|
||||
const defaultRubric = {
|
||||
knockouts: [],
|
||||
dimensions: [
|
||||
{ name: '综合匹配度', weight: 100, criteria: { '1': '不符合', '3': '部分符合', '5': '完全符合' } }
|
||||
]
|
||||
}
|
||||
// 允许为“Rubric 生成”单独指定模型;旧配置未配置 rubric_generation 时,会自动回退到 default/第一个启用模型
|
||||
const modelId = typeof options?.modelId === 'string' ? options.modelId : null
|
||||
const client = getEnabledLlmClient('rubric_generation', modelId)
|
||||
if (!client) return { rubric: defaultRubric }
|
||||
|
||||
const systemContent = `你是一个资深 HR,擅长将招聘需求转化为可量化的候选人评分体系(Rubric)。
|
||||
|
||||
请仔细阅读用户提供的岗位描述(JD),从中提取并生成:
|
||||
|
||||
1. knockouts(一票否决项):
|
||||
- 不满足任意一项即直接淘汰
|
||||
- 数量:根据 JD 实际硬性要求决定,通常 2~4 条
|
||||
- 只写岗位明确说明的硬性条件(禁止背景、资质门槛、明确排除项等),不要臆造
|
||||
- 每条独立,简洁具体,不超过 30 字
|
||||
|
||||
2. dimensions(评分维度):
|
||||
- 数量:根据 JD 核心能力要求决定,通常 3~5 个
|
||||
- 每个维度必须对应 JD 中一个独立的、具体的能力方向(如:实验操作能力、研究独立性、沟通表达能力、工具学习能力等)
|
||||
- 严禁出现「综合匹配度」「整体匹配」「岗位匹配度」等笼统无意义的维度名称
|
||||
- weight 之和必须精确等于 100
|
||||
- criteria 必须是具体的行为或成果描述,严禁使用「不符合/部分符合/完全符合」这类无意义模板:
|
||||
- "1":候选人完全不具备该维度的能力或经验(举例说明具体缺失表现)
|
||||
- "3":候选人具备基础能力,但深度或广度不足(举例说明具体不足之处)
|
||||
- "5":候选人在该维度有突出表现,与岗位高度匹配(举例说明具体优秀表现)
|
||||
|
||||
仅以 JSON 格式回复,不要包含任何其他文字,不要有 markdown 代码块。格式:
|
||||
{
|
||||
"knockouts": ["否决项1", "否决项2"],
|
||||
"dimensions": [
|
||||
{
|
||||
"name": "维度名称",
|
||||
"weight": 30,
|
||||
"criteria": {
|
||||
"1": "1分的具体行为描述",
|
||||
"3": "3分的具体行为描述",
|
||||
"5": "5分的具体行为描述"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
try {
|
||||
logInfo(LOG, 'generateRubricFromJd start', { model: client.model, jdChars: String(sourceJd || '').length })
|
||||
const { completes } = await import('@geekgeekrun/utils/gpt-request.mjs')
|
||||
const completion = await completes(
|
||||
{
|
||||
baseURL: client.baseURL,
|
||||
apiKey: client.apiKey,
|
||||
model: client.model,
|
||||
max_tokens: 2000,
|
||||
response_format: { type: 'json_object' }
|
||||
},
|
||||
[
|
||||
{ role: 'system', content: systemContent },
|
||||
{ role: 'user', content: sourceJd || '(请输入岗位描述)' }
|
||||
]
|
||||
)
|
||||
const raw = completion?.choices?.[0]?.message?.content?.trim()
|
||||
logDebug(LOG, 'generateRubricFromJd raw length', raw?.length ?? 0)
|
||||
if (!raw) return { rubric: defaultRubric }
|
||||
const jsonStr = raw.replace(/^[\s\S]*?(\{[\s\S]*\})[\s\S]*$/, '$1')
|
||||
const parsed = JSON.parse(jsonStr)
|
||||
|
||||
const knockouts = Array.isArray(parsed.knockouts)
|
||||
? parsed.knockouts.filter((k) => typeof k === 'string').slice(0, 5)
|
||||
: []
|
||||
let dimensions
|
||||
const dimList = Array.isArray(parsed.dimensions) ? parsed.dimensions : []
|
||||
const dimCount = Math.min(5, dimList.length) || 1
|
||||
dimensions = dimList
|
||||
.filter((d) => d && typeof d.name === 'string' && d.criteria && typeof d.criteria === 'object')
|
||||
.slice(0, 5)
|
||||
.map((d) => ({
|
||||
name: String(d.name),
|
||||
weight: typeof d.weight === 'number' ? Math.max(0, Math.min(100, d.weight)) : 100 / dimCount,
|
||||
criteria: {
|
||||
'1': String(d.criteria['1'] || d.criteria[1] || ''),
|
||||
'3': String(d.criteria['3'] || d.criteria[3] || ''),
|
||||
'5': String(d.criteria['5'] || d.criteria[5] || '')
|
||||
}
|
||||
}))
|
||||
if (dimensions.length === 0) dimensions = defaultRubric.dimensions
|
||||
|
||||
// 归一化权重
|
||||
const totalWeight = dimensions.reduce((s, d) => s + (d.weight || 0), 0)
|
||||
if (totalWeight > 0) {
|
||||
dimensions = dimensions.map((d) => ({
|
||||
...d,
|
||||
weight: Math.round((100 * (d.weight || 0)) / totalWeight)
|
||||
}))
|
||||
}
|
||||
|
||||
return { rubric: { knockouts, dimensions } }
|
||||
} catch (err) {
|
||||
logError(LOG, 'generateRubricFromJd error', err?.message || err)
|
||||
return { rubric: defaultRubric }
|
||||
}
|
||||
}
|
||||
40
packages/boss-auto-browse-and-chat/logger.mjs
Normal file
40
packages/boss-auto-browse-and-chat/logger.mjs
Normal 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)
|
||||
}
|
||||
24
packages/boss-auto-browse-and-chat/package.json
Normal file
24
packages/boss-auto-browse-and-chat/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
341
packages/boss-auto-browse-and-chat/resume-extractor.mjs
Normal file
341
packages/boss-auto-browse-and-chat/resume-extractor.mjs
Normal file
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* 招聘端简历数据提取工具:网络请求拦截 + Canvas 文字提取
|
||||
*
|
||||
* 沟通页在线简历有两套不同的数据(详见 plan/chat_page_resume_flow.md):
|
||||
* - 简单摘要:geek/info、historyMsg body.resume 等 API 返回的只有简单工作单位、学校等,可拦截后 parseGeekInfoFromIntercepted 得到。
|
||||
* - 完整版(图片里那种):接收加密数据 → WASM(Rust decrypt.rs,Base64+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.data,text 为摘要拼接(供简单关键词/LLM 筛选)
|
||||
*/
|
||||
export function parseGeekInfoFromIntercepted (interceptedMap) {
|
||||
if (!interceptedMap || interceptedMap.size === 0) {
|
||||
return { data: null, text: '' }
|
||||
}
|
||||
for (const [path, raw] of interceptedMap) {
|
||||
if (!path || !path.includes(GEEK_INFO_PATH)) continue
|
||||
const res = typeof raw === 'object' && raw !== null && 'zpData' in raw
|
||||
? raw
|
||||
: null
|
||||
if (!res || !res.zpData || !res.zpData.data) {
|
||||
return { data: null, text: '' }
|
||||
}
|
||||
const d = res.zpData.data
|
||||
const parts = []
|
||||
if (d.name) parts.push(d.name)
|
||||
if (d.ageDesc) parts.push(d.ageDesc)
|
||||
if (d.workYear) parts.push(d.workYear)
|
||||
if (d.edu) parts.push(d.edu)
|
||||
if (d.positionStatus) parts.push(d.positionStatus)
|
||||
if (d.school) parts.push(d.school)
|
||||
if (d.major) parts.push(d.major)
|
||||
if (d.city) parts.push(d.city)
|
||||
if (d.salaryDesc || d.price) parts.push(d.salaryDesc || d.price)
|
||||
if (d.positionName || d.toPosition) parts.push(d.positionName || d.toPosition)
|
||||
if (Array.isArray(d.workExpList) && d.workExpList.length > 0) {
|
||||
parts.push('工作经历:')
|
||||
d.workExpList.forEach(w => {
|
||||
parts.push([w.timeDesc, w.company, w.positionName].filter(Boolean).join(' '))
|
||||
})
|
||||
}
|
||||
if (Array.isArray(d.eduExpList) && d.eduExpList.length > 0) {
|
||||
parts.push('教育经历:')
|
||||
d.eduExpList.forEach(e => {
|
||||
parts.push([e.timeDesc, e.school, e.major, e.degree].filter(Boolean).join(' '))
|
||||
})
|
||||
}
|
||||
const text = parts.join('\n')
|
||||
return { data: d, text }
|
||||
}
|
||||
return { data: null, text: '' }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canvas 文字 Hook(与 laodeng 兼容)— 非 BOSS 自带,可能被反爬检测,沟通页请用 API 拦截
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 在页面上通过 evaluateOnNewDocument 注入 Canvas fillText hook,将绘制文字收集到主页面 window.__canvasCapturedText。
|
||||
*
|
||||
* 实现原理:
|
||||
* - evaluateOnNewDocument 会在主页面和每一个 iframe 中各执行一次。
|
||||
* - 在线简历 iframe 带有 sandbox 属性且不含 allow-same-origin,主页面无法访问其 contentWindow,
|
||||
* 因此必须在 iframe 自身的执行上下文内直接 hook CanvasRenderingContext2D.prototype.fillText。
|
||||
* - iframe 内 hook 到的文字通过 window.top.postMessage 批量发回主页面(同 origin 或跨 origin 均可用)。
|
||||
* - 主页面监听 message 事件并累积到 window.__canvasCapturedText。
|
||||
*
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面实例(必须在 page.goto 之前调用)
|
||||
* @returns {Promise<{ getCapturedText: (page: import('puppeteer').Page) => Promise<Array<{text: string, x: number, y: number}>> }>}
|
||||
*/
|
||||
export async function setupCanvasTextHook (page) {
|
||||
// 转发浏览器内部 [canvasHook] 日志到 Node 侧,便于调试
|
||||
page.on('console', (msg) => {
|
||||
const text = msg.text()
|
||||
if (text.startsWith('[canvasHook]')) {
|
||||
console.log('[canvasHook-browser]', text)
|
||||
}
|
||||
})
|
||||
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
// 此脚本在每个 frame(主页面 + 所有 iframe)中各执行一次。
|
||||
// 策略:
|
||||
// 主页面 → 初始化收集数组,监听来自 iframe 的 postMessage
|
||||
// iframe → 直接 hook 当前窗口的 fillText,批量 postMessage 到 window.top
|
||||
|
||||
const isTopFrame = (window === window.top)
|
||||
|
||||
if (isTopFrame) {
|
||||
window.__canvasCapturedText = []
|
||||
window.addEventListener('message', (evt) => {
|
||||
if (evt.data && evt.data.__bossCanvasHook && Array.isArray(evt.data.__bossCanvasHook)) {
|
||||
if (!window.__canvasCapturedText) window.__canvasCapturedText = []
|
||||
for (const item of evt.data.__bossCanvasHook) {
|
||||
window.__canvasCapturedText.push(item)
|
||||
}
|
||||
console.log('[canvasHook] main received ' + evt.data.__bossCanvasHook.length + ' items, total ' + window.__canvasCapturedText.length)
|
||||
}
|
||||
})
|
||||
console.log('[canvasHook] main: message listener set')
|
||||
}
|
||||
|
||||
// 在当前 window(无论是主页面还是 iframe)上 hook fillText
|
||||
try {
|
||||
const proto = window.CanvasRenderingContext2D?.prototype
|
||||
if (!proto) { console.log('[canvasHook] CanvasRenderingContext2D.prototype not found'); return }
|
||||
if (proto._bossHooked) { console.log('[canvasHook] already hooked, skip'); return }
|
||||
proto._bossHooked = true
|
||||
|
||||
const origFillText = proto.fillText
|
||||
if (typeof origFillText !== 'function') { console.log('[canvasHook] fillText is not a function'); return }
|
||||
|
||||
// 批量缓冲,用 setTimeout(0) 在一个事件循环 tick 后统一发送(WASM 会在同一个同步调用栈内连续 fillText)
|
||||
const captured = []
|
||||
let flushScheduled = false
|
||||
const flush = () => {
|
||||
flushScheduled = false
|
||||
if (captured.length === 0) return
|
||||
const items = captured.splice(0)
|
||||
if (isTopFrame) {
|
||||
if (!window.__canvasCapturedText) window.__canvasCapturedText = []
|
||||
for (const item of items) window.__canvasCapturedText.push(item)
|
||||
console.log('[canvasHook] main fillText wrote ' + items.length + ' items')
|
||||
} else {
|
||||
try {
|
||||
window.top.postMessage({ __bossCanvasHook: items }, '*')
|
||||
console.log('[canvasHook] iframe postMessage sent ' + items.length + ' items')
|
||||
} catch (e) {
|
||||
console.log('[canvasHook] postMessage failed: ' + e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
const scheduleFlush = () => {
|
||||
if (!flushScheduled) { flushScheduled = true; setTimeout(flush, 0) }
|
||||
}
|
||||
|
||||
Object.defineProperty(proto, 'fillText', {
|
||||
value: new Proxy(origFillText, {
|
||||
apply (target, thisArg, args) {
|
||||
const [text, x, y] = args
|
||||
if (typeof text === 'string' && text.trim()) {
|
||||
captured.push({ text, x: Number(x) || 0, y: Number(y) || 0 })
|
||||
scheduleFlush()
|
||||
}
|
||||
return Reflect.apply(target, thisArg, args)
|
||||
}
|
||||
}),
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
console.log('[canvasHook] fillText hook installed, isTopFrame=' + isTopFrame + ' href=' + window.location.href)
|
||||
} catch (e) {
|
||||
console.log('[canvasHook] hook install error: ' + e.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 从主页面读取当前收集的 Canvas 文字并清空。
|
||||
* @param {import('puppeteer').Page} p - 同一页面实例
|
||||
* @returns {Promise<Array<{text: string, x: number, y: number}>>}
|
||||
*/
|
||||
async function getCapturedText (p) {
|
||||
// 给浏览器 150ms 处理待发送的 setTimeout(0)/postMessage 队列
|
||||
await p.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)))
|
||||
const result = await p.evaluate(() => {
|
||||
const arr = window.__canvasCapturedText || []
|
||||
const copy = arr.map(({ text, x, y }) => ({ text, x, y }))
|
||||
window.__canvasCapturedText = []
|
||||
return copy
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空主页面收集数组(不返回数据),用于切换候选人前丢弃上一个 iframe 的残留数据。
|
||||
* @param {import('puppeteer').Page} p - 同一页面实例
|
||||
*/
|
||||
async function clearCapturedText (p) {
|
||||
await p.evaluate(() => { window.__canvasCapturedText = [] })
|
||||
}
|
||||
|
||||
return { getCapturedText, clearCapturedText }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 文字按行整理
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 将 Canvas 捕获的 { text, x, y } 数组按行分组并排序,拼接成按行排列的文字数组。
|
||||
* 同一行:y 坐标差 < 5px;同一行内按 x 排序后去重(相邻 x 差 ≤1px 的视为多次渲染同一字符),再拼接。
|
||||
*
|
||||
* BOSS 直聘在线简历会用多个叠加 Canvas(高清/普通各一层)以及 WASM 多次渲染,
|
||||
* 导致同一字符在相同坐标被 fillText 多次,必须去重否则每字会重复 N 次。
|
||||
*
|
||||
* @param {Array<{text: string, x: number, y: number}>} capturedTextArray - Canvas 捕获结果
|
||||
* @returns {string[]} 按行排列的文字数组(已去重)
|
||||
*/
|
||||
export function extractResumeText (capturedTextArray) {
|
||||
if (!Array.isArray(capturedTextArray) || capturedTextArray.length === 0) {
|
||||
return []
|
||||
}
|
||||
const Y_THRESHOLD = 5
|
||||
// x 坐标差在此范围内视为同一位置的重复绘制(多层/多次渲染)
|
||||
const X_DEDUP_THRESHOLD = 1
|
||||
const rows = new Map()
|
||||
|
||||
for (const { text, x, y } of capturedTextArray) {
|
||||
const yKey = Math.round(y / Y_THRESHOLD) * Y_THRESHOLD
|
||||
if (!rows.has(yKey)) {
|
||||
rows.set(yKey, [])
|
||||
}
|
||||
rows.get(yKey).push({ text, x })
|
||||
}
|
||||
|
||||
const sortedY = Array.from(rows.keys()).sort((a, b) => a - b)
|
||||
const lines = sortedY.map(yKey => {
|
||||
const items = rows.get(yKey)
|
||||
items.sort((a, b) => a.x - b.x)
|
||||
// 去重:相邻项 x 差 ≤ X_DEDUP_THRESHOLD 时视为同一字符的重复绘制,保留第一个
|
||||
const deduped = items.filter((item, i) =>
|
||||
i === 0 || Math.abs(item.x - items[i - 1].x) > X_DEDUP_THRESHOLD
|
||||
)
|
||||
return deduped.map(it => it.text).join('')
|
||||
})
|
||||
return lines
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 统一获取简历数据(API 优先,Canvas 兜底)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 优先从拦截的 API 数据中取简历,若无则从页面 window.__canvasCapturedText 中提取(需先调用 setupCanvasTextHook)。
|
||||
*
|
||||
* @param {import('puppeteer').Page} page - Puppeteer 页面实例
|
||||
* @param {Map<string, unknown>} interceptedData - setupNetworkInterceptor 返回的 getInterceptedData() 的结果
|
||||
* @returns {Promise<{ source: 'api' | 'canvas', data: unknown }>} source 为 'api' 时 data 为 API 响应对象;为 'canvas' 时为 extractResumeText 的结果(字符串数组)
|
||||
*/
|
||||
export async function getResumeData (page, interceptedData) {
|
||||
if (interceptedData && interceptedData.size > 0) {
|
||||
const firstEntry = interceptedData.entries().next()
|
||||
if (!firstEntry.done) {
|
||||
const [path, data] = firstEntry.value
|
||||
return { source: 'api', data: { path, ...(typeof data === 'object' && data !== null ? data : { value: data }) } }
|
||||
}
|
||||
}
|
||||
const captured = await page.evaluate(() => {
|
||||
const arr = window.__canvasCapturedText || []
|
||||
const copy = arr.map(({ text, x, y }) => ({ text, x, y }))
|
||||
window.__canvasCapturedText = []
|
||||
return copy
|
||||
})
|
||||
const lines = extractResumeText(captured)
|
||||
return { source: 'canvas', data: lines }
|
||||
}
|
||||
355
packages/boss-auto-browse-and-chat/resume-scorer.mjs
Normal file
355
packages/boss-auto-browse-and-chat/resume-scorer.mjs
Normal 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 : 0–20
|
||||
* - experience : 0–20
|
||||
* - skills : 0–30
|
||||
* - city : 0–10
|
||||
* - salary : 0–10
|
||||
* - completeness : 0–10
|
||||
*
|
||||
* 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 0–100
|
||||
* @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);
|
||||
}
|
||||
381
packages/boss-auto-browse-and-chat/runtime-file-utils.mjs
Normal file
381
packages/boss-auto-browse-and-chat/runtime-file-utils.mjs
Normal 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 格式(简历筛选)。
|
||||
* 优先级:resumeLlmEnabled(rubric)> resumeLlmEnabled(rule)> resumeKeywordsEnabled > resumeRegExpEnabled
|
||||
*/
|
||||
function jobFilterToChatPageFilter (jobFilter) {
|
||||
if (!jobFilter || typeof jobFilter !== 'object') {
|
||||
return { mode: 'keywords', keywordList: [], llmRule: '', llmConfig: null }
|
||||
}
|
||||
const f = jobFilter
|
||||
// resumeLlmConfig(Rubric 模式)优先
|
||||
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 }
|
||||
}
|
||||
// resumeRegExpEnabled:chat-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))
|
||||
}
|
||||
|
||||
49
packages/run-core-of-boss-auto-browse/daemon-main.mjs
Normal file
49
packages/run-core-of-boss-auto-browse/daemon-main.mjs
Normal 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()
|
||||
57
packages/run-core-of-boss-auto-browse/enums.mjs
Normal file
57
packages/run-core-of-boss-auto-browse/enums.mjs
Normal 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
|
||||
})()
|
||||
101
packages/run-core-of-boss-auto-browse/main.mjs
Normal file
101
packages/run-core-of-boss-auto-browse/main.mjs
Normal 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)
|
||||
}
|
||||
})()
|
||||
19
packages/run-core-of-boss-auto-browse/package.json
Normal file
19
packages/run-core-of-boss-auto-browse/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
30
packages/sqlite-plugin/src/entity/CandidateContactLog.ts
Normal file
30
packages/sqlite-plugin/src/entity/CandidateContactLog.ts
Normal 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;
|
||||
}
|
||||
73
packages/sqlite-plugin/src/entity/CandidateInfo.ts
Normal file
73
packages/sqlite-plugin/src/entity/CandidateInfo.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"sourceMap": false,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"exclude": ["node_modules"],
|
||||
}
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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: '登录状态检查(若浏览器弹出请以招聘者身份登录)'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
399
packages/ui/src/main/features/webhook/index.ts
Normal file
399
packages/ui/src/main/features/webhook/index.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import { app, dialog } from 'electron'
|
||||
import { AsyncSeriesHook, AsyncSeriesWaterfallHook } from 'tapable'
|
||||
import { sleep } from '@geekgeekrun/utils/sleep.mjs'
|
||||
import { AUTO_CHAT_ERROR_EXIT_CODE } from '../../../common/enums/auto-start-chat'
|
||||
import attachListenerForKillSelfOnParentExited from '../../utils/attachListenerForKillSelfOnParentExited'
|
||||
import minimist from 'minimist'
|
||||
import SqlitePluginModule from '@geekgeekrun/sqlite-plugin'
|
||||
import { connectToDaemon, sendToDaemon } from '../OPEN_SETTING_WINDOW/connect-to-daemon'
|
||||
import { checkShouldExit } from '../../utils/worker'
|
||||
import initPublicIpc from '../../utils/initPublicIpc'
|
||||
import { forwardConsoleLogToDaemon } from '../../utils/forwardConsoleLogToDaemon'
|
||||
import { getLastUsedAndAvailableBrowser } from '../DOWNLOAD_DEPENDENCIES/utils/browser-history'
|
||||
import path from 'path'
|
||||
const { default: SqlitePlugin } = SqlitePluginModule
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('收到SIGTERM信号,正在退出')
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
const rerunInterval = (() => {
|
||||
let v = Number(process.env.MAIN_BOSSGEEKGO_RERUN_INTERVAL)
|
||||
if (isNaN(v)) {
|
||||
v = 3000
|
||||
}
|
||||
return v
|
||||
})()
|
||||
|
||||
const initPlugins = async (hooks) => {
|
||||
const { storageFilePath } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
new SqlitePlugin(path.join(storageFilePath, 'public.db')).apply(hooks)
|
||||
}
|
||||
|
||||
const runRecordId = minimist(process.argv.slice(2))['run-record-id'] ?? null
|
||||
|
||||
const log = (msg: string) => {
|
||||
console.log(`[boss-worker] ${msg}`)
|
||||
}
|
||||
|
||||
const runAutoBrowseAndChat = async () => {
|
||||
app.dock?.hide()
|
||||
log('runAutoBrowseAndChat 开始')
|
||||
log(`正在查找可用浏览器...`)
|
||||
let puppeteerExecutable = await getLastUsedAndAvailableBrowser()
|
||||
if (!puppeteerExecutable) {
|
||||
log('未找到可用浏览器,退出')
|
||||
await dialog.showMessageBox({
|
||||
type: `error`,
|
||||
message: `未找到可用的浏览器`,
|
||||
detail: `请重新运行本程序,按照提示安装、配置浏览器`
|
||||
})
|
||||
sendToDaemon({
|
||||
type: 'worker-to-gui-message',
|
||||
data: {
|
||||
type: 'prerequisite-step-by-step-checkstep-by-step-check',
|
||||
step: {
|
||||
id: 'puppeteer-executable-check',
|
||||
status: 'rejected'
|
||||
},
|
||||
runRecordId
|
||||
}
|
||||
})
|
||||
app.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE)
|
||||
return
|
||||
}
|
||||
log(`找到浏览器: ${puppeteerExecutable.executablePath}`)
|
||||
sendToDaemon({
|
||||
type: 'worker-to-gui-message',
|
||||
data: {
|
||||
type: 'prerequisite-step-by-step-checkstep-by-step-check',
|
||||
step: {
|
||||
id: 'puppeteer-executable-check',
|
||||
status: 'fulfilled'
|
||||
},
|
||||
runRecordId
|
||||
}
|
||||
})
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH = puppeteerExecutable.executablePath
|
||||
|
||||
log('正在动态 import boss package...')
|
||||
type BossAutoBrowseModule = {
|
||||
default: (hooks: any, opts?: { returnBrowser?: boolean; jobId?: string; browser?: any; page?: any }) => Promise<void | { browser: any; page: any }>
|
||||
startBossChatPageProcess: (hooks: any, options?: { browser?: any; page?: any; jobId?: string }) => Promise<void>
|
||||
initPuppeteer: () => Promise<any>
|
||||
launchBrowserAndNavigateToChat?: () => Promise<{ browser: any; page: any }>
|
||||
bossAutoBrowseEventBus: InstanceType<typeof import('node:events').EventEmitter>
|
||||
}
|
||||
const {
|
||||
default: startBossAutoBrowse,
|
||||
startBossChatPageProcess,
|
||||
initPuppeteer,
|
||||
launchBrowserAndNavigateToChat,
|
||||
bossAutoBrowseEventBus
|
||||
} = (await import('@geekgeekrun/boss-auto-browse-and-chat/index.mjs')) as unknown as BossAutoBrowseModule
|
||||
log('boss package import 完成,初始化 puppeteer...')
|
||||
|
||||
process.on('disconnect', () => {
|
||||
app.exit()
|
||||
})
|
||||
|
||||
await initPuppeteer()
|
||||
log('puppeteer 初始化完成,初始化 hooks 和插件...')
|
||||
|
||||
const hooks = {
|
||||
beforeBrowserLaunch: new AsyncSeriesHook(['_']),
|
||||
afterBrowserLaunch: new AsyncSeriesHook(['_']),
|
||||
beforeNavigateToRecommend: new AsyncSeriesHook(['_']),
|
||||
onCandidateListLoaded: new AsyncSeriesHook(['_']),
|
||||
onCandidateFiltered: new AsyncSeriesWaterfallHook(['candidates', 'filterResult'] as any),
|
||||
beforeStartChat: new AsyncSeriesHook(['candidate']),
|
||||
afterChatStarted: new AsyncSeriesHook(['candidate', 'result'] as any),
|
||||
onError: new AsyncSeriesHook(['error']),
|
||||
onComplete: new AsyncSeriesHook(['_']),
|
||||
onProgress: new AsyncSeriesHook(['payload'] as any)
|
||||
}
|
||||
|
||||
await initPlugins(hooks)
|
||||
log('插件初始化完成,即将启动浏览器...')
|
||||
|
||||
hooks.beforeBrowserLaunch.tapPromise('log', async () => { log('beforeBrowserLaunch') })
|
||||
hooks.afterBrowserLaunch.tapPromise('log', async () => { log('afterBrowserLaunch - 浏览器已启动') })
|
||||
hooks.beforeNavigateToRecommend.tapPromise('log', async () => { log('beforeNavigateToRecommend - 正在导航到推荐页') })
|
||||
|
||||
bossAutoBrowseEventBus.once('LOGIN_STATUS_INVALID', () => {})
|
||||
|
||||
hooks.onCandidateListLoaded.tap('sendLoginStatusCheck', () => {
|
||||
log('onCandidateListLoaded - 登录成功,候选人列表已加载')
|
||||
sendToDaemon({
|
||||
type: 'worker-to-gui-message',
|
||||
data: {
|
||||
type: 'prerequisite-step-by-step-checkstep-by-step-check',
|
||||
step: {
|
||||
id: 'login-status-check',
|
||||
status: 'fulfilled'
|
||||
},
|
||||
runRecordId
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Accumulate candidate results for webhook reporting
|
||||
const sessionCandidates: Array<{
|
||||
basicInfo?: Record<string, unknown>
|
||||
filterReport?: Record<string, unknown>
|
||||
llmConclusion?: string
|
||||
resumeFile?: { path?: string; filename?: string }
|
||||
}> = []
|
||||
|
||||
hooks.afterChatStarted.tapPromise('collectCandidateForWebhook', async (candidate: unknown) => {
|
||||
const c = candidate as Record<string, unknown>
|
||||
const entry = {
|
||||
basicInfo: c?.info as Record<string, unknown> | undefined,
|
||||
filterReport: {
|
||||
matched: true,
|
||||
matchedRules: (c?.matchedRules as string[] | undefined) ?? [],
|
||||
score: c?.score as number | undefined
|
||||
},
|
||||
llmConclusion: c?.llmConclusion as string | undefined,
|
||||
resumeFile: c?.resumeFilePath
|
||||
? { path: c.resumeFilePath as string, filename: c?.resumeFileName as string | undefined }
|
||||
: undefined
|
||||
}
|
||||
sessionCandidates.push(entry)
|
||||
|
||||
// 逐条实时触发:每打招呼后立即发送一条 webhook
|
||||
try {
|
||||
const { readConfigFile: readBossConfigFile, storageFilePath } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
const webhookConfig = readBossConfigFile('webhook.json')
|
||||
if (webhookConfig?.enabled && webhookConfig?.url && webhookConfig?.sendMode === 'realtime') {
|
||||
const { sendWebhook, normalizeWebhookConfig } = await import('../../features/webhook/index')
|
||||
const normalized = normalizeWebhookConfig(webhookConfig)
|
||||
if (normalized?.sendMode === 'realtime') {
|
||||
const runId = `run-${runRecordId ?? Date.now()}`
|
||||
const timestamp = new Date().toISOString()
|
||||
const webhookPayload = {
|
||||
runId,
|
||||
timestamp,
|
||||
summary: { total: 1, matched: 1, skipped: 0 },
|
||||
candidates: [entry]
|
||||
}
|
||||
log(`webhook 实时发送 1 条候选人...`)
|
||||
await sendWebhook(normalized, webhookPayload, { storageDir: storageFilePath })
|
||||
log(`webhook 实时发送完成`)
|
||||
}
|
||||
}
|
||||
} catch (realtimeErr) {
|
||||
log(
|
||||
`webhook 实时发送失败(不影响主流程):${realtimeErr instanceof Error ? realtimeErr.message : String(realtimeErr)}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
hooks.onProgress.tap('sendProgressToGui', (payload: unknown) => {
|
||||
const p = payload as { phase?: string; current?: number; max?: number }
|
||||
sendToDaemon({
|
||||
type: 'worker-to-gui-message',
|
||||
data: {
|
||||
type: 'boss-auto-browse-progress',
|
||||
workerId: 'bossAutoBrowseAndChatMain',
|
||||
runRecordId,
|
||||
phase: p?.phase,
|
||||
current: p?.current ?? 0,
|
||||
max: p?.max ?? 0
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const { readBossJobsConfig, getMergedJobConfig } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
const jobsConfig = readBossJobsConfig()
|
||||
const sequenceJobs = (jobsConfig.jobs || []).filter(
|
||||
(j: any) => j.sequence?.enabled === true
|
||||
)
|
||||
|
||||
if (sequenceJobs.length > 0) {
|
||||
log(`检测到多职位队列,共 ${sequenceJobs.length} 个职位,依次执行...`)
|
||||
let sharedBrowser: any = null
|
||||
let sharedPage: any = null
|
||||
|
||||
try {
|
||||
for (const job of sequenceJobs) {
|
||||
const jid = job.jobId ?? job.id
|
||||
const jname = job.jobName ?? job.name
|
||||
log(`开始执行职位 ${jid}(${jname})...`)
|
||||
void getMergedJobConfig(jid)
|
||||
|
||||
const runRecommend = job.sequence?.runRecommend !== false
|
||||
const runChat = job.sequence?.runChat !== false
|
||||
|
||||
if (runChat && !sharedPage) {
|
||||
log(`[${jid}] 仅沟通页,先启动浏览器...`)
|
||||
const boot = await launchBrowserAndNavigateToChat()
|
||||
sharedBrowser = boot.browser
|
||||
sharedPage = boot.page
|
||||
}
|
||||
|
||||
if (runRecommend) {
|
||||
log(`[${jid}] 执行推荐页...`)
|
||||
const result = await startBossAutoBrowse(hooks, {
|
||||
returnBrowser: true,
|
||||
jobId: jid,
|
||||
browser: sharedBrowser ?? undefined,
|
||||
page: sharedPage ?? undefined
|
||||
} as any)
|
||||
if (result?.browser) {
|
||||
sharedBrowser = result.browser
|
||||
sharedPage = result.page
|
||||
}
|
||||
}
|
||||
|
||||
if (runChat && sharedBrowser && sharedPage) {
|
||||
log(`[${jid}] 执行沟通页...`)
|
||||
await startBossChatPageProcess(hooks, {
|
||||
browser: sharedBrowser,
|
||||
page: sharedPage,
|
||||
jobId: jid
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (sharedBrowser) {
|
||||
try {
|
||||
await sharedBrowser.close()
|
||||
} catch (e) {
|
||||
void e
|
||||
}
|
||||
sharedBrowser = null
|
||||
sharedPage = null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log('开始执行 startBossAutoBrowse(推荐页)...')
|
||||
const result = await startBossAutoBrowse(hooks, { returnBrowser: true })
|
||||
if (result?.browser && result?.page) {
|
||||
try {
|
||||
log('推荐页完成,开始处理沟通页未读...')
|
||||
await startBossChatPageProcess(hooks, { browser: result.browser, page: result.page })
|
||||
} finally {
|
||||
try {
|
||||
await result.browser.close()
|
||||
} catch (e) {
|
||||
void e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log('startBossAutoBrowse + 沟通页 完成,检查 webhook 配置...')
|
||||
try {
|
||||
const { readConfigFile: readBossConfigFile, storageFilePath } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
const webhookConfig = readBossConfigFile('webhook.json')
|
||||
if (!webhookConfig?.enabled || !webhookConfig?.url) {
|
||||
log('webhook 未启用或未配置 URL,跳过发送')
|
||||
} else if (webhookConfig.sendMode === 'realtime') {
|
||||
log('webhook 为实时模式,已在每条打招呼后发送,跳过汇总发送')
|
||||
} else if (sessionCandidates.length === 0) {
|
||||
log('本轮无候选人数据,跳过 webhook')
|
||||
} else {
|
||||
const { sendWebhook } = await import('../../features/webhook/index')
|
||||
const matched = sessionCandidates.filter((c) => c.filterReport?.matched !== false).length
|
||||
const skipped = sessionCandidates.length - matched
|
||||
const webhookPayload = {
|
||||
runId: `run-${runRecordId ?? Date.now()}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
summary: { total: sessionCandidates.length, matched, skipped },
|
||||
candidates: sessionCandidates as Parameters<typeof sendWebhook>[1]['candidates']
|
||||
}
|
||||
log(`正在发送 webhook,共 ${sessionCandidates.length} 条候选人数据...`)
|
||||
const webhookResult = await sendWebhook(webhookConfig, webhookPayload, {
|
||||
storageDir: storageFilePath
|
||||
})
|
||||
log(`webhook 发送完成,HTTP ${webhookResult.status},body 长度 ${webhookResult.body.length}`)
|
||||
}
|
||||
} catch (webhookErr) {
|
||||
log(`webhook 发送失败(不影响主流程):${webhookErr instanceof Error ? webhookErr.message : String(webhookErr)}`)
|
||||
}
|
||||
sessionCandidates.length = 0
|
||||
log('等待下次运行...')
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes('LOGIN_STATUS_INVALID')) {
|
||||
await dialog.showMessageBox({
|
||||
type: `error`,
|
||||
message: `登录状态无效`,
|
||||
detail: `请重新登录BOSS直聘(招聘端)`
|
||||
})
|
||||
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.LOGIN_STATUS_INVALID)
|
||||
break
|
||||
}
|
||||
if (err.message.includes('ERR_INTERNET_DISCONNECTED')) {
|
||||
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ERR_INTERNET_DISCONNECTED)
|
||||
break
|
||||
}
|
||||
if (err.message.includes('ACCESS_IS_DENIED')) {
|
||||
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ACCESS_IS_DENIED)
|
||||
break
|
||||
}
|
||||
if (
|
||||
err.message.includes(`Could not find Chrome`) ||
|
||||
err.message.includes(`no executable was found`)
|
||||
) {
|
||||
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE)
|
||||
break
|
||||
}
|
||||
}
|
||||
console.error(err)
|
||||
const shouldExit = await checkShouldExit()
|
||||
if (shouldExit) {
|
||||
app.exit()
|
||||
return
|
||||
}
|
||||
console.log(
|
||||
`[Boss Auto Browse Main] An internal error is caught, and browser will be restarted in ${rerunInterval}ms.`
|
||||
)
|
||||
await sleep(rerunInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const waitForProcessHandShakeAndRunAutoChat = async () => {
|
||||
await app.whenReady()
|
||||
app.on('window-all-closed', () => {
|
||||
// keep process alive while worker is running
|
||||
})
|
||||
initPublicIpc()
|
||||
await connectToDaemon()
|
||||
forwardConsoleLogToDaemon('bossAutoBrowseAndChatMain', runRecordId)
|
||||
await sendToDaemon(
|
||||
{
|
||||
type: 'ping'
|
||||
},
|
||||
{
|
||||
needCallback: true
|
||||
}
|
||||
)
|
||||
sendToDaemon({
|
||||
type: 'worker-to-gui-message',
|
||||
data: {
|
||||
type: 'prerequisite-step-by-step-checkstep-by-step-check',
|
||||
step: {
|
||||
id: 'worker-launch',
|
||||
status: 'fulfilled'
|
||||
},
|
||||
runRecordId
|
||||
}
|
||||
})
|
||||
runAutoBrowseAndChat()
|
||||
}
|
||||
|
||||
attachListenerForKillSelfOnParentExited()
|
||||
244
packages/ui/src/main/flow/BOSS_CHAT_DEBUG_MAIN/index.ts
Normal file
244
packages/ui/src/main/flow/BOSS_CHAT_DEBUG_MAIN/index.ts
Normal 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()
|
||||
291
packages/ui/src/main/flow/BOSS_CHAT_PAGE_MAIN/index.ts
Normal file
291
packages/ui/src/main/flow/BOSS_CHAT_PAGE_MAIN/index.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { app, dialog } from 'electron'
|
||||
import { AsyncSeriesHook, AsyncSeriesWaterfallHook } from 'tapable'
|
||||
import { sleep } from '@geekgeekrun/utils/sleep.mjs'
|
||||
import { AUTO_CHAT_ERROR_EXIT_CODE } from '../../../common/enums/auto-start-chat'
|
||||
import attachListenerForKillSelfOnParentExited from '../../utils/attachListenerForKillSelfOnParentExited'
|
||||
import minimist from 'minimist'
|
||||
import SqlitePluginModule from '@geekgeekrun/sqlite-plugin'
|
||||
import { connectToDaemon, sendToDaemon } from '../OPEN_SETTING_WINDOW/connect-to-daemon'
|
||||
import { checkShouldExit } from '../../utils/worker'
|
||||
import initPublicIpc from '../../utils/initPublicIpc'
|
||||
import { forwardConsoleLogToDaemon } from '../../utils/forwardConsoleLogToDaemon'
|
||||
import { getLastUsedAndAvailableBrowser } from '../DOWNLOAD_DEPENDENCIES/utils/browser-history'
|
||||
import path from 'path'
|
||||
const { default: SqlitePlugin } = SqlitePluginModule
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('收到SIGTERM信号,正在退出')
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
const rerunInterval = (() => {
|
||||
let v = Number(process.env.MAIN_BOSSGEEKGO_RERUN_INTERVAL)
|
||||
if (isNaN(v)) {
|
||||
v = 3000
|
||||
}
|
||||
return v
|
||||
})()
|
||||
|
||||
const initPlugins = async (hooks) => {
|
||||
const { storageFilePath } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
new SqlitePlugin(path.join(storageFilePath, 'public.db')).apply(hooks)
|
||||
}
|
||||
|
||||
const runRecordId = minimist(process.argv.slice(2))['run-record-id'] ?? null
|
||||
|
||||
const log = (msg: string) => {
|
||||
console.log(`[boss-chat-page-worker] ${msg}`)
|
||||
}
|
||||
|
||||
const runChatPage = async () => {
|
||||
app.dock?.hide()
|
||||
log('runChatPage 开始')
|
||||
log(`正在查找可用浏览器...`)
|
||||
let puppeteerExecutable = await getLastUsedAndAvailableBrowser()
|
||||
if (!puppeteerExecutable) {
|
||||
log('未找到可用浏览器,退出')
|
||||
await dialog.showMessageBox({
|
||||
type: `error`,
|
||||
message: `未找到可用的浏览器`,
|
||||
detail: `请重新运行本程序,按照提示安装、配置浏览器`
|
||||
})
|
||||
sendToDaemon({
|
||||
type: 'worker-to-gui-message',
|
||||
data: {
|
||||
type: 'prerequisite-step-by-step-checkstep-by-step-check',
|
||||
step: {
|
||||
id: 'puppeteer-executable-check',
|
||||
status: 'rejected'
|
||||
},
|
||||
runRecordId
|
||||
}
|
||||
})
|
||||
app.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE)
|
||||
return
|
||||
}
|
||||
log(`找到浏览器: ${puppeteerExecutable.executablePath}`)
|
||||
sendToDaemon({
|
||||
type: 'worker-to-gui-message',
|
||||
data: {
|
||||
type: 'prerequisite-step-by-step-checkstep-by-step-check',
|
||||
step: {
|
||||
id: 'puppeteer-executable-check',
|
||||
status: 'fulfilled'
|
||||
},
|
||||
runRecordId
|
||||
}
|
||||
})
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH = puppeteerExecutable.executablePath
|
||||
|
||||
log('正在动态 import boss package...')
|
||||
type BossAutoBrowseModule = {
|
||||
startBossChatPageProcess: (hooks: any, options?: { browser?: any; page?: any; getCapturedText?: any; clearCapturedText?: any }) => Promise<void>
|
||||
initPuppeteer: () => Promise<{ puppeteer: any }>
|
||||
}
|
||||
const {
|
||||
startBossChatPageProcess,
|
||||
initPuppeteer
|
||||
} = (await import('@geekgeekrun/boss-auto-browse-and-chat/index.mjs')) as unknown as BossAutoBrowseModule
|
||||
const { setupCanvasTextHook } = (await import('@geekgeekrun/boss-auto-browse-and-chat/resume-extractor.mjs')) as any
|
||||
log('boss package import 完成,初始化 puppeteer...')
|
||||
|
||||
process.on('disconnect', () => {
|
||||
app.exit()
|
||||
})
|
||||
|
||||
const { puppeteer } = await initPuppeteer()
|
||||
log('puppeteer 初始化完成,初始化 hooks 和插件...')
|
||||
|
||||
const hooks = {
|
||||
beforeBrowserLaunch: new AsyncSeriesHook(['_']),
|
||||
afterBrowserLaunch: new AsyncSeriesHook(['_']),
|
||||
beforeNavigateToRecommend: new AsyncSeriesHook(['_']),
|
||||
onCandidateListLoaded: new AsyncSeriesHook(['_']),
|
||||
onCandidateFiltered: new AsyncSeriesWaterfallHook(['candidates', 'filterResult'] as any),
|
||||
beforeStartChat: new AsyncSeriesHook(['candidate']),
|
||||
afterChatStarted: new AsyncSeriesHook(['candidate', 'result'] as any),
|
||||
onError: new AsyncSeriesHook(['error']),
|
||||
onComplete: new AsyncSeriesHook(['_']),
|
||||
onProgress: new AsyncSeriesHook(['payload'] as any)
|
||||
}
|
||||
|
||||
await initPlugins(hooks)
|
||||
log('插件初始化完成')
|
||||
|
||||
hooks.afterBrowserLaunch.tapPromise('log', async () => { log('afterBrowserLaunch - 浏览器已启动') })
|
||||
hooks.onProgress.tap('sendProgressToGui', (payload: unknown) => {
|
||||
const p = payload as { phase?: string; current?: number; max?: number }
|
||||
sendToDaemon({
|
||||
type: 'worker-to-gui-message',
|
||||
data: {
|
||||
type: 'boss-auto-browse-progress',
|
||||
workerId: 'bossChatPageMain',
|
||||
runRecordId,
|
||||
phase: p?.phase,
|
||||
current: p?.current ?? 0,
|
||||
max: p?.max ?? 0
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const { readStorageFile, ensureStorageFileExist } = await import('@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs') as any
|
||||
ensureStorageFileExist()
|
||||
|
||||
const { BOSS_CHAT_PAGE_URL } = await import('@geekgeekrun/boss-auto-browse-and-chat/constant.mjs') as any
|
||||
const { setDomainLocalStorage } = await import('@geekgeekrun/utils/puppeteer/local-storage.mjs') as any
|
||||
const localStoragePageUrl = 'https://www.zhipin.com/desktop/'
|
||||
|
||||
while (true) {
|
||||
let browser: any = null
|
||||
try {
|
||||
const { readConfigFile: readCfg } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
) as any
|
||||
const cfg = readCfg('boss-recruiter.json') as {
|
||||
chatPage?: {
|
||||
runOnceAfterComplete?: boolean
|
||||
rerunIntervalMs?: number
|
||||
keepBrowserOpenAfterRun?: boolean
|
||||
}
|
||||
}
|
||||
const runOnceAfterComplete = cfg?.chatPage?.runOnceAfterComplete === true
|
||||
const keepBrowserOpenAfterRun = cfg?.chatPage?.keepBrowserOpenAfterRun === true
|
||||
|
||||
log('启动浏览器...')
|
||||
await hooks.beforeBrowserLaunch?.promise?.()
|
||||
|
||||
const headless = process.env.HEADLESS === '1'
|
||||
browser = await puppeteer.launch({
|
||||
headless,
|
||||
ignoreHTTPSErrors: true,
|
||||
protocolTimeout: 120000,
|
||||
defaultViewport: { width: 1440, height: 900 - 140 }
|
||||
})
|
||||
|
||||
await hooks.afterBrowserLaunch?.promise?.()
|
||||
|
||||
const bossCookies = readStorageFile('boss-cookies.json')
|
||||
const bossLocalStorage = readStorageFile('boss-local-storage.json')
|
||||
|
||||
const page = (await browser.pages())[0]
|
||||
// 注入 Canvas fillText hook,必须在页面导航前注入(evaluateOnNewDocument)
|
||||
const { getCapturedText, clearCapturedText } = await setupCanvasTextHook(page)
|
||||
if (Array.isArray(bossCookies) && bossCookies.length > 0) {
|
||||
await page.setCookie(...bossCookies)
|
||||
}
|
||||
await setDomainLocalStorage(browser, localStoragePageUrl, bossLocalStorage || {})
|
||||
await page.goto(BOSS_CHAT_PAGE_URL, { timeout: 60 * 1000 })
|
||||
await page.waitForFunction(() => document.readyState === 'complete', { timeout: 120 * 1000 })
|
||||
|
||||
sendToDaemon({
|
||||
type: 'worker-to-gui-message',
|
||||
data: {
|
||||
type: 'prerequisite-step-by-step-checkstep-by-step-check',
|
||||
step: { id: 'login-status-check', status: 'fulfilled' },
|
||||
runRecordId
|
||||
}
|
||||
})
|
||||
|
||||
log('开始执行 startBossChatPageProcess(沟通页)...')
|
||||
await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText })
|
||||
log('startBossChatPageProcess 完成')
|
||||
|
||||
if (runOnceAfterComplete) {
|
||||
if (keepBrowserOpenAfterRun) {
|
||||
log('运行已结束,浏览器保持打开,请手动关闭浏览器窗口后将自动退出')
|
||||
await new Promise<void>((resolve) => {
|
||||
browser!.once('disconnected', () => resolve())
|
||||
})
|
||||
} else {
|
||||
try { await browser.close() } catch (e) { void e }
|
||||
}
|
||||
log('已配置 runOnceAfterComplete,本次运行后停止')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try { await browser.close() } catch (e) { void e }
|
||||
browser = null
|
||||
const rerunMs = cfg?.chatPage?.rerunIntervalMs ?? rerunInterval
|
||||
log(`下次运行将在 ${rerunMs}ms 后开始`)
|
||||
await sleep(rerunMs)
|
||||
} catch (err) {
|
||||
if (browser) {
|
||||
try { await browser.close() } catch (e) { void e }
|
||||
browser = null
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes('LOGIN_STATUS_INVALID')) {
|
||||
await dialog.showMessageBox({
|
||||
type: `error`,
|
||||
message: `登录状态无效`,
|
||||
detail: `请重新登录BOSS直聘(招聘端)`
|
||||
})
|
||||
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.LOGIN_STATUS_INVALID)
|
||||
break
|
||||
}
|
||||
if (err.message.includes('ERR_INTERNET_DISCONNECTED')) {
|
||||
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ERR_INTERNET_DISCONNECTED)
|
||||
break
|
||||
}
|
||||
if (err.message.includes('ACCESS_IS_DENIED')) {
|
||||
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ACCESS_IS_DENIED)
|
||||
break
|
||||
}
|
||||
if (
|
||||
err.message.includes(`Could not find Chrome`) ||
|
||||
err.message.includes(`no executable was found`)
|
||||
) {
|
||||
process.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE)
|
||||
break
|
||||
}
|
||||
}
|
||||
console.error('[Boss Chat Page Main] error:', err instanceof Error ? `${err.name}: ${err.message}\n${err.stack}` : JSON.stringify(err))
|
||||
const shouldExit = await checkShouldExit()
|
||||
if (shouldExit) {
|
||||
app.exit()
|
||||
return
|
||||
}
|
||||
const { readConfigFile: readErrCfg } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
) as any
|
||||
const errCfg = readErrCfg('boss-recruiter.json') as { chatPage?: { rerunIntervalMs?: number } }
|
||||
const errRerunMs = errCfg?.chatPage?.rerunIntervalMs ?? rerunInterval
|
||||
log(`发生错误,浏览器将在 ${errRerunMs}ms 后重启`)
|
||||
await sleep(errRerunMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const waitForProcessHandShakeAndRunAutoChat = async () => {
|
||||
await app.whenReady()
|
||||
app.on('window-all-closed', () => {
|
||||
// keep process alive while worker is running
|
||||
})
|
||||
initPublicIpc()
|
||||
await connectToDaemon()
|
||||
forwardConsoleLogToDaemon('bossChatPageMain', runRecordId)
|
||||
await sendToDaemon(
|
||||
{
|
||||
type: 'ping'
|
||||
},
|
||||
{
|
||||
needCallback: true
|
||||
}
|
||||
)
|
||||
sendToDaemon({
|
||||
type: 'worker-to-gui-message',
|
||||
data: {
|
||||
type: 'prerequisite-step-by-step-checkstep-by-step-check',
|
||||
step: {
|
||||
id: 'worker-launch',
|
||||
status: 'fulfilled'
|
||||
},
|
||||
runRecordId
|
||||
}
|
||||
})
|
||||
runChatPage()
|
||||
}
|
||||
|
||||
attachListenerForKillSelfOnParentExited()
|
||||
281
packages/ui/src/main/flow/BOSS_RECOMMEND_MAIN/index.ts
Normal file
281
packages/ui/src/main/flow/BOSS_RECOMMEND_MAIN/index.ts
Normal 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()
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
readStorageFile,
|
||||
storageFilePath
|
||||
} from '@geekgeekrun/geek-auto-start-chat-with-boss/runtime-file-utils.mjs'
|
||||
|
||||
import { ChildProcess } from 'child_process'
|
||||
import * as JSONStream from 'JSONStream'
|
||||
import { checkCookieListFormat } from '../../../../common/utils/cookie'
|
||||
@@ -420,7 +421,7 @@ export default function initIpc() {
|
||||
modal: true,
|
||||
show: true
|
||||
})
|
||||
const defer = Promise.withResolvers()
|
||||
const defer = Promise.withResolvers<void>()
|
||||
async function saveLlmConfigHandler(_, configToSave) {
|
||||
await writeConfigFile('llm.json', configToSave)
|
||||
defer.resolve()
|
||||
@@ -436,13 +437,52 @@ export default function initIpc() {
|
||||
})
|
||||
ipcMain.on('close-llm-config', () => llmConfigWindow?.close())
|
||||
|
||||
// ── 招聘端 LLM 配置 (boss-llm.json) ─────────────────────────────────────────
|
||||
ipcMain.handle('boss-fetch-llm-config', async () => {
|
||||
const { readBossLlmConfig } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
return readBossLlmConfig()
|
||||
})
|
||||
|
||||
ipcMain.handle('boss-save-llm-config', async (_, payload) => {
|
||||
const { writeBossLlmConfig } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
const config = typeof payload === 'string' ? JSON.parse(payload) : payload
|
||||
return await writeBossLlmConfig(config)
|
||||
})
|
||||
|
||||
ipcMain.handle('boss-test-llm-endpoint', async (_, model: {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
}) => {
|
||||
try {
|
||||
// 使用 GET /models 验证 baseURL + apiKey 的连通性,不消耗任何 token
|
||||
const { net } = await import('electron')
|
||||
const url = model.baseURL.replace(/\/$/, '') + '/models'
|
||||
const res = await net.fetch(url, {
|
||||
headers: { Authorization: `Bearer ${model.apiKey}` }
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
return { ok: false, error: `${res.status} ${res.statusText}${body ? ': ' + body.slice(0, 200) : ''}` }
|
||||
}
|
||||
return { ok: true }
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
return { ok: false, error: msg }
|
||||
}
|
||||
})
|
||||
// ── end 招聘端 LLM 配置窗口 ──────────────────────────────────────────────────
|
||||
|
||||
ipcMain.handle('resume-edit', async () => {
|
||||
createResumeEditorWindow({
|
||||
parent: mainWindow!,
|
||||
modal: true,
|
||||
show: true
|
||||
})
|
||||
const defer = Promise.withResolvers()
|
||||
const defer = Promise.withResolvers<void>()
|
||||
async function saveResumeHandler(_, resumeContent) {
|
||||
await writeConfigFile('resumes.json', [
|
||||
{
|
||||
@@ -607,4 +647,681 @@ export default function initIpc() {
|
||||
ipcMain.handle('exit-app-immediately', () => {
|
||||
app.exit(0)
|
||||
})
|
||||
|
||||
ipcMain.handle('fetch-webhook-config', async () => {
|
||||
const { readConfigFile: readBossConfigFile } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
return readBossConfigFile('webhook.json') ?? null
|
||||
})
|
||||
|
||||
ipcMain.handle('save-webhook-config', async (_, payload) => {
|
||||
const { readConfigFile: readBossConfigFile, writeConfigFile: writeBossConfigFile } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
const config = JSON.parse(payload)
|
||||
const existing = readBossConfigFile('webhook.json') ?? {}
|
||||
return await writeBossConfigFile('webhook.json', { ...existing, ...config })
|
||||
})
|
||||
|
||||
ipcMain.handle('test-webhook', async () => {
|
||||
const { readConfigFile: readBossConfigFile, storageFilePath } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
const config = readBossConfigFile('webhook.json')
|
||||
if (!config?.url) {
|
||||
throw new Error('未配置 Webhook URL')
|
||||
}
|
||||
const { sendWebhook, buildMockPayload } = await import('../../../features/webhook/index')
|
||||
const result = await sendWebhook(config, buildMockPayload(), {
|
||||
storageDir: storageFilePath
|
||||
})
|
||||
console.log(`[webhook] 保存并测试发送完成,HTTP ${result.status}`)
|
||||
return result
|
||||
})
|
||||
|
||||
ipcMain.handle('trigger-webhook-manually', async (_, useRealData?: boolean) => {
|
||||
const { readConfigFile: readBossConfigFile, storageFilePath } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
const config = readBossConfigFile('webhook.json')
|
||||
if (!config?.url) {
|
||||
throw new Error('未配置 Webhook URL')
|
||||
}
|
||||
const pathModule = await import('node:path')
|
||||
const {
|
||||
sendWebhook,
|
||||
buildMockPayload,
|
||||
buildPayloadFromDb
|
||||
} = await import('../../../features/webhook/index')
|
||||
const dbPath = pathModule.default.join(storageFilePath, 'public.db')
|
||||
let payload =
|
||||
useRealData === true
|
||||
? await buildPayloadFromDb(dbPath)
|
||||
: null
|
||||
if (!payload) {
|
||||
payload = buildMockPayload()
|
||||
payload.runId = `manual-${Date.now()}`
|
||||
}
|
||||
const result = await sendWebhook(config, payload, {
|
||||
storageDir: storageFilePath
|
||||
})
|
||||
console.log(
|
||||
`[webhook] 手动触发完成,runId=${payload.runId},${useRealData ? '真实数据' : 'Mock'},HTTP ${result.status}`
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
ipcMain.handle('run-boss-recommend', async () => {
|
||||
const mode = 'bossRecommendMain'
|
||||
const { runRecordId } = await runCommon({ mode })
|
||||
daemonEE.on('message', function handler(message) {
|
||||
if (message.workerId !== mode) {
|
||||
return
|
||||
}
|
||||
if (message.type === 'worker-exited') {
|
||||
mainWindow?.webContents.send('worker-exited', message)
|
||||
}
|
||||
})
|
||||
return { runRecordId }
|
||||
})
|
||||
|
||||
ipcMain.handle('stop-boss-recommend', async () => {
|
||||
mainWindow?.webContents.send('boss-recommend-stopping')
|
||||
const p = new Promise((resolve) => {
|
||||
daemonEE.on('message', function handler(message) {
|
||||
if (message.workerId !== 'bossRecommendMain') {
|
||||
return
|
||||
}
|
||||
if (message.type === 'worker-exited') {
|
||||
daemonEE.off('message', handler)
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
await sendToDaemon(
|
||||
{
|
||||
type: 'stop-worker',
|
||||
workerId: 'bossRecommendMain'
|
||||
},
|
||||
{
|
||||
needCallback: true
|
||||
}
|
||||
)
|
||||
await p
|
||||
mainWindow?.webContents.send('boss-recommend-stopped')
|
||||
})
|
||||
|
||||
ipcMain.handle('run-boss-chat-page', async () => {
|
||||
const mode = 'bossChatPageMain'
|
||||
const { runRecordId } = await runCommon({ mode })
|
||||
daemonEE.on('message', function handler(message) {
|
||||
if (message.workerId !== mode) {
|
||||
return
|
||||
}
|
||||
if (message.type === 'worker-exited') {
|
||||
mainWindow?.webContents.send('worker-exited', message)
|
||||
}
|
||||
})
|
||||
return { runRecordId }
|
||||
})
|
||||
|
||||
ipcMain.handle('stop-boss-chat-page', async () => {
|
||||
mainWindow?.webContents.send('boss-chat-page-stopping')
|
||||
const p = new Promise((resolve) => {
|
||||
daemonEE.on('message', function handler(message) {
|
||||
if (message.workerId !== 'bossChatPageMain') {
|
||||
return
|
||||
}
|
||||
if (message.type === 'worker-exited') {
|
||||
daemonEE.off('message', handler)
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
await sendToDaemon(
|
||||
{
|
||||
type: 'stop-worker',
|
||||
workerId: 'bossChatPageMain'
|
||||
},
|
||||
{
|
||||
needCallback: true
|
||||
}
|
||||
)
|
||||
await p
|
||||
mainWindow?.webContents.send('boss-chat-page-stopped')
|
||||
})
|
||||
|
||||
// ── 招聘端调试工具 ──────────────────────────────────────────────────────────
|
||||
// 通过 stdio fd3 双向 JSON 通信:主进程发命令,worker 返回结果。
|
||||
let bossChatDebugProcess: ChildProcess | null = null
|
||||
let bossChatDebugReadyDefer: PromiseWithResolvers<void> | null = null
|
||||
const bossChatDebugPendingCmds = new Map<string, PromiseWithResolvers<any>>()
|
||||
|
||||
const closeBossChatDebug = () => {
|
||||
if (bossChatDebugProcess && !bossChatDebugProcess.killed) {
|
||||
try {
|
||||
bossChatDebugProcess.kill('SIGTERM')
|
||||
} catch {
|
||||
// Process may already have exited (e.g. user closed browser); ignore
|
||||
}
|
||||
}
|
||||
bossChatDebugProcess = null
|
||||
bossChatDebugReadyDefer = null
|
||||
}
|
||||
|
||||
ipcMain.handle('open-boss-chat-debug', async (ev) => {
|
||||
// 若 worker 已在运行,直接返回
|
||||
if (bossChatDebugProcess && !bossChatDebugProcess.killed) {
|
||||
return { ok: true, alreadyRunning: true }
|
||||
}
|
||||
let puppeteerExecutable = await getLastUsedAndAvailableBrowser()
|
||||
if (!puppeteerExecutable) {
|
||||
try {
|
||||
const parent = BrowserWindow.fromWebContents(ev.sender) || undefined
|
||||
await configWithBrowserAssistant({ autoFind: true, windowOption: { parent, modal: !!parent, show: true } })
|
||||
puppeteerExecutable = await getLastUsedAndAvailableBrowser()
|
||||
} catch { /**/ }
|
||||
}
|
||||
if (!puppeteerExecutable) {
|
||||
return { ok: false, error: 'NO_BROWSER' }
|
||||
}
|
||||
bossChatDebugReadyDefer = Promise.withResolvers()
|
||||
bossChatDebugProcess = childProcess.spawn(
|
||||
process.argv[0],
|
||||
process.env.NODE_ENV === 'development'
|
||||
? [process.argv[1], '--mode=bossChatDebugMain']
|
||||
: ['--mode=bossChatDebugMain'],
|
||||
{
|
||||
env: { ...process.env, PUPPETEER_EXECUTABLE_PATH: puppeteerExecutable.executablePath, GEEKGEEKRUND_PIPE_NAME: process.env.GEEKGEEKRUND_PIPE_NAME },
|
||||
stdio: ['inherit', 'inherit', 'inherit', 'pipe', 'pipe']
|
||||
}
|
||||
)
|
||||
bossChatDebugProcess.once('exit', () => {
|
||||
bossChatDebugProcess = null
|
||||
bossChatDebugReadyDefer = null
|
||||
mainWindow?.webContents.send('boss-chat-debug-exited')
|
||||
for (const [, defer] of bossChatDebugPendingCmds) {
|
||||
defer.reject(new Error('worker exited'))
|
||||
}
|
||||
bossChatDebugPendingCmds.clear()
|
||||
})
|
||||
// fd3=父写→子读,fd4=子写→父读;主进程从 stdio[4] 读 worker 的 READY/响应
|
||||
;(bossChatDebugProcess.stdio[4] as NodeJS.ReadableStream).pipe(JSONStream.parse()).on('data', (msg: any) => {
|
||||
if (msg?.type === 'READY') {
|
||||
if (msg.ok) {
|
||||
bossChatDebugReadyDefer?.resolve()
|
||||
mainWindow?.webContents.send('boss-chat-debug-ready')
|
||||
} else {
|
||||
bossChatDebugReadyDefer?.reject(new Error(msg.error ?? 'READY failed'))
|
||||
}
|
||||
return
|
||||
}
|
||||
// 命令响应(有 id)
|
||||
if (msg?.id) {
|
||||
const defer = bossChatDebugPendingCmds.get(msg.id)
|
||||
if (defer) {
|
||||
bossChatDebugPendingCmds.delete(msg.id)
|
||||
if (msg.ok) { defer.resolve(msg.result) } else { defer.reject(new Error(msg.error ?? 'command failed')) }
|
||||
}
|
||||
}
|
||||
})
|
||||
try {
|
||||
await bossChatDebugReadyDefer.promise
|
||||
return { ok: true }
|
||||
} catch (err: any) {
|
||||
closeBossChatDebug()
|
||||
return { ok: false, error: err?.message }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('boss-debug-command', async (_, cmd: { type: string; [k: string]: any }) => {
|
||||
if (!bossChatDebugProcess || bossChatDebugProcess.killed) {
|
||||
return { ok: false, error: 'DEBUG_WORKER_NOT_RUNNING' }
|
||||
}
|
||||
const id = Math.random().toString(36).slice(2)
|
||||
const defer = Promise.withResolvers<any>()
|
||||
bossChatDebugPendingCmds.set(id, defer)
|
||||
pipeWriteRegardlessError(
|
||||
bossChatDebugProcess.stdio[3] as WriteStream,
|
||||
JSON.stringify({ ...cmd, id }) + '\n'
|
||||
)
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
defer.promise,
|
||||
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 30000))
|
||||
])
|
||||
return { ok: true, result }
|
||||
} catch (err: any) {
|
||||
bossChatDebugPendingCmds.delete(id)
|
||||
return { ok: false, error: err?.message }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('close-boss-chat-debug', () => {
|
||||
closeBossChatDebug()
|
||||
return { ok: true }
|
||||
})
|
||||
// ── end 招聘端调试工具 ───────────────────────────────────────────────────────
|
||||
|
||||
ipcMain.handle('run-boss-auto-browse-and-chat', async () => {
|
||||
const mode = 'bossAutoBrowseAndChatMain'
|
||||
const { runRecordId } = await runCommon({ mode })
|
||||
daemonEE.on('message', function handler(message) {
|
||||
if (message.workerId !== mode) {
|
||||
return
|
||||
}
|
||||
if (message.type === 'worker-exited') {
|
||||
mainWindow?.webContents.send('worker-exited', message)
|
||||
}
|
||||
})
|
||||
return { runRecordId }
|
||||
})
|
||||
|
||||
ipcMain.handle('stop-boss-auto-browse-and-chat', async () => {
|
||||
mainWindow?.webContents.send('boss-auto-browse-and-chat-stopping')
|
||||
const p = new Promise((resolve) => {
|
||||
daemonEE.on('message', function handler(message) {
|
||||
if (message.workerId !== 'bossAutoBrowseAndChatMain') {
|
||||
return
|
||||
}
|
||||
if (message.type === 'worker-exited') {
|
||||
daemonEE.off('message', handler)
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
await sendToDaemon(
|
||||
{
|
||||
type: 'stop-worker',
|
||||
workerId: 'bossAutoBrowseAndChatMain'
|
||||
},
|
||||
{
|
||||
needCallback: true
|
||||
}
|
||||
)
|
||||
await p
|
||||
mainWindow?.webContents.send('boss-auto-browse-and-chat-stopped')
|
||||
})
|
||||
|
||||
ipcMain.handle('check-boss-recruiter-cookie-file', async () => {
|
||||
const { readStorageFile } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
const cookies = readStorageFile('boss-cookies.json')
|
||||
return checkCookieListFormat(cookies)
|
||||
})
|
||||
|
||||
ipcMain.handle('save-boss-recruiter-config', async (_, payload) => {
|
||||
const { readConfigFile: readBossConfigFile, writeConfigFile: writeBossConfigFile } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
payload = JSON.parse(payload)
|
||||
|
||||
const bossRecruiterConfig = readBossConfigFile('boss-recruiter.json') || {}
|
||||
if (hasOwn(payload, 'logLevel')) {
|
||||
bossRecruiterConfig.logLevel = payload.logLevel
|
||||
}
|
||||
if (hasOwn(payload, 'targetJobId')) {
|
||||
bossRecruiterConfig.targetJobId = payload.targetJobId
|
||||
}
|
||||
if (hasOwn(payload, 'autoChat')) {
|
||||
bossRecruiterConfig.autoChat = {
|
||||
...bossRecruiterConfig.autoChat,
|
||||
...payload.autoChat
|
||||
}
|
||||
}
|
||||
if (hasOwn(payload, 'chatPage')) {
|
||||
const chat = { ...bossRecruiterConfig.chatPage, ...payload.chatPage }
|
||||
if (chat.filter) {
|
||||
if (Array.isArray(payload.chatPage?.filter?.keywordList)) {
|
||||
chat.filter.keywordList = payload.chatPage.filter.keywordList
|
||||
} else if (typeof payload.chatPage?.filter?.keywordListStr === 'string') {
|
||||
chat.filter.keywordList = payload.chatPage.filter.keywordListStr
|
||||
.split(/[,,]/)
|
||||
.map((s) => String(s).trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
bossRecruiterConfig.chatPage = chat
|
||||
}
|
||||
if (hasOwn(payload, 'recommendPage')) {
|
||||
bossRecruiterConfig.recommendPage = {
|
||||
...bossRecruiterConfig.recommendPage,
|
||||
...payload.recommendPage
|
||||
}
|
||||
}
|
||||
|
||||
const candidateFilterConfig = readBossConfigFile('candidate-filter.json') || {}
|
||||
if (hasOwn(payload, 'expectCityList')) {
|
||||
candidateFilterConfig.expectCityList = payload.expectCityList
|
||||
}
|
||||
if (hasOwn(payload, 'expectEducationRegExpStr')) {
|
||||
candidateFilterConfig.expectEducationRegExpStr = payload.expectEducationRegExpStr
|
||||
}
|
||||
if (hasOwn(payload, 'expectWorkExpRange')) {
|
||||
candidateFilterConfig.expectWorkExpRange = payload.expectWorkExpRange
|
||||
}
|
||||
if (hasOwn(payload, 'expectSalaryRange')) {
|
||||
candidateFilterConfig.expectSalaryRange = payload.expectSalaryRange
|
||||
}
|
||||
if (hasOwn(payload, 'expectSalaryWhenNegotiable')) {
|
||||
candidateFilterConfig.expectSalaryWhenNegotiable = payload.expectSalaryWhenNegotiable
|
||||
}
|
||||
if (hasOwn(payload, 'expectSkillKeywords')) {
|
||||
candidateFilterConfig.expectSkillKeywords = payload.expectSkillKeywords
|
||||
}
|
||||
if (hasOwn(payload, 'blockCandidateNameRegExpStr')) {
|
||||
candidateFilterConfig.blockCandidateNameRegExpStr = payload.blockCandidateNameRegExpStr
|
||||
}
|
||||
if (hasOwn(payload, 'skipViewedCandidates')) {
|
||||
candidateFilterConfig.skipViewedCandidates = payload.skipViewedCandidates
|
||||
}
|
||||
|
||||
return await Promise.all([
|
||||
writeBossConfigFile('boss-recruiter.json', bossRecruiterConfig),
|
||||
writeBossConfigFile('candidate-filter.json', candidateFilterConfig)
|
||||
])
|
||||
})
|
||||
|
||||
ipcMain.handle('fetch-boss-recruiter-config-file-content', async () => {
|
||||
const { readConfigFile: readBossConfigFile } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
return {
|
||||
config: {
|
||||
'boss-recruiter.json': readBossConfigFile('boss-recruiter.json'),
|
||||
'candidate-filter.json': readBossConfigFile('candidate-filter.json')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('fetch-boss-jobs-config', async () => {
|
||||
const { readBossJobsConfig } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
return readBossJobsConfig()
|
||||
})
|
||||
|
||||
ipcMain.handle('save-boss-jobs-config', async (_, payload) => {
|
||||
const { readBossJobsConfig, writeBossJobsConfig } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
const incoming = typeof payload === 'string' ? JSON.parse(payload) : payload
|
||||
const existing = readBossJobsConfig()
|
||||
const config = {
|
||||
...existing,
|
||||
jobs: incoming.jobs ?? existing.jobs ?? []
|
||||
}
|
||||
return await writeBossJobsConfig(config)
|
||||
})
|
||||
|
||||
ipcMain.handle('generate-llm-rubric', async (_, payload: { sourceJd?: string; modelId?: string | null }) => {
|
||||
const { setLevel, debug: logDebug, info: logInfo, error: logError } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/logger.mjs'
|
||||
)
|
||||
const { readConfigFile } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
const config = readConfigFile('boss-recruiter.json') || {}
|
||||
setLevel((config as { logLevel?: string }).logLevel || 'info')
|
||||
|
||||
const LOG = '[generate-llm-rubric/ipc]'
|
||||
const { generateRubricFromJd } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/llm-rubric.mjs'
|
||||
)
|
||||
const sourceJd = typeof payload?.sourceJd === 'string' ? payload.sourceJd : ''
|
||||
const modelId = typeof payload?.modelId === 'string' ? payload.modelId : null
|
||||
logInfo(LOG, 'start', { jdChars: sourceJd.length, modelId })
|
||||
try {
|
||||
const res = await generateRubricFromJd(sourceJd, { modelId })
|
||||
logDebug(LOG, 'done', { knockouts: res?.rubric?.knockouts?.length, dims: res?.rubric?.dimensions?.length })
|
||||
return res
|
||||
} catch (err: any) {
|
||||
logError(LOG, 'error', err?.message ?? err)
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
// ── 调试工具 LLM 接口 ─────────────────────────────────────────────────────────
|
||||
// llm-screen-resume: 主进程侧直接调用 evaluateResumeByRubric,无需浏览器
|
||||
ipcMain.handle('llm-screen-resume', async (_, payload: {
|
||||
resumeText: string
|
||||
jobId?: string
|
||||
rubric?: { knockouts: string[]; dimensions: any[]; passThreshold?: number }
|
||||
}) => {
|
||||
const { evaluateResumeByRubric } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/llm-rubric.mjs'
|
||||
) as any
|
||||
const { readBossJobsConfig } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
|
||||
let rubricConfig: { knockouts?: string[]; dimensions?: any[]; passThreshold?: number } | null = null
|
||||
|
||||
if (payload?.jobId) {
|
||||
const jobsConfig = readBossJobsConfig()
|
||||
const job = (jobsConfig.jobs || []).find((j: any) => (j.jobId ?? j.id) === payload.jobId)
|
||||
const llmConfig = job?.filter?.resumeLlmConfig
|
||||
if (llmConfig?.rubric) {
|
||||
rubricConfig = {
|
||||
knockouts: llmConfig.rubric.knockouts,
|
||||
dimensions: llmConfig.rubric.dimensions,
|
||||
passThreshold: llmConfig.passThreshold ?? 75
|
||||
}
|
||||
}
|
||||
} else if (payload?.rubric) {
|
||||
rubricConfig = payload.rubric
|
||||
}
|
||||
|
||||
if (!rubricConfig) {
|
||||
return { ok: false, error: '未找到 Rubric 配置,请选择已配置 LLM 的职位或手动填写 Rubric JSON' }
|
||||
}
|
||||
|
||||
const result = await evaluateResumeByRubric(payload.resumeText ?? '', rubricConfig)
|
||||
return { ok: true, ...result }
|
||||
})
|
||||
ipcMain.handle('apply-rubric-to-job', async (_, payload: {
|
||||
jobId: string
|
||||
rubric: { knockouts: string[]; dimensions: any[] }
|
||||
passThreshold?: number
|
||||
}) => {
|
||||
const { readBossJobsConfig, writeBossJobsConfig } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs'
|
||||
)
|
||||
const config = readBossJobsConfig()
|
||||
const jobs: any[] = config.jobs ?? []
|
||||
const idx = jobs.findIndex((j: any) => (j.jobId ?? j.id) === payload.jobId)
|
||||
if (idx === -1) return { ok: false, error: `未找到职位 ${payload.jobId}` }
|
||||
const job = jobs[idx]
|
||||
if (!job.filter) job.filter = {}
|
||||
if (!job.filter.resumeLlmConfig) job.filter.resumeLlmConfig = {}
|
||||
job.filter.resumeLlmEnabled = true
|
||||
job.filter.resumeLlmConfig.rubric = { knockouts: payload.rubric.knockouts, dimensions: payload.rubric.dimensions }
|
||||
job.filter.resumeLlmConfig.passThreshold = payload.passThreshold ?? 75
|
||||
await writeBossJobsConfig({ ...config, jobs })
|
||||
return { ok: true }
|
||||
})
|
||||
// ── end 调试工具 LLM 接口 ──────────────────────────────────────────────────────
|
||||
|
||||
ipcMain.handle('sync-boss-job-list', async (ev) => {
|
||||
const { setLevel, debug: logDebug, info: logInfo } = await import(
|
||||
'@geekgeekrun/boss-auto-browse-and-chat/logger.mjs'
|
||||
)
|
||||
const { readConfigFile, readBossJobsConfig, writeBossJobsConfig, readStorageFile: readBossStorageFile } =
|
||||
await import('@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs')
|
||||
const config = readConfigFile('boss-recruiter.json') || {}
|
||||
setLevel((config as { logLevel?: string }).logLevel || 'info')
|
||||
|
||||
const LOG = '[sync-boss-job-list]'
|
||||
const sendToGui = (message: string) => {
|
||||
mainWindow?.webContents?.send('worker-to-gui-message', {
|
||||
data: { type: 'worker-log' as const, workerId: 'syncBossJobList', message }
|
||||
})
|
||||
}
|
||||
const toStr = (msg: string, ...rest: unknown[]) =>
|
||||
[msg, ...rest].map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')
|
||||
const log = (msg: string, ...rest: unknown[]) => {
|
||||
logInfo(LOG, msg, ...rest)
|
||||
sendToGui(toStr(msg, ...rest))
|
||||
}
|
||||
const logDebugSync = (msg: string, ...rest: unknown[]) => {
|
||||
logDebug(LOG, msg, ...rest)
|
||||
sendToGui(toStr(msg, ...rest))
|
||||
}
|
||||
|
||||
log('查找可用浏览器...')
|
||||
let puppeteerExecutable = await getLastUsedAndAvailableBrowser()
|
||||
if (!puppeteerExecutable) {
|
||||
try {
|
||||
const parent = BrowserWindow.fromWebContents(ev.sender) || undefined
|
||||
await configWithBrowserAssistant({
|
||||
autoFind: true,
|
||||
windowOption: { parent, modal: !!parent, show: true }
|
||||
})
|
||||
puppeteerExecutable = await getLastUsedAndAvailableBrowser()
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
if (!puppeteerExecutable) {
|
||||
log('未找到可用浏览器')
|
||||
throw new Error('NO_BROWSER')
|
||||
}
|
||||
log(`使用浏览器: ${puppeteerExecutable.executablePath}`)
|
||||
|
||||
log('初始化 Puppeteer...')
|
||||
const { initPuppeteer } = await import('@geekgeekrun/boss-auto-browse-and-chat/index.mjs') as any
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH = puppeteerExecutable.executablePath
|
||||
const { puppeteer } = await initPuppeteer()
|
||||
|
||||
const bossCookies = readBossStorageFile('boss-cookies.json')
|
||||
const { setDomainLocalStorage } = await import('@geekgeekrun/utils/puppeteer/local-storage.mjs') as any
|
||||
const bossLocalStorage = readBossStorageFile('boss-local-storage.json')
|
||||
|
||||
// 与招聘端调试工具一致:非 headless、相同 viewport 与 protocolTimeout,避免站点对 headless 做差异化或拦截
|
||||
log('启动浏览器(非 headless,与调试工具一致)...')
|
||||
const browser = await puppeteer.launch({
|
||||
headless: false,
|
||||
ignoreHTTPSErrors: true,
|
||||
protocolTimeout: 120000,
|
||||
defaultViewport: { width: 1440, height: 900 - 140 }
|
||||
})
|
||||
|
||||
const {
|
||||
BOSS_CHAT_INDEX_URL,
|
||||
CHAT_PAGE_JOB_DROPDOWN_SELECTOR,
|
||||
CHAT_PAGE_JOB_ITEM_SELECTOR
|
||||
} = await import('@geekgeekrun/boss-auto-browse-and-chat/constant.mjs')
|
||||
|
||||
try {
|
||||
const page = (await browser.pages())[0]
|
||||
if (Array.isArray(bossCookies) && bossCookies.length > 0) {
|
||||
log(`注入 ${bossCookies.length} 条 Cookie`)
|
||||
await page.setCookie(...bossCookies)
|
||||
}
|
||||
await setDomainLocalStorage(browser, 'https://www.zhipin.com/desktop/', bossLocalStorage || {})
|
||||
|
||||
log(`导航到沟通页: ${BOSS_CHAT_INDEX_URL}`)
|
||||
await page.goto(BOSS_CHAT_INDEX_URL, { timeout: 60000 })
|
||||
const urlAfterGoto = page.url()
|
||||
logDebugSync(`goto 后当前 URL: ${urlAfterGoto}`)
|
||||
|
||||
logDebugSync('等待 document.readyState === complete...')
|
||||
await page.waitForFunction(
|
||||
() => document.readyState === 'complete',
|
||||
{ timeout: 120000 }
|
||||
)
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
const urlAfterReady = page.url()
|
||||
logDebugSync(`readyState 完成且等待 1.5s 后 URL: ${urlAfterReady}`)
|
||||
|
||||
if (
|
||||
urlAfterReady.startsWith('https://www.zhipin.com/web/common/403.html') ||
|
||||
urlAfterReady.startsWith('https://www.zhipin.com/web/common/error.html')
|
||||
) {
|
||||
log('当前为 403/error 页,拒绝访问')
|
||||
throw new Error('ACCESS_IS_DENIED')
|
||||
}
|
||||
const needLogin = await page.evaluate(
|
||||
(chatUrl: string) => {
|
||||
const href = location.href
|
||||
return (
|
||||
!href.startsWith(chatUrl) ||
|
||||
/\/login|\/wapi\/zppassport\//.test(href)
|
||||
)
|
||||
},
|
||||
BOSS_CHAT_INDEX_URL
|
||||
)
|
||||
logDebugSync(`needLogin=${needLogin}`)
|
||||
if (needLogin) {
|
||||
log('未在沟通页或需登录,抛出 NEED_LOGIN')
|
||||
throw new Error('NEED_LOGIN')
|
||||
}
|
||||
|
||||
logDebugSync(`等待职位下拉按钮: ${CHAT_PAGE_JOB_DROPDOWN_SELECTOR}`)
|
||||
await page.waitForSelector(CHAT_PAGE_JOB_DROPDOWN_SELECTOR, { timeout: 30000 })
|
||||
logDebugSync('职位下拉按钮已出现,点击展开')
|
||||
await page.click(CHAT_PAGE_JOB_DROPDOWN_SELECTOR)
|
||||
logDebugSync(`等待职位列表项: ${CHAT_PAGE_JOB_ITEM_SELECTOR}`)
|
||||
await page.waitForSelector(CHAT_PAGE_JOB_ITEM_SELECTOR, { timeout: 10000 })
|
||||
|
||||
const fetchedJobs: Array<{ jobId: string; jobName: string }> = await page.evaluate(
|
||||
(sel: string) => {
|
||||
const items = document.querySelectorAll(sel)
|
||||
return Array.from(items)
|
||||
.map((li: Element) => ({
|
||||
jobId: (li as HTMLElement).getAttribute('value') || '',
|
||||
jobName: (li.textContent || '').trim()
|
||||
}))
|
||||
.filter((j) => j.jobId && j.jobId !== '-1')
|
||||
},
|
||||
CHAT_PAGE_JOB_ITEM_SELECTOR
|
||||
)
|
||||
log(`已获取职位数: ${fetchedJobs.length}`)
|
||||
|
||||
const existing = readBossJobsConfig()
|
||||
const existingMap = new Map((existing.jobs || []).map((j: any) => [j.jobId ?? j.id, j]))
|
||||
|
||||
const mergedJobs = fetchedJobs.map((j) => {
|
||||
const prev = existingMap.get(j.jobId)
|
||||
if (prev) {
|
||||
return { ...prev, jobName: j.jobName }
|
||||
}
|
||||
return {
|
||||
jobId: j.jobId,
|
||||
jobName: j.jobName,
|
||||
sequence: { enabled: true, runRecommend: true, runChat: true },
|
||||
candidateFilter: {},
|
||||
autoChat: {},
|
||||
chatPage: {}
|
||||
}
|
||||
})
|
||||
|
||||
const updatedConfig = { ...existing, jobs: mergedJobs }
|
||||
await writeBossJobsConfig(updatedConfig)
|
||||
log('同步完成,已保存配置')
|
||||
return { jobs: mergedJobs }
|
||||
} catch (err: any) {
|
||||
try {
|
||||
const pages = await browser.pages()
|
||||
const p = pages[0]
|
||||
if (p && !p.isClosed()) {
|
||||
const debugUrl = p.url()
|
||||
const debugTitle = await p.title()
|
||||
logDebugSync(`出错时页面 URL: ${debugUrl}, title: ${debugTitle}`)
|
||||
}
|
||||
} catch (_) {
|
||||
// 忽略调试信息获取失败
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
try {
|
||||
log('关闭浏览器...')
|
||||
await browser.close()
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
57
packages/ui/src/main/global.d.ts
vendored
Normal file
57
packages/ui/src/main/global.d.ts
vendored
Normal 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>
|
||||
}
|
||||
@@ -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
|
||||
|
||||
29
packages/ui/src/main/utils/forwardConsoleLogToDaemon.ts
Normal file
29
packages/ui/src/main/utils/forwardConsoleLogToDaemon.ts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -41,6 +41,18 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.workerId === 'bossAutoBrowseAndChatMain' && (bossProgress.recommend.max > 0 || bossProgress.chatPage.max > 0)"
|
||||
class="progress-block"
|
||||
mb8px
|
||||
>
|
||||
<div v-if="bossProgress.recommend.max > 0" class="progress-line">
|
||||
推荐页:已开聊 {{ bossProgress.recommend.current }} / {{ bossProgress.recommend.max }}
|
||||
</div>
|
||||
<div v-if="bossProgress.chatPage.max > 0" class="progress-line">
|
||||
沟通页:已处理 {{ bossProgress.chatPage.current }} / {{ bossProgress.chatPage.max }}
|
||||
</div>
|
||||
</div>
|
||||
<div flex justify-between items-center w-full>
|
||||
<div>
|
||||
{{ runningStatusTextMapByCode[currentRunningStatus] }}
|
||||
@@ -49,6 +61,17 @@
|
||||
<slot name="op-buttons" :current-running-status="currentRunningStatus" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="workerLogs.length"
|
||||
mt8px
|
||||
style="max-height: 120px; overflow-y: auto; background: #f5f5f5; border-radius: 6px; padding: 6px 8px"
|
||||
>
|
||||
<div
|
||||
v-for="(line, i) in workerLogs"
|
||||
:key="i"
|
||||
style="font-size: 11px; color: #666; line-height: 1.5; word-break: break-all; font-family: monospace"
|
||||
>{{ line }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
@@ -69,6 +92,10 @@ const props = defineProps({
|
||||
},
|
||||
runRecordId: {
|
||||
type: Number
|
||||
},
|
||||
getSteps: {
|
||||
type: Function,
|
||||
default: getAutoStartChatSteps
|
||||
}
|
||||
})
|
||||
// const taskManagerStore = useTaskManagerStore()
|
||||
@@ -78,6 +105,14 @@ const props = defineProps({
|
||||
// })
|
||||
// })
|
||||
const steps = ref([])
|
||||
const workerLogs = ref<string[]>([])
|
||||
const bossProgress = ref<{
|
||||
recommend: { current: number; max: number }
|
||||
chatPage: { current: number; max: number }
|
||||
}>({
|
||||
recommend: { current: 0, max: 0 },
|
||||
chatPage: { current: 0, max: 0 }
|
||||
})
|
||||
const stepsForRender = computed(() => {
|
||||
const clonedSteps = JSON.parse(JSON.stringify(steps.value))
|
||||
if (clonedSteps.some((it) => it.status === 'rejected')) {
|
||||
@@ -96,10 +131,12 @@ const runningStatusTextMapByCode = {
|
||||
}
|
||||
const currentRunningStatus = ref(RUNNING_STATUS_ENUM.RUNNING)
|
||||
function fillEmptySteps() {
|
||||
const arr = getAutoStartChatSteps()
|
||||
const arr = props.getSteps()
|
||||
arr.forEach((it) => (it.status = 'todo'))
|
||||
steps.value = arr
|
||||
currentRunningStatus.value = RUNNING_STATUS_ENUM.RUNNING
|
||||
workerLogs.value = []
|
||||
bossProgress.value = { recommend: { current: 0, max: 0 }, chatPage: { current: 0, max: 0 } }
|
||||
}
|
||||
watch(() => props.runRecordId, fillEmptySteps, {
|
||||
immediate: true
|
||||
@@ -121,6 +158,22 @@ watch(
|
||||
|
||||
const { ipcRenderer } = electron
|
||||
function messageHandler(ev, { data }) {
|
||||
if (data.type === 'worker-log' && data.workerId === props.workerId) {
|
||||
workerLogs.value.push(data.message)
|
||||
return
|
||||
}
|
||||
if (
|
||||
data.type === 'boss-auto-browse-progress' &&
|
||||
data.workerId === props.workerId &&
|
||||
(data.runRecordId == null || data.runRecordId === props.runRecordId)
|
||||
) {
|
||||
if (data.phase === 'recommend') {
|
||||
bossProgress.value.recommend = { current: data.current ?? 0, max: data.max ?? 0 }
|
||||
} else if (data.phase === 'chatPage') {
|
||||
bossProgress.value.chatPage = { current: data.current ?? 0, max: data.max ?? 0 }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (
|
||||
data.type !== 'prerequisite-step-by-step-checkstep-by-step-check' ||
|
||||
data.runRecordId !== props.runRecordId
|
||||
@@ -227,6 +280,13 @@ ipcRenderer.on('worker-exited', (ev, payload) => {
|
||||
padding: var(--el-dialog-padding-primary);
|
||||
//border-radius: 0 0 20px 20px;
|
||||
border-radius: 20px;
|
||||
.progress-block {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
.progress-line {
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
441
packages/ui/src/renderer/src/page/BossLlmConfig/index.vue
Normal file
441
packages/ui/src/renderer/src/page/BossLlmConfig/index.vue
Normal 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>
|
||||
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<div class="boss-auto-browse-and-chat__wrap">
|
||||
<div class="main__wrap">
|
||||
<el-form ref="formRef" :model="formContent" label-position="top">
|
||||
<el-card class="config-section">
|
||||
<el-form-item label="招呼语(全局默认)" prop="autoChat.greetingMessage">
|
||||
<el-input
|
||||
v-model="formContent.autoChat.greetingMessage"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 1 }"
|
||||
placeholder="向候选人发送的第一条消息(各职位可在「职位配置」页覆盖)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="每次最多开聊人数(全局默认)" prop="autoChat.maxChatPerRun">
|
||||
<el-input-number
|
||||
v-model="formContent.autoChat.maxChatPerRun"
|
||||
:min="1"
|
||||
:max="200"
|
||||
controls-position="right"
|
||||
/>
|
||||
<div class="form-tip">单轮运行中最多向多少人发送招呼;各职位可在「职位配置」页覆盖</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="两次开聊间隔(毫秒)">
|
||||
<div class="range-input-wrap">
|
||||
<el-input-number
|
||||
v-model="formContent.autoChat.delayBetweenChats[0]"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
placeholder="最小值"
|
||||
/>
|
||||
<span class="range-sep">~</span>
|
||||
<el-input-number
|
||||
v-model="formContent.autoChat.delayBetweenChats[1]"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
placeholder="最大值"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<el-card class="config-section">
|
||||
<el-form-item mb0>
|
||||
<div class="section-title">推荐页运行策略</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="formContent.recommendRunOnceAfterComplete">
|
||||
单轮运行完成后停止(不再自动重启)
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="formContent.recommendClickNotInterestedForFiltered">
|
||||
对未通过筛选的候选人自动点击"不感兴趣"
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="formContent.recommendSkipViewedCandidates">
|
||||
跳过已读候选人卡片(带 has-viewed)
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item label="两轮之间的等待间隔(毫秒,反检测)">
|
||||
<el-input-number
|
||||
v-model="formContent.recommendRerunIntervalMs"
|
||||
:min="1000"
|
||||
controls-position="right"
|
||||
placeholder="默认 3000"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label='每次点击"不感兴趣"之间的间隔(毫秒,随机[min,max],反检测)'>
|
||||
<div class="range-input-wrap">
|
||||
<el-input-number
|
||||
v-model="formContent.recommendDelayBetweenNotInterestedMs[0]"
|
||||
:min="300"
|
||||
controls-position="right"
|
||||
placeholder="最小"
|
||||
/>
|
||||
<span class="range-sep">~</span>
|
||||
<el-input-number
|
||||
v-model="formContent.recommendDelayBetweenNotInterestedMs[1]"
|
||||
:min="300"
|
||||
controls-position="right"
|
||||
placeholder="最大"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="formContent.recommendKeepBrowserOpenAfterRun">
|
||||
单轮结束后保持浏览器打开(仅招聘端推荐页;需同时勾选"单轮运行完成后停止";关闭浏览器窗口后自动退出)
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<div class="action-bar">
|
||||
<el-button :loading="isSaving" @click="handleSave">仅保存配置</el-button>
|
||||
<el-button type="primary" :loading="isSaving" @click="handleSubmit">
|
||||
保存配置,并开始招聘!
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- RunningOverlay -->
|
||||
<div
|
||||
class="running-overlay__wrap"
|
||||
:style="{
|
||||
pointerEvents: 'none'
|
||||
}"
|
||||
>
|
||||
<RunningOverlay
|
||||
ref="runningOverlayRef"
|
||||
worker-id="bossRecommendMain"
|
||||
:run-record-id="runRecordId"
|
||||
:get-steps="getBossAutoBrowseSteps"
|
||||
>
|
||||
<template #op-buttons="{ currentRunningStatus }">
|
||||
<div>
|
||||
<template v-if="currentRunningStatus === RUNNING_STATUS_ENUM.RUNNING">
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
:loading="isStopButtonLoading"
|
||||
@click="handleStopButtonClick"
|
||||
>结束任务</el-button
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="
|
||||
() => {
|
||||
runningOverlayRef?.hide?.()
|
||||
}
|
||||
"
|
||||
>关闭</el-button
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</RunningOverlay>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import RunningOverlay from '@renderer/features/RunningOverlay/index.vue'
|
||||
import { RUNNING_STATUS_ENUM } from '../../../../../common/enums/auto-start-chat'
|
||||
import { getBossAutoBrowseSteps } from '../../../../../common/prerequisite-step-by-step-check'
|
||||
|
||||
const { ipcRenderer } = electron
|
||||
|
||||
const formRef = ref()
|
||||
const isSaving = ref(false)
|
||||
const runRecordId = ref<number | null>(null)
|
||||
const runningOverlayRef = ref<InstanceType<typeof RunningOverlay> | null>(null)
|
||||
const isStopButtonLoading = ref(false)
|
||||
|
||||
const formContent = reactive({
|
||||
autoChat: {
|
||||
greetingMessage: '',
|
||||
maxChatPerRun: 50,
|
||||
delayBetweenChats: [3000, 8000] as [number, number]
|
||||
},
|
||||
recommendRunOnceAfterComplete: false,
|
||||
recommendClickNotInterestedForFiltered: true,
|
||||
recommendSkipViewedCandidates: false,
|
||||
recommendRerunIntervalMs: 3000,
|
||||
recommendDelayBetweenNotInterestedMs: [800, 2500] as [number, number],
|
||||
recommendKeepBrowserOpenAfterRun: false
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await ipcRenderer.invoke('fetch-boss-recruiter-config-file-content')
|
||||
const recruiterConfig = result?.config?.['boss-recruiter.json'] || {}
|
||||
|
||||
formContent.autoChat.greetingMessage = recruiterConfig.autoChat?.greetingMessage ?? ''
|
||||
formContent.autoChat.maxChatPerRun = recruiterConfig.autoChat?.maxChatPerRun ?? 50
|
||||
formContent.autoChat.delayBetweenChats = recruiterConfig.autoChat?.delayBetweenChats ?? [
|
||||
3000, 8000
|
||||
]
|
||||
|
||||
const recommendPage = recruiterConfig.recommendPage ?? {}
|
||||
formContent.recommendRunOnceAfterComplete = recommendPage.runOnceAfterComplete ?? false
|
||||
formContent.recommendClickNotInterestedForFiltered =
|
||||
recommendPage.clickNotInterestedForFiltered ?? true
|
||||
formContent.recommendSkipViewedCandidates = recommendPage.skipViewedCandidates ?? false
|
||||
formContent.recommendRerunIntervalMs = recommendPage.rerunIntervalMs ?? 3000
|
||||
formContent.recommendDelayBetweenNotInterestedMs =
|
||||
Array.isArray(recommendPage.delayBetweenNotInterestedMs) &&
|
||||
recommendPage.delayBetweenNotInterestedMs.length >= 2
|
||||
? recommendPage.delayBetweenNotInterestedMs
|
||||
: [800, 2500]
|
||||
formContent.recommendKeepBrowserOpenAfterRun =
|
||||
recommendPage.keepBrowserOpenAfterRun ?? false
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
|
||||
const doSave = async () => {
|
||||
const payload = {
|
||||
autoChat: {
|
||||
greetingMessage: formContent.autoChat.greetingMessage,
|
||||
maxChatPerRun: formContent.autoChat.maxChatPerRun,
|
||||
delayBetweenChats: formContent.autoChat.delayBetweenChats
|
||||
},
|
||||
recommendPage: {
|
||||
runOnceAfterComplete: formContent.recommendRunOnceAfterComplete,
|
||||
clickNotInterestedForFiltered: formContent.recommendClickNotInterestedForFiltered,
|
||||
skipViewedCandidates: formContent.recommendSkipViewedCandidates,
|
||||
rerunIntervalMs: formContent.recommendRerunIntervalMs,
|
||||
delayBetweenNotInterestedMs: formContent.recommendDelayBetweenNotInterestedMs,
|
||||
keepBrowserOpenAfterRun: formContent.recommendKeepBrowserOpenAfterRun
|
||||
}
|
||||
}
|
||||
await ipcRenderer.invoke('save-boss-recruiter-config', JSON.stringify(payload))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
isSaving.value = true
|
||||
try {
|
||||
await doSave()
|
||||
ElMessage({ type: 'success', message: '配置已保存' })
|
||||
} catch (err) {
|
||||
ElMessage({ type: 'error', message: '保存失败' })
|
||||
console.error(err)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
isSaving.value = true
|
||||
try {
|
||||
await doSave()
|
||||
runningOverlayRef.value?.show()
|
||||
const { runRecordId: rrId } = await ipcRenderer.invoke('run-boss-recommend')
|
||||
runRecordId.value = rrId
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopButtonClick = async () => {
|
||||
isStopButtonLoading.value = true
|
||||
try {
|
||||
await ipcRenderer.invoke('stop-boss-recommend')
|
||||
runningOverlayRef.value?.hide()
|
||||
} finally {
|
||||
isStopButtonLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.boss-auto-browse-and-chat__wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
|
||||
.main__wrap {
|
||||
padding: 24px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.range-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.range-sep {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.running-overlay__wrap {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="boss-auto-sequence__wrap">
|
||||
<div class="main__wrap">
|
||||
<el-card class="config-section">
|
||||
<template #header>
|
||||
<span>职位执行队列</span>
|
||||
</template>
|
||||
<template v-if="jobsList.length === 0">
|
||||
<el-alert
|
||||
title="请先在「推荐牛人-自动开聊」页面同步职位列表"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="jobsList" style="width: 100%">
|
||||
<el-table-column prop="jobName" label="职位名称" />
|
||||
<el-table-column label="纳入执行" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-checkbox v-model="row.sequence.enabled" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="执行推荐牛人" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-checkbox v-model="row.sequence.runRecommend" :disabled="!row.sequence.enabled" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="执行沟通页" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-checkbox v-model="row.sequence.runChat" :disabled="!row.sequence.enabled" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="queue-save-bar">
|
||||
<el-button :loading="isSavingQueue" @click="handleSaveQueue">保存队列配置</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<el-card class="config-section">
|
||||
<template #header>
|
||||
<span>自动顺序执行</span>
|
||||
</template>
|
||||
<p class="desc">
|
||||
依次执行「推荐牛人 - 自动开聊」和「沟通页」两个任务,配置分别在对应页面中设置。
|
||||
</p>
|
||||
<div class="action-bar">
|
||||
<el-button type="primary" :loading="isSaving" @click="handleSubmit">
|
||||
开始自动顺序执行!
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="running-overlay__wrap"
|
||||
:style="{
|
||||
pointerEvents: 'none'
|
||||
}"
|
||||
>
|
||||
<RunningOverlay
|
||||
ref="runningOverlayRef"
|
||||
worker-id="bossAutoBrowseAndChatMain"
|
||||
:run-record-id="runRecordId"
|
||||
:get-steps="getBossAutoBrowseSteps"
|
||||
>
|
||||
<template #op-buttons="{ currentRunningStatus }">
|
||||
<div>
|
||||
<template v-if="currentRunningStatus === RUNNING_STATUS_ENUM.RUNNING">
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
:loading="isStopButtonLoading"
|
||||
@click="handleStopButtonClick"
|
||||
>结束任务</el-button
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="
|
||||
() => {
|
||||
runningOverlayRef?.hide?.()
|
||||
}
|
||||
"
|
||||
>关闭</el-button
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</RunningOverlay>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import RunningOverlay from '@renderer/features/RunningOverlay/index.vue'
|
||||
import { RUNNING_STATUS_ENUM } from '../../../../../common/enums/auto-start-chat'
|
||||
import { getBossAutoBrowseSteps } from '../../../../../common/prerequisite-step-by-step-check'
|
||||
|
||||
const { ipcRenderer } = electron
|
||||
|
||||
const isSaving = ref(false)
|
||||
const runRecordId = ref<number | null>(null)
|
||||
const runningOverlayRef = ref<InstanceType<typeof RunningOverlay> | null>(null)
|
||||
const isStopButtonLoading = ref(false)
|
||||
|
||||
// ---- 职位执行队列 ----
|
||||
|
||||
interface JobSequenceItem {
|
||||
jobId: string
|
||||
jobName: string
|
||||
sequence: { enabled: boolean; runRecommend: boolean; runChat: boolean }
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const jobsList = ref<JobSequenceItem[]>([])
|
||||
const isSavingQueue = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await ipcRenderer.invoke('fetch-boss-jobs-config')
|
||||
jobsList.value = result?.jobs ?? []
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
|
||||
const handleSaveQueue = async () => {
|
||||
isSavingQueue.value = true
|
||||
try {
|
||||
await ipcRenderer.invoke('save-boss-jobs-config', JSON.stringify({ jobs: jobsList.value }))
|
||||
ElMessage({ type: 'success', message: '队列配置已保存' })
|
||||
} catch (err) {
|
||||
ElMessage({ type: 'error', message: '保存失败' })
|
||||
console.error(err)
|
||||
} finally {
|
||||
isSavingQueue.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
isSaving.value = true
|
||||
try {
|
||||
runningOverlayRef.value?.show()
|
||||
const { runRecordId: rrId } = await ipcRenderer.invoke('run-boss-auto-browse-and-chat')
|
||||
runRecordId.value = rrId
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopButtonClick = async () => {
|
||||
isStopButtonLoading.value = true
|
||||
try {
|
||||
await ipcRenderer.invoke('stop-boss-auto-browse-and-chat')
|
||||
runningOverlayRef.value?.hide()
|
||||
} finally {
|
||||
isStopButtonLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.boss-auto-sequence__wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
|
||||
.main__wrap {
|
||||
padding: 24px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 0 0 1em;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.queue-save-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0 0;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.running-overlay__wrap {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="boss-chat-page__wrap">
|
||||
<div class="main__wrap">
|
||||
<el-form ref="formRef" :model="formContent" label-position="top">
|
||||
<el-card class="config-section">
|
||||
<el-form-item mb0>
|
||||
<div class="section-title">沟通页运行策略</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="每次最多处理未读会话数">
|
||||
<el-input-number
|
||||
v-model="formContent.chatPage.maxProcessPerRun"
|
||||
:min="1"
|
||||
:max="100"
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="formContent.chatPage.runOnceAfterComplete">
|
||||
单轮运行完成后停止(不再自动重启)
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="formContent.chatPage.keepBrowserOpenAfterRun">
|
||||
单轮结束后保持浏览器打开(需同时勾选「单轮运行完成后停止」;关闭浏览器后自动退出)
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item label="两轮之间的等待间隔(毫秒)">
|
||||
<el-input-number
|
||||
v-model="formContent.chatPage.rerunIntervalMs"
|
||||
:min="1000"
|
||||
controls-position="right"
|
||||
placeholder="默认 3000"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<el-alert
|
||||
title="候选人筛选条件及简历筛选规则请在「职位配置」页面按职位配置"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<div class="action-bar">
|
||||
<el-button :loading="isSaving" @click="handleSave">仅保存配置</el-button>
|
||||
<el-button type="primary" :loading="isSaving" @click="handleSubmit">
|
||||
保存配置,并开始处理沟通页!
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="running-overlay__wrap"
|
||||
:style="{
|
||||
pointerEvents: 'none'
|
||||
}"
|
||||
>
|
||||
<RunningOverlay
|
||||
ref="runningOverlayRef"
|
||||
worker-id="bossChatPageMain"
|
||||
:run-record-id="runRecordId"
|
||||
:get-steps="getBossAutoBrowseSteps"
|
||||
>
|
||||
<template #op-buttons="{ currentRunningStatus }">
|
||||
<div>
|
||||
<template v-if="currentRunningStatus === RUNNING_STATUS_ENUM.RUNNING">
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
:loading="isStopButtonLoading"
|
||||
@click="handleStopButtonClick"
|
||||
>结束任务</el-button
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="
|
||||
() => {
|
||||
runningOverlayRef?.hide?.()
|
||||
}
|
||||
"
|
||||
>关闭</el-button
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</RunningOverlay>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import RunningOverlay from '@renderer/features/RunningOverlay/index.vue'
|
||||
import { RUNNING_STATUS_ENUM } from '../../../../../common/enums/auto-start-chat'
|
||||
import { getBossAutoBrowseSteps } from '../../../../../common/prerequisite-step-by-step-check'
|
||||
|
||||
const { ipcRenderer } = electron
|
||||
|
||||
const formRef = ref()
|
||||
const isSaving = ref(false)
|
||||
const runRecordId = ref<number | null>(null)
|
||||
const runningOverlayRef = ref<InstanceType<typeof RunningOverlay> | null>(null)
|
||||
const isStopButtonLoading = ref(false)
|
||||
|
||||
const formContent = reactive({
|
||||
chatPage: {
|
||||
maxProcessPerRun: 20,
|
||||
runOnceAfterComplete: false,
|
||||
keepBrowserOpenAfterRun: false,
|
||||
rerunIntervalMs: 3000
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await ipcRenderer.invoke('fetch-boss-recruiter-config-file-content')
|
||||
const recruiterConfig = result?.config?.['boss-recruiter.json'] || {}
|
||||
const chatPage = recruiterConfig.chatPage ?? {}
|
||||
formContent.chatPage.maxProcessPerRun = chatPage.maxProcessPerRun ?? 20
|
||||
formContent.chatPage.runOnceAfterComplete = chatPage.runOnceAfterComplete ?? false
|
||||
formContent.chatPage.keepBrowserOpenAfterRun = chatPage.keepBrowserOpenAfterRun ?? false
|
||||
formContent.chatPage.rerunIntervalMs = chatPage.rerunIntervalMs ?? 3000
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
|
||||
const doSave = async () => {
|
||||
const payload = {
|
||||
chatPage: {
|
||||
maxProcessPerRun: formContent.chatPage.maxProcessPerRun,
|
||||
runOnceAfterComplete: formContent.chatPage.runOnceAfterComplete,
|
||||
keepBrowserOpenAfterRun: formContent.chatPage.keepBrowserOpenAfterRun,
|
||||
rerunIntervalMs: formContent.chatPage.rerunIntervalMs
|
||||
}
|
||||
}
|
||||
await ipcRenderer.invoke('save-boss-recruiter-config', JSON.stringify(payload))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
isSaving.value = true
|
||||
try {
|
||||
await doSave()
|
||||
ElMessage({ type: 'success', message: '配置已保存' })
|
||||
} catch (err) {
|
||||
ElMessage({ type: 'error', message: '保存失败' })
|
||||
console.error(err)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
isSaving.value = true
|
||||
try {
|
||||
await doSave()
|
||||
runningOverlayRef.value?.show()
|
||||
const { runRecordId: rrId } = await ipcRenderer.invoke('run-boss-chat-page')
|
||||
runRecordId.value = rrId
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopButtonClick = async () => {
|
||||
isStopButtonLoading.value = true
|
||||
try {
|
||||
await ipcRenderer.invoke('stop-boss-chat-page')
|
||||
runningOverlayRef.value?.hide()
|
||||
} finally {
|
||||
isStopButtonLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.boss-chat-page__wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
|
||||
.main__wrap {
|
||||
padding: 24px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.running-overlay__wrap {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,847 @@
|
||||
<template>
|
||||
<div class="debug-tool__wrap">
|
||||
<div class="main__wrap">
|
||||
<!-- 顶部控制栏 -->
|
||||
<el-card class="section">
|
||||
<div class="section-title">招聘端调试工具</div>
|
||||
<div class="section-desc">
|
||||
启动浏览器并打开沟通页,在右侧手动选中一条会话,再用下方按钮测试各项功能。<br />
|
||||
<strong>Tab A</strong> 需要浏览器已就绪;<strong>Tab B「LLM 筛选」</strong>的「运行评估」和「生成 Rubric」不需要浏览器。
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="isLaunching"
|
||||
:disabled="isReady"
|
||||
@click="handleLaunch"
|
||||
>
|
||||
{{ isReady ? '浏览器已启动' : '启动浏览器' }}
|
||||
</el-button>
|
||||
<el-button :disabled="!isReady" @click="handleClose">关闭浏览器</el-button>
|
||||
<el-tag v-if="isReady" type="success">已就绪</el-tag>
|
||||
<el-tag v-else-if="isLaunching" type="warning">启动中...</el-tag>
|
||||
<el-tag v-else type="info">未启动</el-tag>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<el-tabs v-model="activeTab" class="debug-tabs">
|
||||
<!-- ── Tab A: 简历操作 ── -->
|
||||
<el-tab-pane label="简历操作" name="resume">
|
||||
<el-card class="section" :class="{ disabled: !isReady }">
|
||||
<div class="section-title">当前会话操作</div>
|
||||
<div class="cmd-grid">
|
||||
<div v-for="cmd in commands" :key="cmd.type" class="cmd-item">
|
||||
<el-button
|
||||
:loading="runningCmd === cmd.type"
|
||||
:disabled="!isReady || (runningCmd !== null && runningCmd !== cmd.type)"
|
||||
@click="handleCmd(cmd.type)"
|
||||
>
|
||||
{{ cmd.label }}
|
||||
</el-button>
|
||||
<span v-if="results[cmd.type]" class="cmd-result" :class="results[cmd.type].ok ? 'ok' : 'err'">
|
||||
{{ results[cmd.type].text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ── Tab B: LLM 筛选 ── -->
|
||||
<el-tab-pane label="LLM 筛选" name="llm">
|
||||
|
||||
<!-- 区域 1:生成 Rubric(工作流起点) -->
|
||||
<el-card class="section">
|
||||
<div class="section-title">区域 1:生成 Rubric</div>
|
||||
<div class="section-desc">
|
||||
输入 JD → 自动生成评分标准。生成后可直接编辑 JSON,再点「用于评估」传到区域 2。
|
||||
</div>
|
||||
|
||||
<div class="llm-label">岗位描述(JD)</div>
|
||||
<el-input
|
||||
v-model="generateJd"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="粘贴岗位描述、招聘要求或标杆简历片段..."
|
||||
/>
|
||||
<div style="display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap;">
|
||||
<el-button :loading="isGenerating" type="primary" plain @click="handleGenerateRubric">
|
||||
✨ 生成 Rubric
|
||||
</el-button>
|
||||
<template v-if="generatedRubricJson">
|
||||
<el-button @click="copyGeneratedRubric">📋 复制 JSON</el-button>
|
||||
<el-button type="success" plain @click="useGeneratedRubricForEval">
|
||||
➡ 用于评估
|
||||
</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-select
|
||||
v-model="applyTargetJobId"
|
||||
placeholder="选择目标职位..."
|
||||
clearable
|
||||
style="width: 200px"
|
||||
size="small"
|
||||
>
|
||||
<el-option
|
||||
v-for="j in allJobs"
|
||||
:key="j.jobId"
|
||||
:label="j.jobName"
|
||||
:value="j.jobId"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button
|
||||
:disabled="!applyTargetJobId"
|
||||
:loading="isApplying"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleApplyRubricToJob"
|
||||
>
|
||||
应用到职位配置
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template v-if="generatedRubricJson !== null">
|
||||
<div class="llm-label" style="margin-top: 10px;">
|
||||
生成结果(可编辑)
|
||||
<el-text size="small" type="info" style="margin-left: 6px;">修改后点「用于评估」即时生效</el-text>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="generatedRubricJson"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
/>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<!-- 区域 2:提取简历文本 -->
|
||||
<el-card class="section">
|
||||
<div class="section-title">区域 2:提取简历文本</div>
|
||||
<div class="section-desc">需浏览器已就绪且已在沟通页选中一条会话。</div>
|
||||
<el-button
|
||||
:loading="isExtractingText"
|
||||
:disabled="!isReady || isExtractingText"
|
||||
type="primary"
|
||||
plain
|
||||
@click="handleExtractResumeText"
|
||||
>
|
||||
📄 提取当前简历文本
|
||||
</el-button>
|
||||
<template v-if="extractedResumeText">
|
||||
<div class="llm-label" style="margin-top: 10px;">
|
||||
提取结果({{ extractedResumeText.length }} 字)
|
||||
</div>
|
||||
<el-input
|
||||
:model-value="extractedResumeText"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
readonly
|
||||
class="extracted-text"
|
||||
/>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<!-- 区域 3:运行 Rubric 评估 -->
|
||||
<el-card class="section">
|
||||
<div class="section-title">区域 3:运行 Rubric 评估</div>
|
||||
<div class="section-desc">不需要浏览器,直接调用 LLM API。需已配置 <code>boss-llm.json</code>。</div>
|
||||
|
||||
<!-- Rubric 来源选择 -->
|
||||
<div class="llm-label">Rubric 来源</div>
|
||||
<el-radio-group v-model="screenRubricSource" class="rubric-source-group">
|
||||
<el-radio value="job">从职位配置读取</el-radio>
|
||||
<el-radio value="manual">手动填写 JSON</el-radio>
|
||||
</el-radio-group>
|
||||
|
||||
<!-- 职位选择 -->
|
||||
<template v-if="screenRubricSource === 'job'">
|
||||
<div class="llm-label" style="margin-top: 10px;">选择职位</div>
|
||||
<el-select
|
||||
v-model="screenJobId"
|
||||
placeholder="选择已配置 resumeLlmEnabled 的职位"
|
||||
style="width: 100%"
|
||||
@change="loadJobRubricPreview"
|
||||
>
|
||||
<el-option
|
||||
v-for="j in llmEnabledJobs"
|
||||
:key="j.jobId"
|
||||
:label="j.jobName"
|
||||
:value="j.jobId"
|
||||
/>
|
||||
</el-select>
|
||||
<div v-if="jobRubricPreview" class="rubric-preview">
|
||||
<div class="rubric-preview-title">Rubric 预览</div>
|
||||
<pre class="rubric-pre">{{ jobRubricPreview }}</pre>
|
||||
</div>
|
||||
<el-alert v-else-if="screenJobId" type="warning" show-icon :closable="false" style="margin-top: 8px">
|
||||
该职位未配置 resumeLlmConfig.rubric,请先在「职位配置」页面生成。
|
||||
</el-alert>
|
||||
</template>
|
||||
|
||||
<!-- 手动填写 -->
|
||||
<template v-else>
|
||||
<div class="llm-label" style="margin-top: 10px;">
|
||||
Rubric JSON
|
||||
<el-text size="small" type="info">
|
||||
格式: {"knockouts":[],"dimensions":[],"passThreshold":75}
|
||||
</el-text>
|
||||
<el-button
|
||||
v-if="generatedRubricJson"
|
||||
size="small"
|
||||
text
|
||||
style="margin-left: 8px"
|
||||
@click="manualRubricJson = generatedRubricJson"
|
||||
>
|
||||
从区域 1 填入
|
||||
</el-button>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="manualRubricJson"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder='{"knockouts":["必须拥有X经验"],"dimensions":[{"name":"技术能力","weight":100,"criteria":{"1":"无","3":"有","5":"精通"}}],"passThreshold":75}'
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 简历文本 -->
|
||||
<div class="llm-label" style="margin-top: 12px;">简历文本(来自区域 2 或手动粘贴)</div>
|
||||
<el-input
|
||||
v-model="screenResumeText"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="简历全文..."
|
||||
/>
|
||||
<div v-if="extractedResumeText && !screenResumeText" class="form-tip">
|
||||
← 点击「使用区域 2 文本」快速填入
|
||||
<el-button size="small" text @click="screenResumeText = extractedResumeText">使用区域 2 文本</el-button>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
:loading="isScreening"
|
||||
type="primary"
|
||||
style="margin-top: 10px"
|
||||
@click="handleLlmScreen"
|
||||
>
|
||||
🤖 运行 LLM 评估
|
||||
</el-button>
|
||||
|
||||
<!-- 评估结果 -->
|
||||
<template v-if="screenResult">
|
||||
<div class="screen-result" :class="screenResult.isPassed ? 'passed' : 'failed'">
|
||||
<div class="screen-result-status">
|
||||
{{ screenResult.isPassed ? '✅ 通过' : '❌ 未通过' }}
|
||||
<span class="screen-score">{{ screenResult.totalScore }} / 100 分</span>
|
||||
</div>
|
||||
|
||||
<!-- 分维度得分 -->
|
||||
<div v-if="screenResult.dimensionResults?.length" class="dimension-scores">
|
||||
<div
|
||||
v-for="d in screenResult.dimensionResults"
|
||||
:key="d.name"
|
||||
class="dim-row"
|
||||
>
|
||||
<span class="dim-name">{{ d.name }}</span>
|
||||
<el-progress
|
||||
:percentage="Math.round((d.score / 5) * 100)"
|
||||
:color="dimColor(d.score)"
|
||||
:stroke-width="8"
|
||||
style="flex: 1; min-width: 80px;"
|
||||
/>
|
||||
<span class="dim-score">{{ d.score }}/5</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="screen-reason">{{ screenResult.reason }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 统一操作日志 -->
|
||||
<el-card class="section log-section">
|
||||
<div class="section-title">
|
||||
操作日志
|
||||
<el-button size="small" text @click="logs = []">清空</el-button>
|
||||
</div>
|
||||
<div ref="logContainerRef" class="log-content">
|
||||
<div v-for="(line, i) in logs" :key="i" class="log-line" :class="line.type">
|
||||
<span class="log-time">{{ line.time }}</span>
|
||||
<span class="log-msg">{{ line.msg }}</span>
|
||||
</div>
|
||||
<div v-if="logs.length === 0" class="log-empty">暂无日志</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, nextTick, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const { ipcRenderer } = electron
|
||||
|
||||
// ── 浏览器状态 ──────────────────────────────────────────────────────────────
|
||||
const isLaunching = ref(false)
|
||||
const isReady = ref(false)
|
||||
const runningCmd = ref<string | null>(null)
|
||||
const activeTab = ref<'resume' | 'llm'>('resume')
|
||||
|
||||
// ── 日志 ────────────────────────────────────────────────────────────────────
|
||||
type LogLine = { time: string; msg: string; type: 'info' | 'ok' | 'err' }
|
||||
const logs = ref<LogLine[]>([])
|
||||
const logContainerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
type CmdResult = { ok: boolean; text: string }
|
||||
const results = ref<Record<string, CmdResult>>({})
|
||||
|
||||
// ── Tab A 命令列表 ──────────────────────────────────────────────────────────
|
||||
const commands = [
|
||||
{ type: 'get-panel-name', label: '获取当前面板姓名' },
|
||||
{ type: 'dismiss-intent-dialog', label: '关闭「意向沟通」弹窗' },
|
||||
{ type: 'close-online-resume', label: '关闭在线简历弹窗' },
|
||||
{ type: 'open-online-resume', label: '打开在线简历' },
|
||||
{ type: 'check-attach-resume', label: '检查附件简历(是否有「点击预览」)' },
|
||||
{ type: 'accept-incoming-attach-resume', label: '同意对方发来的附件请求(仅当出现「是否同意」时)' },
|
||||
{ type: 'request-attach-resume', label: '请求附件简历' },
|
||||
{ type: 'download-attach-resume', label: '预览并下载附件简历' },
|
||||
{ type: 'ping', label: 'Ping(探活)' },
|
||||
]
|
||||
|
||||
// ── Tab B 状态 ──────────────────────────────────────────────────────────────
|
||||
|
||||
// 区域 1:生成 Rubric
|
||||
const generateJd = ref('')
|
||||
const isGenerating = ref(false)
|
||||
const generatedRubricJson = ref<string | null>(null)
|
||||
const applyTargetJobId = ref('')
|
||||
const isApplying = ref(false)
|
||||
const allJobs = ref<{ jobId: string; jobName: string }[]>([])
|
||||
|
||||
// 区域 2:提取简历文本
|
||||
const isExtractingText = ref(false)
|
||||
const extractedResumeText = ref('')
|
||||
|
||||
// 区域 3:运行评估
|
||||
const screenRubricSource = ref<'job' | 'manual'>('manual')
|
||||
const screenJobId = ref('')
|
||||
const screenResumeText = ref('')
|
||||
const manualRubricJson = ref('')
|
||||
const isScreening = ref(false)
|
||||
type DimResult = { name: string; score: number; weight: number }
|
||||
const screenResult = ref<{
|
||||
isPassed: boolean
|
||||
totalScore: number
|
||||
reason: string
|
||||
dimensionResults?: DimResult[]
|
||||
} | null>(null)
|
||||
const jobRubricPreview = ref('')
|
||||
const llmEnabledJobs = computed(() => allJobs.value.filter((j: any) => (j as any).filter?.resumeLlmEnabled))
|
||||
|
||||
// ── 通用 ─────────────────────────────────────────────────────────────────────
|
||||
function addLog(msg: string, type: LogLine['type'] = 'info') {
|
||||
const now = new Date()
|
||||
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
|
||||
logs.value.push({ time, msg, type })
|
||||
nextTick(() => {
|
||||
if (logContainerRef.value) logContainerRef.value.scrollTop = logContainerRef.value.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
function dimColor(score: number) {
|
||||
if (score >= 4) return '#67c23a'
|
||||
if (score >= 3) return '#e6a23c'
|
||||
return '#f56c6c'
|
||||
}
|
||||
|
||||
// ── 生命周期 / IPC 监听 ───────────────────────────────────────────────────────
|
||||
ipcRenderer.on('boss-chat-debug-exited', () => {
|
||||
isReady.value = false
|
||||
addLog('浏览器已关闭', 'err')
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await ipcRenderer.invoke('fetch-boss-jobs-config')
|
||||
allJobs.value = (result?.jobs ?? []).map((j: any) => ({
|
||||
jobId: j.jobId ?? j.id,
|
||||
jobName: j.jobName ?? j.name ?? j.jobId ?? j.id,
|
||||
filter: j.filter
|
||||
}))
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
ipcRenderer.removeAllListeners('boss-chat-debug-exited')
|
||||
})
|
||||
|
||||
// ── 浏览器控制 ────────────────────────────────────────────────────────────────
|
||||
const handleLaunch = async () => {
|
||||
isLaunching.value = true
|
||||
addLog('正在启动浏览器...')
|
||||
try {
|
||||
const res = await ipcRenderer.invoke('open-boss-chat-debug')
|
||||
if (res?.ok) {
|
||||
isReady.value = true
|
||||
addLog(res.alreadyRunning ? '浏览器已在运行' : '浏览器启动成功,已打开沟通页', 'ok')
|
||||
} else {
|
||||
addLog(`启动失败: ${res?.error ?? '未知错误'}`, 'err')
|
||||
ElMessage({ type: 'error', message: `启动失败: ${res?.error}` })
|
||||
}
|
||||
} catch (err: any) {
|
||||
addLog(`启动异常: ${err?.message}`, 'err')
|
||||
ElMessage({ type: 'error', message: err?.message })
|
||||
} finally {
|
||||
isLaunching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = async () => {
|
||||
await ipcRenderer.invoke('close-boss-chat-debug')
|
||||
isReady.value = false
|
||||
addLog('已关闭浏览器')
|
||||
}
|
||||
|
||||
// ── Tab A:命令执行 ───────────────────────────────────────────────────────────
|
||||
const handleCmd = async (type: string) => {
|
||||
runningCmd.value = type
|
||||
results.value[type] = { ok: true, text: '执行中...' }
|
||||
addLog(`→ ${type}`)
|
||||
try {
|
||||
const res = await ipcRenderer.invoke('boss-debug-command', { type })
|
||||
const ok = res?.ok === true
|
||||
const detail = JSON.stringify(res?.result ?? res?.error ?? '')
|
||||
results.value[type] = { ok, text: ok ? `✓ ${detail}` : `✗ ${res?.error ?? detail}` }
|
||||
addLog(`← ${type}: ${ok ? '成功' : '失败'} ${detail}`, ok ? 'ok' : 'err')
|
||||
} catch (err: any) {
|
||||
results.value[type] = { ok: false, text: `✗ ${err?.message}` }
|
||||
addLog(`← ${type}: 异常 ${err?.message}`, 'err')
|
||||
} finally {
|
||||
runningCmd.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab B:区域 2 — 提取简历文本 ──────────────────────────────────────────────
|
||||
const handleExtractResumeText = async () => {
|
||||
isExtractingText.value = true
|
||||
addLog('→ extract-resume-text(通过浏览器提取简历)')
|
||||
try {
|
||||
const res = await ipcRenderer.invoke('boss-debug-command', { type: 'extract-resume-text' })
|
||||
if (res?.ok) {
|
||||
extractedResumeText.value = res.result?.resumeText ?? ''
|
||||
screenResumeText.value = extractedResumeText.value
|
||||
addLog(`← 提取成功,共 ${res.result?.charCount ?? 0} 字`, 'ok')
|
||||
} else {
|
||||
addLog(`← 提取失败: ${res?.error}`, 'err')
|
||||
ElMessage({ type: 'error', message: `提取失败: ${res?.error}` })
|
||||
}
|
||||
} catch (err: any) {
|
||||
addLog(`← 提取异常: ${err?.message}`, 'err')
|
||||
ElMessage({ type: 'error', message: err?.message })
|
||||
} finally {
|
||||
isExtractingText.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab B:区域 3 — Rubric 来源 ───────────────────────────────────────────────
|
||||
function loadJobRubricPreview(jobId: string) {
|
||||
jobRubricPreview.value = ''
|
||||
const job = allJobs.value.find((j: any) => j.jobId === jobId)
|
||||
const rubric = (job as any)?.filter?.resumeLlmConfig?.rubric
|
||||
if (rubric) {
|
||||
jobRubricPreview.value = JSON.stringify(
|
||||
{
|
||||
knockouts: rubric.knockouts,
|
||||
dimensions: rubric.dimensions,
|
||||
passThreshold: (job as any)?.filter?.resumeLlmConfig?.passThreshold ?? 75
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab B:区域 3 — 运行 LLM 评估 ─────────────────────────────────────────────
|
||||
const handleLlmScreen = async () => {
|
||||
if (!screenResumeText.value.trim()) {
|
||||
ElMessage({ type: 'warning', message: '请先填入简历文本' })
|
||||
return
|
||||
}
|
||||
isScreening.value = true
|
||||
screenResult.value = null
|
||||
addLog('→ llm-screen-resume')
|
||||
|
||||
let payload: Record<string, any> = { resumeText: screenResumeText.value }
|
||||
if (screenRubricSource.value === 'job') {
|
||||
if (!screenJobId.value) {
|
||||
ElMessage({ type: 'warning', message: '请选择职位' })
|
||||
isScreening.value = false
|
||||
return
|
||||
}
|
||||
payload.jobId = screenJobId.value
|
||||
} else {
|
||||
try {
|
||||
payload.rubric = JSON.parse(manualRubricJson.value)
|
||||
} catch {
|
||||
ElMessage({ type: 'error', message: 'Rubric JSON 格式错误,请检查' })
|
||||
isScreening.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await ipcRenderer.invoke('llm-screen-resume', payload)
|
||||
if (res?.ok === false) {
|
||||
addLog(`← LLM 评估失败: ${res.error}`, 'err')
|
||||
ElMessage({ type: 'error', message: res.error })
|
||||
} else {
|
||||
screenResult.value = {
|
||||
isPassed: res.isPassed,
|
||||
totalScore: res.totalScore,
|
||||
reason: res.reason,
|
||||
dimensionResults: res.dimensionResults
|
||||
}
|
||||
addLog(
|
||||
`← LLM 评估完成:${res.isPassed ? '通过' : '未通过'} ${res.totalScore}分 ${res.reason}`,
|
||||
res.isPassed ? 'ok' : 'err'
|
||||
)
|
||||
}
|
||||
} catch (err: any) {
|
||||
addLog(`← LLM 评估异常: ${err?.message}`, 'err')
|
||||
ElMessage({ type: 'error', message: err?.message })
|
||||
} finally {
|
||||
isScreening.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab B:区域 1 — 生成 Rubric ────────────────────────────────────────────────
|
||||
const handleGenerateRubric = async () => {
|
||||
if (!generateJd.value.trim()) {
|
||||
ElMessage({ type: 'warning', message: '请先输入岗位描述' })
|
||||
return
|
||||
}
|
||||
isGenerating.value = true
|
||||
generatedRubricJson.value = null
|
||||
addLog('→ generate-llm-rubric')
|
||||
try {
|
||||
const res = await ipcRenderer.invoke('generate-llm-rubric', { sourceJd: generateJd.value })
|
||||
if (res?.rubric) {
|
||||
generatedRubricJson.value = JSON.stringify(
|
||||
{ ...res.rubric, passThreshold: 75 },
|
||||
null,
|
||||
2
|
||||
)
|
||||
addLog(`← 生成成功:${res.rubric.knockouts?.length ?? 0} 个否决项,${res.rubric.dimensions?.length ?? 0} 个维度`, 'ok')
|
||||
} else {
|
||||
addLog('← 生成失败,请检查 LLM 配置', 'err')
|
||||
ElMessage({ type: 'error', message: '生成失败,请检查 boss-llm.json 配置' })
|
||||
}
|
||||
} catch (err: any) {
|
||||
addLog(`← 生成异常: ${err?.message}`, 'err')
|
||||
ElMessage({ type: 'error', message: err?.message })
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyGeneratedRubric() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedRubricJson.value ?? '')
|
||||
ElMessage({ type: 'success', message: '已复制到剪贴板' })
|
||||
} catch {
|
||||
ElMessage({ type: 'error', message: '复制失败,请手动选中文本复制' })
|
||||
}
|
||||
}
|
||||
|
||||
function useGeneratedRubricForEval() {
|
||||
if (!generatedRubricJson.value) return
|
||||
manualRubricJson.value = generatedRubricJson.value
|
||||
screenRubricSource.value = 'manual'
|
||||
ElMessage({ type: 'success', message: '已填入区域 3,切换为手动模式' })
|
||||
}
|
||||
|
||||
async function handleApplyRubricToJob() {
|
||||
if (!applyTargetJobId.value || !generatedRubricJson.value) return
|
||||
let rubric: any
|
||||
try {
|
||||
rubric = JSON.parse(generatedRubricJson.value)
|
||||
} catch {
|
||||
ElMessage({ type: 'error', message: 'Rubric JSON 格式错误' })
|
||||
return
|
||||
}
|
||||
isApplying.value = true
|
||||
addLog(`→ 应用 Rubric 到职位 ${applyTargetJobId.value}`)
|
||||
try {
|
||||
const res = await ipcRenderer.invoke('apply-rubric-to-job', {
|
||||
jobId: applyTargetJobId.value,
|
||||
rubric: { knockouts: rubric.knockouts, dimensions: rubric.dimensions },
|
||||
passThreshold: rubric.passThreshold ?? 75
|
||||
})
|
||||
if (res?.ok) {
|
||||
addLog(`← 应用成功`, 'ok')
|
||||
ElMessage({ type: 'success', message: '已成功应用到职位配置' })
|
||||
// refresh jobs list
|
||||
const result = await ipcRenderer.invoke('fetch-boss-jobs-config')
|
||||
allJobs.value = (result?.jobs ?? []).map((j: any) => ({
|
||||
jobId: j.jobId ?? j.id,
|
||||
jobName: j.jobName ?? j.name ?? j.jobId ?? j.id,
|
||||
filter: j.filter
|
||||
}))
|
||||
} else {
|
||||
addLog(`← 应用失败: ${res?.error}`, 'err')
|
||||
ElMessage({ type: 'error', message: res?.error ?? '应用失败' })
|
||||
}
|
||||
} catch (err: any) {
|
||||
addLog(`← 应用异常: ${err?.message}`, 'err')
|
||||
ElMessage({ type: 'error', message: err?.message })
|
||||
} finally {
|
||||
isApplying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.debug-tool__wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
|
||||
.main__wrap {
|
||||
padding: 24px;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section {
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.debug-tabs {
|
||||
:deep(.el-tabs__content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tab-pane) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.cmd-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
|
||||
.cmd-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
|
||||
.el-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cmd-result {
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
&.ok { color: #67c23a; }
|
||||
&.err { color: #f56c6c; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab B styles
|
||||
.llm-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.rubric-source-group {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rubric-preview {
|
||||
margin-top: 8px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
|
||||
.rubric-preview-title {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.rubric-pre {
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.screen-result {
|
||||
margin-top: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid;
|
||||
|
||||
&.passed {
|
||||
background: #f0f9eb;
|
||||
border-color: #67c23a;
|
||||
|
||||
.screen-result-status { color: #67c23a; }
|
||||
}
|
||||
|
||||
&.failed {
|
||||
background: #fef0f0;
|
||||
border-color: #f56c6c;
|
||||
|
||||
.screen-result-status { color: #f56c6c; }
|
||||
}
|
||||
|
||||
.screen-result-status {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.screen-score {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.dimension-scores {
|
||||
margin: 10px 0 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.dim-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.dim-name {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
width: 130px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dim-score {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.screen-reason {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-top: 6px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.extracted-text {
|
||||
margin-top: 6px;
|
||||
|
||||
:deep(textarea) {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.log-section {
|
||||
.log-content {
|
||||
height: 260px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
|
||||
.log-line {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
line-height: 1.5;
|
||||
|
||||
.log-time { color: #909399; flex-shrink: 0; }
|
||||
.log-msg { word-break: break-all; }
|
||||
|
||||
&.ok .log-msg { color: #67c23a; }
|
||||
&.err .log-msg { color: #f56c6c; }
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
color: #c0c4cc;
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1071
packages/ui/src/renderer/src/page/MainLayout/BossJobConfig/index.vue
Normal file
1071
packages/ui/src/renderer/src/page/MainLayout/BossJobConfig/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,528 @@
|
||||
<template>
|
||||
<div class="boss-llm-config__wrap">
|
||||
<div class="page-header">
|
||||
<div class="page-title">招聘端大语言模型配置</div>
|
||||
<div class="page-desc">
|
||||
为不同 API 服务商配置模型,同一服务商的多个模型共享 API Key。配置保存到
|
||||
<code>boss-llm.json</code>。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider 列表 -->
|
||||
<div v-if="providers.length" class="provider-list">
|
||||
<el-card
|
||||
v-for="(p, pIdx) in providers"
|
||||
:key="p.id"
|
||||
class="provider-card"
|
||||
shadow="hover"
|
||||
>
|
||||
<!-- Provider 头部 -->
|
||||
<div class="provider-header">
|
||||
<div class="provider-header-left">
|
||||
<el-input
|
||||
v-model="p.name"
|
||||
placeholder="服务商名称(例如:SiliconFlow)"
|
||||
class="provider-name-input"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
text
|
||||
@click="removeProvider(pIdx)"
|
||||
>
|
||||
删除服务商
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Provider 连接信息 -->
|
||||
<div class="form-row-2 provider-conn">
|
||||
<el-form-item label="API Base URL">
|
||||
<el-input v-model="p.baseURL" placeholder="https://api.siliconflow.cn/v1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API Key">
|
||||
<el-input v-model="p.apiKey" type="password" show-password placeholder="sk-xxx" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 该 Provider 下的模型列表 -->
|
||||
<div class="model-list">
|
||||
<div
|
||||
v-for="(m, mIdx) in p.models"
|
||||
:key="m.id"
|
||||
class="model-row"
|
||||
>
|
||||
<div class="model-row-header">
|
||||
<el-switch v-model="m.enabled" style="flex-shrink: 0" />
|
||||
<el-input
|
||||
v-model="m.name"
|
||||
placeholder="模型别名(例如:DeepSeek-R1 简历筛选)"
|
||||
class="model-name-input"
|
||||
size="small"
|
||||
/>
|
||||
<el-button
|
||||
size="small"
|
||||
:loading="m._testing"
|
||||
@click="handleTestEndpoint(p, m)"
|
||||
>
|
||||
测试连接
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
text
|
||||
@click="removeModel(pIdx, mIdx)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="form-row-2 model-fields">
|
||||
<el-form-item label="Model ID">
|
||||
<el-input v-model="m.model" placeholder="Pro/deepseek-ai/DeepSeek-R1" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<span>推理模型</span>
|
||||
<el-tooltip
|
||||
content="支持 DeepSeek-R1、Qwen3 等 thinking model。开启后会自动调整 max_tokens。"
|
||||
placement="top"
|
||||
>
|
||||
<el-icon style="margin-left: 4px; cursor: help"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<div class="thinking-row">
|
||||
<el-checkbox v-model="m.thinking.enabled" label="启用" />
|
||||
<el-input-number
|
||||
v-if="m.thinking.enabled"
|
||||
v-model="m.thinking.budget"
|
||||
:min="256"
|
||||
:max="32768"
|
||||
:step="512"
|
||||
controls-position="right"
|
||||
style="width: 130px; margin-left: 12px"
|
||||
/>
|
||||
<span v-if="m.thinking.enabled" class="form-tip" style="margin-left: 6px">Token 预算</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="m._testResult"
|
||||
:type="m._testResult.ok ? 'success' : 'error'"
|
||||
:title="m._testResult.ok ? '连接成功' : `连接失败:${m._testResult.error}`"
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="margin-top: 4px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加模型按钮 -->
|
||||
<div class="add-model-bar">
|
||||
<el-button size="small" plain @click="addModel(pIdx)">+ 添加模型</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-empty v-else description="暂无服务商,请添加" />
|
||||
|
||||
<!-- 添加 Provider -->
|
||||
<div class="add-provider-bar">
|
||||
<el-button type="primary" plain @click="addProvider">+ 添加服务商</el-button>
|
||||
<el-dropdown @command="addPreset">
|
||||
<el-button plain>
|
||||
从预设添加 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="preset in presets"
|
||||
:key="preset.name"
|
||||
:command="preset"
|
||||
>
|
||||
{{ preset.name }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- 各用途默认模型 -->
|
||||
<el-card class="section" style="margin-top: 16px">
|
||||
<div class="section-title">各用途默认模型</div>
|
||||
<div class="section-desc">当同一用途有多个模型时,指定优先使用哪一个。</div>
|
||||
<div class="form-row-2">
|
||||
<el-form-item
|
||||
v-for="purpose in purposes"
|
||||
:key="purpose.key"
|
||||
:label="purpose.label"
|
||||
>
|
||||
<el-select
|
||||
v-model="purposeDefaultModelId[purpose.key]"
|
||||
clearable
|
||||
placeholder="(跟随第一个启用的模型)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="m in allEnabledModels"
|
||||
:key="m.id"
|
||||
:label="m.displayName"
|
||||
:value="m.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="action-bar">
|
||||
<el-button :loading="isSaving" type="primary" @click="handleSave">保存配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { InfoFilled, ArrowDown } from '@element-plus/icons-vue'
|
||||
|
||||
const { ipcRenderer } = electron
|
||||
|
||||
// ── 数据类型 ─────────────────────────────────────────────────────────────────
|
||||
interface ThinkingConfig {
|
||||
enabled: boolean
|
||||
budget: number
|
||||
}
|
||||
|
||||
interface ModelEntry {
|
||||
id: string
|
||||
name: string
|
||||
model: string
|
||||
enabled: boolean
|
||||
thinking: ThinkingConfig
|
||||
_testing?: boolean
|
||||
_testResult?: { ok: boolean; error?: string } | null
|
||||
}
|
||||
|
||||
interface ProviderEntry {
|
||||
id: string
|
||||
name: string
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
models: ModelEntry[]
|
||||
}
|
||||
|
||||
// ── 状态 ─────────────────────────────────────────────────────────────────────
|
||||
const providers = ref<ProviderEntry[]>([])
|
||||
const purposeDefaultModelId = ref<Record<string, string>>({})
|
||||
const isSaving = ref(false)
|
||||
|
||||
const purposes = [
|
||||
{ key: 'resume_screening', label: '简历筛选' },
|
||||
{ key: 'greeting_generation', label: '招呼语生成' },
|
||||
{ key: 'message_rewrite', label: '消息续写' },
|
||||
{ key: 'default', label: '默认' }
|
||||
]
|
||||
|
||||
// 所有已启用的模型(展平),用于用途默认模型下拉
|
||||
const allEnabledModels = computed(() =>
|
||||
providers.value.flatMap((p) =>
|
||||
p.models
|
||||
.filter((m) => m.enabled)
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
displayName: `${p.name ? p.name + ' / ' : ''}${m.name || m.model}`
|
||||
}))
|
||||
)
|
||||
)
|
||||
|
||||
// ── 预设模板(provider 维度)─────────────────────────────────────────────────
|
||||
const presets = [
|
||||
{
|
||||
name: 'SiliconFlow',
|
||||
baseURL: 'https://api.siliconflow.cn/v1',
|
||||
models: [
|
||||
{ name: 'DeepSeek-R1', model: 'Pro/deepseek-ai/DeepSeek-R1', thinking: { enabled: true, budget: 2048 } },
|
||||
{ name: 'DeepSeek-V3', model: 'Pro/deepseek-ai/DeepSeek-V3', thinking: { enabled: false, budget: 2048 } }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'DeepSeek 官方',
|
||||
baseURL: 'https://api.deepseek.com/v1',
|
||||
models: [
|
||||
{ name: 'DeepSeek-R1', model: 'deepseek-reasoner', thinking: { enabled: false, budget: 2048 } },
|
||||
{ name: 'DeepSeek-V3', model: 'deepseek-chat', thinking: { enabled: false, budget: 2048 } }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '阿里云百炼',
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
models: [
|
||||
{ name: 'Qwen-Plus', model: 'qwen-plus', thinking: { enabled: false, budget: 2048 } }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Ollama 本地',
|
||||
baseURL: 'http://localhost:11434/v1',
|
||||
models: [
|
||||
{ name: 'qwen2.5', model: 'qwen2.5:latest', thinking: { enabled: false, budget: 2048 } }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// ── 工厂函数 ─────────────────────────────────────────────────────────────────
|
||||
function newModel(overrides: Partial<ModelEntry> = {}): ModelEntry {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
model: '',
|
||||
enabled: true,
|
||||
thinking: { enabled: false, budget: 2048 },
|
||||
_testing: false,
|
||||
_testResult: null,
|
||||
...overrides,
|
||||
thinking: { enabled: false, budget: 2048, ...(overrides.thinking ?? {}) }
|
||||
}
|
||||
}
|
||||
|
||||
function newProvider(overrides: Partial<ProviderEntry> = {}): ProviderEntry {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
baseURL: '',
|
||||
apiKey: '',
|
||||
models: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
// ── 生命周期 ─────────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const config = await ipcRenderer.invoke('boss-fetch-llm-config')
|
||||
providers.value = (config?.providers ?? []).map((p: any) => ({
|
||||
...newProvider(),
|
||||
...p,
|
||||
models: (p.models ?? []).map((m: any) => ({
|
||||
...newModel(),
|
||||
...m,
|
||||
thinking: { enabled: false, budget: 2048, ...(m.thinking ?? {}) }
|
||||
}))
|
||||
}))
|
||||
purposeDefaultModelId.value = config?.purposeDefaultModelId ?? {}
|
||||
} catch (err) {
|
||||
console.error('[BossLlmConfig] 加载配置失败', err)
|
||||
}
|
||||
})
|
||||
|
||||
// ── CRUD ─────────────────────────────────────────────────────────────────────
|
||||
function addProvider() {
|
||||
providers.value.push(newProvider())
|
||||
}
|
||||
|
||||
function removeProvider(pIdx: number) {
|
||||
providers.value.splice(pIdx, 1)
|
||||
}
|
||||
|
||||
function addModel(pIdx: number) {
|
||||
providers.value[pIdx].models.push(newModel())
|
||||
}
|
||||
|
||||
function removeModel(pIdx: number, mIdx: number) {
|
||||
providers.value[pIdx].models.splice(mIdx, 1)
|
||||
}
|
||||
|
||||
function addPreset(preset: typeof presets[0]) {
|
||||
providers.value.push(
|
||||
newProvider({
|
||||
name: preset.name,
|
||||
baseURL: preset.baseURL,
|
||||
models: preset.models.map((m) => newModel(m as Partial<ModelEntry>))
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// ── 测试连接 ─────────────────────────────────────────────────────────────────
|
||||
async function handleTestEndpoint(p: ProviderEntry, m: ModelEntry) {
|
||||
m._testing = true
|
||||
m._testResult = null
|
||||
try {
|
||||
const res = await ipcRenderer.invoke('boss-test-llm-endpoint', {
|
||||
baseURL: p.baseURL,
|
||||
apiKey: p.apiKey
|
||||
})
|
||||
m._testResult = res
|
||||
} catch (err: any) {
|
||||
m._testResult = { ok: false, error: err?.message }
|
||||
} finally {
|
||||
m._testing = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 保存 ─────────────────────────────────────────────────────────────────────
|
||||
async function handleSave() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const config = {
|
||||
providers: providers.value.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
baseURL: p.baseURL,
|
||||
apiKey: p.apiKey,
|
||||
models: p.models.map(({ _testing, _testResult, ...rest }) => rest)
|
||||
})),
|
||||
purposeDefaultModelId: purposeDefaultModelId.value
|
||||
}
|
||||
await ipcRenderer.invoke('boss-save-llm-config', JSON.stringify(config))
|
||||
ElMessage({ type: 'success', message: '配置已保存' })
|
||||
} catch (err: any) {
|
||||
ElMessage({ type: 'error', message: `保存失败:${err?.message}` })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.boss-llm-config__wrap {
|
||||
padding: 24px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
|
||||
.page-header {
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.provider-card {
|
||||
.provider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.provider-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.provider-name-input {
|
||||
flex: 1;
|
||||
max-width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-conn {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.model-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.model-row {
|
||||
background: #f8fdfb;
|
||||
border: 1px solid #dce8e6;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
|
||||
.model-row-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.model-name-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.model-fields {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.add-model-bar {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
|
||||
.thinking-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.add-provider-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section {
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 0 8px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: #fff;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="group-item">
|
||||
<div class="group-title">招聘BOSS</div>
|
||||
<div flex flex-col class="link-list">
|
||||
<RouterLink :to="{ name: 'BossJobConfig' }">
|
||||
职位配置
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'BossAutoBrowseAndChat' }">
|
||||
推荐牛人 - 自动开聊
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'BossChatPage' }">
|
||||
沟通
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'BossAutoSequence' }">
|
||||
自动顺序执行
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'WebhookIntegration' }">
|
||||
Webhook / 外部集成
|
||||
</RouterLink>
|
||||
<a href="javascript:void(0)" @click="handleClickRecruiterLogin">
|
||||
编辑登录凭据<TopRight w-1em h-1em mr10px />
|
||||
</a>
|
||||
<a href="javascript:void(0)" @click="handleLaunchRecruiterBossSite">
|
||||
手动逛逛<TopRight w-1em h-1em mr10px />
|
||||
</a>
|
||||
<RouterLink :to="{ name: 'BossDebugTool' }">
|
||||
招聘端调试工具
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'BossLlmConfig' }">
|
||||
配置大语言模型
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { debounce } from 'lodash'
|
||||
import { TopRight } from '@element-plus/icons-vue'
|
||||
import { gtagRenderer } from '@renderer/utils/gtag'
|
||||
|
||||
const handleClickRecruiterLogin = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'BOSS 直聘的招聘端和求职端是两个独立身份。请在接下来的登录页面中,确保以「招聘者」身份登录(即登录后能看到"推荐牛人"等招聘功能)。',
|
||||
'注意:使用招聘端账号登录',
|
||||
{
|
||||
confirmButtonText: '我知道了,去登录',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
await electron.ipcRenderer.invoke('login-with-cookie-assistant')
|
||||
ElMessage({ type: 'success', message: '登录凭据保存成功' })
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
const handleLaunchRecruiterBossSite = debounce(
|
||||
async () => {
|
||||
gtagRenderer('launch_recruiter_boss_site_clicked')
|
||||
return await electron.ipcRenderer.invoke('open-site-with-boss-cookie', {
|
||||
url: `https://www.zhipin.com/web/chat/recommend`
|
||||
})
|
||||
},
|
||||
1000,
|
||||
{ leading: true, trailing: false }
|
||||
)
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss" src="./style.scss"></style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '招聘端大语言模型配置'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
5
plan/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
*.log
|
||||
*.log*
|
||||
*.txt
|
||||
*.resolved
|
||||
|
||||
125
plan/STATUS_2026-03-18.md
Normal file
125
plan/STATUS_2026-03-18.md
Normal 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 配置页(保存 / 测试发送 / 手动触发)
|
||||
- 主进程已提供 IPC:fetch/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 提示符合预期(至少能看到失败原因)
|
||||
|
||||
89
plan/boss_auto_browse_tabs.md
Normal file
89
plan/boss_auto_browse_tabs.md
Normal 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`
|
||||
325
plan/chat_page_resume_flow.md
Normal file
325
plan/chat_page_resume_flow.md
Normal 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 中 2~4 之一。
|
||||
|
||||
---
|
||||
|
||||
## 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`(无 href,Vue 点击事件)
|
||||
- **在线简历弹窗**(点击按钮后出现):
|
||||
```
|
||||
div#boss-dynamic-dialog-<动态id>.dialog-wrap.active
|
||||
└─ div.boss-popup__wrapper.boss-dialog.boss-dialog__wrapper
|
||||
.resume-common-dialog.search-resume
|
||||
.new-chat-resume-dialog-main-ui.resume-container
|
||||
├─ div.boss-popup__content
|
||||
│ └─ div.resume-recommend.resume-common-wrap
|
||||
│ └─ div.resume-detail.iframe-resume-detail
|
||||
│ └─ <iframe sandbox="allow-scripts ..." src="/web/frame/c-resume/...">
|
||||
└─ div.boss-popup__close ← 关闭按钮(selector: .resume-common-dialog .boss-popup__close)
|
||||
└─ i.icon-close
|
||||
```
|
||||
- 弹窗 ID(`#boss-dynamic-dialog-...`)是动态生成的,**不能**用 ID 匹配,应用类名:`.resume-common-dialog .boss-popup__close`。
|
||||
- 切换候选人时弹窗**不会自动关闭**,需在打开新候选人的在线简历之前先调用 `page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR)` 并等待关闭按钮从 DOM 消失(`waitForSelector(closeSelector, { hidden: true })`)。
|
||||
- 等待「iframe 消失」来判断弹窗关闭是不稳定的;等「关闭按钮消失」更可靠。
|
||||
- **附件简历按钮**:`.resume-btn-file`(div,未发起请求时带 `disabled` class;点击仍会触发确认弹窗)
|
||||
- **消息列表**:`.chat-message-list .message-item`
|
||||
- **对方发来的附件简历消息** HTML 特征:
|
||||
```html
|
||||
<div class="item-friend">
|
||||
<div class="message-card-wrap boss-green">
|
||||
<div class="message-card-buttons">
|
||||
<span class="card-btn">点击预览附件简历</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
选择器:`div.message-card-buttons > span.card-btn`
|
||||
- **页面加载时机**:`document.readyState='complete'` 之后,Vue 虚拟滚动列表仍需时间渲染,必须用 `waitForSelector(CHAT_PAGE_ITEM_SELECTOR)` 等待至少一条 `.geek-item` 出现后再解析,否则得到空列表。
|
||||
|
||||
---
|
||||
|
||||
## 6. 与简历图片的对应关系(产品/测试对照)
|
||||
|
||||
- **简历图片 / #resume 上看到的完整版**:对应「**加密数据 → WASM 解密 → Canvas 绘制**」这一条链路,**不是** geek/info 或 historyMsg 的 JSON。
|
||||
- **聊天框/列表里的简单信息**:对应 **geek/info** 的 **`zpData.data`**、**historyMsg** 里 **`body.resume`**(仅简单工作单位、学校等摘要)。
|
||||
- 若自动化需要「和图片一致的完整版」做筛选:**推荐用 Canvas hook**(解密后、fillText 时截获明文,无需破解、无需 OCR);若不能接受 hook,再考虑 OCR 或逆向 WASM。
|
||||
|
||||
---
|
||||
|
||||
## 7. 文件与职责小结
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `plan/chat_page_resume_flow.md` | 本文档:流程与实现说明 |
|
||||
| **`plan/cv_canvas_solution.md`** | **完整版简历 Canvas/WASM 破解方案(已验证)** |
|
||||
| **`plan/recruiter_mouse_trajectory.md`** | **招聘端拟人鼠标轨迹(反人机),各 Phase 涉及点击/移动时必读** |
|
||||
| `packages/boss-auto-browse-and-chat/constant.mjs` | 沟通页选择器、#resume、URL 常量 |
|
||||
| `packages/boss-auto-browse-and-chat/resume-extractor.mjs` | 网络拦截、parseGeekInfoFromIntercepted、Canvas hook |
|
||||
| `packages/boss-auto-browse-and-chat/chat-page-resume.mjs` | openOnlineResume、getOnlineResumeDataFromApi、getOnlineResumeText、requestAttachmentResume、waitForAttachmentResumeMessage、openPreviewAndDownloadPdf |
|
||||
| `packages/boss-auto-browse-and-chat/chat-page-processor.mjs` | 沟通页主流程:parseConversationList、selectConversationById、screenCandidateWithLlm、startBossChatPageProcess;含阶段一初步信息筛选和阶段二全文筛选逻辑;读取 `chatPage.attachmentResume.skipDownload` 开关决定是否跳过 PDF 下载 |
|
||||
| `packages/boss-auto-browse-and-chat/default-config-file/boss-recruiter.json` | 默认配置,含 `chatPage.attachmentResume.skipDownload`(默认 `false`) |
|
||||
| `packages/boss-auto-browse-and-chat/candidate-processor.mjs` | 共用筛选工具:filterCandidates、parseWorkExpYears、parseSalaryRange;可复用于沟通页初步信息筛选(preFilter) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 原作者建议(招聘端反检测)
|
||||
|
||||
> 作者说明:之前调研过招聘端;在职时太忙没推下去,现在失业也没有招聘权限。若有人要做,以下为个人想法。
|
||||
|
||||
> 简历解密相关方案(WASM/Canvas hook/get_export_geek_detail_info 等)已由 Claude Code 分析并验证,**详见 [plan/cv_canvas_solution.md](cv_canvas_solution.md)**。
|
||||
|
||||
### 8.1 鼠标轨迹(反人机)
|
||||
|
||||
- **现象**:BOSS 会对招聘端**鼠标移动轨迹**做埋点,可能是判断人机的特征之一。
|
||||
- **建议**:尝试借助一些库生成**拟人的鼠标轨迹**(例如贝塞尔曲线、随机抖动、加速度等),让 Puppeteer 操作时不是「瞬移」而是沿轨迹移动,降低被识别为脚本的概率。
|
||||
|
||||
---
|
||||
|
||||
*文档维护:随实现变更时请同步更新本 plan。*
|
||||
194
plan/cv_canvas_solution.md
Normal file
194
plan/cv_canvas_solution.md
Normal 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 crate(pure Rust,fixslice32 实现)
|
||||
- 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`(len):WASM 线性内存中的 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
70
plan/logger_usage.md
Normal 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
241
plan/multi-job-switching.md
Normal 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
393
plan/recommend_page_flow.md
Normal 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
|
||||
// 在 recommendFrame(iframe)内使用
|
||||
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' // 简历详情弹窗关闭
|
||||
|
||||
// 在 recommendFrame(iframe)内使用,每条 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() → recommendFrame(iframe 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.onCandidateListLoaded(GUI 登录状态 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):**
|
||||
|
||||
- WASM(Base64+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`):**
|
||||
|
||||
| 筛选 reason(candidate-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` | — | 主循环正常结束 |
|
||||
423
plan/recruiter_architecture.md
Normal file
423
plan/recruiter_architecture.md
Normal file
@@ -0,0 +1,423 @@
|
||||
# 招聘端(Recruiter/BOSS)架构总览
|
||||
|
||||
> **定位**:供 AI Agent 快速理解招聘端全貌,用于协作开发时减少 token 消耗。
|
||||
> 最后更新:2026-03-16
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体架构一览
|
||||
|
||||
```
|
||||
UI 层 (Electron Renderer)
|
||||
└── 四个配置/启动页面
|
||||
├── BossAutoBrowseAndChat → 推荐牛人:配置 + 启动
|
||||
├── BossChatPage → 沟通页:配置 + 启动
|
||||
├── BossAutoSequence → 顺序执行(无独立配置,复用上两者)
|
||||
└── WebhookIntegration → Webhook / 外部集成(配置 + 测试发送)
|
||||
|
||||
IPC 层 (Electron Main)
|
||||
└── ipc/index.ts → run / stop / save / fetch-config 处理器
|
||||
|
||||
Worker 层 (独立 Electron 子进程,通过 daemon 管理)
|
||||
├── bossRecommendMain → flow/BOSS_RECOMMEND_MAIN/index.ts
|
||||
├── bossChatPageMain → flow/BOSS_CHAT_PAGE_MAIN/index.ts
|
||||
└── bossAutoBrowseAndChatMain → flow/BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts(串联上两者)
|
||||
|
||||
自动化核心 (packages/boss-auto-browse-and-chat/)
|
||||
├── index.mjs → startBossAutoBrowse(推荐牛人主入口)
|
||||
├── chat-page-processor.mjs → startBossChatPageProcess(沟通页主入口)
|
||||
├── candidate-processor.mjs → 解析列表、筛选候选人、滚动加载
|
||||
├── chat-handler.mjs → 点击打招呼、发送招呼语、检查日限
|
||||
├── resume-extractor.mjs → 网络拦截 + Canvas hook 提取简历文本
|
||||
├── chat-page-resume.mjs → 在线简历、附件简历、下载 PDF
|
||||
├── data-manager.mjs → 查重、保存候选人信息、写联系日志
|
||||
├── humanMouse.mjs → ghost-cursor 人类鼠标模拟
|
||||
├── constant.mjs → URL 常量、DOM 选择器
|
||||
└── runtime-file-utils.mjs → 读写 config / storage 文件工具
|
||||
|
||||
持久化 (@geekgeekrun/sqlite-plugin)
|
||||
└── SQLite: ~/.geekgeekrun/storage/public.db
|
||||
├── CandidateInfo → 候选人基础信息
|
||||
└── CandidateContactLog → 联系记录日志
|
||||
|
||||
外部集成 (packages/ui/src/main/features/webhook/)
|
||||
└── index.ts → sendWebhook / buildMockPayload
|
||||
配置文件:~/.geekgeekrun/config/webhook.json(boss-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 + localStorage(boss-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.json(boss-auto-browse 配置路径)
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"url": "https://your-paperless.example.com/api/documents/post_document/",
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"Authorization": "Token YOUR_TOKEN"
|
||||
},
|
||||
"payloadOptions": {
|
||||
"includeBasicInfo": true,
|
||||
"includeFilterReason": true,
|
||||
"includeLlmConclusion": true,
|
||||
"includeResume": "path"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`includeResume` 取值:`"none"` | `"path"` | `"base64"`
|
||||
|
||||
**Payload 结构(发出的 JSON):**
|
||||
```json
|
||||
{
|
||||
"runId": "run-<runRecordId>",
|
||||
"timestamp": "ISO8601",
|
||||
"summary": { "total": 10, "matched": 7, "skipped": 3 },
|
||||
"candidates": [
|
||||
{
|
||||
"basicInfo": { "name": "...", "education": "...", "workExpYears": 3, "city": "...", "salary": "...", "skills": [] },
|
||||
"filterReport": { "matched": true, "matchedRules": [], "score": 85 },
|
||||
"llmConclusion": "...",
|
||||
"resumeFile": { "path": "/abs/path/to/resume.pdf", "filename": "张三.pdf" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**自动触发时机:** `bossAutoBrowseAndChatMain` worker 中每轮(推荐页 + 沟通页)执行完毕后,读取 webhook.json,若 `enabled=true` 则发送汇总报告;失败不影响主流程,清空 `sessionCandidates` 后等待下轮。
|
||||
|
||||
**候选人数据来源:** `afterChatStarted` hook(每次打招呼后触发),收集 `candidate` 对象中的 `info`、`matchedRules`、`score`、`llmConclusion`、`resumeFilePath`、`resumeFileName` 字段。
|
||||
|
||||
---
|
||||
|
||||
### boss-recruiter.json
|
||||
```json
|
||||
{
|
||||
"targetJobId": "",
|
||||
"autoChat": {
|
||||
"greetingMessage": "你好,...",
|
||||
"maxChatPerRun": 50,
|
||||
"delayBetweenChats": [3000, 8000]
|
||||
},
|
||||
"chatPage": {
|
||||
"maxProcessPerRun": 20,
|
||||
"filter": {
|
||||
"mode": "keywords",
|
||||
"keywordList": ["Python", "机器学习"],
|
||||
"llmRule": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### candidate-filter.json
|
||||
```json
|
||||
{
|
||||
"expectCityList": ["北京", "上海"],
|
||||
"expectEducationList": ["本科", "硕士"],
|
||||
"expectWorkExpRange": [1, 5],
|
||||
"expectSalaryRange": [15, 50],
|
||||
"expectSkillKeywords": ["Vue", "React"],
|
||||
"blockCandidateNameRegExpStr": "测试|内推"
|
||||
}
|
||||
```
|
||||
|
||||
**路径:** `~/.geekgeekrun/storage/`
|
||||
|
||||
- `boss-cookies.json` — 招聘端 Cookie
|
||||
- `boss-local-storage.json` — 域名 localStorage
|
||||
- `public.db` — SQLite 数据库
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键常量(constant.mjs)
|
||||
|
||||
```js
|
||||
// URL
|
||||
BOSS_RECOMMEND_PAGE_URL = 'https://www.zhipin.com/web/chat/recommend'
|
||||
BOSS_CHAT_INDEX_URL = 'https://www.zhipin.com/web/chat/index'
|
||||
BOSS_CHAT_PAGE_URL = 'https://www.zhipin.com/web/chat/index'
|
||||
|
||||
// 推荐牛人页选择器
|
||||
CANDIDATE_LIST_SELECTOR = '#recommend-list'
|
||||
CANDIDATE_ITEM_SELECTOR = '#recommend-list ul.card-list > li.card-item'
|
||||
CANDIDATE_NAME_SELECTOR = 'span.name'
|
||||
CHAT_START_BUTTON_SELECTOR = 'div.operate-side button.btn-greet'
|
||||
GREETING_SENT_KNOW_BTN_SELECTOR = 'div.dialog-wrap button.btn-sure-v2'
|
||||
CONTINUE_CHAT_BUTTON_SELECTOR = 'div.operate-side > div > span > div > div'
|
||||
CHAT_INPUT_SELECTOR = '#boss-chat-global-input'
|
||||
|
||||
// 沟通页选择器(CHAT_PAGE_* 前缀)
|
||||
CHAT_PAGE_ITEM_SELECTOR = 'div.user-container > div > div:nth-child(2) > div:nth-child(1) > div'
|
||||
CHAT_PAGE_NAME_SELECTOR = 'span.geek-name'
|
||||
CHAT_PAGE_JOB_SELECTOR = 'span.source-job'
|
||||
CHAT_PAGE_ONLINE_RESUME_SELECTOR = 'div.resume-btn-content > a > svg'
|
||||
CHAT_PAGE_ATTACH_RESUME_BTN_SELECTOR = 'div.resume-btn-content > div > div.btn.resume-btn-file'
|
||||
CHAT_PAGE_ASK_RESUME_CONFIRM_BTN_SELECTOR = 'div.ask-for-resume-confirm .content button.boss-btn-primary'
|
||||
CHAT_PAGE_MESSAGE_ITEM_SELECTOR = 'div.chat-message-list.is-to-top > div > div:nth-child(2) > div'
|
||||
CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR = 'div.message-card-buttons > span'
|
||||
CHAT_PAGE_DOWNLOAD_PDF_BTN_SELECTOR = 'div.resume-common-dialog div.resume-footer-wrap > div > div > div:nth-child(3)'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. IPC 通信接口(main ↔ renderer)
|
||||
|
||||
### 招聘端专用 IPC
|
||||
|
||||
| Channel | 方向 | 说明 |
|
||||
|---------|------|------|
|
||||
| `run-boss-recommend` | invoke | 启动推荐牛人 worker,返回 `{ runRecordId }` |
|
||||
| `stop-boss-recommend` | invoke | 停止推荐牛人 worker(等待退出) |
|
||||
| `run-boss-chat-page` | invoke | 启动沟通页 worker,返回 `{ runRecordId }` |
|
||||
| `stop-boss-chat-page` | invoke | 停止沟通页 worker |
|
||||
| `run-boss-auto-browse-and-chat` | invoke | 启动顺序执行 worker,返回 `{ runRecordId }` |
|
||||
| `stop-boss-auto-browse-and-chat` | invoke | 停止顺序执行 worker |
|
||||
| `save-boss-recruiter-config` | invoke | 保存招聘端配置(JSON 字符串),同时写 boss-recruiter.json 和 candidate-filter.json |
|
||||
| `fetch-boss-recruiter-config-file-content` | invoke | 读取配置,返回 `{ config: { 'boss-recruiter.json': {}, 'candidate-filter.json': {} } }` |
|
||||
| `check-boss-recruiter-cookie-file` | invoke | 检查 Cookie 格式是否合法 |
|
||||
| `fetch-webhook-config` | invoke | 读取 webhook.json,返回配置对象或 null |
|
||||
| `save-webhook-config` | invoke | 写入 webhook.json(接收 JSON 字符串) |
|
||||
| `test-webhook` | invoke | 发送 mock payload 到已配置的 URL,返回 `{ status, body }` |
|
||||
| `trigger-webhook-manually` | invoke | 用 mock payload(标记为 manual)手动触发一次,返回 `{ status, body }` |
|
||||
|
||||
### Worker → GUI 消息(通过 daemon)
|
||||
|
||||
消息类型(`data.type` 字段):
|
||||
|
||||
| type | 说明 |
|
||||
|------|------|
|
||||
| `worker-log` | 普通日志文本 |
|
||||
| `prerequisite-step-by-step-checkstep-by-step-check` | 前置步骤状态更新,含 `{ step: { id, status } }` |
|
||||
| `boss-auto-browse-progress` | 进度更新,含 `{ phase, current, max }` |
|
||||
| `worker-exited` | Worker 进程退出 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 前置步骤(RunningOverlay 进度条)
|
||||
|
||||
```
|
||||
getBossAutoBrowseSteps() → [
|
||||
{ id: 'worker-launch', describe: '启动子进程' },
|
||||
{ id: 'puppeteer-executable-check', describe: 'Puppeteer 可执行程序检查' },
|
||||
{ id: 'login-status-check', describe: '登录状态检查(若浏览器弹出请以招聘者身份登录)' }
|
||||
]
|
||||
```
|
||||
|
||||
步骤状态:`'todo' | 'pending' | 'fulfilled' | 'rejected'`
|
||||
|
||||
---
|
||||
|
||||
## 9. Hooks 体系
|
||||
|
||||
所有 worker 都构造相同结构的 hooks 传给核心模块,SqlitePlugin 在上面挂载 DB 操作:
|
||||
|
||||
```ts
|
||||
const hooks = {
|
||||
beforeBrowserLaunch: AsyncSeriesHook,
|
||||
afterBrowserLaunch: AsyncSeriesHook,
|
||||
beforeNavigateToRecommend: AsyncSeriesHook,
|
||||
onCandidateListLoaded: AsyncSeriesHook,
|
||||
onCandidateFiltered: AsyncSeriesWaterfallHook, // ['candidates', 'filterResult']
|
||||
beforeStartChat: AsyncSeriesHook, // ['candidate']
|
||||
afterChatStarted: AsyncSeriesHook, // ['candidate', 'result']
|
||||
onError: AsyncSeriesHook, // ['error']
|
||||
onComplete: AsyncSeriesHook,
|
||||
onProgress: AsyncSeriesHook, // ['payload']
|
||||
// SqlitePlugin 额外挂载:
|
||||
createOrUpdateCandidateInfo: AsyncSeriesHook
|
||||
insertCandidateContactLog: AsyncSeriesHook
|
||||
queryCandidateByEncryptId: AsyncSeriesWaterfallHook
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 反检测机制
|
||||
|
||||
- **puppeteer-extra-plugin-stealth** — 抹除 headless 特征
|
||||
- **@geekgeekrun/puppeteer-extra-plugin-laodeng** — 自定义反检测
|
||||
- **puppeteer-extra-plugin-anonymize-ua** — 随机 UserAgent(`makeWindows: false`)
|
||||
- **ghost-cursor** — `createHumanCursor(page)` 模拟人类鼠标轨迹,所有点击走 cursor 而非 `page.click()`
|
||||
- **sleepWithRandomDelay(base, range)** — 操作间随机延迟
|
||||
- 滚动:`page.mouse.wheel({ deltaY })` 分步骤随机延迟
|
||||
|
||||
---
|
||||
|
||||
## 11. 路由与导航
|
||||
|
||||
| 路由 Name | 路径 | 页面组件 |
|
||||
|----------|------|--------|
|
||||
| `BossAutoBrowseAndChat` | `/main-layout/BossAutoBrowseAndChat` | 推荐牛人 - 自动开聊 |
|
||||
| `BossChatPage` | `/main-layout/BossChatPage` | 沟通页 |
|
||||
| `BossAutoSequence` | `/main-layout/BossAutoSequence` | 自动顺序执行 |
|
||||
| `WebhookIntegration` | `/main-layout/WebhookIntegration` | Webhook / 外部集成 |
|
||||
|
||||
切换到招聘端身份时默认重定向到 `BossAutoBrowseAndChat`。
|
||||
|
||||
`RECRUITER_ROUTES = ['BossAutoBrowseAndChat', 'BossChatPage', 'BossAutoSequence']` 用于判断当前身份模式。
|
||||
> 注:`WebhookIntegration` 是纯配置页,无需加入 `RECRUITER_ROUTES`(无 RunningOverlay)。
|
||||
|
||||
---
|
||||
|
||||
## 12. 如何新增招聘端页面
|
||||
|
||||
1. **新建页面组件**
|
||||
```
|
||||
packages/ui/src/renderer/src/page/MainLayout/Boss<NewPage>/index.vue
|
||||
```
|
||||
- 若需要启动任务:参考 `BossAutoBrowseAndChat/index.vue` 或 `BossChatPage/index.vue`
|
||||
- 使用 `RunningOverlay` 组件显示进度:
|
||||
```vue
|
||||
<RunningOverlay worker-id="<workerId>" :run-record-id="runRecordId" :get-steps="getBossAutoBrowseSteps" />
|
||||
```
|
||||
|
||||
2. **注册路由** — 在 `packages/ui/src/renderer/src/router/index.ts` 的 `/main-layout` children 中添加:
|
||||
```ts
|
||||
{ name: 'Boss<NewPage>', path: 'Boss<NewPage>', component: () => import(...), meta: { title: '...' } }
|
||||
```
|
||||
|
||||
3. **更新 RECRUITER_ROUTES** — 在 `packages/ui/src/renderer/src/page/MainLayout/index.vue` 中:
|
||||
```ts
|
||||
const RECRUITER_ROUTES = ['BossAutoBrowseAndChat', 'BossChatPage', 'BossAutoSequence', 'Boss<NewPage>']
|
||||
```
|
||||
|
||||
4. **添加导航入口** — 在 `packages/ui/src/renderer/src/page/MainLayout/LeftNavBar/RecruiterPart.vue`:
|
||||
```vue
|
||||
<RouterLink :to="{ name: 'Boss<NewPage>' }">页面名称</RouterLink>
|
||||
```
|
||||
|
||||
5. **(如需新 Worker)新建 flow 文件**
|
||||
```
|
||||
packages/ui/src/main/flow/BOSS_<NEW>_MAIN/index.ts
|
||||
```
|
||||
- 复制 `BOSS_RECOMMEND_MAIN/index.ts` 为模板
|
||||
- 修改 `workerId`、调用的核心函数、日志前缀
|
||||
|
||||
6. **注册新 mode** — 在 `packages/ui/src/main/index.ts` 的 switch 中:
|
||||
```ts
|
||||
case 'boss<New>Main': {
|
||||
const { waitForProcessHandShakeAndRunAutoChat } = await import('./flow/BOSS_<NEW>_MAIN/index')
|
||||
waitForProcessHandShakeAndRunAutoChat()
|
||||
break
|
||||
}
|
||||
```
|
||||
|
||||
7. **添加 IPC 处理器** — 在 `packages/ui/src/main/flow/OPEN_SETTING_WINDOW/ipc/index.ts`:
|
||||
```ts
|
||||
ipcMain.handle('run-boss-<new>', async () => {
|
||||
const mode = 'boss<New>Main'
|
||||
const { runRecordId } = await runCommon({ mode })
|
||||
daemonEE.on('message', function handler(message) {
|
||||
if (message.workerId !== mode) return
|
||||
if (message.type === 'worker-exited') mainWindow?.webContents.send('worker-exited', message)
|
||||
})
|
||||
return { runRecordId }
|
||||
})
|
||||
ipcMain.handle('stop-boss-<new>', async () => {
|
||||
// 参考 stop-boss-recommend 的模式
|
||||
})
|
||||
```
|
||||
|
||||
8. **(如需新配置字段)更新 save-boss-recruiter-config 处理器**(同文件)
|
||||
|
||||
---
|
||||
|
||||
## 13. 如何在核心模块新增功能
|
||||
|
||||
### 新增候选人筛选条件
|
||||
- 在 `candidate-processor.mjs` 的 `filterCandidates()` 添加判断逻辑
|
||||
- 在 `candidate-filter.json` 对应结构增加字段
|
||||
- 在 `BossAutoBrowseAndChat/index.vue` 增加 UI 表单项
|
||||
- 在 IPC `save-boss-recruiter-config` 中处理新字段的保存/读取
|
||||
|
||||
### 新增 Hook 钩子点
|
||||
- 在 worker flow 文件的 `hooks` 对象中添加 `new AsyncSeriesHook(['...'])`
|
||||
- 在核心 .mjs 文件对应位置调用 `await hooks.newHook?.promise?.(payload)`
|
||||
|
||||
### 新增联系日志类型
|
||||
- 在 `data-manager.mjs` 的 `logContact()` 调用时传入新的 `contactType` 字符串
|
||||
- SQLite Plugin 会自动持久化
|
||||
|
||||
---
|
||||
|
||||
## 14. 相关文档
|
||||
|
||||
- [boss_auto_browse_tabs.md](boss_auto_browse_tabs.md) — 双 Tab 设计(沟通 vs 推荐牛人)
|
||||
- [chat_page_resume_flow.md](chat_page_resume_flow.md) — 沟通页简历流程详述
|
||||
- [cv_canvas_solution.md](cv_canvas_solution.md) — Canvas/WASM 简历解密方案
|
||||
- [recruiter_mouse_trajectory.md](recruiter_mouse_trajectory.md) — 反检测鼠标轨迹方案
|
||||
- [webhook_integration.md](webhook_integration.md) — Webhook / 外部集成详述(Paperless-ngx 对接)
|
||||
214
plan/recruiter_debug_tool.md
Normal file
214
plan/recruiter_debug_tool.md
Normal 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` 到 Renderer,GUI 自动将状态置为「未就绪」
|
||||
|
||||
### 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`)中未单独列出,可在此文档中查阅其设计与用法。
|
||||
263
plan/recruiter_llm_integration.md
Normal file
263
plan/recruiter_llm_integration.md
Normal 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`:及格线(0–100)
|
||||
- **向后兼容**:若无 `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 待实现的 IPC(BossLlmConfig 配置页)
|
||||
|
||||
| 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 Builder(Step 1)+ Visual Rubric Editor(Step 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] IPC:generate-llm-rubric
|
||||
- [x] BossJobConfig:resumeLlmConfig 数据结构 + 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] IPC:boss-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,需先映射到 0–1 再乘权重,最终总分落在 0–100。推荐公式:
|
||||
|
||||
\[
|
||||
\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) |
|
||||
| 用途 | 职位匹配、已读不回提醒等 | 简历筛选、招呼语、消息续写等 |
|
||||
76
plan/recruiter_mouse_trajectory.md
Normal file
76
plan/recruiter_mouse_trajectory.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# 招聘端拟人鼠标轨迹(反人机)
|
||||
|
||||
## 一、原作者提醒(必须重视)
|
||||
|
||||
> **鼠标轨迹**:BOSS 会对招聘端**鼠标移动轨迹**进行埋点,这个可能是判断人机的特征之一。可以试试看能不能借助一些库生成**拟人的鼠标轨迹**。
|
||||
|
||||
含义:招聘端所有在页面上发生的**点击、移动**,若以「瞬移」方式执行(如直接 `element.click()` 或 `page.mouse.click(x, y)`),容易被埋点识别为脚本。**各 Phase 中凡涉及浏览器内点击/移动的操作,都应使用拟人轨迹**,而不能忽略此问题。
|
||||
|
||||
---
|
||||
|
||||
## 二、适用范围(各 Phase)
|
||||
|
||||
| Phase | 是否涉及页面点击/移动 | 说明 |
|
||||
|-------|----------------------|------|
|
||||
| **Phase 0** | 否(仅脚手架与入口) | 无浏览器操作,可不考虑。 |
|
||||
| **Phase 1** | **是** | 1B 导航、Cookie;1C 若在 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 1~4 的 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
204
plan/webhook_integration.md
Normal 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 handlers:fetch/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、candidate(JSON)、document(简历文件),支持直传 Paperless 等。
|
||||
- **重试机制**:配置项 `retryTimes`、`retryDelayMs`,失败时指数退避重试;`queueFileOnFailure: true` 时最终失败写入 `~/.geekgeekrun/storage/webhook-failed-queue.jsonl`。
|
||||
- **手动触发使用真实数据**:`trigger-webhook-manually` 支持第二参数 `useRealData`;为 true 时从 SQLite `CandidateContactLog` + `CandidateInfo` 查最近 50 条联系人组装 payload,无数据时回退为 Mock。
|
||||
73
pnpm-lock.yaml
generated
73
pnpm-lock.yaml
generated
@@ -54,6 +54,39 @@ importers:
|
||||
specifier: ^7.6.0
|
||||
version: 7.6.0
|
||||
|
||||
packages/boss-auto-browse-and-chat:
|
||||
dependencies:
|
||||
'@geekgeekrun/puppeteer-extra-plugin-laodeng':
|
||||
specifier: workspace:*
|
||||
version: link:../laodeng
|
||||
'@geekgeekrun/sqlite-plugin':
|
||||
specifier: workspace:*
|
||||
version: link:../sqlite-plugin
|
||||
'@geekgeekrun/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../utils
|
||||
ghost-cursor:
|
||||
specifier: ^1.3.1
|
||||
version: 1.4.2
|
||||
json5:
|
||||
specifier: ^2.2.3
|
||||
version: 2.2.3
|
||||
puppeteer:
|
||||
specifier: 24.19.0
|
||||
version: 24.19.0(typescript@5.3.3)
|
||||
puppeteer-extra:
|
||||
specifier: 3.3.6
|
||||
version: 3.3.6(puppeteer@24.19.0)
|
||||
puppeteer-extra-plugin-anonymize-ua:
|
||||
specifier: 2.4.6
|
||||
version: 2.4.6(patch_hash=5bafoxpvwneq2ywcyjznkii37m)(puppeteer-extra@3.3.6)
|
||||
puppeteer-extra-plugin-stealth:
|
||||
specifier: 2.11.2
|
||||
version: 2.11.2(puppeteer-extra@3.3.6)
|
||||
tapable:
|
||||
specifier: ^2.2.1
|
||||
version: 2.2.1
|
||||
|
||||
packages/dingtalk-plugin: {}
|
||||
|
||||
packages/geek-auto-start-chat-with-boss:
|
||||
@@ -105,6 +138,18 @@ importers:
|
||||
specifier: 28.2.0
|
||||
version: 28.2.0
|
||||
|
||||
packages/run-core-of-boss-auto-browse:
|
||||
dependencies:
|
||||
'@geekgeekrun/boss-auto-browse-and-chat':
|
||||
specifier: workspace:*
|
||||
version: link:../boss-auto-browse-and-chat
|
||||
'@geekgeekrun/sqlite-plugin':
|
||||
specifier: workspace:*
|
||||
version: link:../sqlite-plugin
|
||||
'@geekgeekrun/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../utils
|
||||
|
||||
packages/run-core-of-geek-auto-start-chat-with-boss:
|
||||
dependencies:
|
||||
'@geekgeekrun/dingtalk-plugin':
|
||||
@@ -159,6 +204,9 @@ importers:
|
||||
'@electron-toolkit/utils':
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0(electron@39.2.7)
|
||||
'@geekgeekrun/boss-auto-browse-and-chat':
|
||||
specifier: workspace:*
|
||||
version: link:../boss-auto-browse-and-chat
|
||||
'@geekgeekrun/launch-bosszhipin-login-page-with-preload-extension':
|
||||
specifier: workspace:*
|
||||
version: link:../launch-bosszhipin-login-page-with-preload-extension
|
||||
@@ -1687,6 +1735,10 @@ packages:
|
||||
/@tsconfig/node16@1.0.4:
|
||||
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
|
||||
|
||||
/@types/bezier-js@4.1.3:
|
||||
resolution: {integrity: sha512-FNVVCu5mx/rJCWBxLTcL7oOajmGtWtBTDjq6DSUWUI12GeePivrZZXz+UgE0D6VYsLEjvExRO03z4hVtu3pTEQ==}
|
||||
dev: false
|
||||
|
||||
/@types/cacheable-request@6.0.3:
|
||||
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
|
||||
dependencies:
|
||||
@@ -2704,6 +2756,10 @@ packages:
|
||||
prebuild-install: 7.1.3
|
||||
dev: false
|
||||
|
||||
/bezier-js@6.1.4:
|
||||
resolution: {integrity: sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==}
|
||||
dev: false
|
||||
|
||||
/binary-extensions@2.2.0:
|
||||
resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3223,13 +3279,8 @@ packages:
|
||||
resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==}
|
||||
dev: false
|
||||
|
||||
/dayjs@1.11.13:
|
||||
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
|
||||
dev: true
|
||||
|
||||
/dayjs@1.11.19:
|
||||
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
|
||||
dev: false
|
||||
|
||||
/de-indent@1.0.2:
|
||||
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
|
||||
@@ -3595,7 +3646,7 @@ packages:
|
||||
'@types/lodash-es': 4.17.12
|
||||
'@vueuse/core': 9.13.0(vue@3.4.15)
|
||||
async-validator: 4.2.5
|
||||
dayjs: 1.11.13
|
||||
dayjs: 1.11.19
|
||||
escape-html: 1.0.3
|
||||
lodash: 4.17.21
|
||||
lodash-es: 4.17.21
|
||||
@@ -4255,6 +4306,16 @@ packages:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
/ghost-cursor@1.4.2:
|
||||
resolution: {integrity: sha512-NPMuH05Ik9h99zrAb4fvAzhB4T6MqKwdPoI+Me0IJWN3676j4UoAsjKN/cJq5AG4zyZwAEPPggZXHVU0P57/5g==}
|
||||
dependencies:
|
||||
'@types/bezier-js': 4.1.3
|
||||
bezier-js: 6.1.4
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/github-from-package@0.0.0:
|
||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||
dev: false
|
||||
|
||||
Reference in New Issue
Block a user