@reconcrap/boss-recruit-mcp 1.0.16 → 1.0.18

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
@@ -234,6 +234,7 @@ boss-recruit-mcp doctor --port <port>
234
234
  - 确认后自动执行:搜索 CLI -> 点击搜索 -> 勾选“过滤近14天查看”(按轮次规则) -> 筛选 CLI
235
235
  - 返回摘要:目标数、已处理、通过数、耗时、输出 CSV
236
236
  - 执行前会先做本地依赖预检查,若目录 / 入口 / 配置文件缺失则返回 `PIPELINE_PREFLIGHT_FAILED`
237
+ - preflight 会检查 Node.js 与 npm 依赖(`chrome-remote-interface` / `ws`);失败时会返回 `diagnostics.recovery`(含有序修复步骤与 `agent_prompt`)
237
238
  - 若缺少 `favorite-calibration.json`,会返回 `CALIBRATION_REQUIRED`
238
239
  - 若某轮搜索返回可筛选候选人但筛选 `processed_count` 非法或为 0,会先导出当前累计 CSV,再返回 `SCREEN_NO_PROGRESS`
239
240
  - 若当前运行环境不允许启动子进程,会返回更明确的权限错误码而不是笼统失败
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recruit-mcp",
3
- "version": "1.0.16",
3
+ "version": "1.0.18",
4
4
  "description": "Unified MCP pipeline for boss-search-cli and boss-screen-cli",
5
5
  "keywords": [
6
6
  "boss",
@@ -289,6 +289,8 @@ description: "Use when users ask to recruit candidates on Boss Zhipin via the bo
289
289
  - 不要把底层 stderr 原样大段贴给用户,只提炼关键错误和下一步。
290
290
  - 如果失败原因明显是环境问题,要直接说明不是用户输入有误。
291
291
  - 如果工具已经返回 `diagnostics.checks`,优先基于这些检查项生成排障建议。
292
+ - 如果返回 `PIPELINE_PREFLIGHT_FAILED` 且含 `diagnostics.recovery.agent_prompt`,优先把该提示词直接交给 AI agent 自动修复依赖。
293
+ - 自动修复依赖必须严格串行:先 Node.js,再 npm 依赖;若涉及 Python / Pillow,先 Python 再 Pillow。
292
294
  - 如果工具返回 `output_csv`,在摘要里给出路径,避免重复解释内部流程。
293
295
  - 如果端口还没确认,必须先问用户“是否使用推荐的 `9222`,还是你已经有别的远程调试端口”,不能直接把 `9222` 当成已确认值。
294
296
  - 用户确认端口后,先执行一次 `boss-recruit-mcp set-port --port <port>`,让后续 `doctor / launch-chrome / calibrate / run` 自动复用同一端口。
package/src/adapters.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { spawn } from "node:child_process";
4
+ import { spawn, spawnSync } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import CDP from "chrome-remote-interface";
7
7
  const currentFilePath = fileURLToPath(import.meta.url);
@@ -156,6 +156,117 @@ function runProcess({ command, args, cwd, timeoutMs }) {
156
156
  });
157
157
  }
158
158
 
159
+ function runProcessSync({ command, args, cwd }) {
160
+ try {
161
+ const result = spawnSync(command, args, {
162
+ cwd,
163
+ windowsHide: true,
164
+ shell: false,
165
+ env: process.env,
166
+ encoding: "utf8"
167
+ });
168
+ const stdout = String(result.stdout || "").trim();
169
+ const stderr = String(result.stderr || "").trim();
170
+ return {
171
+ ok: result.status === 0,
172
+ status: result.status,
173
+ stdout,
174
+ stderr,
175
+ output: [stdout, stderr].filter(Boolean).join("\n").trim(),
176
+ error_code: result.error?.code || null,
177
+ error_message: result.error?.message || null
178
+ };
179
+ } catch (error) {
180
+ return {
181
+ ok: false,
182
+ status: -1,
183
+ stdout: "",
184
+ stderr: "",
185
+ output: "",
186
+ error_code: error.code || "SPAWN_FAILED",
187
+ error_message: error.message || String(error)
188
+ };
189
+ }
190
+ }
191
+
192
+ function parseMajorVersion(raw) {
193
+ const match = String(raw || "").match(/v?(\d+)(?:\.\d+){0,2}/);
194
+ if (!match) return null;
195
+ const major = Number.parseInt(match[1], 10);
196
+ return Number.isFinite(major) ? major : null;
197
+ }
198
+
199
+ function buildNodeCommandCheck() {
200
+ const probe = runProcessSync({
201
+ command: "node",
202
+ args: ["--version"]
203
+ });
204
+ const major = parseMajorVersion(probe.output);
205
+ const versionOk = Number.isInteger(major) && major >= 18;
206
+ return {
207
+ key: "node_cli",
208
+ ok: probe.ok && versionOk,
209
+ path: "node --version",
210
+ message: probe.ok
211
+ ? (versionOk
212
+ ? `Node 命令可用 (${probe.output || "unknown version"})`
213
+ : `Node 版本过低 (${probe.output || "unknown version"}),要求 >= 18`)
214
+ : `未找到 node 命令,请先安装 Node.js >= 18。${probe.error_message ? ` (${probe.error_message})` : ""}`
215
+ };
216
+ }
217
+
218
+ function buildNodePackageCheck({ key, moduleName, cwd, missingMessage }) {
219
+ if (!cwd || !pathExists(cwd)) {
220
+ return {
221
+ key,
222
+ ok: false,
223
+ path: moduleName,
224
+ module: moduleName,
225
+ install_cwd: null,
226
+ message: missingMessage
227
+ };
228
+ }
229
+ const probe = runProcessSync({
230
+ command: "node",
231
+ args: ["-e", `require.resolve(${JSON.stringify(moduleName)});`],
232
+ cwd
233
+ });
234
+ return {
235
+ key,
236
+ ok: probe.ok,
237
+ path: moduleName,
238
+ module: moduleName,
239
+ install_cwd: cwd,
240
+ message: probe.ok
241
+ ? `${moduleName} npm 依赖可用`
242
+ : `缺少 npm 依赖 ${moduleName},请在 boss-recruit-mcp 目录执行 npm install。`
243
+ };
244
+ }
245
+
246
+ function buildRuntimeDependencyChecks({ searchDir, screenDir }) {
247
+ return [
248
+ buildNodeCommandCheck(),
249
+ buildNodePackageCheck({
250
+ key: "npm_dep_chrome_remote_interface_search",
251
+ moduleName: "chrome-remote-interface",
252
+ cwd: searchDir,
253
+ missingMessage: "无法校验 chrome-remote-interface:boss-search-cli 目录不存在。"
254
+ }),
255
+ buildNodePackageCheck({
256
+ key: "npm_dep_chrome_remote_interface_screen",
257
+ moduleName: "chrome-remote-interface",
258
+ cwd: screenDir,
259
+ missingMessage: "无法校验 chrome-remote-interface:boss-screen-cli 目录不存在。"
260
+ }),
261
+ buildNodePackageCheck({
262
+ key: "npm_dep_ws",
263
+ moduleName: "ws",
264
+ cwd: screenDir,
265
+ missingMessage: "无法校验 ws:boss-screen-cli 目录不存在。"
266
+ })
267
+ ];
268
+ }
269
+
159
270
  function parseSearchCount(output) {
160
271
  const m = output.match(/找到\s*(\d+)\s*个候选人/);
161
272
  if (!m) return null;
@@ -351,13 +462,18 @@ export function runPipelinePreflight(workspaceRoot) {
351
462
  message: "favorite-calibration.json 不存在(可选,仅在旧页面回退点击时需要)"
352
463
  }
353
464
  ];
465
+ checks.push(...buildRuntimeDependencyChecks({ searchDir, screenDir }));
354
466
 
355
467
  const requiredCheckKeys = new Set([
356
468
  "search_cli_dir",
357
469
  "search_cli_entry",
358
470
  "screen_cli_dir",
359
471
  "screen_cli_entry",
360
- "screen_config"
472
+ "screen_config",
473
+ "node_cli",
474
+ "npm_dep_chrome_remote_interface_search",
475
+ "npm_dep_chrome_remote_interface_screen",
476
+ "npm_dep_ws"
361
477
  ]);
362
478
 
363
479
  return {
package/src/index.js CHANGED
@@ -8,11 +8,18 @@ const require = createRequire(import.meta.url);
8
8
  const { version: SERVER_VERSION } = require("../package.json");
9
9
  const TOOL_NAME = "run_recruit_pipeline";
10
10
  const SERVER_NAME = "boss-recruit-mcp";
11
+ const FRAMING_UNKNOWN = "unknown";
12
+ const FRAMING_HEADER = "header";
13
+ const FRAMING_LINE = "line";
11
14
 
12
- function writeMessage(message) {
15
+ function writeMessage(message, framing = FRAMING_LINE) {
13
16
  const body = JSON.stringify(message);
14
- const header = `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n`;
15
- process.stdout.write(header + body);
17
+ if (framing === FRAMING_HEADER) {
18
+ const header = `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n`;
19
+ process.stdout.write(header + body);
20
+ return;
21
+ }
22
+ process.stdout.write(`${body}\n`);
16
23
  }
17
24
 
18
25
  function createJsonRpcError(id, code, message) {
@@ -178,42 +185,91 @@ export function startServer() {
178
185
  const mcpRoot = path.resolve(path.dirname(thisFile), "..");
179
186
  const workspaceRoot = envRoot ? path.resolve(envRoot) : path.resolve(mcpRoot, "..");
180
187
  let buffer = Buffer.alloc(0);
188
+ let framing = FRAMING_UNKNOWN;
181
189
 
182
190
  process.stdin.on("data", async (chunk) => {
183
191
  buffer = Buffer.concat([buffer, chunk]);
192
+ if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
193
+ buffer = buffer.slice(3);
194
+ }
184
195
 
185
196
  while (true) {
186
- const headerEnd = buffer.indexOf("\r\n\r\n");
187
- if (headerEnd === -1) break;
197
+ const crlfHeaderEnd = buffer.indexOf("\r\n\r\n");
198
+ const lfHeaderEnd = buffer.indexOf("\n\n");
199
+ const crHeaderEnd = buffer.indexOf("\r\r");
200
+ let headerEnd = -1;
201
+ let headerSeparatorLength = 0;
202
+ if (
203
+ crlfHeaderEnd !== -1
204
+ && (lfHeaderEnd === -1 || crlfHeaderEnd < lfHeaderEnd)
205
+ && (crHeaderEnd === -1 || crlfHeaderEnd < crHeaderEnd)
206
+ ) {
207
+ headerEnd = crlfHeaderEnd;
208
+ headerSeparatorLength = 4;
209
+ } else if (lfHeaderEnd !== -1 && (crHeaderEnd === -1 || lfHeaderEnd < crHeaderEnd)) {
210
+ headerEnd = lfHeaderEnd;
211
+ headerSeparatorLength = 2;
212
+ } else if (crHeaderEnd !== -1) {
213
+ headerEnd = crHeaderEnd;
214
+ headerSeparatorLength = 2;
215
+ }
216
+ if (headerEnd !== -1) {
217
+ const headerText = buffer.slice(0, headerEnd).toString("utf8");
218
+ const contentLengthLine = headerText
219
+ .split(/\r\n|\n|\r/)
220
+ .find((line) => line.toLowerCase().startsWith("content-length:"));
188
221
 
189
- const headerText = buffer.slice(0, headerEnd).toString("utf8");
190
- const contentLengthLine = headerText
191
- .split("\r\n")
192
- .find((line) => line.toLowerCase().startsWith("content-length:"));
222
+ if (!contentLengthLine) {
223
+ buffer = buffer.slice(headerEnd + headerSeparatorLength);
224
+ continue;
225
+ }
226
+
227
+ const contentLength = Number.parseInt(contentLengthLine.split(":")[1].trim(), 10);
228
+ if (!Number.isFinite(contentLength) || contentLength < 0) {
229
+ buffer = buffer.slice(headerEnd + headerSeparatorLength);
230
+ continue;
231
+ }
193
232
 
194
- if (!contentLengthLine) {
195
- buffer = buffer.slice(headerEnd + 4);
233
+ const bodyStart = headerEnd + headerSeparatorLength;
234
+ const bodyEnd = bodyStart + contentLength;
235
+ if (buffer.length < bodyEnd) break;
236
+
237
+ const body = buffer.slice(bodyStart, bodyEnd).toString("utf8");
238
+ buffer = buffer.slice(bodyEnd);
239
+ framing = FRAMING_HEADER;
240
+
241
+ let message;
242
+ try {
243
+ message = JSON.parse(body);
244
+ } catch {
245
+ writeMessage(createJsonRpcError(null, -32700, "Parse error"), FRAMING_HEADER);
246
+ continue;
247
+ }
248
+
249
+ const response = await handleRequest(message, workspaceRoot);
250
+ if (response) writeMessage(response, framing);
196
251
  continue;
197
252
  }
198
253
 
199
- const contentLength = Number.parseInt(contentLengthLine.split(":")[1].trim(), 10);
200
- const bodyStart = headerEnd + 4;
201
- const bodyEnd = bodyStart + contentLength;
202
- if (buffer.length < bodyEnd) break;
203
-
204
- const body = buffer.slice(bodyStart, bodyEnd).toString("utf8");
205
- buffer = buffer.slice(bodyEnd);
254
+ const newlineIndex = buffer.indexOf("\n");
255
+ if (newlineIndex === -1) break;
256
+ const rawLine = buffer.slice(0, newlineIndex).toString("utf8").replace(/\r$/, "");
257
+ if (/^\s*content-length:/i.test(rawLine)) break;
258
+ buffer = buffer.slice(newlineIndex + 1);
259
+ const line = rawLine.trim();
260
+ if (!line) continue;
261
+ framing = FRAMING_LINE;
206
262
 
207
263
  let message;
208
264
  try {
209
- message = JSON.parse(body);
265
+ message = JSON.parse(line);
210
266
  } catch {
211
- writeMessage(createJsonRpcError(null, -32700, "Parse error"));
267
+ writeMessage(createJsonRpcError(null, -32700, "Parse error"), FRAMING_LINE);
212
268
  continue;
213
269
  }
214
270
 
215
271
  const response = await handleRequest(message, workspaceRoot);
216
- if (response) writeMessage(response);
272
+ if (response) writeMessage(response, framing);
217
273
  }
218
274
  });
219
275
  }
package/src/pipeline.js CHANGED
@@ -8,6 +8,126 @@ import {
8
8
  runScreenCli
9
9
  } from "./adapters.js";
10
10
 
11
+ function dedupe(values = []) {
12
+ return [...new Set(values.filter(Boolean))];
13
+ }
14
+
15
+ function failedCheckSet(checks = []) {
16
+ const failed = checks
17
+ .filter((item) => item && item.ok === false && typeof item.key === "string")
18
+ .map((item) => item.key);
19
+ return new Set(failed);
20
+ }
21
+
22
+ function collectNpmInstallDirs(checks = [], workspaceRoot) {
23
+ const npmCheckKeys = new Set([
24
+ "npm_dep_chrome_remote_interface_search",
25
+ "npm_dep_chrome_remote_interface_screen",
26
+ "npm_dep_ws"
27
+ ]);
28
+ const dirs = checks
29
+ .filter((item) => item && item.ok === false && npmCheckKeys.has(item.key))
30
+ .map((item) => item.install_cwd)
31
+ .filter((value) => typeof value === "string" && value.trim());
32
+ if (dirs.length > 0) return dedupe(dirs);
33
+ return workspaceRoot ? [workspaceRoot] : [];
34
+ }
35
+
36
+ function buildNpmInstallCommands(checks = [], workspaceRoot) {
37
+ const dirs = collectNpmInstallDirs(checks, workspaceRoot);
38
+ const commands = [];
39
+ for (const dir of dirs) {
40
+ const escaped = String(dir).replace(/'/g, "''");
41
+ commands.push(`Set-Location '${escaped}'`);
42
+ commands.push("npm install");
43
+ }
44
+ return commands;
45
+ }
46
+
47
+ function formatCommandBlock(commands = []) {
48
+ return commands.map((command) => `- ${command}`).join("\n");
49
+ }
50
+
51
+ function buildPreflightRecovery(checks = [], workspaceRoot) {
52
+ const failed = failedCheckSet(checks);
53
+ if (failed.size === 0) return null;
54
+
55
+ const needNode = failed.has("node_cli");
56
+ const needNpm = (
57
+ failed.has("npm_dep_chrome_remote_interface_search")
58
+ || failed.has("npm_dep_chrome_remote_interface_screen")
59
+ || failed.has("npm_dep_ws")
60
+ );
61
+ const needPython = failed.has("python_cli");
62
+ const needPillow = failed.has("python_pillow");
63
+
64
+ const ordered_steps = [];
65
+ if (needNode) {
66
+ ordered_steps.push({
67
+ id: "install_nodejs",
68
+ title: "安装 Node.js >= 18",
69
+ blocked_by: [],
70
+ commands: [
71
+ "winget install OpenJS.NodeJS.LTS",
72
+ "node --version"
73
+ ]
74
+ });
75
+ }
76
+ if (needNpm) {
77
+ ordered_steps.push({
78
+ id: "install_npm_dependencies",
79
+ title: "安装 npm 依赖(chrome-remote-interface / ws)",
80
+ blocked_by: needNode ? ["install_nodejs"] : [],
81
+ commands: buildNpmInstallCommands(checks, workspaceRoot)
82
+ });
83
+ }
84
+ if (needPython) {
85
+ ordered_steps.push({
86
+ id: "install_python",
87
+ title: "安装 Python(确保 python 命令可用)",
88
+ blocked_by: [],
89
+ commands: [
90
+ "winget install Python.Python.3.12",
91
+ "python --version"
92
+ ]
93
+ });
94
+ }
95
+ if (needPillow) {
96
+ ordered_steps.push({
97
+ id: "install_pillow",
98
+ title: "安装 Pillow",
99
+ blocked_by: needPython ? ["install_python"] : [],
100
+ commands: [
101
+ "python -m pip install --upgrade pip",
102
+ "python -m pip install pillow"
103
+ ]
104
+ });
105
+ }
106
+
107
+ const promptLines = [
108
+ "你是环境修复 agent。请先读取 diagnostics.checks,再严格按下面顺序执行,不要并行跳步:",
109
+ "1) node_cli 失败 -> 先安装 Node.js,未成功前禁止执行 npm install。",
110
+ "2) npm_dep_* 失败 -> 再安装 npm 依赖(chrome-remote-interface / ws)。",
111
+ "3) python_cli 失败 -> 安装 Python 并确保 python 命令可用。",
112
+ "4) python_pillow 失败 -> 最后安装 Pillow。",
113
+ "每一步完成后都重新运行 doctor,直到所有检查通过后再重试 run_recruit_pipeline。"
114
+ ];
115
+
116
+ if (needNpm) {
117
+ const npmCommands = buildNpmInstallCommands(checks, workspaceRoot);
118
+ if (npmCommands.length > 0) {
119
+ promptLines.push("建议执行的 npm 命令:");
120
+ promptLines.push(formatCommandBlock(npmCommands));
121
+ }
122
+ }
123
+
124
+ return {
125
+ failed_check_keys: [...failed],
126
+ ordered_steps,
127
+ agent_prompt: promptLines.join("\n")
128
+ };
129
+ }
130
+
11
131
  function buildRequiredConfirmations(parsedResult) {
12
132
  const confirmations = [];
13
133
 
@@ -280,6 +400,7 @@ export async function runRecruitPipeline({
280
400
 
281
401
  const preflight = runPreflight(workspaceRoot);
282
402
  if (!preflight.ok) {
403
+ const recovery = buildPreflightRecovery(preflight.checks, workspaceRoot);
283
404
  return buildFailedResponse(
284
405
  "PIPELINE_PREFLIGHT_FAILED",
285
406
  "招聘流水线运行前检查失败,请先修复缺失的本地依赖或配置文件。",
@@ -289,7 +410,8 @@ export async function runRecruitPipeline({
289
410
  diagnostics: {
290
411
  checks: preflight.checks,
291
412
  debug_port: preflight.debug_port,
292
- calibration_path: preflight.calibration_path
413
+ calibration_path: preflight.calibration_path,
414
+ recovery
293
415
  }
294
416
  }
295
417
  );
@@ -420,6 +420,45 @@ async function testNeedInputGateStillWorks() {
420
420
  assert.equal(preflightCalled, false);
421
421
  }
422
422
 
423
+ async function testPreflightRecoveryPlanOrder() {
424
+ const tempDir = createTempDir("preflight-recovery");
425
+ const parsed = createParsed();
426
+ const deps = {
427
+ parseRecruitInstruction: () => parsed,
428
+ runPipelinePreflight: () => ({
429
+ ok: false,
430
+ debug_port: 9222,
431
+ calibration_path: null,
432
+ checks: [
433
+ { key: "node_cli", ok: false },
434
+ { key: "npm_dep_ws", ok: false, install_cwd: "C:/workspace/boss-recruit-mcp" }
435
+ ]
436
+ }),
437
+ ensureBossSearchPageReady: async () => ({ ok: true, state: "SEARCH_READY", debug_port: 9222, page_state: {} }),
438
+ runSearchCli: async () => buildSearchOk(0),
439
+ runScreenCli: async () => buildScreenOk({ processedCount: 0, passedCount: 0, outputCsv: null })
440
+ };
441
+
442
+ const result = await runRecruitPipeline(
443
+ {
444
+ workspaceRoot: tempDir,
445
+ instruction: "test",
446
+ confirmation: {},
447
+ overrides: {}
448
+ },
449
+ deps
450
+ );
451
+
452
+ assert.equal(result.status, "FAILED");
453
+ assert.equal(result.error.code, "PIPELINE_PREFLIGHT_FAILED");
454
+ assert.deepEqual(
455
+ result.diagnostics.recovery.ordered_steps.map((item) => item.id),
456
+ ["install_nodejs", "install_npm_dependencies"]
457
+ );
458
+ assert.deepEqual(result.diagnostics.recovery.ordered_steps[1].blocked_by, ["install_nodejs"]);
459
+ assert.equal(result.diagnostics.recovery.agent_prompt.includes("不要并行跳步"), true);
460
+ }
461
+
423
462
  async function main() {
424
463
  await testSearchExhaustedCompletesAndMergesCsv();
425
464
  await testSearchExhaustedByTipNodataEvenWhenCandidateCountPositive();
@@ -427,6 +466,7 @@ async function main() {
427
466
  await testScreenNoProgressWithZeroProcessedExportsBeforeFail();
428
467
  await testScreenNoProgressWithInvalidProcessedAndNoCsv();
429
468
  await testNeedInputGateStillWorks();
469
+ await testPreflightRecoveryPlanOrder();
430
470
  // eslint-disable-next-line no-console
431
471
  console.log("pipeline tests passed");
432
472
  }