@reconcrap/boss-recommend-mcp 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,6 +25,7 @@ MCP 工具名:`run_recommend_pipeline`
25
25
  - 学历支持单选与多选语义:如“本科及以上”会展开为 `本科/硕士/博士`;如“大专、本科”只勾选这两项
26
26
  - 执行前会逐项确认筛选参数:学校标签 / 学历 / 性别 / 是否过滤近14天已看
27
27
  - 页面就绪(已登录且在 recommend 页)后,会先提取岗位栏全部岗位并要求用户确认本次岗位;确认后先点击岗位,再执行 search/screen
28
+ - 在真正开始 search/screen 前,会进行最后一轮全参数总确认(岗位 + 全部筛选参数 + criteria + target_count + post_action + max_greet_count)
28
29
  - npm 全局安装后会自动执行 install:生成 skill、导出 MCP 模板,并自动尝试写入已检测到的外部 agent MCP 配置(含 Trae / trae-cn / Cursor / Claude / OpenClaw)
29
30
  - `post_action` 必须在每次完整运行开始时确认一次
30
31
  - `target_count` 会在每次运行开始时询问一次(可留空,不设上限)
@@ -64,6 +65,7 @@ BOSS_RECOMMEND_EXTERNAL_SKILL_DIRS # JSON 数组或系统 path 分隔路径列
64
65
 
65
66
  ```bash
66
67
  node src/cli.js run --instruction "推荐页筛选985男生,近14天没有,有大模型平台经验,符合标准的收藏"
68
+ npx -y @reconcrap/boss-recommend-mcp@latest run --instruction "推荐页筛选985男生,近14天没有,有大模型平台经验,符合标准的收藏"
67
69
  ```
68
70
 
69
71
  ## 配置
@@ -135,6 +137,7 @@ node src/cli.js run --instruction-file request.txt --confirmation-file confirmat
135
137
  "target_count_value": 20,
136
138
  "post_action_confirmed": true,
137
139
  "post_action_value": "greet",
140
+ "final_confirmed": true,
138
141
  "job_confirmed": true,
139
142
  "job_value": "算法工程师(视频/图像模型方向) _ 杭州",
140
143
  "max_greet_count_confirmed": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -51,7 +51,11 @@ description: "Use when users ask to run Boss recommend-page filtering and screen
51
51
  - 严禁在未询问用户的情况下自动把 `max_greet_count` 设为 `target_count` 或其他默认值
52
52
  - 岗位(`job`)是否确定
53
53
  - 必须先列出 recommend 页岗位栏里识别到的全部岗位,让用户明确选择
54
+ - 即使前序步骤已提取到 `job` 参数,执行前也必须再次展示岗位列表并让用户二次确认
54
55
  - 用户确认后必须先点击该岗位,再开始 search 和 screen
56
+ - 正式开始 search/screen 前,必须做最后一轮“全参数总确认”
57
+ - 需要向用户复述并确认:岗位、school_tag、degree、gender、recent_not_view、criteria、target_count、post_action、max_greet_count
58
+ - 只有用户明确最终确认后才允许执行
55
59
 
56
60
  `post_action` 的确认是**单次运行级别**的:
57
61
 
@@ -75,6 +79,7 @@ description: "Use when users ask to run Boss recommend-page filtering and screen
75
79
  - `target_count_value` (integer, optional)
76
80
  - `post_action_confirmed`
77
81
  - `post_action_value` (`favorite|greet`)
82
+ - `final_confirmed`
78
83
  - `job_confirmed`
79
84
  - `job_value` (string)
80
85
  - `max_greet_count_confirmed`
@@ -98,11 +103,17 @@ description: "Use when users ask to run Boss recommend-page filtering and screen
98
103
  - recommend-screen-cli 负责滚动推荐列表、打开详情、提取完整简历图、调用多模态模型判断,并按单次确认的 `post_action` 执行收藏或打招呼。
99
104
  - 详情页处理完成后必须关闭详情页并确认已关闭。
100
105
 
101
- ## Fallback
102
-
103
- 如果 MCP 不可用,改用:
104
-
105
- `boss-recommend-mcp run --instruction "..." [--confirmation-json '{...}'] [--overrides-json '{...}']`
106
+ ## Fallback
107
+
108
+ 如果 MCP 不可用,改用:
109
+
110
+ `npx -y @reconcrap/boss-recommend-mcp@latest run --instruction "..." [--confirmation-json '{...}'] [--overrides-json '{...}']`
111
+
112
+ 禁止错误回退:
113
+
114
+ - 不能把 recommend 请求回退到 `boss-recruit-mcp` / `run_recruit_pipeline`
115
+ - 不能执行 `boss-recruit-mcp doctor` 作为 recommend 流程的环境检查
116
+ - 若检测到当前环境只有 recruit MCP,应先修复 recommend MCP 配置,再继续
106
117
 
107
118
  CLI fallback 的状态机与 MCP 保持一致:
108
119
 
@@ -135,6 +146,8 @@ CLI fallback 的状态机与 MCP 保持一致:
135
146
  当工具返回 `status=FAILED` 且 `error.code=PIPELINE_PREFLIGHT_FAILED` 时:
136
147
 
137
148
  1. 若 `diagnostics.checks` 中 `screen_config` 失败,优先引导用户填写 `screening-config.json` 的 `baseUrl/apiKey/model`(必须让用户提供真实值,不可保留模板值)。
149
+ - 禁止 agent 自行代填或猜测示例值(如 `test-key` / `mock-key` / `https://example.com` / `gpt-4` 占位等)
150
+ - 必须逐项向用户确认 `baseUrl`、`apiKey`、`model` 后再写入
138
151
  2. 优先查看 `diagnostics.auto_repair`,若有自动修复动作则先基于其结果继续执行或给出最小化补救提示。
139
152
  3. 若自动修复后仍失败,再读取 `diagnostics.recovery.agent_prompt`,直接把这段提示词交给 AI agent 执行环境修复。
140
153
  4. 若 `diagnostics.recovery.agent_prompt` 不存在,使用下面的兜底提示词(严格顺序,不可跳步):
package/src/adapters.js CHANGED
@@ -860,6 +860,19 @@ export async function inspectBossRecommendPageState(port, options = {}) {
860
860
  (tab) => typeof tab?.url === "string" && tab.url.includes("/web/chat/recommend")
861
861
  );
862
862
  if (exactTab) {
863
+ if (isBossLoginTab(exactTab)) {
864
+ return buildBossPageState({
865
+ ok: false,
866
+ state: "LOGIN_REQUIRED",
867
+ path: exactTab.url || bossLoginUrl,
868
+ current_url: exactTab.url || bossLoginUrl,
869
+ title: exactTab.title || null,
870
+ requires_login: true,
871
+ expected_url: expectedUrl,
872
+ login_url: bossLoginUrl,
873
+ message: "当前标签页虽在 recommend 路径,但检测到登录态页面特征,请先完成 Boss 登录。"
874
+ });
875
+ }
863
876
  return buildBossPageState({
864
877
  ok: true,
865
878
  state: "RECOMMEND_READY",
package/src/cli.js CHANGED
@@ -27,6 +27,8 @@ const supportedMcpClients = ["generic", "cursor", "trae", "claudecode", "opencla
27
27
  const defaultMcpServerName = "boss-recommend";
28
28
  const defaultMcpCommand = "npx";
29
29
  const defaultMcpArgs = ["-y", "@reconcrap/boss-recommend-mcp@latest", "start"];
30
+ const recommendMcpPackageName = "@reconcrap/boss-recommend-mcp";
31
+ const recommendMcpBinaryName = "boss-recommend-mcp";
30
32
  const autoSyncSkipCommands = new Set(["install", "install-skill", "where", "help", "--help", "-h"]);
31
33
  const externalMcpTargetsEnv = "BOSS_RECOMMEND_MCP_CONFIG_TARGETS";
32
34
  const externalSkillDirsEnv = "BOSS_RECOMMEND_EXTERNAL_SKILL_DIRS";
@@ -91,6 +93,32 @@ function dedupePaths(items) {
91
93
  return result;
92
94
  }
93
95
 
96
+ function dedupeLower(values = []) {
97
+ const seen = new Set();
98
+ const result = [];
99
+ for (const value of values) {
100
+ const normalized = String(value || "").trim();
101
+ if (!normalized) continue;
102
+ const key = normalized.toLowerCase();
103
+ if (seen.has(key)) continue;
104
+ seen.add(key);
105
+ result.push(normalized);
106
+ }
107
+ return result;
108
+ }
109
+
110
+ function discoverAppDataDirsByPattern(baseDir, pattern) {
111
+ try {
112
+ if (!pathExists(baseDir)) return [];
113
+ const entries = fs.readdirSync(baseDir, { withFileTypes: true });
114
+ return entries
115
+ .filter((entry) => entry.isDirectory() && pattern.test(entry.name))
116
+ .map((entry) => entry.name);
117
+ } catch {
118
+ return [];
119
+ }
120
+ }
121
+
94
122
  function getDesktopDir() {
95
123
  return path.join(os.homedir(), "Desktop");
96
124
  }
@@ -306,10 +334,19 @@ function parseAgentTargets(rawValue) {
306
334
  function getKnownExternalMcpConfigPathsByAgent() {
307
335
  const home = os.homedir();
308
336
  const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
337
+ const traeDirNames = dedupeLower([
338
+ "Trae",
339
+ "Trae CN",
340
+ "TraeCN",
341
+ "trae-cn",
342
+ "trae_cn",
343
+ ...discoverAppDataDirsByPattern(appData, /^trae(?:[\s\-_]?cn)?$/i)
344
+ ]);
345
+ const traeConfigPaths = traeDirNames.map((dir) => path.join(appData, dir, "User", "mcp.json"));
309
346
  return {
310
347
  cursor: [path.join(appData, "Cursor", "User", "mcp.json"), path.join(home, ".cursor", "mcp.json")],
311
- trae: [path.join(appData, "Trae", "User", "mcp.json"), path.join(home, ".trae", "mcp.json")],
312
- "trae-cn": [path.join(appData, "Trae CN", "User", "mcp.json"), path.join(home, ".trae-cn", "mcp.json")],
348
+ trae: [...traeConfigPaths, path.join(home, ".trae", "mcp.json"), path.join(home, ".trae-cn", "mcp.json")],
349
+ "trae-cn": [...traeConfigPaths, path.join(home, ".trae-cn", "mcp.json"), path.join(home, ".trae", "mcp.json")],
313
350
  claude: [path.join(home, ".claude", "mcp.json")],
314
351
  openclaw: [path.join(home, ".openclaw", "mcp.json")]
315
352
  };
@@ -383,15 +420,76 @@ function installExternalMcpConfigs(options = {}) {
383
420
  function getKnownExternalSkillBaseDirsByAgent() {
384
421
  const home = os.homedir();
385
422
  const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
423
+ const traeDirNames = dedupeLower([
424
+ "Trae",
425
+ "Trae CN",
426
+ "TraeCN",
427
+ "trae-cn",
428
+ "trae_cn",
429
+ ...discoverAppDataDirsByPattern(appData, /^trae(?:[\s\-_]?cn)?$/i)
430
+ ]);
431
+ const traeSkillDirs = traeDirNames.map((dir) => path.join(appData, dir, "User", "skills"));
386
432
  return {
387
433
  cursor: [path.join(home, ".cursor", "skills"), path.join(appData, "Cursor", "User", "skills")],
388
- trae: [path.join(home, ".trae", "skills"), path.join(appData, "Trae", "User", "skills")],
389
- "trae-cn": [path.join(home, ".trae-cn", "skills"), path.join(appData, "Trae CN", "User", "skills")],
434
+ trae: [path.join(home, ".trae", "skills"), path.join(home, ".trae-cn", "skills"), ...traeSkillDirs],
435
+ "trae-cn": [path.join(home, ".trae-cn", "skills"), path.join(home, ".trae", "skills"), ...traeSkillDirs],
390
436
  claude: [path.join(home, ".claude", "skills")],
391
437
  openclaw: [path.join(home, ".openclaw", "skills"), path.join(appData, "OpenClaw", "User", "skills")]
392
438
  };
393
439
  }
394
440
 
441
+ function isRecommendMcpLaunchConfig(launchConfig) {
442
+ if (!launchConfig || typeof launchConfig !== "object") return false;
443
+ const command = String(launchConfig.command || "").toLowerCase();
444
+ const args = Array.isArray(launchConfig.args) ? launchConfig.args : [];
445
+ const joined = `${command} ${args.map((item) => String(item || "")).join(" ")}`.toLowerCase();
446
+ return (
447
+ joined.includes(recommendMcpPackageName.toLowerCase())
448
+ || joined.includes(`${recommendMcpBinaryName} start`)
449
+ || (command.endsWith(recommendMcpBinaryName) && args.includes("start"))
450
+ || command === recommendMcpBinaryName
451
+ );
452
+ }
453
+
454
+ function inspectMcpServerEntries(filePath) {
455
+ if (!pathExists(filePath)) {
456
+ return {
457
+ exists: false,
458
+ has_boss_recommend: false,
459
+ has_boss_recruit: false,
460
+ recommend_server_names: [],
461
+ recruit_server_names: []
462
+ };
463
+ }
464
+ const parsed = readJsonObjectFileSafe(filePath);
465
+ const servers = parsed?.mcpServers && typeof parsed.mcpServers === "object" && !Array.isArray(parsed.mcpServers)
466
+ ? parsed.mcpServers
467
+ : {};
468
+ const recommendNames = [];
469
+ const recruitNames = [];
470
+ for (const [name, config] of Object.entries(servers)) {
471
+ const lowerName = String(name || "").toLowerCase();
472
+ if (isRecommendMcpLaunchConfig(config) || lowerName.includes("boss-recommend")) {
473
+ recommendNames.push(name);
474
+ }
475
+ const serialized = JSON.stringify(config || {}).toLowerCase();
476
+ if (
477
+ lowerName.includes("boss-recruit")
478
+ || serialized.includes("@reconcrap/boss-recruit-mcp")
479
+ || serialized.includes("boss-recruit-mcp")
480
+ ) {
481
+ recruitNames.push(name);
482
+ }
483
+ }
484
+ return {
485
+ exists: true,
486
+ has_boss_recommend: recommendNames.length > 0,
487
+ has_boss_recruit: recruitNames.length > 0,
488
+ recommend_server_names: recommendNames,
489
+ recruit_server_names: recruitNames
490
+ };
491
+ }
492
+
395
493
  function resolveExternalSkillBaseDirs(options = {}) {
396
494
  const fromEnv = parsePathListFromEnv(process.env[externalSkillDirsEnv]);
397
495
  const pathMap = getKnownExternalSkillBaseDirsByAgent();
@@ -650,16 +748,21 @@ function inspectAgentIntegration(agentRaw) {
650
748
 
651
749
  const mcpPathMap = getKnownExternalMcpConfigPathsByAgent();
652
750
  const skillPathMap = getKnownExternalSkillBaseDirsByAgent();
653
- const mcp_paths = dedupePaths(mcpPathMap[agent] || []);
654
- const skill_bases = dedupePaths(skillPathMap[agent] || []);
751
+ const mcp_paths = dedupePaths([
752
+ ...(mcpPathMap[agent] || []),
753
+ ...parsePathListFromEnv(process.env[externalMcpTargetsEnv])
754
+ ]);
755
+ const skill_bases = dedupePaths([
756
+ ...(skillPathMap[agent] || []),
757
+ ...parsePathListFromEnv(process.env[externalSkillDirsEnv])
758
+ ]);
655
759
 
656
760
  const mcp_checks = mcp_paths.map((mcpPath) => {
657
- if (!pathExists(mcpPath)) {
658
- return { path: mcpPath, exists: false, has_boss_recommend: false };
659
- }
660
- const parsed = readJsonObjectFileSafe(mcpPath);
661
- const has = Boolean(parsed?.mcpServers && parsed.mcpServers["boss-recommend"]);
662
- return { path: mcpPath, exists: true, has_boss_recommend: has };
761
+ const detail = inspectMcpServerEntries(mcpPath);
762
+ return {
763
+ path: mcpPath,
764
+ ...detail
765
+ };
663
766
  });
664
767
 
665
768
  const hasRecommendIntent = (content) => /(recommend|推荐页|boss recommend|recommend page)/i.test(content);
@@ -736,6 +839,10 @@ async function printDoctor(options = {}) {
736
839
  if (typeof options.agent === "string" && options.agent.trim()) {
737
840
  agentIntegration = inspectAgentIntegration(options.agent.trim());
738
841
  const agentMcpOk = agentIntegration.mcp_checks.some((item) => item.has_boss_recommend);
842
+ const agentRecruitOnly = (
843
+ !agentMcpOk
844
+ && agentIntegration.mcp_checks.some((item) => item.has_boss_recruit)
845
+ );
739
846
  const agentSkillOk = agentIntegration.skill_checks.some((item) => item.exists);
740
847
  const agentRecruitRouteGuardOk = agentIntegration.skill_checks.every(
741
848
  (item) => !item.recruit_skill_exists || item.recruit_route_guard
@@ -750,7 +857,9 @@ async function printDoctor(options = {}) {
750
857
  path: agentIntegration.mcp_checks.map((item) => item.path).join(" | "),
751
858
  message: agentMcpOk
752
859
  ? "目标 Agent MCP 配置已检测到 boss-recommend。"
753
- : "目标 Agent MCP 配置未检测到 boss-recommend。"
860
+ : agentRecruitOnly
861
+ ? "目标 Agent MCP 配置未检测到 boss-recommend,但检测到 boss-recruit(可能导致错误调用 recruit pipeline)。"
862
+ : "目标 Agent MCP 配置未检测到 boss-recommend。"
754
863
  });
755
864
  checks.push({
756
865
  key: `agent_${agentIntegration.agent}_skill`,
package/src/index.js CHANGED
@@ -60,6 +60,7 @@ function createToolSchema() {
60
60
  type: "string",
61
61
  enum: ["favorite", "greet"]
62
62
  },
63
+ final_confirmed: { type: "boolean" },
63
64
  job_confirmed: { type: "boolean" },
64
65
  job_value: { type: "string" },
65
66
  max_greet_count_confirmed: { type: "boolean" },
package/src/pipeline.js CHANGED
@@ -19,13 +19,14 @@ function normalizeText(value) {
19
19
  function normalizeJobTitle(value) {
20
20
  const text = normalizeText(value);
21
21
  if (!text) return "";
22
- const strippedRange = text
22
+ const byGap = text.split(/\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
23
+ const strippedRange = byGap
23
24
  .replace(/\s+\d+(?:\.\d+)?\s*(?:-|~|—|至)\s*\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)?$/u, "")
24
25
  .trim();
25
26
  const strippedSingle = strippedRange
26
27
  .replace(/\s+\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)$/u, "")
27
28
  .trim();
28
- return strippedSingle || text;
29
+ return strippedSingle || byGap;
29
30
  }
30
31
 
31
32
  function normalizeJobOptions(jobOptions = []) {
@@ -299,8 +300,8 @@ function buildNeedInputResponse(parsedResult) {
299
300
  };
300
301
  }
301
302
 
302
- function buildNeedConfirmationResponse(parsedResult) {
303
- return {
303
+ function buildNeedConfirmationResponse(parsedResult) {
304
+ return {
304
305
  status: "NEED_CONFIRMATION",
305
306
  required_confirmations: buildRequiredConfirmations(parsedResult),
306
307
  search_params: parsedResult.searchParams,
@@ -311,10 +312,22 @@ function buildNeedConfirmationResponse(parsedResult) {
311
312
  max_greet_count: parsedResult.proposed_max_greet_count || parsedResult.screenParams.max_greet_count
312
313
  },
313
314
  pending_questions: parsedResult.pending_questions,
314
- review: parsedResult.review
315
- };
316
- }
317
-
315
+ review: parsedResult.review
316
+ };
317
+ }
318
+
319
+ function buildFinalReviewQuestion({ searchParams, screenParams, selectedJob }) {
320
+ return {
321
+ field: "final_review",
322
+ question: "开始执行搜索和筛选前,请最后确认全部参数(岗位/筛选条件/筛选 criteria/目标人数/post_action/max_greet_count)无误。",
323
+ value: {
324
+ job: selectedJob?.title || selectedJob?.label || selectedJob?.value || null,
325
+ search_params: searchParams,
326
+ screen_params: screenParams
327
+ }
328
+ };
329
+ }
330
+
318
331
  function buildFailedResponse(code, message, extra = {}) {
319
332
  return {
320
333
  status: "FAILED",
@@ -445,7 +458,7 @@ export async function runRecommendPipeline(
445
458
  "2) apiKey",
446
459
  "3) model",
447
460
  `配置文件路径:${screenConfigCheck.path}`,
448
- "注意:不要使用模板占位符(例如 replace-with-openai-api-key)。填写完成后重试。"
461
+ "注意:不要使用模板占位符(例如 replace-with-openai-api-key),也不要由 agent 自行猜测或代填示例值。必须向用户逐项确认真实可用值后再重试。"
449
462
  ].join("\n")
450
463
  }
451
464
  : undefined,
@@ -559,6 +572,34 @@ export async function runRecommendPipeline(
559
572
  }
560
573
  const selectedJob = selectedJobResolution.job;
561
574
  const selectedJobToken = selectedJob.value || selectedJob.title || selectedJob.label;
575
+ if (confirmation?.final_confirmed !== true) {
576
+ const pendingQuestions = (parsed.pending_questions || []).filter((item) => item?.field !== "final_review");
577
+ pendingQuestions.push(buildFinalReviewQuestion({
578
+ searchParams: parsed.searchParams,
579
+ screenParams: {
580
+ ...parsed.screenParams,
581
+ target_count: parsed.proposed_target_count ?? parsed.screenParams.target_count,
582
+ post_action: parsed.proposed_post_action || parsed.screenParams.post_action,
583
+ max_greet_count: parsed.proposed_max_greet_count || parsed.screenParams.max_greet_count
584
+ },
585
+ selectedJob
586
+ }));
587
+ return {
588
+ status: "NEED_CONFIRMATION",
589
+ required_confirmations: dedupe([...buildRequiredConfirmations(parsed), "final_review"]),
590
+ search_params: parsed.searchParams,
591
+ screen_params: {
592
+ ...parsed.screenParams,
593
+ target_count: parsed.proposed_target_count ?? parsed.screenParams.target_count,
594
+ post_action: parsed.proposed_post_action || parsed.screenParams.post_action,
595
+ max_greet_count: parsed.proposed_max_greet_count || parsed.screenParams.max_greet_count
596
+ },
597
+ selected_job: selectedJob,
598
+ pending_questions: pendingQuestions,
599
+ review: parsed.review,
600
+ job_options: jobOptions
601
+ };
602
+ }
562
603
 
563
604
  const searchResult = await searchCli({
564
605
  workspaceRoot,
@@ -30,6 +30,14 @@ function createJobListResult() {
30
30
  }
31
31
 
32
32
  function createJobConfirmedConfirmation() {
33
+ return {
34
+ job_confirmed: true,
35
+ job_value: "数据分析实习生 _ 杭州",
36
+ final_confirmed: true
37
+ };
38
+ }
39
+
40
+ function createJobConfirmedWithoutFinalConfirmation() {
33
41
  return {
34
42
  job_confirmed: true,
35
43
  job_value: "数据分析实习生 _ 杭州"
@@ -318,6 +326,29 @@ async function testNeedJobConfirmationGate() {
318
326
  assert.equal(result.job_options.length, 2);
319
327
  }
320
328
 
329
+ async function testNeedFinalReviewConfirmationGate() {
330
+ const result = await runRecommendPipeline(
331
+ {
332
+ workspaceRoot: process.cwd(),
333
+ instruction: "test",
334
+ confirmation: createJobConfirmedWithoutFinalConfirmation(),
335
+ overrides: {}
336
+ },
337
+ {
338
+ parseRecommendInstruction: () => createParsed(),
339
+ runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
340
+ ensureBossRecommendPageReady: async () => ({ ok: true, state: "RECOMMEND_READY", page_state: {} }),
341
+ listRecommendJobs: async () => createJobListResult(),
342
+ runRecommendSearchCli: async () => ({ ok: true, summary: {} }),
343
+ runRecommendScreenCli: async () => ({ ok: true, summary: {} })
344
+ }
345
+ );
346
+
347
+ assert.equal(result.status, "NEED_CONFIRMATION");
348
+ assert.equal(result.required_confirmations.includes("final_review"), true);
349
+ assert.equal(result.pending_questions.some((item) => item.field === "final_review"), true);
350
+ }
351
+
321
352
  async function testLoginRequiredShouldReturnGuidance() {
322
353
  const result = await runRecommendPipeline(
323
354
  {
@@ -587,6 +618,7 @@ async function main() {
587
618
  await testNeedMaxGreetCountConfirmationGate();
588
619
  await testNeedInputGate();
589
620
  await testNeedJobConfirmationGate();
621
+ await testNeedFinalReviewConfirmationGate();
590
622
  await testCompletedPipeline();
591
623
  await testSearchFailure();
592
624
  await testLoginRequiredShouldReturnGuidance();
@@ -1407,14 +1407,26 @@ class RecommendScreenCli {
1407
1407
  }
1408
1408
  }
1409
1409
 
1410
+ const isListReadyNow = async () => {
1411
+ const detailState = await this.getDetailClosedState();
1412
+ if (!detailState?.closed) return false;
1413
+ const listState = await this.evaluate(jsGetListState);
1414
+ return Boolean(listState?.ok);
1415
+ };
1416
+
1410
1417
  let know = null;
1411
1418
  for (let index = 0; index < 10; index += 1) {
1412
1419
  await sleep(humanDelay(260, 80));
1413
1420
  know = await this.evaluate(jsGetKnowButtonState);
1414
1421
  if (know?.ok) break;
1422
+ if (await isListReadyNow()) {
1423
+ log("[打招呼] 未检测到“知道了”弹窗,页面已自动返回列表,继续下一位候选人。");
1424
+ return { actionTaken: "greet", ackMode: "auto_return_no_popup" };
1425
+ }
1415
1426
  }
1416
1427
  if (!know?.ok) {
1417
- throw this.buildError("ACK_BUTTON_FAILED", know?.error || "未出现知道了确认按钮");
1428
+ log(`[打招呼] 未检测到“知道了”弹窗(${know?.error || "ACK_BUTTON_NOT_FOUND"}),按无弹窗流程继续。`);
1429
+ return { actionTaken: "greet", ackMode: "no_popup_detected" };
1418
1430
  }
1419
1431
 
1420
1432
  try {
@@ -1422,7 +1434,12 @@ class RecommendScreenCli {
1422
1434
  } catch {
1423
1435
  const fallback = await this.evaluate(jsClickKnowFallback);
1424
1436
  if (!fallback?.ok) {
1425
- throw this.buildError("ACK_BUTTON_FAILED", fallback?.error || "知道了按钮点击失败");
1437
+ if (await isListReadyNow()) {
1438
+ log("[打招呼] “知道了”点击兜底失败,但页面已回列表,继续下一位候选人。");
1439
+ return { actionTaken: "greet", ackMode: "ack_click_failed_but_list_ready" };
1440
+ }
1441
+ log(`[打招呼] “知道了”按钮点击失败(${fallback?.error || "unknown"}),后续由详情关闭流程兜底。`);
1442
+ return { actionTaken: "greet", ackMode: "ack_click_failed" };
1426
1443
  }
1427
1444
  }
1428
1445
 
@@ -1430,11 +1447,18 @@ class RecommendScreenCli {
1430
1447
  await sleep(humanDelay(220, 60));
1431
1448
  const state = await this.evaluate(jsGetKnowButtonState);
1432
1449
  if (!state?.ok) {
1433
- return { actionTaken: "greet" };
1450
+ return { actionTaken: "greet", ackMode: "ack_closed" };
1451
+ }
1452
+ if (await isListReadyNow()) {
1453
+ return { actionTaken: "greet", ackMode: "auto_return_after_ack" };
1434
1454
  }
1435
1455
  }
1436
1456
 
1437
- throw this.buildError("ACK_BUTTON_FAILED", "知道了弹窗未成功关闭。");
1457
+ if (await isListReadyNow()) {
1458
+ return { actionTaken: "greet", ackMode: "ack_still_visible_but_list_ready" };
1459
+ }
1460
+ log("[打招呼] “知道了”弹窗关闭未确认,后续由详情关闭流程兜底。");
1461
+ return { actionTaken: "greet", ackMode: "ack_close_unconfirmed" };
1438
1462
  }
1439
1463
 
1440
1464
  async closeDetailPage(maxRetries = 3) {
@@ -19,13 +19,14 @@ function normalizeText(value) {
19
19
  function normalizeJobTitle(value) {
20
20
  const text = normalizeText(value);
21
21
  if (!text) return "";
22
- const strippedRange = text
22
+ const byGap = text.split(/\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
23
+ const strippedRange = byGap
23
24
  .replace(/\s+\d+(?:\.\d+)?\s*(?:-|~|—|至)\s*\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)?$/u, "")
24
25
  .trim();
25
26
  const strippedSingle = strippedRange
26
27
  .replace(/\s+\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)$/u, "")
27
28
  .trim();
28
- return strippedSingle || text;
29
+ return strippedSingle || byGap;
29
30
  }
30
31
 
31
32
  function parsePositiveInteger(raw) {
@@ -419,13 +420,14 @@ class RecommendSearchCli {
419
420
  const normalizeTitle = (value) => {
420
421
  const text = normalize(value);
421
422
  if (!text) return '';
422
- const strippedRange = text
423
+ const byGap = text.split(/\\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
424
+ const strippedRange = byGap
423
425
  .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:-|~|—|至)\\s*\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)?$/u, '')
424
426
  .trim();
425
427
  const strippedSingle = strippedRange
426
428
  .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)$/u, '')
427
429
  .trim();
428
- return strippedSingle || text;
430
+ return strippedSingle || byGap;
429
431
  };
430
432
  const isVisible = (el) => {
431
433
  if (!el) return false;
@@ -437,9 +439,13 @@ class RecommendSearchCli {
437
439
  return rect.width > 2 && rect.height > 2;
438
440
  };
439
441
 
440
- const items = Array.from(
441
- doc.querySelectorAll('.ui-dropmenu-list .job-list .job-item, .job-selecter-options .job-list .job-item, .job-list .job-item')
442
- );
442
+ const items = Array.from(doc.querySelectorAll([
443
+ '.ui-dropmenu-list .job-list .job-item',
444
+ '.job-selecter-options .job-list .job-item',
445
+ '.job-selector-options .job-list .job-item',
446
+ '.dropmenu-list .job-list .job-item',
447
+ '.job-list .job-item'
448
+ ].join(',')));
443
449
  const jobs = [];
444
450
  const seen = new Set();
445
451
  for (const item of items) {
@@ -481,9 +487,13 @@ class RecommendSearchCli {
481
487
  const doc = frame.contentDocument;
482
488
  const selectors = [
483
489
  '.chat-job-select',
490
+ '.chat-job-selector',
484
491
  '.job-selecter',
492
+ '.job-selector',
485
493
  '.job-select-wrap',
486
494
  '.job-select',
495
+ '.job-select-box',
496
+ '.job-wrap',
487
497
  '.chat-job-name',
488
498
  '.top-chat-search'
489
499
  ];
@@ -564,17 +574,22 @@ class RecommendSearchCli {
564
574
  const normalizeTitle = (value) => {
565
575
  const text = normalize(value);
566
576
  if (!text) return '';
567
- const strippedRange = text
577
+ const byGap = text.split(/\\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
578
+ const strippedRange = byGap
568
579
  .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:-|~|—|至)\\s*\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)?$/u, '')
569
580
  .trim();
570
581
  const strippedSingle = strippedRange
571
582
  .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)$/u, '')
572
583
  .trim();
573
- return strippedSingle || text;
584
+ return strippedSingle || byGap;
574
585
  };
575
- const items = Array.from(
576
- doc.querySelectorAll('.ui-dropmenu-list .job-list .job-item, .job-selecter-options .job-list .job-item, .job-list .job-item')
577
- );
586
+ const items = Array.from(doc.querySelectorAll([
587
+ '.ui-dropmenu-list .job-list .job-item',
588
+ '.job-selecter-options .job-list .job-item',
589
+ '.job-selector-options .job-list .job-item',
590
+ '.dropmenu-list .job-list .job-item',
591
+ '.job-list .job-item'
592
+ ].join(',')));
578
593
  const target = items.find((item) => {
579
594
  const value = normalize(item.getAttribute('value') || item.dataset?.value || '');
580
595
  const label = normalize(item.querySelector('.label')?.textContent || item.textContent || '');