@reconcrap/boss-recommend-mcp 1.0.0 → 1.0.2

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
@@ -27,6 +27,7 @@ MCP 工具名:`run_recommend_pipeline`
27
27
  - 页面就绪(已登录且在 recommend 页)后,会先提取岗位栏全部岗位并要求用户确认本次岗位;确认后先点击岗位,再执行 search/screen
28
28
  - 在真正开始 search/screen 前,会进行最后一轮全参数总确认(岗位 + 全部筛选参数 + criteria + target_count + post_action + max_greet_count)
29
29
  - npm 全局安装后会自动执行 install:生成 skill、导出 MCP 模板,并自动尝试写入已检测到的外部 agent MCP 配置(含 Trae / trae-cn / Cursor / Claude / OpenClaw)
30
+ - npm / npx 安装后会自动初始化 `screening-config.json` 模板(优先写入 workspace 的 `config/`,不可写时回退到用户目录)
30
31
  - `post_action` 必须在每次完整运行开始时确认一次
31
32
  - `target_count` 会在每次运行开始时询问一次(可留空,不设上限)
32
33
  - 当 `post_action=greet` 时,必须在运行开始时确认 `max_greet_count`
@@ -70,11 +71,13 @@ npx -y @reconcrap/boss-recommend-mcp@latest run --instruction "推荐页筛选98
70
71
 
71
72
  ## 配置
72
73
 
73
- 用户配置文件默认路径:
74
+ `screening-config.json` 默认写入路径(按优先级):
74
75
 
75
- ```bash
76
- ~/.boss-recommend-mcp/screening-config.json
77
- ```
76
+ 1. `BOSS_RECOMMEND_SCREEN_CONFIG`(若已设置)
77
+ 2. `<workspace>/config/screening-config.json`(本地 `npm install` / `npx ... install` 默认会优先写这里)
78
+ 3. `<workspace>/boss-recommend-mcp/config/screening-config.json`
79
+ 4. `~/.boss-recommend-mcp/screening-config.json`(当 workspace 不可写或无 workspace 时回退)
80
+ 5. 兼容旧路径:`$CODEX_HOME/boss-recommend-mcp/screening-config.json`
78
81
 
79
82
  配置路径优先级:
80
83
 
@@ -86,8 +89,8 @@ npx -y @reconcrap/boss-recommend-mcp@latest run --instruction "推荐页筛选98
86
89
 
87
90
  注意:
88
91
 
89
- - `install` 阶段不会自动创建 `screening-config.json`
90
- - 首次运行若缺配置,会由 doctor / pipeline 明确提示用户填写 `baseUrl`、`apiKey`、`model`
92
+ - `install` / `postinstall` 会自动创建 `screening-config.json` 模板(若目标路径可写)
93
+ - 首次运行时,若仍检测到默认占位词(如 `replace-with-openai-api-key`),pipeline 会返回配置目录并要求用户修改后确认“已修改完成”再继续
91
94
  - 在 `npx` 临时目录(如 `AppData\\Local\\npm-cache\\_npx\\...`)执行时,不会再把该临时目录当作 `screening-config.json` 目标路径
92
95
 
93
96
  配置样例见:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -14,16 +14,18 @@ function isGlobalInstall() {
14
14
  }
15
15
 
16
16
  function main() {
17
- if (!isGlobalInstall()) {
18
- return;
19
- }
20
-
21
17
  const cliPath = path.join(__dirname, "..", "src", "cli.js");
22
18
  if (!fs.existsSync(cliPath)) {
23
19
  return;
24
20
  }
25
21
 
26
- const result = spawnSync(process.execPath, [cliPath, "install"], {
22
+ const initCwd = String(process.env.INIT_CWD || "").trim();
23
+ const workspaceArgs = initCwd ? ["--workspace-root", path.resolve(initCwd)] : [];
24
+ const cliArgs = isGlobalInstall()
25
+ ? [cliPath, "install", ...workspaceArgs]
26
+ : [cliPath, "init-config", ...workspaceArgs];
27
+
28
+ const result = spawnSync(process.execPath, cliArgs, {
27
29
  cwd: path.join(__dirname, ".."),
28
30
  stdio: "inherit",
29
31
  windowsHide: true,
@@ -131,7 +131,8 @@ CLI fallback 的状态机与 MCP 保持一致:
131
131
  执行前先检查:
132
132
 
133
133
  - `boss-recommend-mcp` 是否已安装
134
- - `screening-config.json` 是否存在且 `baseUrl/apiKey/model` 均已由用户填写为可用值(不能是模板占位符)
134
+ - `screening-config.json` 是否存在(安装后通常会自动生成模板)
135
+ - `baseUrl/apiKey/model` 是否已由用户填写为可用值(不能是模板占位符)
135
136
  - Chrome 远程调试端口是否可连
136
137
  - 当前 Chrome 是否停留在 `https://www.zhipin.com/web/chat/recommend`
137
138
 
@@ -145,11 +146,13 @@ CLI fallback 的状态机与 MCP 保持一致:
145
146
  - 若检测到 Boss 未登录:提示用户先登录;用户登录后先 navigate 到 recommend 页面再继续
146
147
  - 自动修复后仍失败时,才提示用户介入并等待“已就绪”后重试
147
148
 
148
- ## Preflight 失败自动修复
149
-
149
+ ## Preflight 失败自动修复
150
+
150
151
  当工具返回 `status=FAILED` 且 `error.code=PIPELINE_PREFLIGHT_FAILED` 时:
151
152
 
152
153
  1. 若 `diagnostics.checks` 中 `screen_config` 失败,优先引导用户填写 `screening-config.json` 的 `baseUrl/apiKey/model`(必须让用户提供真实值,不可保留模板值)。
154
+ - 若 `required_user_action=confirm_screening_config_updated`,表示检测到默认占位词未替换。
155
+ - 这时必须先把 `guidance.config_dir` / `guidance.config_path` 告诉用户,让用户去该目录修改后明确回复“已修改完成”,再继续下一步。
153
156
  - 禁止 agent 自行代填或猜测示例值(如 `test-key` / `mock-key` / `https://example.com` / `gpt-4` 占位等)
154
157
  - 必须逐项向用户确认 `baseUrl`、`apiKey`、`model` 后再写入
155
158
  2. 优先查看 `diagnostics.auto_repair`,若有自动修复动作则先基于其结果继续执行或给出最小化补救提示。
package/src/adapters.js CHANGED
@@ -198,6 +198,7 @@ function validateScreenConfig(config) {
198
198
  if (!config || typeof config !== "object" || Array.isArray(config)) {
199
199
  return {
200
200
  ok: false,
201
+ reason: "INVALID_OR_MISSING_CONFIG",
201
202
  message: "screening-config.json 缺失或格式无效。请填写 baseUrl、apiKey、model。"
202
203
  };
203
204
  }
@@ -211,12 +212,14 @@ function validateScreenConfig(config) {
211
212
  if (missing.length > 0) {
212
213
  return {
213
214
  ok: false,
215
+ reason: "MISSING_REQUIRED_FIELDS",
214
216
  message: `screening-config.json 缺少必填字段:${missing.join(", ")}。`
215
217
  };
216
218
  }
217
219
  if (/^replace-with/i.test(apiKey) || apiKey === screenConfigTemplateDefaults.apiKey) {
218
220
  return {
219
221
  ok: false,
222
+ reason: "PLACEHOLDER_API_KEY",
220
223
  message: "screening-config.json 的 apiKey 仍是模板占位符,请填写真实 API Key。"
221
224
  };
222
225
  }
@@ -227,10 +230,11 @@ function validateScreenConfig(config) {
227
230
  ) {
228
231
  return {
229
232
  ok: false,
233
+ reason: "PLACEHOLDER_TEMPLATE_VALUES",
230
234
  message: "screening-config.json 仍是默认模板值,请填写 baseUrl、apiKey、model。"
231
235
  };
232
236
  }
233
- return { ok: true, message: "screening-config.json 校验通过。" };
237
+ return { ok: true, reason: "OK", message: "screening-config.json 校验通过。" };
234
238
  }
235
239
 
236
240
  function resolveWorkspaceDebugPort(workspaceRoot) {
@@ -696,6 +700,7 @@ export function runPipelinePreflight(workspaceRoot) {
696
700
  key: "screen_config",
697
701
  ok: screenConfigValidation.ok,
698
702
  path: screenConfigPath,
703
+ reason: screenConfigValidation.reason || null,
699
704
  message: screenConfigValidation.ok ? "screening-config.json 可用" : screenConfigValidation.message
700
705
  }
701
706
  ];
package/src/cli.js CHANGED
@@ -1016,7 +1016,7 @@ function printHelp() {
1016
1016
  console.log(" boss-recommend-mcp Start the MCP server");
1017
1017
  console.log(" boss-recommend-mcp start Start the MCP server");
1018
1018
  console.log(" boss-recommend-mcp run Run the recommend pipeline once via CLI and print JSON");
1019
- console.log(" boss-recommend-mcp install Install skill and MCP config templates (supports --agent trae-cn/cursor/...)");
1019
+ console.log(" boss-recommend-mcp install Install skill/MCP templates and auto-init screening-config.json (supports --agent trae-cn/cursor/...)");
1020
1020
  console.log(" boss-recommend-mcp install-skill Install only the Codex skill");
1021
1021
  console.log(" boss-recommend-mcp init-config Create screening-config.json if missing (prefer workspace config/, fallback ~/.boss-recommend-mcp)");
1022
1022
  console.log(" boss-recommend-mcp config set Write baseUrl/apiKey/model (prefer workspace config/, fallback ~/.boss-recommend-mcp)");
@@ -1048,11 +1048,17 @@ function printMcpConfig(options = {}) {
1048
1048
 
1049
1049
  function installAll(options = {}) {
1050
1050
  const skillTarget = installSkill();
1051
+ const configResult = ensureUserConfig(options);
1051
1052
  const mcpTemplateResult = writeMcpConfigFiles({ client: "all" });
1052
1053
  const externalMcpResult = installExternalMcpConfigs(options);
1053
1054
  const externalSkillResult = mirrorSkillToExternalDirs(options);
1054
1055
  console.log(`Skill installed to: ${skillTarget}`);
1055
- console.log("screening-config.json 不会在 install 阶段自动创建。首次运行请按 doctor / agent 提示填写 baseUrl、apiKey、model。");
1056
+ console.log(
1057
+ configResult.created
1058
+ ? `screening-config.json created: ${configResult.path}`
1059
+ : `screening-config.json already exists: ${configResult.path}`
1060
+ );
1061
+ console.log(`请在该目录修改 baseUrl/apiKey/model 并替换占位词后再运行:${path.dirname(configResult.path)}`);
1056
1062
  console.log(`MCP config templates exported to: ${mcpTemplateResult.outputDir}`);
1057
1063
  for (const item of mcpTemplateResult.files) {
1058
1064
  console.log(`- ${item.client}: ${item.file}`);
package/src/pipeline.js CHANGED
@@ -1,4 +1,5 @@
1
- import { parseRecommendInstruction } from "./parser.js";
1
+ import path from "node:path";
2
+ import { parseRecommendInstruction } from "./parser.js";
2
3
  import {
3
4
  attemptPipelineAutoRepair,
4
5
  ensureBossRecommendPageReady,
@@ -441,6 +442,14 @@ export async function runRecommendPipeline(
441
442
 
442
443
  if (!preflight.ok) {
443
444
  const screenConfigCheck = preflight.checks?.find((item) => item?.key === "screen_config" && item?.ok === false);
445
+ const screenConfigPath = String(screenConfigCheck?.path || "");
446
+ const screenConfigDir = screenConfigPath ? path.dirname(screenConfigPath) : null;
447
+ const screenConfigReason = String(screenConfigCheck?.reason || "").trim().toUpperCase();
448
+ const screenConfigMessage = String(screenConfigCheck?.message || "");
449
+ const screenConfigHasPlaceholder = (
450
+ screenConfigReason.includes("PLACEHOLDER")
451
+ || /占位符|默认模板值|replace-with-openai-api-key/i.test(screenConfigMessage)
452
+ );
444
453
  const recovery = buildPreflightRecovery(preflight.checks, workspaceRoot);
445
454
  return buildFailedResponse(
446
455
  "PIPELINE_PREFLIGHT_FAILED",
@@ -448,17 +457,30 @@ export async function runRecommendPipeline(
448
457
  {
449
458
  search_params: parsed.searchParams,
450
459
  screen_params: parsed.screenParams,
451
- required_user_action: screenConfigCheck ? "provide_screening_config" : undefined,
460
+ required_user_action: screenConfigCheck
461
+ ? (screenConfigHasPlaceholder ? "confirm_screening_config_updated" : "provide_screening_config")
462
+ : undefined,
452
463
  guidance: screenConfigCheck
453
464
  ? {
454
465
  config_path: screenConfigCheck.path,
466
+ config_dir: screenConfigDir,
455
467
  agent_prompt: [
456
- "请先让用户填写 screening-config.json 的以下字段:",
457
- "1) baseUrl",
458
- "2) apiKey",
459
- "3) model",
460
- `配置文件路径:${screenConfigCheck.path}`,
461
- "注意:不要使用模板占位符(例如 replace-with-openai-api-key),也不要由 agent 自行猜测或代填示例值。必须向用户逐项确认真实可用值后再重试。"
468
+ ...(screenConfigHasPlaceholder
469
+ ? [
470
+ "检测到 screening-config.json 仍包含默认占位词,当前禁止继续执行。",
471
+ `请引导用户在以下目录修改配置文件:${screenConfigDir || "(unknown)"}`,
472
+ `配置文件路径:${screenConfigCheck.path}`,
473
+ "必须替换为真实可用值:baseUrl、apiKey、model(不要保留任何模板占位符)。",
474
+ "修改完成后,必须先让用户明确回复“已修改完成”,再继续下一步。"
475
+ ]
476
+ : [
477
+ "请先让用户填写 screening-config.json 的以下字段:",
478
+ "1) baseUrl",
479
+ "2) apiKey",
480
+ "3) model",
481
+ `配置文件路径:${screenConfigCheck.path}`,
482
+ "注意:不要使用模板占位符(例如 replace-with-openai-api-key),也不要由 agent 自行猜测或代填示例值。必须向用户逐项确认真实可用值后再重试。"
483
+ ])
462
484
  ].join("\n")
463
485
  }
464
486
  : undefined,
@@ -543,6 +543,7 @@ async function testScreenConfigFailureShouldRequireUserProvidedConfig() {
543
543
  key: "screen_config",
544
544
  ok: false,
545
545
  path: "C:/Users/test/.boss-recommend-mcp/screening-config.json",
546
+ reason: "MISSING_REQUIRED_FIELDS",
546
547
  message: "screening-config.json 缺失或格式无效"
547
548
  }
548
549
  ]
@@ -558,6 +559,7 @@ async function testScreenConfigFailureShouldRequireUserProvidedConfig() {
558
559
  key: "screen_config",
559
560
  ok: false,
560
561
  path: "C:/Users/test/.boss-recommend-mcp/screening-config.json",
562
+ reason: "MISSING_REQUIRED_FIELDS",
561
563
  message: "screening-config.json 缺失或格式无效"
562
564
  }
563
565
  ]
@@ -573,11 +575,65 @@ async function testScreenConfigFailureShouldRequireUserProvidedConfig() {
573
575
  assert.equal(result.error.code, "PIPELINE_PREFLIGHT_FAILED");
574
576
  assert.equal(result.required_user_action, "provide_screening_config");
575
577
  assert.equal(result.guidance.config_path.includes("screening-config.json"), true);
578
+ assert.equal(result.guidance.config_dir.includes(".boss-recommend-mcp"), true);
576
579
  assert.equal(result.guidance.agent_prompt.includes("baseUrl"), true);
577
580
  assert.equal(result.guidance.agent_prompt.includes("apiKey"), true);
578
581
  assert.equal(result.guidance.agent_prompt.includes("model"), true);
579
582
  }
580
583
 
584
+ async function testScreenConfigPlaceholderShouldRequireUserConfirmationAfterUpdate() {
585
+ const result = await runRecommendPipeline(
586
+ {
587
+ workspaceRoot: process.cwd(),
588
+ instruction: "test",
589
+ confirmation: {},
590
+ overrides: {}
591
+ },
592
+ {
593
+ parseRecommendInstruction: () => createParsed(),
594
+ runPipelinePreflight: () => ({
595
+ ok: false,
596
+ debug_port: 9222,
597
+ checks: [
598
+ {
599
+ key: "screen_config",
600
+ ok: false,
601
+ path: "C:/Users/test/workspace/config/screening-config.json",
602
+ reason: "PLACEHOLDER_API_KEY",
603
+ message: "screening-config.json 的 apiKey 仍是模板占位符,请填写真实 API Key。"
604
+ }
605
+ ]
606
+ }),
607
+ attemptPipelineAutoRepair: () => ({
608
+ attempted: false,
609
+ actions: [],
610
+ preflight: {
611
+ ok: false,
612
+ debug_port: 9222,
613
+ checks: [
614
+ {
615
+ key: "screen_config",
616
+ ok: false,
617
+ path: "C:/Users/test/workspace/config/screening-config.json",
618
+ reason: "PLACEHOLDER_API_KEY",
619
+ message: "screening-config.json 的 apiKey 仍是模板占位符,请填写真实 API Key。"
620
+ }
621
+ ]
622
+ }
623
+ }),
624
+ ensureBossRecommendPageReady: async () => ({ ok: true, state: "RECOMMEND_READY", page_state: {} }),
625
+ runRecommendSearchCli: async () => ({ ok: true, summary: {} }),
626
+ runRecommendScreenCli: async () => ({ ok: true, summary: {} })
627
+ }
628
+ );
629
+
630
+ assert.equal(result.status, "FAILED");
631
+ assert.equal(result.error.code, "PIPELINE_PREFLIGHT_FAILED");
632
+ assert.equal(result.required_user_action, "confirm_screening_config_updated");
633
+ assert.equal(result.guidance.config_dir, "C:/Users/test/workspace/config");
634
+ assert.equal(result.guidance.agent_prompt.includes("已修改完成"), true);
635
+ }
636
+
581
637
  async function testScreenConfigRecoveryStepShouldBeFirst() {
582
638
  const result = await runRecommendPipeline(
583
639
  {
@@ -627,6 +683,7 @@ async function main() {
627
683
  await testPreflightAutoRepairCanUnblockPipeline();
628
684
  await testPreflightAutoRepairStillFailShouldExposeDiagnostics();
629
685
  await testScreenConfigFailureShouldRequireUserProvidedConfig();
686
+ await testScreenConfigPlaceholderShouldRequireUserConfirmationAfterUpdate();
630
687
  await testScreenConfigRecoveryStepShouldBeFirst();
631
688
  console.log("pipeline tests passed");
632
689
  }
@@ -76,9 +76,15 @@ function normalizeDegree(value) {
76
76
  return DEGREE_OPTIONS.includes(normalized) ? normalized : null;
77
77
  }
78
78
 
79
- function sortDegreeSelection(values) {
80
- return Array.from(new Set(values.filter(Boolean))).sort((left, right) => DEGREE_ORDER.indexOf(left) - DEGREE_ORDER.indexOf(right));
81
- }
79
+ function sortDegreeSelection(values) {
80
+ return Array.from(new Set(values.filter(Boolean))).sort((left, right) => DEGREE_ORDER.indexOf(left) - DEGREE_ORDER.indexOf(right));
81
+ }
82
+
83
+ function selectionEquals(left, right) {
84
+ if (!Array.isArray(left) || !Array.isArray(right)) return false;
85
+ if (left.length !== right.length) return false;
86
+ return left.every((value, index) => value === right[index]);
87
+ }
82
88
 
83
89
  function expandDegreeAtOrAbove(value) {
84
90
  const normalized = normalizeDegree(value);
@@ -1115,35 +1121,53 @@ class RecommendSearchCli {
1115
1121
  })()`);
1116
1122
  }
1117
1123
 
1118
- async selectSchoolFilter(labels) {
1119
- const ensure = await this.ensureGroupReady("school");
1120
- if (!ensure?.ok) {
1121
- throw new Error(ensure?.error || "GROUP_NOT_FOUND");
1122
- }
1123
-
1124
- const targetLabels = Array.isArray(labels) && labels.length > 0 ? labels : ["不限"];
1125
- if (targetLabels.includes("不限")) {
1126
- await this.selectOption("school", "不限");
1127
- return;
1128
- }
1129
-
1130
- const currentState = await this.getSchoolFilterState();
1131
- if (!currentState?.ok) {
1132
- throw new Error(currentState?.error || "SCHOOL_FILTER_STATE_FAILED");
1133
- }
1134
- const current = sortSchoolSelection(currentState.activeLabels || []);
1135
- const desired = sortSchoolSelection(targetLabels);
1136
- const same =
1137
- !currentState.defaultActive
1138
- && current.length === desired.length
1139
- && current.every((value, index) => value === desired[index]);
1140
- if (same) return;
1141
-
1142
- await this.selectOption("school", "不限");
1143
- for (const label of desired) {
1144
- await this.selectOption("school", label);
1145
- }
1146
- }
1124
+ async selectSchoolFilter(labels) {
1125
+ const ensure = await this.ensureGroupReady("school");
1126
+ if (!ensure?.ok) {
1127
+ throw new Error(ensure?.error || "GROUP_NOT_FOUND");
1128
+ }
1129
+
1130
+ const targetLabels = Array.isArray(labels) && labels.length > 0 ? labels : ["不限"];
1131
+ const desired = sortSchoolSelection(targetLabels);
1132
+ const expectDefaultOnly = desired.includes("不限");
1133
+ let lastState = null;
1134
+
1135
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1136
+ const state = await this.getSchoolFilterState();
1137
+ if (!state?.ok) {
1138
+ throw new Error(state?.error || "SCHOOL_FILTER_STATE_FAILED");
1139
+ }
1140
+ lastState = state;
1141
+ const current = sortSchoolSelection(state.activeLabels || []);
1142
+ const matched = expectDefaultOnly
1143
+ ? Boolean(state.defaultActive)
1144
+ : (!state.defaultActive && selectionEquals(current, desired));
1145
+ if (matched) {
1146
+ return;
1147
+ }
1148
+
1149
+ if (expectDefaultOnly) {
1150
+ await this.selectOption("school", "不限");
1151
+ await sleep(humanDelay(180, 50));
1152
+ continue;
1153
+ }
1154
+
1155
+ if (state.defaultActive) {
1156
+ const clearDefault = await this.clickOptionBySelector("school", "不限");
1157
+ if (!clearDefault?.ok) {
1158
+ throw new Error(clearDefault?.error || "SCHOOL_DEFAULT_CLEAR_FAILED");
1159
+ }
1160
+ await sleep(humanDelay(180, 50));
1161
+ }
1162
+ for (const label of desired) {
1163
+ await this.selectOption("school", label);
1164
+ await sleep(humanDelay(120, 40));
1165
+ }
1166
+ await sleep(humanDelay(180, 50));
1167
+ }
1168
+
1169
+ throw new Error(`SCHOOL_FILTER_VERIFY_FAILED:${JSON.stringify(lastState || {})}`);
1170
+ }
1147
1171
 
1148
1172
  async getDegreeFilterState() {
1149
1173
  return this.evaluate(`(() => {
@@ -1183,35 +1207,53 @@ class RecommendSearchCli {
1183
1207
  })()`);
1184
1208
  }
1185
1209
 
1186
- async selectDegreeFilter(labels) {
1187
- const ensure = await this.ensureGroupReady("degree");
1188
- if (!ensure?.ok) {
1189
- throw new Error(ensure?.error || "GROUP_NOT_FOUND");
1190
- }
1191
-
1192
- const targetLabels = Array.isArray(labels) && labels.length > 0 ? labels : ["不限"];
1193
- if (targetLabels.includes("不限")) {
1194
- await this.selectOption("degree", "不限");
1195
- return;
1196
- }
1197
-
1198
- const currentState = await this.getDegreeFilterState();
1199
- if (!currentState?.ok) {
1200
- throw new Error(currentState?.error || "DEGREE_FILTER_STATE_FAILED");
1201
- }
1202
- const current = sortDegreeSelection(currentState.activeLabels || []);
1203
- const desired = sortDegreeSelection(targetLabels);
1204
- const same =
1205
- !currentState.defaultActive
1206
- && current.length === desired.length
1207
- && current.every((value, index) => value === desired[index]);
1208
- if (same) return;
1209
-
1210
- await this.selectOption("degree", "不限");
1211
- for (const label of desired) {
1212
- await this.selectOption("degree", label);
1213
- }
1214
- }
1210
+ async selectDegreeFilter(labels) {
1211
+ const ensure = await this.ensureGroupReady("degree");
1212
+ if (!ensure?.ok) {
1213
+ throw new Error(ensure?.error || "GROUP_NOT_FOUND");
1214
+ }
1215
+
1216
+ const targetLabels = Array.isArray(labels) && labels.length > 0 ? labels : ["不限"];
1217
+ const desired = sortDegreeSelection(targetLabels);
1218
+ const expectDefaultOnly = desired.includes("不限");
1219
+ let lastState = null;
1220
+
1221
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1222
+ const state = await this.getDegreeFilterState();
1223
+ if (!state?.ok) {
1224
+ throw new Error(state?.error || "DEGREE_FILTER_STATE_FAILED");
1225
+ }
1226
+ lastState = state;
1227
+ const current = sortDegreeSelection(state.activeLabels || []);
1228
+ const matched = expectDefaultOnly
1229
+ ? Boolean(state.defaultActive)
1230
+ : (!state.defaultActive && selectionEquals(current, desired));
1231
+ if (matched) {
1232
+ return;
1233
+ }
1234
+
1235
+ if (expectDefaultOnly) {
1236
+ await this.selectOption("degree", "不限");
1237
+ await sleep(humanDelay(180, 50));
1238
+ continue;
1239
+ }
1240
+
1241
+ if (state.defaultActive) {
1242
+ const clearDefault = await this.clickOptionBySelector("degree", "不限");
1243
+ if (!clearDefault?.ok) {
1244
+ throw new Error(clearDefault?.error || "DEGREE_DEFAULT_CLEAR_FAILED");
1245
+ }
1246
+ await sleep(humanDelay(180, 50));
1247
+ }
1248
+ for (const label of desired) {
1249
+ await this.selectOption("degree", label);
1250
+ await sleep(humanDelay(120, 40));
1251
+ }
1252
+ await sleep(humanDelay(180, 50));
1253
+ }
1254
+
1255
+ throw new Error(`DEGREE_FILTER_VERIFY_FAILED:${JSON.stringify(lastState || {})}`);
1256
+ }
1215
1257
 
1216
1258
  async countCandidates() {
1217
1259
  return this.evaluate(`(() => {
@@ -1306,20 +1348,26 @@ class RecommendSearchCli {
1306
1348
  await this.openFilterPanel();
1307
1349
  await this.selectSchoolFilter(this.args.schoolTag);
1308
1350
  await this.selectOption("gender", this.args.gender);
1309
- await this.selectOption("recentNotView", this.args.recentNotView);
1310
- await this.selectDegreeFilter(this.args.degree);
1311
- await this.closeFilterPanel();
1312
- const candidateInfo = await this.waitForCandidateCountStable();
1313
-
1314
- console.log(JSON.stringify({
1315
- status: "COMPLETED",
1316
- result: {
1351
+ await this.selectOption("recentNotView", this.args.recentNotView);
1352
+ await this.selectDegreeFilter(this.args.degree);
1353
+ const verifiedSchoolState = await this.getSchoolFilterState();
1354
+ const verifiedDegreeState = await this.getDegreeFilterState();
1355
+ await this.closeFilterPanel();
1356
+ const candidateInfo = await this.waitForCandidateCountStable();
1357
+
1358
+ console.log(JSON.stringify({
1359
+ status: "COMPLETED",
1360
+ result: {
1317
1361
  applied_filters: {
1318
1362
  school_tag: this.args.schoolTag,
1319
1363
  degree: this.args.degree,
1320
1364
  gender: this.args.gender,
1321
1365
  recent_not_view: this.args.recentNotView
1322
1366
  },
1367
+ verified_filters: {
1368
+ school: verifiedSchoolState,
1369
+ degree: verifiedDegreeState
1370
+ },
1323
1371
  selected_job: selectedJob,
1324
1372
  candidate_count: candidateInfo?.candidateCount ?? null,
1325
1373
  page_state: {