# 推荐牛人页(Recommend Page)完整逻辑文档 > **定位**:供 AI Agent 快速理解推荐牛人页的完整运行逻辑、DOM 结构、选择器与各模块分工。 > 最后更新:2026-03-16 --- ## 1. 入口与文件分工 | 文件 | 职责 | |------|------| | `packages/boss-auto-browse-and-chat/index.mjs` | 主入口 `startBossAutoBrowse(hooks, opts)`:浏览器启动、登录、主循环 | | `packages/boss-auto-browse-and-chat/candidate-processor.mjs` | `parseCandidateList`、`filterCandidates`、`scrollAndLoadMore`、`navigateToNextPage` | | `packages/boss-auto-browse-and-chat/chat-handler.mjs` | `viewCandidateDetail`、`startChatWithCandidate`、`processCandidate`、`checkDailyLimit` | | `packages/boss-auto-browse-and-chat/resume-extractor.mjs` | 网络拦截 + Canvas iframe hook,提取 Canvas 简历文字 | | `packages/boss-auto-browse-and-chat/constant.mjs` | 所有 CSS 选择器与 URL 常量 | | `packages/boss-auto-browse-and-chat/humanMouse.mjs` | `createHumanCursor(page)` — ghost-cursor 人类鼠标轨迹 | --- ## 2. 页面架构:主页面 + iframe(关键) 推荐牛人页使用 **两层 iframe** 架构,这是候选人列表选择器长期失效的根本原因: ``` 主页面(/web/chat/recommend) └── iframe[name="recommendFrame"] ← 候选人列表在此 iframe 内! src="/web/frame/recommend/?jobid=...&version=XXXX" └── div.recommend-list-wrap └── div#recommend-list.list-wrap.card-list-wrap └── div.list-body └── ul.card-list ← CANDIDATE_LIST_SELECTOR └── li.card-item ← CANDIDATE_ITEM_SELECTOR └── div.candidate-card-wrap └── div.card-inner[data-geek][data-geekid] ├── div.col-1 │ ├── div.avatar-wrap > img.avatar │ └── div.salary-wrap > span ← 薪资 ├── div.col-2 │ ├── div.row.name-wrap │ │ └── span.name ← 姓名 │ ├── div.row │ │ └── div.join-text-wrap.base-info > span × N │ │ └── 年龄 / 工作年限 / 学历 / 求职状态 │ └── div.row.row-flex.expect-wrap │ └── span.content > div.join-text-wrap > span × N │ └── 期望城市 / 期望职位 └── div.operate-side └── div.button-chat-wrap.button-chat └── button.btn.btn-greet ← 打招呼(CHAT_START_BUTTON_SELECTOR) ``` 点击候选人卡片后,主页面弹出详情 dialog(不在 iframe 内): ``` 主页面 dialog(点击卡片后弹出) └── div.boss-popup__wrapper.dialog-lib-resume.recommendV2 └── div.lib-resume-recommend.wasm-resume-layout ├── div.resume-detail-wrap │ └── iframe[src="/web/frame/c-resume/?source=recommend"] ← 简历 Canvas 在此 iframe 内 │ └── canvas#resume ← WASM 解密后绘制 └── div.resume-right-side ← 右侧操作区(在主页面) └── div.button-list-wrap > div.button-chat-wrap.resumeGreet └── button.btn-v2.btn-sure-v2.btn-greet ← 也可点此打招呼 ``` **关键 data 属性:** - `div.card-inner[data-geek]` — encryptGeekId(用于去重、写 DB) - `div.base-info span` 含"年"字 → workExp;含学历关键词 → education **操作对象分工:** - **`recommendFrame`(iframe Frame 对象)**:parseCandidateList、scrollAndLoadMore、查找 btn-greet - **主页面 `page`**:「知道了」弹窗、风控检测、CHAT_INPUT_SELECTOR、checkDailyLimit --- ## 3. 选择器常量(constant.mjs) 所有候选人列表选择器均在 **iframe Frame** 内使用,不在主页面: ```js // 在 recommendFrame(iframe)内使用 CANDIDATE_LIST_SELECTOR = 'ul.card-list' CANDIDATE_ITEM_SELECTOR = 'ul.card-list > li.card-item' CANDIDATE_NAME_SELECTOR = 'span.name' CANDIDATE_DETAIL_SELECTOR = '' // 无独立详情面板 CHAT_START_BUTTON_SELECTOR = 'button.btn-greet' // 在 li.card-item 内 CONTINUE_CHAT_BUTTON_SELECTOR = 'div.operate-side div.button-chat' // 在主页面(page)使用 GREETING_SENT_KNOW_BTN_SELECTOR = 'div.dialog-wrap button.btn-sure-v2' CHAT_INPUT_SELECTOR = '#boss-chat-global-input' RESUME_POPUP_CLOSE_SELECTOR = 'div.boss-popup__close' // 简历详情弹窗关闭 // 在 recommendFrame(iframe)内使用,每条 card-item 内 NOT_INTERESTED_IN_ITEM_SELECTOR = 'div.tooltip-wrap.suitable' // 不感兴趣(点击后弹出原因) NOT_INTERESTED_REASON_POPUP_SELECTOR = 'div.card-reason-f1.show' // 原因弹窗 NOT_INTERESTED_REASON_ITEMS_SELECTOR = 'div.card-reason-f1.show span.first-reason-item' // 所有选项,按 NOT_INTERESTED_REASON_MAP 匹配 NOT_INTERESTED_REASON_MAP = { city:'牛人距离远', education:'不考虑本科', salary:'期望薪资偏高', workExp:'工作经历和制剂研发无关', viewed:'重复推荐', skills:'其他原因', blockName:'其他原因' } NOT_INTERESTED_REASON_FALLBACK = '其他原因' BOSS_RECOMMEND_PAGE_URL = 'https://www.zhipin.com/web/chat/recommend' BOSS_CHAT_INDEX_URL = 'https://www.zhipin.com/web/chat/index' ``` --- ## 4. 主循环流程(startBossAutoBrowse) ``` startBossAutoBrowse(hooks, { returnBrowser? }) 1. hooks.beforeBrowserLaunch 2. puppeteer.launch({ headless, viewport: 1440×760 }) 3. page(单 Tab) → BOSS_RECOMMEND_PAGE_URL(注入 cookie + localStorage) 4. 登录检测:URL 未在推荐页则等待用户手动登录(最长 5 分钟)→ storeStorage() 5. page.waitForSelector('iframe[name="recommendFrame"]') → recommendFrameHandle 6. recommendFrameHandle.contentFrame() → recommendFrame(iframe Frame 对象) 7. recommendFrame.waitForSelector('ul.card-list > li.card-item', 30s) 8. setupNetworkInterceptor(page) // 拦截 resume/geek/info API(主页面) 9. setupCanvasTextHook(page) // MutationObserver 注入 c-resume iframe fillText hook 10. 读取 boss-recruiter.json / candidate-filter.json 11. hooks.onCandidateListLoaded(GUI 登录状态 fulfilled) 主循环 while(true): a. parseCandidateList(recommendFrame) → candidates[] ← 在 iframe frame 内操作 ├── 方式一:Vue __vue__ / __vueParentComponent 获取数据(自动解析所有字段) └── 方式二(兜底):DOM 解析 · encryptGeekId ← div.card-inner[data-geek] · geekName ← span.name · salary ← div.salary-wrap span · workExp ← div.base-info span(匹配 /年|经验不限/) · education ← div.base-info span(匹配学历关键词) · city ← div.expect-wrap span.content div.join-text-wrap span[0] · jobTitle ← div.expect-wrap span.content div.join-text-wrap span[1] b. filterCandidates(candidates, filterConfig) + hooks.onCandidateFiltered 筛选条件:city / education / workExp / salary / skills / blockName c. for each matched candidate (index i): checkDailyLimit(page) → 已达上限则 break mainLoop ← 在主页面检测 chatCount >= maxChatPerRun → break mainLoop processCandidate(recommendFrame, candidate, config, hooks, { candidateIndex: i, mainPage: page }) └── viewCandidateDetail(recommendFrame, candidate, { candidateIndex: i }) · 拟人 cursor 点击 li.card-item[i](展开,在 iframe 内) · 从 DOM / 网络拦截 / Canvas 提取简历文字 └── startChatWithCandidate(recommendFrame, candidate, greetingMessage, { candidateIndex: i, mainPage: page }) · 在 recommendFrame 的 li.card-item[i] 内找 button.btn-greet → cursor.click()(iframe 内) · mainPage.waitForSelector('div.dialog-wrap button.btn-sure-v2', 6s)(主页面等弹窗) · cursor.click(knowBtn BoundingBox)(知道了,主页面弹窗) · recommendFrame 内找 CONTINUE_CHAT_BUTTON_SELECTOR → click()(可选) · mainPage 的 CHAT_INPUT_SELECTOR 输入 greetingMessage → Enter(可选) └── hooks.afterChatStarted(candidate, chatResult) └── sleepWithRandomDelay(delayBetweenChats) chatResult.success → chatCount++, hooks.onProgress({ phase:'recommend', current, max }) chatResult.reason === DAILY_LIMIT_REACHED / RISK_CONTROL → break mainLoop d. scrollAndLoadMore(recommendFrame) ← 在 iframe frame 内滚动 · page.mouse.wheel 小步随机滚动(3 轮 × 4 步,每步 25-40px,间隔 80-160ms) · 检测页面文本 /没有更多|已经到底/ 或 Vue hasMore===false → return false · false → break mainLoop(已加载全部) 10. hooks.onComplete 11. returnBrowser ? return { browser, page } : browser.close() ``` --- ## 5. 候选人简历弹窗(动态弹出的详情) 点击 `li.card-item` 后,页面弹出一个动态 dialog,内含 iframe 渲染的完整简历: ```html