diff --git a/.gitignore b/.gitignore index 3ee526c..872df5e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1c3b503 --- /dev/null +++ b/CLAUDE.md @@ -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 -- `** 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 ` + + diff --git a/packages/ui/src/renderer/src/page/MainLayout/BossAutoBrowseAndChat/index.vue b/packages/ui/src/renderer/src/page/MainLayout/BossAutoBrowseAndChat/index.vue new file mode 100644 index 0000000..3229f92 --- /dev/null +++ b/packages/ui/src/renderer/src/page/MainLayout/BossAutoBrowseAndChat/index.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/packages/ui/src/renderer/src/page/MainLayout/BossAutoSequence/index.vue b/packages/ui/src/renderer/src/page/MainLayout/BossAutoSequence/index.vue new file mode 100644 index 0000000..f173cbe --- /dev/null +++ b/packages/ui/src/renderer/src/page/MainLayout/BossAutoSequence/index.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/packages/ui/src/renderer/src/page/MainLayout/BossChatPage/index.vue b/packages/ui/src/renderer/src/page/MainLayout/BossChatPage/index.vue new file mode 100644 index 0000000..647dcb6 --- /dev/null +++ b/packages/ui/src/renderer/src/page/MainLayout/BossChatPage/index.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/packages/ui/src/renderer/src/page/MainLayout/BossDebugTool/index.vue b/packages/ui/src/renderer/src/page/MainLayout/BossDebugTool/index.vue new file mode 100644 index 0000000..f9c2994 --- /dev/null +++ b/packages/ui/src/renderer/src/page/MainLayout/BossDebugTool/index.vue @@ -0,0 +1,847 @@ + + + + + diff --git a/packages/ui/src/renderer/src/page/MainLayout/BossJobConfig/index.vue b/packages/ui/src/renderer/src/page/MainLayout/BossJobConfig/index.vue new file mode 100644 index 0000000..b13b34c --- /dev/null +++ b/packages/ui/src/renderer/src/page/MainLayout/BossJobConfig/index.vue @@ -0,0 +1,1071 @@ + + + + + diff --git a/packages/ui/src/renderer/src/page/MainLayout/BossLlmConfig/index.vue b/packages/ui/src/renderer/src/page/MainLayout/BossLlmConfig/index.vue new file mode 100644 index 0000000..5d76d02 --- /dev/null +++ b/packages/ui/src/renderer/src/page/MainLayout/BossLlmConfig/index.vue @@ -0,0 +1,528 @@ + + + + + diff --git a/packages/ui/src/renderer/src/page/MainLayout/LeftNavBar/GlabalConfigPart.vue b/packages/ui/src/renderer/src/page/MainLayout/LeftNavBar/GlabalConfigPart.vue index 8782032..e90628e 100644 --- a/packages/ui/src/renderer/src/page/MainLayout/LeftNavBar/GlabalConfigPart.vue +++ b/packages/ui/src/renderer/src/page/MainLayout/LeftNavBar/GlabalConfigPart.vue @@ -2,13 +2,13 @@
全局设置
+``` + +**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 +
+
+
+
已向牛人发送招呼
+
你好,我们公司正在招聘研究员,请问考虑吗
+
+
+ + +
+
+
+``` + +`GREETING_SENT_KNOW_BTN_SELECTOR = 'div.dialog-wrap button.btn-sure-v2'` + +--- + +## 6.5 不感兴趣与原因弹窗 + +对未通过筛选的候选人点击卡片上的"不感兴趣"(`div.tooltip-wrap.suitable`)后,**会先弹出原因选择弹窗**(在 iframe 内),须选一项后弹窗才关闭、该候选人才从列表移除。 + +**弹窗结构(iframe 内,与列表同属 recommendFrame):** + +```html +
+
+
+
+
选择不喜欢的原因,为您优化推荐
+
+ 牛人距离远 + 不考虑本科 + 期望薪资偏高 + + 其他原因 +
+
+
+
...
+
+``` + +**流程:** + +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` | — | 主循环正常结束 | diff --git a/plan/recruiter_architecture.md b/plan/recruiter_architecture.md new file mode 100644 index 0000000..457b365 --- /dev/null +++ b/plan/recruiter_architecture.md @@ -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-", + "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/index.vue + ``` + - 若需要启动任务:参考 `BossAutoBrowseAndChat/index.vue` 或 `BossChatPage/index.vue` + - 使用 `RunningOverlay` 组件显示进度: + ```vue + + ``` + +2. **注册路由** — 在 `packages/ui/src/renderer/src/router/index.ts` 的 `/main-layout` children 中添加: + ```ts + { name: 'Boss', path: 'Boss', component: () => import(...), meta: { title: '...' } } + ``` + +3. **更新 RECRUITER_ROUTES** — 在 `packages/ui/src/renderer/src/page/MainLayout/index.vue` 中: + ```ts + const RECRUITER_ROUTES = ['BossAutoBrowseAndChat', 'BossChatPage', 'BossAutoSequence', 'Boss'] + ``` + +4. **添加导航入口** — 在 `packages/ui/src/renderer/src/page/MainLayout/LeftNavBar/RecruiterPart.vue`: + ```vue + 页面名称 + ``` + +5. **(如需新 Worker)新建 flow 文件** + ``` + packages/ui/src/main/flow/BOSS__MAIN/index.ts + ``` + - 复制 `BOSS_RECOMMEND_MAIN/index.ts` 为模板 + - 修改 `workerId`、调用的核心函数、日志前缀 + +6. **注册新 mode** — 在 `packages/ui/src/main/index.ts` 的 switch 中: + ```ts + case 'bossMain': { + const { waitForProcessHandShakeAndRunAutoChat } = await import('./flow/BOSS__MAIN/index') + waitForProcessHandShakeAndRunAutoChat() + break + } + ``` + +7. **添加 IPC 处理器** — 在 `packages/ui/src/main/flow/OPEN_SETTING_WINDOW/ipc/index.ts`: + ```ts + ipcMain.handle('run-boss-', async () => { + const mode = 'bossMain' + 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-', 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 对接) diff --git a/plan/recruiter_debug_tool.md b/plan/recruiter_debug_tool.md new file mode 100644 index 0000000..a60fa99 --- /dev/null +++ b/plan/recruiter_debug_tool.md @@ -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`)中未单独列出,可在此文档中查阅其设计与用法。 diff --git a/plan/recruiter_llm_integration.md b/plan/recruiter_llm_integration.md new file mode 100644 index 0000000..ec7ef5a --- /dev/null +++ b/plan/recruiter_llm_integration.md @@ -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) | +| 用途 | 职位匹配、已读不回提醒等 | 简历筛选、招呼语、消息续写等 | diff --git a/plan/recruiter_mouse_trajectory.md b/plan/recruiter_mouse_trajectory.md new file mode 100644 index 0000000..2a5bb85 --- /dev/null +++ b/plan/recruiter_mouse_trajectory.md @@ -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 或实现处注明。* diff --git a/plan/webhook_integration.md b/plan/webhook_integration.md new file mode 100644 index 0000000..fc20c70 --- /dev/null +++ b/plan/webhook_integration.md @@ -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-", + "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 " + }, + "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。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 048214a..70ae845 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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