From 95c1e54c665accf00a6a05b898732c3854462ec6 Mon Sep 17 00:00:00 2001 From: rqi14 Date: Wed, 18 Mar 2026 17:37:24 +0800 Subject: [PATCH 01/14] 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 --- .gitignore | 28 +- CLAUDE.md | 116 ++ .../candidate-processor.mjs | 411 +++++++ .../chat-handler.mjs | 486 ++++++++ .../chat-page-processor.mjs | 720 +++++++++++ .../chat-page-resume.mjs | 393 ++++++ .../boss-auto-browse-and-chat/constant.mjs | 216 ++++ .../data-manager.mjs | 181 +++ .../default-config-file/boss-recruiter.json | 34 + .../default-config-file/candidate-filter.json | 10 + .../default-storage-file/boss-cookies.json | 1 + .../boss-local-storage.json | 1 + .../boss-auto-browse-and-chat/humanMouse.mjs | 111 ++ packages/boss-auto-browse-and-chat/index.mjs | 550 +++++++++ .../boss-auto-browse-and-chat/llm-rubric.mjs | 310 +++++ packages/boss-auto-browse-and-chat/logger.mjs | 40 + .../boss-auto-browse-and-chat/package.json | 24 + .../resume-extractor.mjs | 341 ++++++ .../resume-scorer.mjs | 355 ++++++ .../runtime-file-utils.mjs | 381 ++++++ .../daemon-main.mjs | 49 + .../run-core-of-boss-auto-browse/enums.mjs | 57 + .../run-core-of-boss-auto-browse/main.mjs | 101 ++ .../run-core-of-boss-auto-browse/package.json | 19 + .../src/entity/CandidateContactLog.ts | 30 + .../sqlite-plugin/src/entity/CandidateInfo.ts | 73 ++ packages/sqlite-plugin/src/handlers.ts | 65 + packages/sqlite-plugin/src/index.ts | 119 +- .../1766466476823-AddCandidateTables.ts | 15 + packages/sqlite-plugin/tsconfig.json | 1 + packages/ui/package.json | 1 + .../common/prerequisite-step-by-step-check.ts | 15 + packages/ui/src/main/features/updater.ts | 2 - .../ui/src/main/features/webhook/index.ts | 399 ++++++ .../BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts | 398 ++++++ .../main/flow/BOSS_CHAT_DEBUG_MAIN/index.ts | 244 ++++ .../main/flow/BOSS_CHAT_PAGE_MAIN/index.ts | 291 +++++ .../main/flow/BOSS_RECOMMEND_MAIN/index.ts | 281 +++++ .../flow/OPEN_SETTING_WINDOW/ipc/index.ts | 721 ++++++++++- packages/ui/src/main/global.d.ts | 57 + packages/ui/src/main/index.ts | 28 + .../main/utils/forwardConsoleLogToDaemon.ts | 29 + packages/ui/src/main/utils/initPublicIpc.ts | 10 + packages/ui/src/main/utils/overrideConsole.ts | 19 +- .../src/features/RunningOverlay/index.vue | 62 +- .../renderer/src/page/BossLlmConfig/index.vue | 441 +++++++ .../BossAutoBrowseAndChat/index.vue | 313 +++++ .../MainLayout/BossAutoSequence/index.vue | 214 ++++ .../page/MainLayout/BossChatPage/index.vue | 220 ++++ .../page/MainLayout/BossDebugTool/index.vue | 847 +++++++++++++ .../page/MainLayout/BossJobConfig/index.vue | 1071 +++++++++++++++++ .../page/MainLayout/BossLlmConfig/index.vue | 528 ++++++++ .../LeftNavBar/GlabalConfigPart.vue | 8 +- .../MainLayout/LeftNavBar/RecruiterPart.vue | 73 ++ .../MainLayout/WebhookIntegration/index.vue | 481 ++++++++ .../renderer/src/page/MainLayout/index.vue | 330 ++++- packages/ui/src/renderer/src/router/index.ts | 61 +- packages/utils/gpt-request.mjs | 59 +- packages/utils/sleep.mjs | 6 +- plan/.gitignore | 5 + plan/STATUS_2026-03-18.md | 125 ++ plan/boss_auto_browse_tabs.md | 89 ++ plan/chat_page_resume_flow.md | 325 +++++ plan/cv_canvas_solution.md | 194 +++ plan/logger_usage.md | 70 ++ plan/multi-job-switching.md | 241 ++++ plan/recommend_page_flow.md | 393 ++++++ plan/recruiter_architecture.md | 423 +++++++ plan/recruiter_debug_tool.md | 214 ++++ plan/recruiter_llm_integration.md | 263 ++++ plan/recruiter_mouse_trajectory.md | 76 ++ plan/webhook_integration.md | 204 ++++ pnpm-lock.yaml | 73 +- 73 files changed, 15053 insertions(+), 89 deletions(-) create mode 100644 CLAUDE.md create mode 100644 packages/boss-auto-browse-and-chat/candidate-processor.mjs create mode 100644 packages/boss-auto-browse-and-chat/chat-handler.mjs create mode 100644 packages/boss-auto-browse-and-chat/chat-page-processor.mjs create mode 100644 packages/boss-auto-browse-and-chat/chat-page-resume.mjs create mode 100644 packages/boss-auto-browse-and-chat/constant.mjs create mode 100644 packages/boss-auto-browse-and-chat/data-manager.mjs create mode 100644 packages/boss-auto-browse-and-chat/default-config-file/boss-recruiter.json create mode 100644 packages/boss-auto-browse-and-chat/default-config-file/candidate-filter.json create mode 100644 packages/boss-auto-browse-and-chat/default-storage-file/boss-cookies.json create mode 100644 packages/boss-auto-browse-and-chat/default-storage-file/boss-local-storage.json create mode 100644 packages/boss-auto-browse-and-chat/humanMouse.mjs create mode 100644 packages/boss-auto-browse-and-chat/index.mjs create mode 100644 packages/boss-auto-browse-and-chat/llm-rubric.mjs create mode 100644 packages/boss-auto-browse-and-chat/logger.mjs create mode 100644 packages/boss-auto-browse-and-chat/package.json create mode 100644 packages/boss-auto-browse-and-chat/resume-extractor.mjs create mode 100644 packages/boss-auto-browse-and-chat/resume-scorer.mjs create mode 100644 packages/boss-auto-browse-and-chat/runtime-file-utils.mjs create mode 100644 packages/run-core-of-boss-auto-browse/daemon-main.mjs create mode 100644 packages/run-core-of-boss-auto-browse/enums.mjs create mode 100644 packages/run-core-of-boss-auto-browse/main.mjs create mode 100644 packages/run-core-of-boss-auto-browse/package.json create mode 100644 packages/sqlite-plugin/src/entity/CandidateContactLog.ts create mode 100644 packages/sqlite-plugin/src/entity/CandidateInfo.ts create mode 100644 packages/sqlite-plugin/src/migrations/1766466476823-AddCandidateTables.ts create mode 100644 packages/ui/src/main/features/webhook/index.ts create mode 100644 packages/ui/src/main/flow/BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts create mode 100644 packages/ui/src/main/flow/BOSS_CHAT_DEBUG_MAIN/index.ts create mode 100644 packages/ui/src/main/flow/BOSS_CHAT_PAGE_MAIN/index.ts create mode 100644 packages/ui/src/main/flow/BOSS_RECOMMEND_MAIN/index.ts create mode 100644 packages/ui/src/main/global.d.ts create mode 100644 packages/ui/src/main/utils/forwardConsoleLogToDaemon.ts create mode 100644 packages/ui/src/renderer/src/page/BossLlmConfig/index.vue create mode 100644 packages/ui/src/renderer/src/page/MainLayout/BossAutoBrowseAndChat/index.vue create mode 100644 packages/ui/src/renderer/src/page/MainLayout/BossAutoSequence/index.vue create mode 100644 packages/ui/src/renderer/src/page/MainLayout/BossChatPage/index.vue create mode 100644 packages/ui/src/renderer/src/page/MainLayout/BossDebugTool/index.vue create mode 100644 packages/ui/src/renderer/src/page/MainLayout/BossJobConfig/index.vue create mode 100644 packages/ui/src/renderer/src/page/MainLayout/BossLlmConfig/index.vue create mode 100644 packages/ui/src/renderer/src/page/MainLayout/LeftNavBar/RecruiterPart.vue create mode 100644 packages/ui/src/renderer/src/page/MainLayout/WebhookIntegration/index.vue create mode 100644 plan/.gitignore create mode 100644 plan/STATUS_2026-03-18.md create mode 100644 plan/boss_auto_browse_tabs.md create mode 100644 plan/chat_page_resume_flow.md create mode 100644 plan/cv_canvas_solution.md create mode 100644 plan/logger_usage.md create mode 100644 plan/multi-job-switching.md create mode 100644 plan/recommend_page_flow.md create mode 100644 plan/recruiter_architecture.md create mode 100644 plan/recruiter_debug_tool.md create mode 100644 plan/recruiter_llm_integration.md create mode 100644 plan/recruiter_mouse_trajectory.md create mode 100644 plan/webhook_integration.md 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 From 3fb7089c9e22c02d867d5ea58a154e0fc75213da Mon Sep 17 00:00:00 2001 From: rqi14 Date: Fri, 20 Mar 2026 19:07:59 +0800 Subject: [PATCH 02/14] recruiter: enhance chat page processing, boss browse flow, and UI improvements - Improve chat-page-processor with better candidate handling and filtering - Update chat-page-resume extraction logic - Add new constants to constant.mjs - Enhance boss auto browse main flow with verification detection and multi-job sequence support - Expand boss chat page main flow with HR guide features - Update BossAutoSequence and BossChatPage Vue components - Add plan docs: current_status and recruiter_chat_page_hr_guide Co-Authored-By: Claude Sonnet 4.6 --- .../chat-page-processor.mjs | 273 ++++-- .../chat-page-resume.mjs | 16 +- .../boss-auto-browse-and-chat/constant.mjs | 3 + packages/boss-auto-browse-and-chat/index.mjs | 41 +- .../BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts | 861 ++++++++++-------- .../main/flow/BOSS_CHAT_PAGE_MAIN/index.ts | 217 ++++- .../MainLayout/BossAutoSequence/index.vue | 11 +- .../page/MainLayout/BossChatPage/index.vue | 56 +- plan/current_status_2026_03_18.md | 101 ++ plan/recruiter_chat_page_hr_guide.md | 423 +++++++++ 10 files changed, 1455 insertions(+), 547 deletions(-) create mode 100644 plan/current_status_2026_03_18.md create mode 100644 plan/recruiter_chat_page_hr_guide.md diff --git a/packages/boss-auto-browse-and-chat/chat-page-processor.mjs b/packages/boss-auto-browse-and-chat/chat-page-processor.mjs index 2b8838c..35f77db 100644 --- a/packages/boss-auto-browse-and-chat/chat-page-processor.mjs +++ b/packages/boss-auto-browse-and-chat/chat-page-processor.mjs @@ -25,6 +25,7 @@ import { CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR, CHAT_PAGE_ITEM_SELECTOR, CHAT_PAGE_ITEM_UNREAD_SELECTOR, + CHAT_PAGE_ALL_FILTER_SELECTOR, CHAT_PAGE_UNREAD_FILTER_SELECTOR, CHAT_PAGE_NAME_SELECTOR, CHAT_PAGE_JOB_SELECTOR, @@ -41,21 +42,44 @@ const LOG = '[chat-page-processor]' */ async function switchChatPageJobId (page, jobId) { try { - await page.click('.ui-dropmenu.chat-top-job .ui-dropmenu-label') + const cursor = await createHumanCursor(page) + // 用拟人轨迹点击下拉触发按钮 + const dropdownBtn = await page.$('.ui-dropmenu.chat-top-job .ui-dropmenu-label') + if (dropdownBtn) { + const box = await dropdownBtn.boundingBox().catch(() => null) + if (box) { + await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 }) + } else { + await dropdownBtn.click() + } + } else { + await page.click('.ui-dropmenu.chat-top-job .ui-dropmenu-label') + } await page.waitForSelector('.ui-dropmenu.chat-top-job .ui-dropmenu-list', { timeout: 5000 }) - 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) + await sleepWithRandomDelay(150, 300) + // 用拟人轨迹点击目标职位项 + const items = await page.$$('.ui-dropmenu.chat-top-job .ui-dropmenu-list li') + let found = false + for (const item of items) { + const val = await item.evaluate(el => el.getAttribute('value')).catch(() => null) + if (val === jobId) { + const itemBox = await item.boundingBox().catch(() => null) + if (itemBox) { + await cursor.click({ x: itemBox.x + itemBox.width / 2, y: itemBox.y + itemBox.height / 2 }) + } else { + await item.click() + } + found = true + break + } + } if (!found) { logWarn(`${LOG} 职位 ${jobId} 未在沟通页下拉列表中找到,将使用默认职位继续`) await page.keyboard.press('Escape') return } // 等待左侧会话列表刷新 - await new Promise(r => setTimeout(r, 500)) + await sleepWithRandomDelay(400, 700) logInfo(`${LOG} 已切换到职位 ${jobId}`) } catch (e) { logWarn(`${LOG} 切换沟通页职位失败(${e.message}),将使用默认职位继续`) @@ -277,11 +301,29 @@ async function waitForGeekInfo (peekFn, opts = {}) { /** * 沟通页自动化主入口。 * @param {object} hooksFromCaller - 与 startBossAutoBrowse 相同的 hooks(onError, insertCandidateContactLog, createOrUpdateCandidateInfo, queryCandidateByEncryptId 等) - * @param {{ browser?: import('puppeteer').Browser, page?: import('puppeteer').Page }} [options] - 若传入则复用已有 browser/page,否则内部不启动浏览器(调用方需先导航到沟通页或由推荐页流程传入) + * @param {{ + * browser?: import('puppeteer').Browser, + * page?: import('puppeteer').Page, + * getCapturedText?: Function, + * clearCapturedText?: Function, + * jobId?: string | null, + * retryCandidate?: { encryptGeekId: string, geekName: string, jobTitle: string } | null, + * processContext?: { currentCandidate: object | null } | null + * }} [options] + * - retryCandidate: 验证中断后需优先重试的候选人(此前已被点击成"已读",需在「全部」tab 找回) + * - processContext: 调用方传入的可变对象,本函数在处理每条会话前将 currentCandidate 写入, + * 供调用方在捕获错误时读取"是哪位候选人被中断" */ export default async function startBossChatPageProcess (hooksFromCaller, options = {}) { const hooks = hooksFromCaller || {} - const { page: existingPage, getCapturedText, clearCapturedText, jobId = null } = options + const { + page: existingPage, + getCapturedText, + clearCapturedText, + jobId = null, + retryCandidate = null, + processContext = null + } = options /** @type {import('puppeteer').Page} */ let page = existingPage @@ -344,60 +386,54 @@ export default async function startBossChatPageProcess (hooksFromCaller, options 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})`) + + // ──────────────────────────────────────────────────────────────────────────── + // 内部辅助:切换到指定 tab(封闭 page、cursor) + // ──────────────────────────────────────────────────────────────────────────── + const switchToTab = async (selector, tabName) => { + const isActive = await page.evaluate( + (sel) => document.querySelector(sel)?.classList.contains('active') ?? false, + selector + ) + if (isActive) { + logDebug(`${LOG} 已在「${tabName}」tab`) + return + } + logInfo(`${LOG} 切换到「${tabName}」tab...`) + const tabEl = await page.$(selector) + if (!tabEl) { + logWarn(`${LOG} 未找到「${tabName}」tab 元素(selector: ${selector})`) + return + } + const box = await tabEl.boundingBox().catch(() => null) + if (box) { + await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 }) + await sleepWithRandomDelay(400, 600) + try { + await page.waitForSelector(CHAT_PAGE_ITEM_SELECTOR, { timeout: 5000 }) + logDebug(`${LOG} 「${tabName}」tab 切换后列表已刷新`) + } catch { + logDebug(`${LOG} 「${tabName}」tab 切换后列表为空(无会话)`) + } } - } 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] + // ──────────────────────────────────────────────────────────────────────────── + // 内部核心:处理单条会话的完整逻辑(闭包,封闭所有外层状态变量) + // 返回 { processed: boolean, skipped: boolean } + // ──────────────────────────────────────────────────────────────────────────── + const processOneCandidateConversation = async (item) => { const { encryptGeekId, geekName, jobTitle } = item - logInfo(`${LOG} ── [${i + 1}/${toProcess.length}] 开始处理 ${geekName}(${encryptGeekId})──`) + + // 向调用方暴露当前正在处理的候选人(发生错误时可读取) + if (processContext) processContext.currentCandidate = item const { contacted } = await checkIfAlreadyContacted(encryptGeekId, hooks) if (contacted) { logInfo(`${LOG} → 已在数据库中联系过,跳过`) - continue + if (processContext) processContext.currentCandidate = null + return { processed: false, skipped: true } } logDebug(`${LOG} → 数据库未记录,继续处理`) @@ -408,12 +444,16 @@ export default async function startBossChatPageProcess (hooksFromCaller, options const resumeCloseBtn = await page.$(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => null) if (resumeCloseBtn) { logDebug(`${LOG} → 检测到在线简历弹窗未关闭,点击关闭...`) - await page.click(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR).catch(() => {}) + const closeBox = await resumeCloseBtn.boundingBox().catch(() => null) + if (closeBox) { + await cursor.click({ x: closeBox.x + closeBox.width / 2, y: closeBox.y + closeBox.height / 2 }) + } else { + await resumeCloseBtn.click().catch(() => {}) + } try { 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 超时),继续尝试切换会话,但可能影响会话切换成功率`) @@ -428,7 +468,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options const selected = await selectConversationById(page, encryptGeekId, { cursor }) if (!selected) { logWarn(`${LOG} → 无法在 DOM 中找到该会话(可能已被标为已读或滚出虚拟滚动视口),跳过`) - continue + if (processContext) processContext.currentCandidate = null + return { processed: false, skipped: true } } logInfo(`${LOG} → 会话已选中,等待页面加载...`) await sleepWithRandomDelay(600, 1200) @@ -439,7 +480,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options if (panelName && !geekName.includes(panelName) && !panelName.includes(geekName)) { logWarn(`${LOG} → 右侧面板姓名「${panelName}」与期望「${geekName}」不符,会话切换未生效,跳过`) await sleepWithRandomDelay(300, 600) - continue + if (processContext) processContext.currentCandidate = null + return { processed: false, skipped: true } } if (panelName) { logDebug(`${LOG} → 右侧面板验证:「${panelName}」✓`) @@ -452,12 +494,23 @@ export default async function startBossChatPageProcess (hooksFromCaller, options if (intentKnowBtn) { logInfo(`${LOG} → 检测到「意向沟通」提示弹窗,点击「我知道了」关闭...`) try { - await page.click(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR) + const knowBox = await intentKnowBtn.boundingBox().catch(() => null) + if (knowBox) { + await cursor.click({ x: knowBox.x + knowBox.width / 2, y: knowBox.y + knowBox.height / 2 }) + } else { + await intentKnowBtn.click().catch(() => {}) + } } catch { - // 备用:点关闭图标 - await page.click(CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR).catch(() => {}) + const closeIconEl = await page.$(CHAT_PAGE_INTENT_DIALOG_CLOSE_SELECTOR).catch(() => null) + if (closeIconEl) { + const closeIconBox = await closeIconEl.boundingBox().catch(() => null) + if (closeIconBox) { + await cursor.click({ x: closeIconBox.x + closeIconBox.width / 2, y: closeIconBox.y + closeIconBox.height / 2 }) + } else { + await closeIconEl.click().catch(() => {}) + } + } } - // 等弹窗消失(点击后 Vue 会移除 DOM) try { await page.waitForSelector(CHAT_PAGE_INTENT_DIALOG_KNOW_BTN_SELECTOR, { hidden: true, timeout: 3000 }) logDebug(`${LOG} → 「意向沟通」弹窗已关闭`) @@ -474,7 +527,6 @@ export default async function startBossChatPageProcess (hooksFromCaller, options 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 }) @@ -502,10 +554,10 @@ export default async function startBossChatPageProcess (hooksFromCaller, options 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 + if (processContext) processContext.currentCandidate = null + return { processed: false, skipped: true } } logInfo(`${LOG} → 初步信息筛选通过`) } else { @@ -547,36 +599,31 @@ export default async function startBossChatPageProcess (hooksFromCaller, options } } 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 + await sleepWithRandomDelay(2000, 4000) + if (processContext) processContext.currentCandidate = null + return { processed: true, skipped: false } } // 无附件简历 → 说明对方只是打招呼,需要我方先筛选再决定是否索取 - // 点击「查看在线简历」(等 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 + if (processContext) processContext.currentCandidate = null + return { processed: false, skipped: true } } logInfo(`${LOG} → 在线简历 iframe 已出现,等待 Canvas 渲染完成...`) let resumeText = '' if (typeof getCapturedText === 'function') { const { extractResumeText } = await import('./resume-extractor.mjs') - // 稳定轮询:连续两次 peek 到相同数量(且 > 0)视为渲染完成 - // WASM 通常在 iframe 出现后 1~2s 内完成全部渲染 const POLL_INTERVAL_MS = 400 const STABLE_POLLS_NEEDED = 2 const CANVAS_POLL_TIMEOUT = 8000 @@ -595,10 +642,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options 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})`) @@ -609,8 +654,6 @@ export default async function startBossChatPageProcess (hooksFromCaller, options 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)) { @@ -618,7 +661,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options logWarn(`${LOG} → 右侧面板未切换到本会话(geek/info 超时或被安全验证打断),跳过,下次运行时重试`) getInterceptedData() await sleepWithRandomDelay(300, 600) - continue + if (processContext) processContext.currentCandidate = null + return { processed: false, skipped: true } } } } else { @@ -635,6 +679,8 @@ export default async function startBossChatPageProcess (hooksFromCaller, options logInfo(`${LOG} → 简历文本获取成功(共 ${resumeText.length} 字)`) } + await sleepWithRandomDelay(2000, 4500) + let pass = true let filterReason = '' @@ -669,19 +715,22 @@ export default async function startBossChatPageProcess (hooksFromCaller, options 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(() => {}) + const closeBox2 = await openResumeCloseBtn.boundingBox().catch(() => null) + if (closeBox2) { + await cursor.click({ x: closeBox2.x + closeBox2.width / 2, y: closeBox2.y + closeBox2.height / 2 }) + } else { + await openResumeCloseBtn.click().catch(() => {}) + } try { await page.waitForSelector(CHAT_PAGE_ONLINE_RESUME_CLOSE_SELECTOR, { hidden: true, timeout: 3000 }) logDebug(`${LOG} → 在线简历弹窗已关闭`) } catch { logWarn(`${LOG} → 在线简历弹窗关闭超时,继续尝试(可能影响附件简历按钮点击)`) } - // 等待弹窗 CSS 动画完全结束,确保不再遮挡下方按钮 - await new Promise(r => setTimeout(r, 400)) + await sleepWithRandomDelay(500, 1000) } const { requested, error } = await requestAttachmentResume(page, { cursor }) if (requested) { @@ -706,10 +755,58 @@ export default async function startBossChatPageProcess (hooksFromCaller, options }, hooks ) - processed++ - await hooks.onProgress?.promise?.({ phase: 'chatPage', current: processed, max: toProcess.length }).catch(() => {}) getInterceptedData() - await sleepWithRandomDelay(1000, 2500) + await sleepWithRandomDelay(2000, 4500) + if (processContext) processContext.currentCandidate = null + return { processed: true, skipped: false } + } + // ──────────────────────────────────────────────────────────────────────────── + + // ── 验证恢复:若上次被验证中断,优先重试被中断的候选人 ──────────────────────── + if (retryCandidate) { + logInfo(`${LOG} ── 验证恢复:重试被中断候选人 ${retryCandidate.geekName}(${retryCandidate.encryptGeekId})──`) + // 候选人此前已被点击,状态变为"已读",需切换到「全部」tab 才能找到 + await switchToTab(CHAT_PAGE_ALL_FILTER_SELECTOR, '全部') + await sleepWithRandomDelay(300) + const retrySel = await selectConversationById(page, retryCandidate.encryptGeekId, { cursor }) + if (retrySel) { + logInfo(`${LOG} 重试候选人会话已找到,开始处理...`) + await sleepWithRandomDelay(600, 1200) + await processOneCandidateConversation(retryCandidate) + } else { + logWarn(`${LOG} 未在「全部」会话中找到重试候选人 ${retryCandidate.geekName}(可能已被处理或不可见),跳过`) + } + // 切回「未读」tab 进行正常扫描 + await switchToTab(CHAT_PAGE_UNREAD_FILTER_SELECTOR, '未读') + await sleepWithRandomDelay(300) + } + + // ── 正常扫描:切换到「未读」tab,处理未读会话 ─────────────────────────────── + await switchToTab(CHAT_PAGE_UNREAD_FILTER_SELECTOR, '未读') + await sleepWithRandomDelay(300) + + const conversations = await parseConversationList(page) + logDebug(`${LOG} DOM 解析到 ${conversations.length} 条会话`) + + const unreadItems = conversations.filter((c) => c.encryptGeekId) + const toProcess = unreadItems.slice(0, maxProcessPerRun) + logInfo(`${LOG} 未读会话 ${unreadItems.length} 条,本次最多处理 ${toProcess.length} 条`) + if (toProcess.length > 0) { + logDebug(`${LOG} 候选人列表:${toProcess.map((c, i) => `[${i}] ${c.geekName}(${c.encryptGeekId})`).join(', ')}`) + } + + await hooks.onProgress?.promise?.({ phase: 'chatPage', current: 0, max: toProcess.length }).catch(() => {}) + + let processed = 0 + + for (let i = 0; i < toProcess.length; i++) { + const item = toProcess[i] + logInfo(`${LOG} ── [${i + 1}/${toProcess.length}] 开始处理 ${item.geekName}(${item.encryptGeekId})──`) + const result = await processOneCandidateConversation(item) + if (result.processed) { + processed++ + await hooks.onProgress?.promise?.({ phase: 'chatPage', current: processed, max: toProcess.length }).catch(() => {}) + } } logInfo(`${LOG} 本次共处理 ${processed} 条未读会话`) diff --git a/packages/boss-auto-browse-and-chat/chat-page-resume.mjs b/packages/boss-auto-browse-and-chat/chat-page-resume.mjs index 45186bd..bf91f5b 100644 --- a/packages/boss-auto-browse-and-chat/chat-page-resume.mjs +++ b/packages/boss-auto-browse-and-chat/chat-page-resume.mjs @@ -55,15 +55,11 @@ export async function openOnlineResume (page, options = {}) { 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 }) - } + const closeBox = await closeBtn.boundingBox().catch(() => null) + if (closeBox) { + await cursor.click({ x: closeBox.x + closeBox.width / 2, y: closeBox.y + closeBox.height / 2 }) + } else { + await closeBtn.click().catch(() => {}) } // 等关闭按钮从 DOM 消失(即弹窗完全关闭),比等 iframe 消失更可靠 try { @@ -331,7 +327,7 @@ export async function openPreviewAndDownloadPdf (page, messageElement, options = // 预览按钮在消息少时会紧贴 tab 栏,拟人轨迹从别处移过来会经过「已交换微信」等 tab,一点就切到空白。 // 此处直接用 Puppeteer 的 element.click():无移动轨迹,先 scrollIntoView 再点,避免误触 tab。 await previewBtn.evaluate((el) => el.scrollIntoView({ block: 'center', inline: 'nearest' })) - await sleepWithRandomDelay(150, 300) + await sleepWithRandomDelay(300, 600) console.log('[openPreviewAndDownloadPdf] 点击「点击预览附件简历」按钮(原生 click,避免轨迹误触 tab)') await previewBtn.click() diff --git a/packages/boss-auto-browse-and-chat/constant.mjs b/packages/boss-auto-browse-and-chat/constant.mjs index cf15eda..20e90f2 100644 --- a/packages/boss-auto-browse-and-chat/constant.mjs +++ b/packages/boss-auto-browse-and-chat/constant.mjs @@ -119,6 +119,9 @@ export const CHAT_PAGE_JOB_ITEM_SELECTOR = '.chat-top-job .ui-dropmenu-list li' // - CHAT_PAGE_PREVIEW_RESUME_BTN_SELECTOR:消息里"点击预览附件简历"(对方同意并发来 PDF 后,新消息里会出现) // - CHAT_PAGE_DOWNLOAD_PDF_BTN_SELECTOR:简历预览弹窗里的"下载 PDF"按钮 +/** 沟通页:顶部"全部"筛选 tab(span:nth-child(1) 在 .chat-message-filter-left 内) */ +export const CHAT_PAGE_ALL_FILTER_SELECTOR = '.chat-message-filter-left span:nth-child(1)' + /** 沟通页:顶部"未读"筛选 tab(span:nth-child(2) 在 .chat-message-filter-left 内,active 时有 class="active") */ export const CHAT_PAGE_UNREAD_FILTER_SELECTOR = '.chat-message-filter-left span:nth-child(2)' diff --git a/packages/boss-auto-browse-and-chat/index.mjs b/packages/boss-auto-browse-and-chat/index.mjs index 1e7321c..a1c887d 100644 --- a/packages/boss-auto-browse-and-chat/index.mjs +++ b/packages/boss-auto-browse-and-chat/index.mjs @@ -141,22 +141,45 @@ async function storeStorage (page) { */ async function switchRecommendJobId (page, jobId) { try { - await page.click(RECOMMEND_JOB_DROPDOWN_LABEL_SELECTOR) + const { createHumanCursor } = await import('./humanMouse.mjs') + const cursor = await createHumanCursor(page) + // 用拟人轨迹点击下拉触发按钮 + const dropdownBtn = await page.$(RECOMMEND_JOB_DROPDOWN_LABEL_SELECTOR) + if (dropdownBtn) { + const box = await dropdownBtn.boundingBox().catch(() => null) + if (box) { + await cursor.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 }) + } else { + await dropdownBtn.click() + } + } else { + await page.click(RECOMMEND_JOB_DROPDOWN_LABEL_SELECTOR) + } await page.waitForSelector(RECOMMEND_JOB_ITEM_SELECTOR, { timeout: 5000 }) - 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) + await sleepWithRandomDelay(150, 300) + // 用拟人轨迹点击目标职位项 + const items = await page.$$(RECOMMEND_JOB_ITEM_SELECTOR) + let found = false + for (const item of items) { + const val = await item.evaluate(el => el.getAttribute('value')).catch(() => null) + if (val === jobId) { + const itemBox = await item.boundingBox().catch(() => null) + if (itemBox) { + await cursor.click({ x: itemBox.x + itemBox.width / 2, y: itemBox.y + itemBox.height / 2 }) + } else { + await item.click() + } + found = true + break + } + } if (!found) { logWarn(`[boss-auto-browse] 职位 ${jobId} 未在下拉列表中找到,将使用默认职位继续`) - // 关闭下拉 await page.keyboard.press('Escape') return } // 等候选人列表重新加载 - await new Promise(r => setTimeout(r, 500)) + await sleepWithRandomDelay(400, 800) logInfo(`[boss-auto-browse] 已切换到职位 ${jobId}`) } catch (e) { logWarn(`[boss-auto-browse] 切换推荐页职位失败(${e.message}),将使用默认职位继续`) diff --git a/packages/ui/src/main/flow/BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts b/packages/ui/src/main/flow/BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts index 0b130a6..cec9b0c 100644 --- a/packages/ui/src/main/flow/BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts +++ b/packages/ui/src/main/flow/BOSS_AUTO_BROWSE_AND_CHAT_MAIN/index.ts @@ -1,398 +1,463 @@ -import { app, dialog } from 'electron' -import { AsyncSeriesHook, AsyncSeriesWaterfallHook } from 'tapable' -import { sleep } from '@geekgeekrun/utils/sleep.mjs' -import { AUTO_CHAT_ERROR_EXIT_CODE } from '../../../common/enums/auto-start-chat' -import attachListenerForKillSelfOnParentExited from '../../utils/attachListenerForKillSelfOnParentExited' -import minimist from 'minimist' -import SqlitePluginModule from '@geekgeekrun/sqlite-plugin' -import { connectToDaemon, sendToDaemon } from '../OPEN_SETTING_WINDOW/connect-to-daemon' -import { checkShouldExit } from '../../utils/worker' -import initPublicIpc from '../../utils/initPublicIpc' -import { forwardConsoleLogToDaemon } from '../../utils/forwardConsoleLogToDaemon' -import { getLastUsedAndAvailableBrowser } from '../DOWNLOAD_DEPENDENCIES/utils/browser-history' -import path from 'path' -const { default: SqlitePlugin } = SqlitePluginModule - -process.on('SIGTERM', () => { - console.log('收到SIGTERM信号,正在退出') - process.exit(0) -}) - -const rerunInterval = (() => { - let v = Number(process.env.MAIN_BOSSGEEKGO_RERUN_INTERVAL) - if (isNaN(v)) { - v = 3000 - } - return v -})() - -const initPlugins = async (hooks) => { - const { storageFilePath } = await import( - '@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs' - ) - new SqlitePlugin(path.join(storageFilePath, 'public.db')).apply(hooks) -} - -const runRecordId = minimist(process.argv.slice(2))['run-record-id'] ?? null - -const log = (msg: string) => { - console.log(`[boss-worker] ${msg}`) -} - -const 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 - startBossChatPageProcess: (hooks: any, options?: { browser?: any; page?: any; jobId?: string }) => Promise - initPuppeteer: () => Promise - launchBrowserAndNavigateToChat?: () => Promise<{ browser: any; page: any }> - bossAutoBrowseEventBus: InstanceType - } - 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 - filterReport?: Record - llmConclusion?: string - resumeFile?: { path?: string; filename?: string } - }> = [] - - hooks.afterChatStarted.tapPromise('collectCandidateForWebhook', async (candidate: unknown) => { - const c = candidate as Record - const entry = { - basicInfo: c?.info as Record | 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[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() +import { app, dialog } from 'electron' +import { AsyncSeriesHook, AsyncSeriesWaterfallHook } from 'tapable' +import { sleep } from '@geekgeekrun/utils/sleep.mjs' +import { AUTO_CHAT_ERROR_EXIT_CODE } from '../../../common/enums/auto-start-chat' +import attachListenerForKillSelfOnParentExited from '../../utils/attachListenerForKillSelfOnParentExited' +import minimist from 'minimist' +import SqlitePluginModule from '@geekgeekrun/sqlite-plugin' +import { connectToDaemon, sendToDaemon } from '../OPEN_SETTING_WINDOW/connect-to-daemon' +import { checkShouldExit } from '../../utils/worker' +import initPublicIpc from '../../utils/initPublicIpc' +import { forwardConsoleLogToDaemon } from '../../utils/forwardConsoleLogToDaemon' +import { getLastUsedAndAvailableBrowser } from '../DOWNLOAD_DEPENDENCIES/utils/browser-history' +import path from 'path' +const { default: SqlitePlugin } = SqlitePluginModule + +process.on('SIGTERM', () => { + console.log('收到SIGTERM信号,正在退出') + process.exit(0) +}) + +const rerunInterval = (() => { + let v = Number(process.env.MAIN_BOSSGEEKGO_RERUN_INTERVAL) + if (isNaN(v)) { + v = 3000 + } + return v +})() + +const initPlugins = async (hooks) => { + const { storageFilePath } = await import( + '@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs' + ) + new SqlitePlugin(path.join(storageFilePath, 'public.db')).apply(hooks) +} + +const runRecordId = minimist(process.argv.slice(2))['run-record-id'] ?? null + +const log = (msg: string) => { + console.log(`[boss-worker] ${msg}`) +} + +const checkForBossVerification = async (page: any): Promise => { + try { + const url: string = page.url() + if (/verify|captcha|security.?check|safe\b|\/safe\/|安全验证/.test(url)) return true + return await page.evaluate(() => { + const hasVerifyText = /请完成.{0,10}验证|安全验证|滑动.{0,6}滑块|人机验证|完成验证后继续|异常.{0,6}操作|验证码/.test( + document.body?.innerText || '' + ) + const hasVerifyEl = !!( + document.querySelector('#nc_mask') || + document.querySelector('.verify-container') || + document.querySelector('.captcha-wrap') || + document.querySelector('.nc-container') || + document.querySelector('[class*="verify"][class*="wrap"]') || + document.querySelector('[class*="captcha"]') + ) + return hasVerifyText || hasVerifyEl + }) + } catch { + return false + } +} + +const waitForBossVerificationCompletion = async (page: any, expectedUrlPrefix: string): Promise => { + log('⚠️ 检测到 BOSS 安全验证,请在浏览器窗口中手动完成验证,完成后将自动继续...') + try { + const { Notification } = await import('electron') + new Notification({ + title: 'GeekGeekRun - 需要人工验证', + body: '检测到 BOSS 直聘安全验证,请在打开的浏览器窗口中完成验证,完成后程序将自动继续。' + }).show() + } catch { /* Notification 不可用时静默忽略 */ } + + const deadline = Date.now() + 5 * 60 * 1000 + while (Date.now() < deadline) { + await sleep(2000) + try { + const url: string = page.url() + const isStillVerify = await checkForBossVerification(page) + if (url.startsWith(expectedUrlPrefix) && !isStillVerify) { + log('✅ 安全验证已完成,继续处理...') + return true + } + } catch { /* 页面可能正在跳转,继续等待 */ } + } + log('验证等待超时(5 分钟),将重启浏览器重试') + return false +} + +const runAutoBrowseAndChat = async () => { + app.dock?.hide() + log('runAutoBrowseAndChat 开始') + log(`正在查找可用浏览器...`) + let puppeteerExecutable = await getLastUsedAndAvailableBrowser() + if (!puppeteerExecutable) { + log('未找到可用浏览器,退出') + await dialog.showMessageBox({ + type: `error`, + message: `未找到可用的浏览器`, + detail: `请重新运行本程序,按照提示安装、配置浏览器` + }) + sendToDaemon({ + type: 'worker-to-gui-message', + data: { + type: 'prerequisite-step-by-step-checkstep-by-step-check', + step: { + id: 'puppeteer-executable-check', + status: 'rejected' + }, + runRecordId + } + }) + app.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE) + return + } + log(`找到浏览器: ${puppeteerExecutable.executablePath}`) + sendToDaemon({ + type: 'worker-to-gui-message', + data: { + type: 'prerequisite-step-by-step-checkstep-by-step-check', + step: { + id: 'puppeteer-executable-check', + status: 'fulfilled' + }, + runRecordId + } + }) + process.env.PUPPETEER_EXECUTABLE_PATH = puppeteerExecutable.executablePath + + log('正在动态 import boss package...') + type BossAutoBrowseModule = { + default: (hooks: any, opts?: { returnBrowser?: boolean; jobId?: string; browser?: any; page?: any }) => Promise + startBossChatPageProcess: (hooks: any, options?: { browser?: any; page?: any; jobId?: string }) => Promise + initPuppeteer: () => Promise + launchBrowserAndNavigateToChat?: () => Promise<{ browser: any; page: any }> + bossAutoBrowseEventBus: InstanceType + } + 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 + filterReport?: Record + llmConclusion?: string + resumeFile?: { path?: string; filename?: string } + }> = [] + + hooks.afterChatStarted.tapPromise('collectCandidateForWebhook', async (candidate: unknown) => { + const c = candidate as Record + const entry = { + basicInfo: c?.info as Record | 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[1]['candidates'] + } + log(`正在发送 webhook,共 ${sessionCandidates.length} 条候选人数据...`) + const webhookResult = await sendWebhook(webhookConfig, webhookPayload, { + storageDir: storageFilePath + }) + log(`webhook 发送完成,HTTP ${webhookResult.status},body 长度 ${webhookResult.body.length}`) + } + } catch (webhookErr) { + log(`webhook 发送失败(不影响主流程):${webhookErr instanceof Error ? webhookErr.message : String(webhookErr)}`) + } + sessionCandidates.length = 0 + log('等待下次运行...') + } catch (err) { + // ── 检测是否为安全验证触发的超时,若是则发送 OS 通知提醒用户 ── + // (推荐页流程浏览器由内部管理,验证后浏览器会重启;此处仅通知用户需要手动完成验证) + try { + const errMsg = err instanceof Error ? err.message : String(err) + if (/TimeoutError|timeout|waitForSelector|waitForFunction/i.test(errMsg)) { + log('检测到超时类错误,可能是 BOSS 安全验证导致。若浏览器窗口有验证提示,请手动完成,程序将在下一轮自动重启。') + try { + const { Notification } = await import('electron') + new Notification({ + title: 'GeekGeekRun - 可能需要人工验证', + body: 'BOSS 直聘可能弹出了安全验证。请检查浏览器窗口,完成验证后程序将在下一轮自动重启继续。' + }).show() + } catch { /* Notification 不可用时静默忽略 */ } + } + } catch { /* 不影响主流程 */ } + + if (err instanceof Error) { + if (err.message.includes('LOGIN_STATUS_INVALID')) { + await dialog.showMessageBox({ + type: `error`, + message: `登录状态无效`, + detail: `请重新登录BOSS直聘(招聘端)` + }) + process.exit(AUTO_CHAT_ERROR_EXIT_CODE.LOGIN_STATUS_INVALID) + break + } + if (err.message.includes('ERR_INTERNET_DISCONNECTED')) { + process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ERR_INTERNET_DISCONNECTED) + break + } + if (err.message.includes('ACCESS_IS_DENIED')) { + process.exit(AUTO_CHAT_ERROR_EXIT_CODE.ACCESS_IS_DENIED) + break + } + if ( + err.message.includes(`Could not find Chrome`) || + err.message.includes(`no executable was found`) + ) { + process.exit(AUTO_CHAT_ERROR_EXIT_CODE.PUPPETEER_IS_NOT_EXECUTABLE) + break + } + } + console.error(err) + const shouldExit = await checkShouldExit() + if (shouldExit) { + app.exit() + return + } + console.log( + `[Boss Auto Browse Main] An internal error is caught, and browser will be restarted in ${rerunInterval}ms.` + ) + await sleep(rerunInterval) + } + } +} + +export const waitForProcessHandShakeAndRunAutoChat = async () => { + await app.whenReady() + app.on('window-all-closed', () => { + // keep process alive while worker is running + }) + initPublicIpc() + await connectToDaemon() + forwardConsoleLogToDaemon('bossAutoBrowseAndChatMain', runRecordId) + await sendToDaemon( + { + type: 'ping' + }, + { + needCallback: true + } + ) + sendToDaemon({ + type: 'worker-to-gui-message', + data: { + type: 'prerequisite-step-by-step-checkstep-by-step-check', + step: { + id: 'worker-launch', + status: 'fulfilled' + }, + runRecordId + } + }) + runAutoBrowseAndChat() +} + +attachListenerForKillSelfOnParentExited() diff --git a/packages/ui/src/main/flow/BOSS_CHAT_PAGE_MAIN/index.ts b/packages/ui/src/main/flow/BOSS_CHAT_PAGE_MAIN/index.ts index f700797..aec5875 100644 --- a/packages/ui/src/main/flow/BOSS_CHAT_PAGE_MAIN/index.ts +++ b/packages/ui/src/main/flow/BOSS_CHAT_PAGE_MAIN/index.ts @@ -39,6 +39,64 @@ const log = (msg: string) => { console.log(`[boss-chat-page-worker] ${msg}`) } +/** + * 检测当前页面是否为 BOSS 安全验证页(URL 特征 + 页面文字 + 常见验证组件选择器)。 + * 没有具体截图样本,使用多重信号:命中任意一条即判定为验证页。 + */ +const checkForBossVerification = async (page: any): Promise => { + try { + const url: string = page.url() + if (/verify|captcha|security.?check|safe\b|\/safe\/|安全验证/.test(url)) return true + return await page.evaluate(() => { + const text = (document.body?.innerText || '').toLowerCase() + const hasVerifyText = /请完成.{0,10}验证|安全验证|滑动.{0,6}滑块|人机验证|完成验证后继续|异常.{0,6}操作|验证码/.test( + document.body?.innerText || '' + ) + const hasVerifyEl = !!( + document.querySelector('#nc_mask') || + document.querySelector('.verify-container') || + document.querySelector('.captcha-wrap') || + document.querySelector('.nc-container') || + document.querySelector('[class*="verify"][class*="wrap"]') || + document.querySelector('[class*="captcha"]') + ) + return hasVerifyText || hasVerifyEl + }) + } catch { + return false + } +} + +/** + * 等待用户完成验证(最长 5 分钟)。 + * 期间每 2s 轮询页面状态;完成后返回 true,超时返回 false。 + */ +const waitForBossVerificationCompletion = async (page: any, expectedUrlPrefix: string, logFn: (msg: string) => void): Promise => { + logFn('⚠️ 检测到 BOSS 安全验证,请在浏览器窗口中手动完成验证,完成后将自动继续...') + try { + const { Notification } = await import('electron') + new Notification({ + title: 'GeekGeekRun - 需要人工验证', + body: '检测到 BOSS 直聘安全验证,请在打开的浏览器窗口中完成验证,完成后程序将自动继续。' + }).show() + } catch { /* Notification 不可用时静默忽略 */ } + + const deadline = Date.now() + 5 * 60 * 1000 + while (Date.now() < deadline) { + await sleep(2000) + try { + const url: string = page.url() + const isStillVerify = await checkForBossVerification(page) + if (url.startsWith(expectedUrlPrefix) && !isStillVerify) { + logFn('✅ 安全验证已完成,继续处理...') + return true + } + } catch { /* 页面可能正在跳转,继续等待 */ } + } + logFn('验证等待超时(5 分钟),将重启浏览器重试') + return false +} + const runChatPage = async () => { app.dock?.hide() log('runChatPage 开始') @@ -81,7 +139,12 @@ const runChatPage = async () => { log('正在动态 import boss package...') type BossAutoBrowseModule = { - startBossChatPageProcess: (hooks: any, options?: { browser?: any; page?: any; getCapturedText?: any; clearCapturedText?: any }) => Promise + startBossChatPageProcess: (hooks: any, options?: { + browser?: any; page?: any; getCapturedText?: any; clearCapturedText?: any; + jobId?: string | null; + retryCandidate?: { encryptGeekId: string; geekName: string; jobTitle: string } | null; + processContext?: { currentCandidate: any } | null; + }) => Promise initPuppeteer: () => Promise<{ puppeteer: any }> } const { @@ -137,8 +200,15 @@ const runChatPage = async () => { const { setDomainLocalStorage } = await import('@geekgeekrun/utils/puppeteer/local-storage.mjs') as any const localStoragePageUrl = 'https://www.zhipin.com/desktop/' + // browser/page/canvas hooks 提升到循环外,验证完成后可复用 + let browser: any = null + let page: any = null + let getCapturedText: any = null + let clearCapturedText: any = null + // processContext 提升到循环外,catch 块中可读取被中断的候选人 + const processContext: { currentCandidate: any } = { currentCandidate: null } + while (true) { - let browser: any = null try { const { readConfigFile: readCfg } = await import( '@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs' @@ -153,43 +223,77 @@ const runChatPage = async () => { const runOnceAfterComplete = cfg?.chatPage?.runOnceAfterComplete === true const keepBrowserOpenAfterRun = cfg?.chatPage?.keepBrowserOpenAfterRun === true - log('启动浏览器...') - await hooks.beforeBrowserLaunch?.promise?.() + // 仅在没有复用浏览器时才重新启动 + if (!browser) { + log('启动浏览器...') + await hooks.beforeBrowserLaunch?.promise?.() - const headless = process.env.HEADLESS === '1' - browser = await puppeteer.launch({ - headless, - ignoreHTTPSErrors: true, - protocolTimeout: 120000, - defaultViewport: { width: 1440, height: 900 - 140 } - }) + 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?.() + await hooks.afterBrowserLaunch?.promise?.() - const bossCookies = readStorageFile('boss-cookies.json') - const bossLocalStorage = readStorageFile('boss-local-storage.json') + 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 + page = (await browser.pages())[0] + // 注入 Canvas fillText hook,必须在页面导航前注入(evaluateOnNewDocument) + const canvasHooks = await setupCanvasTextHook(page) + getCapturedText = canvasHooks.getCapturedText + clearCapturedText = canvasHooks.clearCapturedText + 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 }) - log('开始执行 startBossChatPageProcess(沟通页)...') - await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText }) + sendToDaemon({ + type: 'worker-to-gui-message', + data: { + type: 'prerequisite-step-by-step-checkstep-by-step-check', + step: { id: 'login-status-check', status: 'fulfilled' }, + runRecordId + } + }) + } else { + log('复用已有浏览器实例,直接开始处理...') + } + + log('读取职位队列配置...') + const { readBossJobsConfig } = await import( + '@geekgeekrun/boss-auto-browse-and-chat/runtime-file-utils.mjs' + ) as any + const jobsConfig = readBossJobsConfig() + const allJobs = jobsConfig?.jobs || [] + + if (allJobs.length > 0) { + const chatJobs = allJobs.filter( + (j: any) => j.sequence?.enabled === true && j.sequence?.runChat !== false + ) + if (chatJobs.length > 0) { + log(`检测到 ${chatJobs.length} 个职位纳入沟通处理,依次执行...`) + for (const job of chatJobs) { + const jid = job.jobId ?? job.id + const jname = job.jobName ?? job.name + log(`开始处理职位 ${jid}(${jname})的沟通页...`) + processContext.currentCandidate = null + await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, jobId: jid, processContext }) + log(`职位 ${jid} 沟通页处理完成`) + } + } else { + log('当前没有勾选"纳入处理"的职位,跳过本轮沟通页扫描') + } + } else { + log('未配置职位队列,开始执行 startBossChatPageProcess(处理所有未读)...') + processContext.currentCandidate = null + await startBossChatPageProcess(hooks, { browser, page, getCapturedText, clearCapturedText, processContext }) + } log('startBossChatPageProcess 完成') if (runOnceAfterComplete) { @@ -207,13 +311,60 @@ const runChatPage = async () => { try { await browser.close() } catch (e) { void e } browser = null + page = null + getCapturedText = null + clearCapturedText = null const rerunMs = cfg?.chatPage?.rerunIntervalMs ?? rerunInterval log(`下次运行将在 ${rerunMs}ms 后开始`) await sleep(rerunMs) } catch (err) { + // ── 优先检测安全验证,命中则等待完成后复用浏览器继续,而非重启 ── + if (page) { + try { + const isVerify = await checkForBossVerification(page) + if (isVerify) { + // 保存被中断的候选人,验证完成后通过 retryCandidate 重试 + const interruptedCandidate = processContext.currentCandidate ?? null + if (interruptedCandidate) { + log(`⚠️ 验证中断时正在处理候选人:${interruptedCandidate.geekName}(${interruptedCandidate.encryptGeekId}),验证后将优先重试`) + } + + const completed = await waitForBossVerificationCompletion(page, BOSS_CHAT_PAGE_URL, log) + if (completed) { + // 验证完成:导航回沟通页 + try { + await page.goto(BOSS_CHAT_PAGE_URL, { timeout: 60 * 1000 }) + await page.waitForFunction(() => document.readyState === 'complete', { timeout: 60 * 1000 }) + } catch { /* 导航失败则让下一轮处理 */ } + + // 若有被中断的候选人,立即单独重试(不依赖 jobId,在「全部」tab 中找回) + if (interruptedCandidate) { + log(`🔄 正在重试被验证中断的候选人:${interruptedCandidate.geekName}...`) + try { + await startBossChatPageProcess(hooks, { + browser, page, getCapturedText, clearCapturedText, + retryCandidate: interruptedCandidate, + processContext: { currentCandidate: null } + }) + log(`重试候选人 ${interruptedCandidate.geekName} 完成`) + } catch (retryErr) { + log(`重试候选人时发生错误:${retryErr instanceof Error ? retryErr.message : String(retryErr)}`) + } + } + + continue // 重新进入循环,进行正常扫描 + } + } + } catch { /* 检测本身出错,走正常错误处理 */ } + } + + // ── 正常错误处理:关闭浏览器、识别错误类型 ── if (browser) { try { await browser.close() } catch (e) { void e } browser = null + page = null + getCapturedText = null + clearCapturedText = null } if (err instanceof Error) { if (err.message.includes('LOGIN_STATUS_INVALID')) { diff --git a/packages/ui/src/renderer/src/page/MainLayout/BossAutoSequence/index.vue b/packages/ui/src/renderer/src/page/MainLayout/BossAutoSequence/index.vue index f173cbe..e240a21 100644 --- a/packages/ui/src/renderer/src/page/MainLayout/BossAutoSequence/index.vue +++ b/packages/ui/src/renderer/src/page/MainLayout/BossAutoSequence/index.vue @@ -7,7 +7,7 @@