@reconcrap/boss-recruit-mcp 1.0.5 → 1.0.7

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
@@ -24,9 +24,55 @@ npx @reconcrap/boss-recruit-mcp install
24
24
 
25
25
  - 安装 Codex skill 到 `$CODEX_HOME/skills/boss-recruit-pipeline`
26
26
  - 初始化用户配置到 `$CODEX_HOME/boss-recruit-mcp/screening-config.json`
27
+ - 生成通用 MCP 配置模板到 `$CODEX_HOME/boss-recruit-mcp/agent-mcp-configs`
27
28
  - 包内自带 `boss-search-cli` 与 `boss-screen-cli` 运行时文件,无需额外目录结构
28
29
  - 不包含 `favorite-calibration.json`,首次使用前需要自行校准生成
29
30
 
31
+ ## 跨 Agent 快速接入(Cursor / Trae / Claude Code / OpenClaw)
32
+
33
+ 生成 MCP 配置模板:
34
+
35
+ ```bash
36
+ boss-recruit-mcp mcp-config --client all
37
+ ```
38
+
39
+ 默认会输出到:
40
+
41
+ ```bash
42
+ $CODEX_HOME/boss-recruit-mcp/agent-mcp-configs
43
+ ```
44
+
45
+ 包含:
46
+
47
+ - `mcp.cursor.json`
48
+ - `mcp.trae.json`
49
+ - `mcp.claudecode.json`
50
+ - `mcp.openclaw.json`
51
+ - `mcp.generic.json`
52
+
53
+ 把对应文件里的 `mcpServers` 合并到你的 AI 客户端 MCP 配置中即可。
54
+
55
+ 如果你只需要某一个客户端模板:
56
+
57
+ ```bash
58
+ boss-recruit-mcp mcp-config --client cursor
59
+ boss-recruit-mcp mcp-config --client claudecode
60
+ boss-recruit-mcp mcp-config --client trae
61
+ boss-recruit-mcp mcp-config --client openclaw
62
+ boss-recruit-mcp mcp-config --client generic
63
+ ```
64
+
65
+ 默认模板会使用:
66
+
67
+ - `command: npx`
68
+ - `args: ["-y", "@reconcrap/boss-recruit-mcp@latest", "start"]`
69
+
70
+ 如果你希望改成本地全局命令:
71
+
72
+ ```bash
73
+ boss-recruit-mcp mcp-config --client generic --command boss-recruit-mcp --args-json "[\"start\"]"
74
+ ```
75
+
30
76
  ## 准备配置
31
77
 
32
78
  1. 初始化后编辑用户配置文件:
@@ -41,6 +87,7 @@ $CODEX_HOME/boss-recruit-mcp/screening-config.json
41
87
  - `debugPort` 可选,默认 `9222`
42
88
  - `calibrationFile` 可选;不填时默认使用 `$CODEX_HOME/boss-recruit-mcp/favorite-calibration.json`
43
89
  - `outputDir` 可选;不填时默认输出到用户桌面
90
+ - 学校标签支持 `统招本科` / `双一流院校` / `985` / `211` / `qs100` / `qs500`;如果输入 `qs50`、`qs200`、`qs500` 等其他 `QS数字`,会按 `<=100 -> qs100`、`>100 -> qs500` 归一
44
91
 
45
92
  ## 运行
46
93
 
@@ -55,13 +102,13 @@ boss-recruit-mcp start
55
102
  如果当前 AI agent 无法添加新的 MCP、MCP 数量受限,或者只支持 shell/命令执行,也可以直接调用同一后端的 CLI fallback:
56
103
 
57
104
  ```bash
58
- boss-recruit-mcp run --instruction "在 Boss 上找做过推荐系统的人,城市杭州,本科,学校 985/211/QS100,目标 10 "
105
+ boss-recruit-mcp run --instruction "在 Boss 上找做过推荐系统的人,城市杭州,本科,学校 985/211/QS100,目标 10 人,过滤近14天查看过的人选"
59
106
  ```
60
107
 
61
108
  也支持通过 JSON 传确认信息与覆盖参数:
62
109
 
63
110
  ```bash
64
- boss-recruit-mcp run --instruction "在 Boss 上找做过推荐系统的人" --confirmation-json "{\"keyword_confirmed\":true,\"keyword_value\":\"推荐系统\",\"search_params_confirmed\":true}" --overrides-json "{\"city\":\"杭州\",\"degree\":\"本科\",\"schools\":[\"985\",\"211\",\"qs100\"],\"target_count\":10}"
111
+ boss-recruit-mcp run --instruction "在 Boss 上找做过推荐系统的人" --confirmation-json "{\"keyword_confirmed\":true,\"keyword_value\":\"推荐系统\",\"search_params_confirmed\":true}" --overrides-json "{\"city\":\"杭州\",\"degree\":\"本科\",\"schools\":[\"985\",\"211\",\"qs100\"],\"filter_recent_viewed\":true,\"target_count\":10}"
65
112
  ```
66
113
 
67
114
  如果命令行中放长文本不方便,改用文件:
@@ -132,6 +179,7 @@ boss-recruit-mcp doctor --port <port>
132
179
  "use_default_for_missing": false
133
180
  },
134
181
  "overrides": {
182
+ "filter_recent_viewed": true,
135
183
  "target_count": 500
136
184
  }
137
185
  }
@@ -140,10 +188,12 @@ boss-recruit-mcp doctor --port <port>
140
188
  ## 行为说明
141
189
 
142
190
  - 若缺 `city/degree/schools/keyword/target_count`,返回 `NEED_INPUT`
143
- - 若 keyword 由语义自动抽取、或搜索参数仍未被用户明确确认,返回 `NEED_CONFIRMATION`
191
+ - 若 keyword 由语义自动抽取、搜索参数仍未被用户明确确认,或用户未说明是否过滤近 14 天查看过的人选,返回 `NEED_CONFIRMATION`
144
192
  - 正式执行前应先单独做一轮参数确认,把已识别参数、待确认项、缺失项、默认值风险分开给用户确认
193
+ - 若用户没提“是否过滤近14天查看”,会在 `pending_questions` 里返回该问题,调用方应先补问再继续
145
194
  - 用户未补齐缺失参数时,只有在明确同意默认值及其质量风险后,才允许继续
146
- - 确认后自动执行:搜索 CLI -> 筛选 CLI
195
+ - `target_count` 表示“目标处理人数”,不是“目标通过人数”;状态一旦是 `COMPLETED`,就表示本轮已完成,不应因通过人数不足而自动重跑
196
+ - 确认后自动执行:搜索 CLI -> 点击搜索 -> 勾选“过滤近14天查看”(如启用) -> 筛选 CLI
147
197
  - 返回摘要:目标数、已处理、通过数、耗时、输出 CSV
148
198
  - 执行前会先做本地依赖预检查,若目录 / 入口 / 配置文件缺失则返回 `PIPELINE_PREFLIGHT_FAILED`
149
199
  - 若缺少 `favorite-calibration.json`,会返回 `CALIBRATION_REQUIRED`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recruit-mcp",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Unified MCP pipeline for boss-search-cli and boss-screen-cli",
5
5
  "keywords": [
6
6
  "boss",
@@ -12,8 +12,15 @@ npx @reconcrap/boss-recruit-mcp install
12
12
 
13
13
  - 把 skill 安装到 `$CODEX_HOME/skills/boss-recruit-pipeline`
14
14
  - 在 `$CODEX_HOME/boss-recruit-mcp/screening-config.json` 创建配置模板
15
+ - 在 `$CODEX_HOME/boss-recruit-mcp/agent-mcp-configs` 生成 Cursor/Trae/Claude Code/OpenClaw 的 MCP 模板
15
16
  - 默认把筛选结果输出到用户桌面
16
17
 
18
+ 如果你只想导出 MCP 模板,可执行:
19
+
20
+ ```powershell
21
+ boss-recruit-mcp mcp-config --client all
22
+ ```
23
+
17
24
  ## 前置要求
18
25
 
19
26
  - Chrome 需使用远程调试端口启动;推荐 `9222`,但也可以使用你已在运行的其他端口
@@ -22,6 +22,7 @@
22
22
 
23
23
  1. 先检查 MCP 是否已安装 / 可调用;若未安装,优先执行:
24
24
  - `npx @reconcrap/boss-recruit-mcp install`
25
+ - 若要给 Cursor / Trae / Claude Code / OpenClaw 快速接入,执行 `boss-recruit-mcp mcp-config --client all`,并把生成文件中的 `mcpServers` 合并到对应客户端配置
25
26
  2. 若用户还未确认 Chrome 调试端口,必须先询问:
26
27
  - 建议使用 `9222`
27
28
  - 但也允许用户明确提供一个已在使用的其他远程调试端口
@@ -78,6 +79,7 @@
78
79
  - Tool response 重点字段:
79
80
  - `status`
80
81
  - `required_confirmations`
82
+ - `pending_questions`
81
83
  - `review.extracted_search_params`
82
84
  - `review.current_search_params`
83
85
  - `review.missing_fields`
@@ -157,8 +159,9 @@
157
159
  6. 缺失项常见含义:
158
160
  - `city`: 城市,如“杭州”
159
161
  - `degree`: 学历,如“本科”“硕士及以上”
160
- - `schools`: 学校标签,如“985、211、qs100”
161
- - `target_count`: 目标筛选人数,如“10”
162
+ - `schools`: 学校标签,如“统招本科、双一流院校、985、211、qs100、qs500
163
+ - `filter_recent_viewed`: 是否过滤近 14 天内查看过的人选
164
+ - `target_count`: 目标处理人数,如“10”;表示本轮需要处理多少位候选人,不表示必须有多少人通过
162
165
  - `keyword`: 搜索关键词,如“AI infra”“推荐系统”
163
166
  7. 若返回 `NEED_INPUT`:
164
167
  - 不要只问一次就结束;
@@ -168,8 +171,10 @@
168
171
  8. 若返回 `NEED_CONFIRMATION`:
169
172
  - 询问用户是否确认 `proposed_keyword`;
170
173
  - 同时也要让用户确认其他已提取参数里是否有误;
174
+ - 若 `required_confirmations` 或 `pending_questions` 里包含 `filter_recent_viewed`,必须明确补问:是否需要过滤近 14 天查看过的人选;
171
175
  - 若确认,带 `confirmation.keyword_confirmed=true` 和 `keyword_value` 再次调用;
172
- - 若用户修改关键词,传用户给的新词作为 `keyword_value` 再次调用。
176
+ - 若用户修改关键词,传用户给的新词作为 `keyword_value` 再次调用;
177
+ - 对“是否过滤近 14 天查看”这个问题,需把用户选择写入 `overrides.filter_recent_viewed=true/false` 再次调用。
173
178
  9. 当仍有缺失参数但用户想直接开始时:
174
179
  - 先明确告知默认值及风险;
175
180
  - 必须得到用户明确确认“可以按默认值继续”后,才能继续执行。
@@ -178,7 +183,10 @@
178
183
  - 缺失参数已补齐,或用户已明确接受默认值;
179
184
  - `NEED_CONFIRMATION` 分支中的关键词也已确认。
180
185
  11. 若返回 `COMPLETED`:
181
- - 向用户返回摘要:目标数、已处理、通过数、耗时、输出文件路径。
186
+ - 向用户返回摘要:目标处理人数、已处理、通过数、耗时、输出文件路径。
187
+ - 只要状态是 `COMPLETED`,就视为本轮任务完成;
188
+ - 不要因为 `passed_count < target_count` 就自动重跑;
189
+ - `target_count` 的含义是“处理人数目标”,不是“通过人数目标”。
182
190
  12. 若返回 `FAILED`:
183
191
  - 先提炼 `error.code`、`error.message`、`diagnostics`;
184
192
  - 如果是 `PIPELINE_PREFLIGHT_FAILED`,明确指出缺失的本地目录 / 文件;
@@ -193,6 +201,9 @@
193
201
  - 当用户提到“做过 AI infra / 推荐系统 / 搜索 / 广告 / 多模态”等经历,但没有显式写“关键词”,默认允许流水线先自动抽取,再走确认分支。
194
202
  - 当用户附带筛选要求(如“必须发表过 CCF-A 区论文”“有开源项目”“带过团队”),这些要求应该保留在 `criteria` 中,不应被误当作搜索过滤条件。
195
203
  - 若参数提取结果出现明显噪声、截断、短语串接、非标准枚举值,优先视为“识别不可靠”,要求用户确认,不要为了推进流程直接采用。
204
+ - 若用户输入 `qs50`、`qs200`、`qs500` 等任意 `QS数字` 学校标签,统一按 `<=100 -> qs100`、`>100 -> qs500` 处理;不要把原始 `QS200` 再传到底层搜索命令。
205
+ - 若用户没有明确提到“是否过滤近 14 天查看过的人选”,必须在参数确认阶段主动补问,不能静默默认开启或关闭。
206
+ - 若用户说“过滤近 14 天查看”“排除最近看过的”,映射为 `filter_recent_viewed=true`;若用户说“不过滤近 14 天查看”“保留最近看过的”,映射为 `filter_recent_viewed=false`。
196
207
  - 不要把“用户没有继续回复”解释为“默认同意”;默认值只能在用户明确口头确认后使用。
197
208
  - 参数确认对话里,优先采用这种结构:
198
209
  - 已识别参数
@@ -213,7 +224,8 @@
213
224
  - `已识别参数` 只放当前看起来可信的值,例如:
214
225
  - 城市:杭州
215
226
  - 学历:本科
216
- - 学校标签:985 / 211 / QS100
227
+ - 学校标签:统招本科 / 双一流院校 / 985 / 211 / QS100 / QS500(按实际需求选择)
228
+ - 过滤近14天查看:需要 / 不需要
217
229
  - 关键词:AI infra
218
230
  - 目标人数:10
219
231
  - `待确认 / 待修正` 要明确写出“识别值 -> 疑点 -> 需要用户给出的标准值”,例如:
@@ -227,7 +239,7 @@
227
239
  - 若继续会使用哪些默认值
228
240
  - 会导致搜索范围变宽、相关性下降或结果偏差增大
229
241
  - `请用户回复` 要求用户一次性回复完整,优先使用这种收口方式:
230
- - 请直接按“城市 / 学历 / 学校标签 / 关键词 / 目标人数”补充或修正
242
+ - 请直接按“城市 / 学历 / 学校标签 / 是否过滤近14天查看 / 关键词 / 目标人数”补充或修正
231
243
  - 如果你接受默认值继续,请明确回复“确认按默认值继续”
232
244
  - 若用户补充后仍有缺失,再发第二轮确认时继续复用同一结构,只保留:
233
245
  - 已更新的参数
@@ -262,7 +274,7 @@
262
274
 
263
275
  用户:
264
276
 
265
- - “在 Boss 上找做过 AI infra 的候选人,必须发过 CCF-A 区论文,城市杭州,本科,学校 985/211/QS100,目标 10 人”
277
+ - “在 Boss 上找做过 AI infra 的候选人,必须发过 CCF-A 区论文,城市杭州,本科,学校 统招本科/双一流院校/985/211/QS100/QS500 中按需选择,目标 10 人”
266
278
 
267
279
  期望行为:
268
280
 
@@ -285,5 +297,6 @@
285
297
  - 校准缺失时,直接指导用户重新校准,不要建议复制或复用任何历史 calibration 文件。
286
298
  - 若当前 agent 受 MCP 数量限制,明确告诉用户“本轮改走 CLI fallback”,但用户体验上仍保持同一套确认和状态输出。
287
299
  - 新开 Chrome 后若检测到跳转登录,先提示用户手动登录并等待确认,再继续。
300
+ - 若返回 `COMPLETED`,不要把“通过人数不足”理解成“任务未完成”;除非用户明确要求“必须找到 N 个通过人选”,否则不要自动追加新一轮搜索。
288
301
  - 若要使用默认值,必须写明“请确认是否按默认值继续”,不能模糊带过。
289
302
  - 若运行失败,优先给用户“现在卡在哪一步 + 怎么继续”。
package/src/adapters.js CHANGED
@@ -157,7 +157,7 @@ function parseSearchCount(output) {
157
157
  function parseScreenSummary(output) {
158
158
  const processed = output.match(/已处理:\s*(\d+)\s*人/);
159
159
  const passed = output.match(/通过筛选:\s*(\d+)\s*人/);
160
- const target = output.match(/目标人数:\s*(\d+)\s*人/);
160
+ const target = output.match(/目标(?:处理)?人数:\s*(\d+)\s*人/);
161
161
  const csv = output.match(/结果已导出到:\s*(.+)/);
162
162
 
163
163
  return {
@@ -299,6 +299,10 @@ export async function runSearchCli({ workspaceRoot, searchParams }) {
299
299
  String(debugPort)
300
300
  ];
301
301
 
302
+ if (typeof searchParams.filter_recent_viewed === "boolean") {
303
+ args.push("--filter-recent-viewed", String(searchParams.filter_recent_viewed));
304
+ }
305
+
302
306
  const result = await runProcess({
303
307
  command: "node",
304
308
  args,
package/src/cli.js CHANGED
@@ -21,6 +21,10 @@ const calibrationScriptPath = path.join(
21
21
  "calibrate-favorite-position-v2.cjs"
22
22
  );
23
23
  const bossUrl = "https://www.zhipin.com/web/chat/search";
24
+ const SUPPORTED_MCP_CLIENTS = ["generic", "cursor", "trae", "claudecode", "openclaw"];
25
+ const DEFAULT_MCP_SERVER_NAME = "boss-recruit";
26
+ const DEFAULT_MCP_COMMAND = "npx";
27
+ const DEFAULT_MCP_ARGS = ["-y", "@reconcrap/boss-recruit-mcp@latest", "start"];
24
28
 
25
29
  function getCodexHome() {
26
30
  return process.env.CODEX_HOME
@@ -70,6 +74,112 @@ function parseOptions(args) {
70
74
  return options;
71
75
  }
72
76
 
77
+ function parseJsonObjectOption(value, label) {
78
+ if (value === undefined || value === null || value === "") {
79
+ return {};
80
+ }
81
+ const parsed = parseJsonOption(value, label);
82
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
83
+ throw new Error(`${label} must be a JSON object`);
84
+ }
85
+ return parsed;
86
+ }
87
+
88
+ function parseStringArrayOption(value, label) {
89
+ if (value === undefined || value === null || value === "") {
90
+ return [];
91
+ }
92
+ const parsed = parseJsonOption(value, label);
93
+ if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) {
94
+ throw new Error(`${label} must be a JSON string array`);
95
+ }
96
+ return parsed;
97
+ }
98
+
99
+ function normalizeMcpClientName(value) {
100
+ const raw = String(value || "").trim().toLowerCase();
101
+ if (!raw) return "";
102
+ if (raw === "claude-code") return "claudecode";
103
+ return raw;
104
+ }
105
+
106
+ function parseMcpClientTargets(rawValue) {
107
+ if (!rawValue) return SUPPORTED_MCP_CLIENTS.slice();
108
+ const raw = String(rawValue).trim().toLowerCase();
109
+ if (!raw || raw === "all") {
110
+ return SUPPORTED_MCP_CLIENTS.slice();
111
+ }
112
+ const candidates = raw
113
+ .split(",")
114
+ .map((item) => normalizeMcpClientName(item))
115
+ .filter(Boolean);
116
+ const unique = [...new Set(candidates)];
117
+ const invalid = unique.filter((item) => !SUPPORTED_MCP_CLIENTS.includes(item));
118
+ if (invalid.length) {
119
+ throw new Error(
120
+ `Unsupported --client value: ${invalid.join(", ")}. Supported: ${SUPPORTED_MCP_CLIENTS.join(", ")}`
121
+ );
122
+ }
123
+ return unique;
124
+ }
125
+
126
+ function getAgentConfigOutputDir(options = {}) {
127
+ if (typeof options["output-dir"] === "string" && options["output-dir"].trim()) {
128
+ return path.resolve(options["output-dir"]);
129
+ }
130
+ return path.join(getCodexHome(), "boss-recruit-mcp", "agent-mcp-configs");
131
+ }
132
+
133
+ function buildMcpLaunchConfig(options = {}) {
134
+ const command =
135
+ typeof options.command === "string" && options.command.trim()
136
+ ? options.command.trim()
137
+ : DEFAULT_MCP_COMMAND;
138
+ const args = parseStringArrayOption(options["args-json"], "args-json");
139
+ const env = parseJsonObjectOption(options["env-json"], "env-json");
140
+ const launchArgs = args.length
141
+ ? args
142
+ : command === "boss-recruit-mcp"
143
+ ? ["start"]
144
+ : DEFAULT_MCP_ARGS.slice();
145
+ const launchConfig = {
146
+ command,
147
+ args: launchArgs
148
+ };
149
+ if (Object.keys(env).length > 0) {
150
+ launchConfig.env = env;
151
+ }
152
+ return launchConfig;
153
+ }
154
+
155
+ function buildMcpConfigFileContent(options = {}) {
156
+ const serverName =
157
+ typeof options["server-name"] === "string" && options["server-name"].trim()
158
+ ? options["server-name"].trim()
159
+ : DEFAULT_MCP_SERVER_NAME;
160
+ return {
161
+ mcpServers: {
162
+ [serverName]: buildMcpLaunchConfig(options)
163
+ }
164
+ };
165
+ }
166
+
167
+ function writeMcpConfigFiles(options = {}) {
168
+ const clients = parseMcpClientTargets(options.client);
169
+ const outputDir = getAgentConfigOutputDir(options);
170
+ ensureDir(outputDir);
171
+ const files = [];
172
+
173
+ for (const client of clients) {
174
+ const filePath = path.join(outputDir, `mcp.${client}.json`);
175
+ const content = buildMcpConfigFileContent(options);
176
+ fs.writeFileSync(filePath, JSON.stringify(content, null, 2), "utf8");
177
+ files.push({ client, file: filePath });
178
+ }
179
+
180
+ return { outputDir, files };
181
+ }
182
+
73
183
  function readTextFile(filePath, label) {
74
184
  const resolved = path.resolve(String(filePath));
75
185
  try {
@@ -411,6 +521,7 @@ function printHelp() {
411
521
  console.log(" boss-recruit-mcp install Install Codex skill and initialize user config");
412
522
  console.log(" boss-recruit-mcp install-skill Install only the Codex skill");
413
523
  console.log(" boss-recruit-mcp init-config Create ~/.codex/boss-recruit-mcp/screening-config.json if missing");
524
+ console.log(" boss-recruit-mcp mcp-config Generate MCP config JSON for Cursor/Trae/Claude Code/OpenClaw");
414
525
  console.log(" boss-recruit-mcp doctor Check config, calibration, and runtime prerequisites");
415
526
  console.log(" boss-recruit-mcp calibrate Run favorite-button calibration and save favorite-calibration.json");
416
527
  console.log(" boss-recruit-mcp launch-chrome Launch Chrome in remote-debugging mode, open Boss search, and check login state");
@@ -419,6 +530,11 @@ function printHelp() {
419
530
  console.log("Run command:");
420
531
  console.log(" boss-recruit-mcp run --instruction \"找杭州本科做过推荐系统的人\" [--confirmation-json '{...}'] [--overrides-json '{...}']");
421
532
  console.log(" boss-recruit-mcp run --instruction-file request.txt [--confirmation-file confirmation.json] [--overrides-file overrides.json]");
533
+ console.log("");
534
+ console.log("MCP config command:");
535
+ console.log(" boss-recruit-mcp mcp-config --client cursor");
536
+ console.log(" boss-recruit-mcp mcp-config --client all --output-dir <dir>");
537
+ console.log(" boss-recruit-mcp mcp-config --client generic --command boss-recruit-mcp --args-json '[\"start\"]'");
422
538
  }
423
539
 
424
540
  function printPaths() {
@@ -432,23 +548,48 @@ function printPaths() {
432
548
  console.log(`desktop_output_default=${getDesktopDir()}`);
433
549
  }
434
550
 
551
+ function printMcpConfig(options = {}) {
552
+ const clients = parseMcpClientTargets(options.client);
553
+ if (clients.length === 1 && !options["output-dir"]) {
554
+ const config = buildMcpConfigFileContent(options);
555
+ printJson(config);
556
+ return;
557
+ }
558
+
559
+ const result = writeMcpConfigFiles(options);
560
+ console.log(`MCP config templates exported to: ${result.outputDir}`);
561
+ for (const item of result.files) {
562
+ console.log(`- ${item.client}: ${item.file}`);
563
+ }
564
+ console.log("");
565
+ console.log("Tip:");
566
+ console.log("1. Choose the template file matching your AI client.");
567
+ console.log("2. Merge its mcpServers block into that client's MCP config.");
568
+ }
569
+
435
570
  function installAll() {
436
571
  const skillTarget = installSkill();
437
572
  const configResult = ensureUserConfig();
573
+ const mcpTemplateResult = writeMcpConfigFiles({ client: "all" });
438
574
  console.log(`Skill installed to: ${skillTarget}`);
439
575
  if (configResult.created) {
440
576
  console.log(`Config template created at: ${configResult.path}`);
441
577
  } else {
442
578
  console.log(`Config already exists at: ${configResult.path}`);
443
579
  }
580
+ console.log(`MCP config templates exported to: ${mcpTemplateResult.outputDir}`);
581
+ for (const item of mcpTemplateResult.files) {
582
+ console.log(`- ${item.client}: ${item.file}`);
583
+ }
444
584
  console.log("");
445
585
  console.log("Next steps:");
446
586
  console.log("1. Fill in baseUrl/apiKey/model in the config file above.");
447
- console.log("2. Choose a Chrome remote-debugging port (9222 is recommended, but you can reuse an existing port).");
448
- console.log("3. Run `boss-recruit-mcp doctor --port <your-port>` to verify config, calibration, and runtime prerequisites.");
449
- console.log("4. Run `boss-recruit-mcp launch-chrome --port <your-port>`; if it reports the page redirected away from search, log in to Boss manually in that Chrome window.");
450
- console.log("5. Run `boss-recruit-mcp calibrate --port <your-port>` to generate favorite-calibration.json for this environment.");
451
- console.log("6. Run `boss-recruit-mcp start` or configure your MCP client to launch `boss-recruit-mcp`.");
587
+ console.log("2. Choose a client template from the exported MCP config files and merge it into your AI client config.");
588
+ console.log("3. Choose a Chrome remote-debugging port (9222 is recommended, but you can reuse an existing port).");
589
+ console.log("4. Run `boss-recruit-mcp doctor --port <your-port>` to verify config, calibration, and runtime prerequisites.");
590
+ console.log("5. Run `boss-recruit-mcp launch-chrome --port <your-port>`; if it reports the page redirected away from search, log in to Boss manually in that Chrome window.");
591
+ console.log("6. Run `boss-recruit-mcp calibrate --port <your-port>` to generate favorite-calibration.json for this environment.");
592
+ console.log("7. Run `boss-recruit-mcp start` or configure your MCP client to launch the command from the generated template.");
452
593
  }
453
594
 
454
595
  async function runPipelineOnce(options) {
@@ -504,6 +645,14 @@ switch (command) {
504
645
  );
505
646
  break;
506
647
  }
648
+ case "mcp-config":
649
+ try {
650
+ printMcpConfig(options);
651
+ } catch (error) {
652
+ console.error(error.message || "Failed to generate MCP config template.");
653
+ process.exitCode = 1;
654
+ }
655
+ break;
507
656
  case "doctor":
508
657
  await printDoctor(options);
509
658
  break;
package/src/index.js CHANGED
@@ -49,6 +49,7 @@ function createToolSchema() {
49
49
  properties: {
50
50
  city: { type: "string" },
51
51
  degree: { type: "string" },
52
+ filter_recent_viewed: { type: "boolean" },
52
53
  schools: {
53
54
  anyOf: [
54
55
  { type: "array", items: { type: "string" } },
package/src/parser.js CHANGED
@@ -1,7 +1,18 @@
1
1
  const SEARCH_SCHOOL_MAP = {
2
+ "统招": "统招本科",
3
+ "统招本科": "统招本科",
4
+ "统招本": "统招本科",
5
+ "全日制本科": "统招本科",
6
+ "双一流": "双一流院校",
7
+ "双一流院校": "双一流院校",
8
+ "双一流学校": "双一流院校",
2
9
  "985": "985院校",
10
+ "985院校": "985院校",
3
11
  "211": "211院校",
4
- "qs100": "QS 100"
12
+ "211院校": "211院校",
13
+ "qs": "QS 100",
14
+ "qs100": "QS 100",
15
+ "qs500": "QS 500"
5
16
  };
6
17
  const KNOWN_SCHOOL_LABELS = new Set(Object.values(SEARCH_SCHOOL_MAP));
7
18
  const DEFAULT_PARAM_VALUES = {
@@ -39,8 +50,12 @@ function normalizeSchoolLabel(value) {
39
50
  }
40
51
 
41
52
  const compact = raw.toLowerCase().replace(/\s+/g, "");
42
- if (/^qs\d+$/.test(compact)) {
43
- return SEARCH_SCHOOL_MAP.qs100;
53
+ const qsMatch = compact.match(/^qs(\d+)$/);
54
+ if (qsMatch) {
55
+ const rank = Number.parseInt(qsMatch[1], 10);
56
+ if (Number.isFinite(rank)) {
57
+ return rank > 100 ? SEARCH_SCHOOL_MAP.qs500 : SEARCH_SCHOOL_MAP.qs100;
58
+ }
44
59
  }
45
60
  return SEARCH_SCHOOL_MAP[compact] || SEARCH_SCHOOL_MAP[raw] || raw;
46
61
  }
@@ -82,6 +97,7 @@ function extractCity(text) {
82
97
  function extractDegree(text) {
83
98
  if (/(博士及以上|博士)/.test(text)) return "博士";
84
99
  if (/(硕士及以上|硕士以上)/.test(text)) return "硕士及以上";
100
+ if (/硕士/.test(text)) return "硕士";
85
101
  if (/(本科及以上|本科以上)/.test(text)) return "本科及以上";
86
102
  if (/本科/.test(text)) return "本科";
87
103
  return null;
@@ -89,12 +105,47 @@ function extractDegree(text) {
89
105
 
90
106
  function extractSchools(text) {
91
107
  const schools = [];
108
+ if (/统招(?:本科)?/.test(text)) schools.push(SEARCH_SCHOOL_MAP["统招"]);
109
+ if (/双一流(?:院校|学校)?/.test(text)) schools.push(SEARCH_SCHOOL_MAP["双一流"]);
92
110
  if (/(^|[^0-9])985([^0-9]|$)/.test(text)) schools.push(SEARCH_SCHOOL_MAP["985"]);
93
111
  if (/(^|[^0-9])211([^0-9]|$)/.test(text)) schools.push(SEARCH_SCHOOL_MAP["211"]);
94
- if (/\bqs\s*\d+\b/i.test(text)) schools.push(SEARCH_SCHOOL_MAP["qs100"]);
112
+ const qsMatches = text.matchAll(/\bqs\s*(\d+)\b/ig);
113
+ for (const match of qsMatches) {
114
+ const rank = Number.parseInt(match[1], 10);
115
+ if (Number.isFinite(rank)) {
116
+ schools.push(rank > 100 ? SEARCH_SCHOOL_MAP.qs500 : SEARCH_SCHOOL_MAP.qs100);
117
+ }
118
+ }
95
119
  return uniqueList(schools);
96
120
  }
97
121
 
122
+ function extractRecentViewedFilter(text) {
123
+ const negativePatterns = [
124
+ /(?:不|别|无需|不用|不要).{0,6}(?:过滤|排除|去掉|剔除).{0,8}(?:近?14天(?:内)?查看(?:过)?)/i,
125
+ /(?:保留|包含).{0,8}(?:近?14天(?:内)?查看(?:过)?)/i,
126
+ /(?:近?14天(?:内)?查看(?:过)?).{0,8}(?:不要|不用|无需|不需要|不必).{0,4}(?:过滤|排除|去掉|剔除)/i
127
+ ];
128
+
129
+ for (const pattern of negativePatterns) {
130
+ if (pattern.test(text)) {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ const positivePatterns = [
136
+ /(?:过滤|排除|去掉|剔除).{0,8}(?:近?14天(?:内)?查看(?:过)?)/i,
137
+ /(?:近?14天(?:内)?查看(?:过)?).{0,8}(?:过滤|排除|去掉|剔除)/i
138
+ ];
139
+
140
+ for (const pattern of positivePatterns) {
141
+ if (pattern.test(text)) {
142
+ return true;
143
+ }
144
+ }
145
+
146
+ return null;
147
+ }
148
+
98
149
  function normalizeStringOverride(value) {
99
150
  if (typeof value !== "string") return null;
100
151
  const normalized = value.trim();
@@ -176,6 +227,10 @@ function sanitizeClause(clause) {
176
227
  .trim();
177
228
  }
178
229
 
230
+ function isCountPlanningClause(clause) {
231
+ return /(?:目标(?:筛选)?(?:人数|数量)?|至少筛选|筛选\s*\d+\s*位|输出\s*\d+\s*(?:位|个|个人选|个候选人)?|最终输出\s*\d+\s*(?:位|个|个人选|个候选人)?|处理\s*\d+\s*(?:位|人)|(?:浏览|拉取|抓取).*(?:至少\s*)?\d+\s*(?:位|个|个人选|个候选人)?|最匹配.*\d+\s*(?:位|个|个人选|个候选人)?)/i.test(clause);
232
+ }
233
+
179
234
  function buildScreenCriteria(text, searchParams) {
180
235
  const clauses = text
181
236
  .split(/[,,。;;\n]/)
@@ -186,8 +241,9 @@ function buildScreenCriteria(text, searchParams) {
186
241
  if (/搜索关键词|关键词|keyword/i.test(clause)) return false;
187
242
  if (/地点|城市/.test(clause)) return false;
188
243
  if (/学历|本科|硕士|博士/.test(clause) && !/论文|项目|经验/.test(clause)) return false;
189
- if (/985|211|qs\s*\d+|院校/i.test(clause) && !/论文|经验|项目/.test(clause)) return false;
190
- if (/至少筛选|目标人数|目标数量|筛选\d+位/.test(clause)) return false;
244
+ if (/985|211|qs\s*\d+|双一流|统招(?:本科)?|院校/i.test(clause) && !/论文|经验|项目/.test(clause)) return false;
245
+ if (/近?14天(?:内)?查看(?:过)?|过滤近14天查看/.test(clause)) return false;
246
+ if (isCountPlanningClause(clause)) return false;
191
247
  return true;
192
248
  });
193
249
 
@@ -345,6 +401,7 @@ export function parseRecruitInstruction({ instruction, confirmation, overrides }
345
401
  city: extractCity(text),
346
402
  degree: extractDegree(text),
347
403
  schools: extractSchools(text),
404
+ filter_recent_viewed: extractRecentViewedFilter(text),
348
405
  keyword_explicit: extractKeywordExplicit(text),
349
406
  keyword_auto: extractKeywordAuto(text),
350
407
  target_count: extractTargetCount(text)
@@ -355,11 +412,15 @@ export function parseRecruitInstruction({ instruction, confirmation, overrides }
355
412
  const overrideDegree = normalizeStringOverride(overrides.degree);
356
413
  const overrideSchools = normalizeSchoolsOverride(overrides.schools);
357
414
  const overrideKeyword = normalizeStringOverride(overrides.keyword);
415
+ const overrideRecentViewed = typeof overrides.filter_recent_viewed === "boolean"
416
+ ? overrides.filter_recent_viewed
417
+ : null;
358
418
 
359
419
  if (overrideCity) parsed.city = overrideCity;
360
420
  if (overrideDegree) parsed.degree = overrideDegree;
361
421
  if (overrideSchools && overrideSchools.length > 0) parsed.schools = overrideSchools;
362
422
  if (overrideKeyword) parsed.keyword_override = overrideKeyword;
423
+ if (overrideRecentViewed !== null) parsed.filter_recent_viewed = overrideRecentViewed;
363
424
 
364
425
  if (Number.isFinite(overrides.target_count) && overrides.target_count > 0) {
365
426
  parsed.target_count = Number.parseInt(String(overrides.target_count), 10);
@@ -371,6 +432,7 @@ export function parseRecruitInstruction({ instruction, confirmation, overrides }
371
432
  city: parsed.city,
372
433
  degree: parsed.degree,
373
434
  schools: parsed.schools,
435
+ filter_recent_viewed: parsed.filter_recent_viewed,
374
436
  keyword: keywordResolution.keyword
375
437
  };
376
438
 
@@ -397,6 +459,19 @@ export function parseRecruitInstruction({ instruction, confirmation, overrides }
397
459
  { skipKeywordDefault }
398
460
  );
399
461
  const suspicious_fields = collectSuspiciousFields(searchParams, screenParams);
462
+ const needs_recent_viewed_filter_confirmation = searchParams.filter_recent_viewed === null;
463
+ const pending_questions = needs_recent_viewed_filter_confirmation
464
+ ? [
465
+ {
466
+ field: "filter_recent_viewed",
467
+ question: "是否需要过滤近14天查看过的人选?",
468
+ options: [
469
+ { label: "需要过滤", value: true },
470
+ { label: "不过滤", value: false }
471
+ ]
472
+ }
473
+ ]
474
+ : [];
400
475
 
401
476
  return {
402
477
  parsed,
@@ -406,8 +481,10 @@ export function parseRecruitInstruction({ instruction, confirmation, overrides }
406
481
  has_unresolved_missing_fields: missingBeforeDefaults.length > 0 && !useDefaultForMissing,
407
482
  suspicious_fields,
408
483
  needs_keyword_confirmation: keywordResolution.needsConfirmation,
484
+ needs_recent_viewed_filter_confirmation,
409
485
  needs_search_params_confirmation: confirmation?.search_params_confirmed !== true,
410
486
  proposed_keyword: keywordResolution.proposedKeyword,
487
+ pending_questions,
411
488
  default_preview: defaultPreview,
412
489
  applied_defaults: appliedDefaults,
413
490
  review: {
@@ -418,6 +495,7 @@ export function parseRecruitInstruction({ instruction, confirmation, overrides }
418
495
  missing_fields: missingBeforeDefaults,
419
496
  has_unresolved_missing_fields: missingBeforeDefaults.length > 0 && !useDefaultForMissing,
420
497
  suspicious_fields,
498
+ pending_questions,
421
499
  default_preview: defaultPreview,
422
500
  applied_defaults: appliedDefaults
423
501
  }
package/src/pipeline.js CHANGED
@@ -10,6 +10,9 @@ function buildRequiredConfirmations(parsedResult) {
10
10
  if (parsedResult.needs_keyword_confirmation) {
11
11
  confirmations.push("keyword");
12
12
  }
13
+ if (parsedResult.needs_recent_viewed_filter_confirmation) {
14
+ confirmations.push("filter_recent_viewed");
15
+ }
13
16
  if (parsedResult.has_unresolved_missing_fields) {
14
17
  confirmations.push("missing_fields_or_defaults");
15
18
  }
@@ -25,6 +28,7 @@ function buildNeedInputResponse(parsedResult) {
25
28
  required_confirmations: buildRequiredConfirmations(parsedResult),
26
29
  search_params: parsedResult.searchParams,
27
30
  screen_params: parsedResult.screenParams,
31
+ pending_questions: parsedResult.pending_questions,
28
32
  review: parsedResult.review,
29
33
  error: {
30
34
  code: "MISSING_REQUIRED_FIELDS",
@@ -44,6 +48,7 @@ function buildNeedConfirmationResponse(parsedResult) {
44
48
  keyword: parsedResult.proposed_keyword || parsedResult.searchParams.keyword
45
49
  },
46
50
  screen_params: parsedResult.screenParams,
51
+ pending_questions: parsedResult.pending_questions,
47
52
  review: parsedResult.review
48
53
  };
49
54
  }
@@ -146,7 +151,11 @@ export async function runRecruitPipeline({
146
151
  return buildNeedInputResponse(parsed);
147
152
  }
148
153
 
149
- if (parsed.needs_keyword_confirmation || parsed.needs_search_params_confirmation) {
154
+ if (
155
+ parsed.needs_keyword_confirmation
156
+ || parsed.needs_search_params_confirmation
157
+ || parsed.needs_recent_viewed_filter_confirmation
158
+ ) {
150
159
  return buildNeedConfirmationResponse(parsed);
151
160
  }
152
161
 
@@ -237,7 +246,10 @@ export async function runRecruitPipeline({
237
246
  processed_count: summary.processed_count ?? null,
238
247
  passed_count: summary.passed_count ?? null,
239
248
  duration_sec: durationSec,
240
- output_csv: summary.output_csv
241
- }
249
+ output_csv: summary.output_csv,
250
+ completion_reason: "processed_target_reached",
251
+ target_count_semantics: "target_count means processed candidate count, not passed candidate count"
252
+ },
253
+ message: "流水线已完成。target_count 表示处理人数目标,而不是通过人数目标;即使通过人数小于 target_count,只要已处理达到目标人数,也应视为本轮完成。"
242
254
  };
243
255
  }
@@ -10,6 +10,7 @@ function testNeedInput() {
10
10
 
11
11
  assert.equal(r.needs_keyword_confirmation, true);
12
12
  assert.equal(r.needs_search_params_confirmation, true);
13
+ assert.equal(r.needs_recent_viewed_filter_confirmation, true);
13
14
  assert.equal(r.proposed_keyword?.toLowerCase(), "ai infra");
14
15
  assert.deepEqual(
15
16
  r.default_preview,
@@ -37,6 +38,7 @@ function testExampleExtraction() {
37
38
  assert.equal(firstPass.searchParams.city, "杭州");
38
39
  assert.equal(firstPass.searchParams.degree, "本科及以上");
39
40
  assert.deepEqual(firstPass.searchParams.schools.sort(), ["211院校", "985院校", "QS 100"].sort());
41
+ assert.equal(firstPass.needs_recent_viewed_filter_confirmation, true);
40
42
  assert.equal(firstPass.screenParams.target_count, 500);
41
43
 
42
44
  const confirmed = parseRecruitInstruction({
@@ -46,12 +48,16 @@ function testExampleExtraction() {
46
48
  keyword_value: "ai infra",
47
49
  search_params_confirmed: true
48
50
  },
49
- overrides: null
51
+ overrides: {
52
+ filter_recent_viewed: false
53
+ }
50
54
  });
51
55
 
52
56
  assert.equal(confirmed.needs_keyword_confirmation, false);
53
57
  assert.equal(confirmed.needs_search_params_confirmation, false);
58
+ assert.equal(confirmed.needs_recent_viewed_filter_confirmation, false);
54
59
  assert.equal(confirmed.searchParams.keyword, "ai infra");
60
+ assert.equal(confirmed.searchParams.filter_recent_viewed, false);
55
61
  assert.equal(confirmed.missing_fields.length, 0);
56
62
  }
57
63
 
@@ -74,11 +80,14 @@ function testStructuredInputAndCriteriaCleanup() {
74
80
  keyword_value: "AI infra",
75
81
  search_params_confirmed: true
76
82
  },
77
- overrides: null
83
+ overrides: {
84
+ filter_recent_viewed: false
85
+ }
78
86
  });
79
87
 
80
88
  assert.equal(r.searchParams.city, "杭州");
81
89
  assert.equal(r.searchParams.degree, "本科");
90
+ assert.equal(r.searchParams.filter_recent_viewed, false);
82
91
  assert.equal(r.screenParams.target_count, 10);
83
92
  assert.equal(
84
93
  r.screenParams.criteria,
@@ -86,9 +95,9 @@ function testStructuredInputAndCriteriaCleanup() {
86
95
  );
87
96
  }
88
97
 
89
- function testQsVariantsNormalizeToQs100() {
98
+ function testSchoolAliasesAndQsBuckets() {
90
99
  const byInstruction = parseRecruitInstruction({
91
- instruction: "帮我找做过推荐系统的人,城市杭州,学历本科,学校要求 qs50、985,目标人数 20 人",
100
+ instruction: "帮我找做过推荐系统的人,城市杭州,学历本科,学校要求 qs50、qs200、985、双一流、统招,过滤掉14天内查看过的人选,目标人数 20 人",
92
101
  confirmation: {
93
102
  keyword_confirmed: true,
94
103
  keyword_value: "推荐系统",
@@ -97,7 +106,11 @@ function testQsVariantsNormalizeToQs100() {
97
106
  overrides: null
98
107
  });
99
108
 
100
- assert.deepEqual(byInstruction.searchParams.schools.sort(), ["985院校", "QS 100"].sort());
109
+ assert.deepEqual(
110
+ byInstruction.searchParams.schools.sort(),
111
+ ["985院校", "QS 100", "QS 500", "双一流院校", "统招本科"].sort()
112
+ );
113
+ assert.equal(byInstruction.searchParams.filter_recent_viewed, true);
101
114
 
102
115
  const byOverride = parseRecruitInstruction({
103
116
  instruction: "帮我找做过推荐系统的人,城市杭州,学历本科,目标人数 20 人",
@@ -107,11 +120,42 @@ function testQsVariantsNormalizeToQs100() {
107
120
  search_params_confirmed: true
108
121
  },
109
122
  overrides: {
110
- schools: ["qs50", "qs500", "211"]
123
+ schools: ["qs50", "qs500", "211", "双一流学校", "统招本"],
124
+ filter_recent_viewed: false
111
125
  }
112
126
  });
113
127
 
114
- assert.deepEqual(byOverride.searchParams.schools.sort(), ["211院校", "QS 100"].sort());
128
+ assert.deepEqual(
129
+ byOverride.searchParams.schools.sort(),
130
+ ["211院校", "QS 100", "QS 500", "双一流院校", "统招本科"].sort()
131
+ );
132
+ assert.equal(byOverride.searchParams.filter_recent_viewed, false);
133
+ }
134
+
135
+ function testPlanningClausesRemovedAndMasterDegreeParsed() {
136
+ const r = parseRecruitInstruction({
137
+ instruction:
138
+ "在 Boss 直聘上搜索候选人:城市杭州;学历硕士;学校标签:985、211、QS200;关键词:算法。请先尽可能多地浏览/拉取候选人(至少 50 个),再按硬性要求筛选:简历中明确出现 CCF-A 类会议或期刊论文。最终输出 5 个最匹配的人选。",
139
+ confirmation: {
140
+ keyword_confirmed: true,
141
+ keyword_value: "算法",
142
+ search_params_confirmed: true
143
+ },
144
+ overrides: {
145
+ schools: ["985院校", "211院校", "QS200"],
146
+ filter_recent_viewed: false,
147
+ target_count: 5
148
+ }
149
+ });
150
+
151
+ assert.equal(r.searchParams.degree, "硕士");
152
+ assert.deepEqual(r.searchParams.schools.sort(), ["985院校", "211院校", "QS 500"].sort());
153
+ assert.equal(r.searchParams.filter_recent_viewed, false);
154
+ assert.equal(r.screenParams.target_count, 5);
155
+ assert.equal(
156
+ r.screenParams.criteria,
157
+ "候选人需有算法相关经历;再按硬性要求筛选:简历中明确出现 CCF-A 类会议或期刊论文"
158
+ );
115
159
  }
116
160
 
117
161
  function testCitySanitizationAndConfirmationGate() {
@@ -124,6 +168,7 @@ function testCitySanitizationAndConfirmationGate() {
124
168
 
125
169
  assert.equal(r.searchParams.city, "杭州");
126
170
  assert.equal(r.needs_search_params_confirmation, true);
171
+ assert.equal(r.needs_recent_viewed_filter_confirmation, true);
127
172
  assert.equal(r.suspicious_fields.length, 0);
128
173
  }
129
174
 
@@ -142,6 +187,7 @@ function testDefaultsCanOnlyApplyWhenExplicitlyRequested() {
142
187
  assert.equal(r.searchParams.city, null);
143
188
  assert.equal(r.searchParams.degree, "不限");
144
189
  assert.deepEqual(r.searchParams.schools, []);
190
+ assert.equal(r.needs_recent_viewed_filter_confirmation, true);
145
191
  assert.equal(r.screenParams.target_count, 10);
146
192
  assert.deepEqual(r.applied_defaults, {
147
193
  city: "不限城市",
@@ -151,14 +197,53 @@ function testDefaultsCanOnlyApplyWhenExplicitlyRequested() {
151
197
  });
152
198
  }
153
199
 
200
+ function testRecentViewedFilterPromptAndNegativeOverride() {
201
+ const missingChoice = parseRecruitInstruction({
202
+ instruction: "帮我找杭州本科做过推荐系统的人,学校 985,目标人数 10 人",
203
+ confirmation: {
204
+ keyword_confirmed: true,
205
+ keyword_value: "推荐系统",
206
+ search_params_confirmed: true
207
+ },
208
+ overrides: null
209
+ });
210
+
211
+ assert.equal(missingChoice.needs_recent_viewed_filter_confirmation, true);
212
+ assert.deepEqual(missingChoice.pending_questions, [
213
+ {
214
+ field: "filter_recent_viewed",
215
+ question: "是否需要过滤近14天查看过的人选?",
216
+ options: [
217
+ { label: "需要过滤", value: true },
218
+ { label: "不过滤", value: false }
219
+ ]
220
+ }
221
+ ]);
222
+
223
+ const explicitNo = parseRecruitInstruction({
224
+ instruction: "帮我找杭州本科做过推荐系统的人,学校 985,不过滤近14天查看过的人选,目标人数 10 人",
225
+ confirmation: {
226
+ keyword_confirmed: true,
227
+ keyword_value: "推荐系统",
228
+ search_params_confirmed: true
229
+ },
230
+ overrides: null
231
+ });
232
+
233
+ assert.equal(explicitNo.needs_recent_viewed_filter_confirmation, false);
234
+ assert.equal(explicitNo.searchParams.filter_recent_viewed, false);
235
+ }
236
+
154
237
  function main() {
155
238
  testNeedInput();
156
239
  testExampleExtraction();
157
240
  testMissingFieldsBatch();
158
241
  testStructuredInputAndCriteriaCleanup();
159
- testQsVariantsNormalizeToQs100();
242
+ testSchoolAliasesAndQsBuckets();
243
+ testPlanningClausesRemovedAndMasterDegreeParsed();
160
244
  testCitySanitizationAndConfirmationGate();
161
245
  testDefaultsCanOnlyApplyWhenExplicitlyRequested();
246
+ testRecentViewedFilterPromptAndNegativeOverride();
162
247
  // eslint-disable-next-line no-console
163
248
  console.log("parser tests passed");
164
249
  }
@@ -1233,7 +1233,7 @@ async function main() {
1233
1233
  while (processedCount < targetCount) {
1234
1234
  console.log('');
1235
1235
  console.log('----------------------------------------');
1236
- console.log(`处理进度: ${processedCount}/${targetCount} 已通过 ${passedCandidates.length} 人 未确认收藏 ${uncertainFavoriteCount} 人`);
1236
+ console.log(`处理进度: 已处理 ${processedCount}/${targetCount}(目标处理人数) 已通过 ${passedCandidates.length} 人 未确认收藏 ${uncertainFavoriteCount} 人`);
1237
1237
 
1238
1238
  const processedKeysArray = Array.from(processedCardKeys);
1239
1239
  const findCardExpr = jsFindNextUnprocessedCard + '(' + currentCardIndex + ',' + JSON.stringify(processedKeysArray) + ')';
@@ -1606,7 +1606,7 @@ async function main() {
1606
1606
  }
1607
1607
 
1608
1608
  if (processedCount >= targetCount * 3) {
1609
- console.log('警告: 已处理超过目标数量3倍,强制结束');
1609
+ console.log('警告: 已处理超过目标处理人数3倍,强制结束');
1610
1610
  break;
1611
1611
  }
1612
1612
  }
@@ -1636,7 +1636,8 @@ async function main() {
1636
1636
  console.log('处理结果:');
1637
1637
  console.log(` 已处理: ${processedCount} 人`);
1638
1638
  console.log(` 通过筛选: ${passedCandidates.length} 人`);
1639
- console.log(` 目标人数: ${targetCount} 人`);
1639
+ console.log(` 目标处理人数: ${targetCount} 人`);
1640
+ console.log(` 完成条件: 已处理人数达到目标处理人数;不要求通过人数达到该值`);
1640
1641
  console.log('');
1641
1642
  console.log('Done.');
1642
1643
  }
@@ -149,17 +149,24 @@ export class BossSearcher {
149
149
  const safeSchool = school.replace(/'/g, "\\'");
150
150
  const result = await this.evaluate(
151
151
  '(function() {' +
152
+ ' const normalizeText = function(value) {' +
153
+ ' return String(value || "").replace(/\\s+/g, "").trim();' +
154
+ ' };' +
152
155
  ' const iframe = document.querySelector("iframe");' +
153
156
  ' if (!iframe || !iframe.contentWindow) return { error: "no iframe" };' +
154
157
  ' const doc = iframe.contentWindow.document;' +
155
- ' const schoolItems = doc.querySelectorAll(".school-item");' +
156
- ' const targetTexts = ["统招本科", "双一流院校", "211院校", "985院校", "留学生", "QS 100", "QS 500"];' +
157
- ' const targetIdx = targetTexts.indexOf("' + safeSchool + '");' +
158
- ' if (targetIdx >= 0 && schoolItems[targetIdx]) {' +
159
- ' const label = schoolItems[targetIdx].querySelector("label.checkbox");' +
158
+ ' const schoolItems = Array.from(doc.querySelectorAll(".school-item"));' +
159
+ ' const targetSchool = normalizeText("' + safeSchool + '");' +
160
+ ' const schoolItem = schoolItems.find(function(item) {' +
161
+ ' const textNode = item.querySelector(".checkbox-text");' +
162
+ ' return normalizeText(textNode ? textNode.textContent : item.textContent) === targetSchool;' +
163
+ ' });' +
164
+ ' if (schoolItem) {' +
165
+ ' const label = schoolItem.querySelector("label.checkbox");' +
160
166
  ' if (label) {' +
161
- ' label.click();' +
162
- ' const checkbox = schoolItems[targetIdx].querySelector(".checkbox-input");' +
167
+ ' const checkbox = schoolItem.querySelector(".checkbox-input");' +
168
+ ' const isChecked = label.classList.contains("checked") || Boolean(checkbox && checkbox.checked);' +
169
+ ' if (!isChecked) label.click();' +
163
170
  ' return { success: true, school: "' + safeSchool + '", checked: checkbox ? checkbox.checked : false };' +
164
171
  ' }' +
165
172
  ' }' +
@@ -618,6 +625,42 @@ export class BossSearcher {
618
625
  }
619
626
  }
620
627
 
628
+ async setRecentViewedFilter(enabled = true) {
629
+ console.log('🕒 设置近14天查看过滤: ' + (enabled ? '开启' : '关闭'));
630
+ try {
631
+ const result = await this.evaluate(
632
+ '(function() {' +
633
+ ' const iframe = document.querySelector("iframe");' +
634
+ ' if (!iframe || !iframe.contentWindow) return { error: "no iframe" };' +
635
+ ' const doc = iframe.contentWindow.document;' +
636
+ ' const normalizeText = function(value) {' +
637
+ ' return String(value || "").replace(/\\s+/g, "").trim();' +
638
+ ' };' +
639
+ ' const targetLabel = doc.querySelector(\'label.checkbox.high_search_checkbox[ka="search_change_view_resume"]\')' +
640
+ ' || Array.from(doc.querySelectorAll("label.checkbox.high_search_checkbox")).find(function(label) {' +
641
+ ' const textNode = label.querySelector(".checkbox-text");' +
642
+ ' return normalizeText(textNode ? textNode.textContent : label.textContent) === "过滤近14天查看";' +
643
+ ' });' +
644
+ ' if (!targetLabel) return { error: "recent viewed filter not found" };' +
645
+ ' const checkbox = targetLabel.querySelector(".checkbox-input");' +
646
+ ' const currentChecked = targetLabel.classList.contains("checked") || Boolean(checkbox && checkbox.checked);' +
647
+ ' const desiredChecked = ' + (enabled ? 'true' : 'false') + ';' +
648
+ ' if (currentChecked !== desiredChecked) {' +
649
+ ' targetLabel.click();' +
650
+ ' }' +
651
+ ' const finalChecked = targetLabel.classList.contains("checked") || Boolean(checkbox && checkbox.checked);' +
652
+ ' return { success: true, checked: finalChecked, changed: currentChecked !== desiredChecked };' +
653
+ '})()'
654
+ );
655
+ await this.sleep(1200);
656
+ console.log(' 近14天查看过滤: ' + (result && result.checked ? '✅ 已开启' : '⬜ 未开启'));
657
+ return result;
658
+ } catch (e) {
659
+ console.log(' 设置近14天查看过滤时出错:', e.message);
660
+ return { error: e.message };
661
+ }
662
+ }
663
+
621
664
  async getResults() {
622
665
  console.log('📋 获取搜索结果...');
623
666
  try {
@@ -54,6 +54,7 @@ class BossSearchCLI {
54
54
  degree: '不限',
55
55
  schools: [],
56
56
  city: null,
57
+ filterRecentViewed: null,
57
58
  port: 9222,
58
59
  experience: '不限',
59
60
  ageMin: null,
@@ -62,14 +63,49 @@ class BossSearchCLI {
62
63
 
63
64
  const schoolMap = {
64
65
  '211': '211院校',
66
+ '211院校': '211院校',
65
67
  '985': '985院校',
68
+ '985院校': '985院校',
69
+ 'qs': 'QS 100',
66
70
  'qs100': 'QS 100',
67
71
  'qs500': 'QS 500',
68
72
  '双一流': '双一流院校',
73
+ '双一流院校': '双一流院校',
74
+ '双一流学校': '双一流院校',
69
75
  '留学生': '留学生',
70
- '统招': '统招本科'
76
+ '统招': '统招本科',
77
+ '统招本科': '统招本科',
78
+ '统招本': '统招本科',
79
+ '全日制本科': '统招本科'
71
80
  };
72
81
 
82
+ function resolveQsSchool(normalizedSchool) {
83
+ const matched = normalizedSchool.match(/^qs(\d+)$/);
84
+ if (!matched) return null;
85
+
86
+ const rank = Number.parseInt(matched[1], 10);
87
+ if (!Number.isFinite(rank)) return null;
88
+
89
+ return rank > 100 ? 'QS 500' : 'QS 100';
90
+ }
91
+
92
+ function normalizeSchool(rawSchool) {
93
+ const raw = String(rawSchool || '').trim();
94
+ if (!raw) return raw;
95
+ const normalized = raw.toLowerCase().replace(/\s+/g, '');
96
+ const qsSchool = resolveQsSchool(normalized);
97
+ if (qsSchool) return qsSchool;
98
+ return schoolMap[normalized] || schoolMap[raw] || raw;
99
+ }
100
+
101
+ function parseBooleanArg(rawValue) {
102
+ const normalized = String(rawValue || '').trim().toLowerCase();
103
+ if (!normalized) return null;
104
+ if (['true', '1', 'yes', 'y', 'on', '是', '要', '需要', '过滤'].includes(normalized)) return true;
105
+ if (['false', '0', 'no', 'n', 'off', '否', '不要', '不需要', '不过滤'].includes(normalized)) return false;
106
+ return null;
107
+ }
108
+
73
109
  const argv = process.argv.slice(2);
74
110
  for (let i = 0; i < argv.length; i++) {
75
111
  const arg = argv[i];
@@ -78,13 +114,12 @@ class BossSearchCLI {
78
114
  } else if (arg === '--degree' || arg === '-d') {
79
115
  args.degree = argv[++i];
80
116
  } else if (arg === '--schools' || arg === '-s') {
81
- const schools = argv[++i].split(',');
82
- args.schools = schools.map(function(s) {
83
- const normalized = s.trim().toLowerCase();
84
- return schoolMap[normalized] || s;
85
- });
117
+ const schools = String(argv[++i] || '').split(/[,,]/);
118
+ args.schools = Array.from(new Set(schools.map(normalizeSchool).filter(Boolean)));
86
119
  } else if (arg === '--city' || arg === '-c') {
87
120
  args.city = argv[++i];
121
+ } else if (arg === '--filter-recent-viewed') {
122
+ args.filterRecentViewed = parseBooleanArg(argv[++i]);
88
123
  } else if (arg === '--port' || arg === '-p') {
89
124
  const port = Number.parseInt(argv[++i], 10);
90
125
  if (Number.isFinite(port) && port > 0) {
@@ -108,6 +143,9 @@ class BossSearchCLI {
108
143
  if (config.schools.length > 0) {
109
144
  console.log(' 院校要求:', config.schools.join(', '));
110
145
  }
146
+ if (typeof config.filterRecentViewed === 'boolean') {
147
+ console.log(' 过滤近14天查看:', config.filterRecentViewed ? '是' : '否');
148
+ }
111
149
  console.log('');
112
150
 
113
151
  await this.searcher.sleep(500);
@@ -127,6 +165,11 @@ class BossSearchCLI {
127
165
  }
128
166
 
129
167
  await this.searcher.clickSearch();
168
+
169
+ if (config.filterRecentViewed) {
170
+ await this.searcher.setRecentViewedFilter(true);
171
+ }
172
+
130
173
  await this.searcher.sleep(2000);
131
174
  await this.searcher.getResults();
132
175