@reconcrap/boss-recruit-mcp 1.0.14 → 1.0.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
@@ -218,11 +218,14 @@ boss-recruit-mcp doctor --port <port>
218
218
  - 正式执行前应先单独做一轮参数确认,把已识别参数、待确认项、缺失项、默认值风险分开给用户确认
219
219
  - 若用户没提“是否过滤近14天查看”,会在 `pending_questions` 里返回该问题,调用方应先补问再继续
220
220
  - 用户未补齐缺失参数时,只有在明确同意默认值及其质量风险后,才允许继续
221
- - `target_count` 表示“目标处理人数”,不是“目标通过人数”;状态一旦是 `COMPLETED`,就表示本轮已完成,不应因通过人数不足而自动重跑
222
- - 确认后自动执行:搜索 CLI -> 点击搜索 -> 勾选“过滤近14天查看”(如启用) -> 筛选 CLI
221
+ - `target_count` 表示“目标处理人数”,不是“目标通过人数”;会按“累计处理人数”自动多轮执行,直到达到目标处理人数,或新一轮搜索返回 0 个可筛选人选
222
+ - 若搜索页出现 `i.tip-nodata`(即使仍能看到候选人卡片),也会判定候选池已耗尽并结束多轮流程
223
+ - 第 2 轮及后续轮次会强制 `filter_recent_viewed=true`(即过滤近 14 天查看过的人选),不受首轮开关影响
224
+ - 确认后自动执行:搜索 CLI -> 点击搜索 -> 勾选“过滤近14天查看”(按轮次规则) -> 筛选 CLI
223
225
  - 返回摘要:目标数、已处理、通过数、耗时、输出 CSV
224
226
  - 执行前会先做本地依赖预检查,若目录 / 入口 / 配置文件缺失则返回 `PIPELINE_PREFLIGHT_FAILED`
225
227
  - 若缺少 `favorite-calibration.json`,会返回 `CALIBRATION_REQUIRED`
228
+ - 若某轮搜索返回可筛选候选人但筛选 `processed_count` 非法或为 0,会先导出当前累计 CSV,再返回 `SCREEN_NO_PROGRESS`
226
229
  - 若当前运行环境不允许启动子进程,会返回更明确的权限错误码而不是笼统失败
227
230
  - 配置文件查找顺序:`BOSS_RECRUIT_SCREEN_CONFIG` > 工作区 `boss-recruit-mcp/config/screening-config.json` > 用户目录 `$CODEX_HOME/boss-recruit-mcp/screening-config.json` > 包内示例配置
228
231
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recruit-mcp",
3
- "version": "1.0.14",
3
+ "version": "1.0.15",
4
4
  "description": "Unified MCP pipeline for boss-search-cli and boss-screen-cli",
5
5
  "keywords": [
6
6
  "boss",
@@ -20,6 +20,8 @@
20
20
  "install:local": "node src/cli.js install",
21
21
  "postinstall": "node scripts/postinstall.cjs",
22
22
  "test:parser": "node src/test-parser.js",
23
+ "test:pipeline": "node src/test-pipeline.js",
24
+ "test:protocol:win": "powershell -ExecutionPolicy Bypass -File scripts/verify-multi-round-protocol.ps1 -Mode unit",
23
25
  "test:regression:win": "powershell -ExecutionPolicy Bypass -File scripts/regression.ps1 -Mode quick"
24
26
  },
25
27
  "files": [
package/src/adapters.js CHANGED
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawn } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
+ import CDP from "chrome-remote-interface";
6
7
  const currentFilePath = fileURLToPath(import.meta.url);
7
8
  const packagedMcpDir = path.resolve(path.dirname(currentFilePath), "..");
8
9
  const bossSearchUrl = "https://www.zhipin.com/web/chat/search";
@@ -161,6 +162,82 @@ function parseSearchCount(output) {
161
162
  return Number.parseInt(m[1], 10);
162
163
  }
163
164
 
165
+ async function detectSearchNoDataTip(debugPort) {
166
+ let client = null;
167
+ try {
168
+ const targets = await CDP.List({ port: debugPort });
169
+ const target = targets.find(
170
+ (item) => typeof item?.url === "string" && item.url.includes("/web/chat/search")
171
+ ) || targets.find((item) => item?.type === "page");
172
+ if (!target) {
173
+ return {
174
+ ok: false,
175
+ exhausted: null,
176
+ error: "No page target found on Chrome DevTools"
177
+ };
178
+ }
179
+
180
+ client = await CDP({
181
+ port: debugPort,
182
+ target
183
+ });
184
+ const { Runtime } = client;
185
+ await Runtime.enable();
186
+
187
+ const expression = `(function () {
188
+ try {
189
+ var rootDoc = document;
190
+ var iframe = document.querySelector("iframe");
191
+ if (iframe && iframe.contentWindow && iframe.contentWindow.document) {
192
+ rootDoc = iframe.contentWindow.document;
193
+ }
194
+ var tip = rootDoc.querySelector("i.tip-nodata");
195
+ return {
196
+ exhausted: Boolean(tip),
197
+ selector: "i.tip-nodata"
198
+ };
199
+ } catch (err) {
200
+ return {
201
+ exhausted: false,
202
+ selector: "i.tip-nodata",
203
+ error: String(err && err.message ? err.message : err)
204
+ };
205
+ }
206
+ })()`;
207
+ const evaluated = await Runtime.evaluate({
208
+ expression,
209
+ returnByValue: true,
210
+ awaitPromise: true
211
+ });
212
+ if (evaluated.exceptionDetails) {
213
+ return {
214
+ ok: false,
215
+ exhausted: null,
216
+ error: evaluated.exceptionDetails.exception?.description || "Runtime.evaluate failed"
217
+ };
218
+ }
219
+
220
+ const value = evaluated.result?.value || {};
221
+ return {
222
+ ok: true,
223
+ exhausted: value.exhausted === true,
224
+ details: value
225
+ };
226
+ } catch (error) {
227
+ return {
228
+ ok: false,
229
+ exhausted: null,
230
+ error: error.message
231
+ };
232
+ } finally {
233
+ if (client) {
234
+ try {
235
+ await client.close();
236
+ } catch {}
237
+ }
238
+ }
239
+ }
240
+
164
241
  function parseScreenSummary(output) {
165
242
  const processed = output.match(/已处理:\s*(\d+)\s*人/);
166
243
  const passed = output.match(/通过筛选:\s*(\d+)\s*人/);
@@ -569,11 +646,18 @@ export async function runSearchCli({ workspaceRoot, searchParams }) {
569
646
 
570
647
  const combined = `${result.stdout}\n${result.stderr}`;
571
648
  const candidateCount = parseSearchCount(combined);
649
+ const tipCheck = result.code === 0
650
+ ? await detectSearchNoDataTip(debugPort)
651
+ : { ok: false, exhausted: null, error: null };
572
652
 
573
653
  return {
574
654
  ok: result.code === 0,
575
655
  exit_code: result.code,
576
656
  candidate_count: candidateCount,
657
+ no_data_tip_present: tipCheck.ok ? tipCheck.exhausted : null,
658
+ no_data_tip_check: tipCheck.ok
659
+ ? { ok: true, details: tipCheck.details || null }
660
+ : { ok: false, error: tipCheck.error || null },
577
661
  stdout: result.stdout,
578
662
  stderr: result.stderr,
579
663
  error_code: result.error_code || null
package/src/pipeline.js CHANGED
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { parseRecruitInstruction } from "./parser.js";
2
4
  import {
3
5
  ensureBossSearchPageReady,
@@ -73,6 +75,92 @@ function buildFailedResponse(code, message, extra = {}) {
73
75
  };
74
76
  }
75
77
 
78
+ function normalizeCsvPath(csvPath) {
79
+ if (typeof csvPath !== "string") return null;
80
+ const trimmed = csvPath.trim();
81
+ if (!trimmed) return null;
82
+ return path.resolve(trimmed);
83
+ }
84
+
85
+ function isReadableFile(filePath) {
86
+ try {
87
+ if (!filePath || !fs.existsSync(filePath)) return false;
88
+ const stat = fs.statSync(filePath);
89
+ return stat.isFile();
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ function collectReadableCsvPaths(csvPaths) {
96
+ const unique = new Set();
97
+ for (const rawPath of csvPaths || []) {
98
+ const normalized = normalizeCsvPath(rawPath);
99
+ if (!normalized || unique.has(normalized)) continue;
100
+ if (isReadableFile(normalized)) {
101
+ unique.add(normalized);
102
+ }
103
+ }
104
+ return Array.from(unique);
105
+ }
106
+
107
+ function parseCsvContent(content) {
108
+ const normalized = String(content || "").replace(/^\uFEFF/, "");
109
+ const lines = normalized.split(/\r?\n/).filter((line) => line.trim() !== "");
110
+ if (lines.length === 0) return null;
111
+ return {
112
+ header: lines[0],
113
+ rows: lines.slice(1)
114
+ };
115
+ }
116
+
117
+ function mergeRoundCsvFiles(csvPaths) {
118
+ const readablePaths = collectReadableCsvPaths(csvPaths);
119
+ if (readablePaths.length === 0) return null;
120
+
121
+ let header = null;
122
+ const rows = [];
123
+
124
+ for (const csvPath of readablePaths) {
125
+ let content;
126
+ try {
127
+ content = fs.readFileSync(csvPath, "utf8");
128
+ } catch {
129
+ continue;
130
+ }
131
+ const parsed = parseCsvContent(content);
132
+ if (!parsed) continue;
133
+ if (!header) {
134
+ header = parsed.header;
135
+ }
136
+ rows.push(...parsed.rows);
137
+ }
138
+
139
+ if (!header) return null;
140
+
141
+ const outputDir = path.dirname(readablePaths[0]);
142
+ const outputPath = path.join(outputDir, `筛选结果_合并_${Date.now()}.csv`);
143
+ const mergedContent = `\uFEFF${header}\n${rows.join("\n")}${rows.length > 0 ? "\n" : ""}`;
144
+ fs.writeFileSync(outputPath, mergedContent, "utf8");
145
+ return outputPath;
146
+ }
147
+
148
+ function buildProgressDiagnostics({
149
+ preflight,
150
+ totalProcessedCount,
151
+ totalPassedCount,
152
+ roundCount,
153
+ extra = {}
154
+ }) {
155
+ return {
156
+ debug_port: preflight.debug_port,
157
+ total_processed_count: totalProcessedCount,
158
+ total_passed_count: totalPassedCount,
159
+ round_count: roundCount,
160
+ ...extra
161
+ };
162
+ }
163
+
76
164
  function classifySearchFailure(searchResult) {
77
165
  const stderr = searchResult.stderr || "";
78
166
  const errorCode = searchResult.error_code || "";
@@ -149,14 +237,29 @@ function classifyScreenFailure(screenResult) {
149
237
  };
150
238
  }
151
239
 
240
+ const defaultDependencies = {
241
+ parseRecruitInstruction,
242
+ ensureBossSearchPageReady,
243
+ runPipelinePreflight,
244
+ runSearchCli,
245
+ runScreenCli
246
+ };
247
+
152
248
  export async function runRecruitPipeline({
153
249
  workspaceRoot,
154
250
  instruction,
155
251
  confirmation,
156
252
  overrides
157
- }) {
253
+ }, dependencies = defaultDependencies) {
254
+ const {
255
+ parseRecruitInstruction: parseInstruction,
256
+ ensureBossSearchPageReady: ensureSearchPageReady,
257
+ runPipelinePreflight: runPreflight,
258
+ runSearchCli: searchCli,
259
+ runScreenCli: screenCli
260
+ } = dependencies;
158
261
  const startedAt = Date.now();
159
- const parsed = parseRecruitInstruction({
262
+ const parsed = parseInstruction({
160
263
  instruction,
161
264
  confirmation,
162
265
  overrides
@@ -175,7 +278,7 @@ export async function runRecruitPipeline({
175
278
  return buildNeedConfirmationResponse(parsed);
176
279
  }
177
280
 
178
- const preflight = runPipelinePreflight(workspaceRoot);
281
+ const preflight = runPreflight(workspaceRoot);
179
282
  if (!preflight.ok) {
180
283
  return buildFailedResponse(
181
284
  "PIPELINE_PREFLIGHT_FAILED",
@@ -192,127 +295,241 @@ export async function runRecruitPipeline({
192
295
  );
193
296
  }
194
297
 
195
- const pageCheck = await ensureBossSearchPageReady(workspaceRoot, {
196
- port: preflight.debug_port
197
- });
198
- if (!pageCheck.ok) {
199
- if (
200
- pageCheck.state === "LOGIN_REQUIRED"
201
- || pageCheck.state === "LOGIN_REQUIRED_AFTER_REDIRECT"
202
- ) {
203
- return buildFailedResponse(
204
- "BOSS_LOGIN_REQUIRED",
205
- "Boss 页面未稳定停留在 search 页面,疑似未登录或登录态失效。请先在当前 Chrome 窗口手动登录 Boss,登录完成后再继续搜索和筛选。",
206
- {
207
- search_params: parsed.searchParams,
208
- screen_params: parsed.screenParams,
209
- diagnostics: {
210
- debug_port: pageCheck.debug_port,
211
- page_state: pageCheck.page_state
212
- }
213
- }
214
- );
215
- }
216
-
298
+ const initialTargetCount = parsed.screenParams.target_count;
299
+ if (!Number.isInteger(initialTargetCount) || initialTargetCount <= 0) {
217
300
  return buildFailedResponse(
218
- "BOSS_SEARCH_PAGE_NOT_READY",
219
- "无法确认 Boss search 页面已就绪。请先确保 Chrome 调试端口可连,并且页面能稳定停留在 https://www.zhipin.com/web/chat/search。",
301
+ "INVALID_TARGET_COUNT",
302
+ "目标处理人数无效,请确认 target_count 为正整数后重试。",
220
303
  {
221
304
  search_params: parsed.searchParams,
222
- screen_params: parsed.screenParams,
223
- diagnostics: {
224
- debug_port: pageCheck.debug_port,
225
- page_state: pageCheck.page_state
226
- }
305
+ screen_params: parsed.screenParams
227
306
  }
228
307
  );
229
308
  }
230
309
 
231
- const searchResult = await runSearchCli({
232
- workspaceRoot,
233
- searchParams: parsed.searchParams
234
- });
310
+ let totalProcessedCount = 0;
311
+ let totalPassedCount = 0;
312
+ let roundCount = 0;
313
+ const roundOutputCsvPaths = [];
235
314
 
236
- if (!searchResult.ok) {
237
- const failure = classifySearchFailure(searchResult);
238
- return buildFailedResponse(
239
- failure.code,
240
- failure.message,
241
- {
242
- search_params: parsed.searchParams,
243
- screen_params: parsed.screenParams,
244
- diagnostics: {
245
- exit_code: searchResult.exit_code,
246
- error_code: searchResult.error_code,
247
- stderr: searchResult.stderr?.slice(0, 1200)
248
- }
315
+ while (totalProcessedCount < initialTargetCount) {
316
+ roundCount += 1;
317
+
318
+ const remainingTargetCount = Math.max(0, initialTargetCount - totalProcessedCount);
319
+ const roundSearchParams = {
320
+ ...parsed.searchParams,
321
+ filter_recent_viewed: roundCount >= 2 ? true : parsed.searchParams.filter_recent_viewed
322
+ };
323
+ const roundScreenParams = {
324
+ ...parsed.screenParams,
325
+ target_count: remainingTargetCount
326
+ };
327
+
328
+ const pageCheck = await ensureSearchPageReady(workspaceRoot, {
329
+ port: preflight.debug_port
330
+ });
331
+ if (!pageCheck.ok) {
332
+ if (
333
+ pageCheck.state === "LOGIN_REQUIRED"
334
+ || pageCheck.state === "LOGIN_REQUIRED_AFTER_REDIRECT"
335
+ ) {
336
+ return buildFailedResponse(
337
+ "BOSS_LOGIN_REQUIRED",
338
+ "Boss 页面未稳定停留在 search 页面,疑似未登录或登录态失效。请先在当前 Chrome 窗口手动登录 Boss,登录完成后再继续搜索和筛选。",
339
+ {
340
+ search_params: roundSearchParams,
341
+ screen_params: roundScreenParams,
342
+ diagnostics: buildProgressDiagnostics({
343
+ preflight,
344
+ totalProcessedCount,
345
+ totalPassedCount,
346
+ roundCount,
347
+ extra: {
348
+ page_state: pageCheck.page_state
349
+ }
350
+ })
351
+ }
352
+ );
249
353
  }
250
- );
251
- }
252
354
 
253
- if (!Number.isInteger(searchResult.candidate_count)) {
254
- return buildFailedResponse(
255
- "SEARCH_RESULT_UNVERIFIED",
256
- "搜索流程未能确认候选人数量,说明搜索步骤可能没有真正完成,已停止后续筛选。",
257
- {
258
- search_params: parsed.searchParams,
259
- screen_params: parsed.screenParams,
260
- diagnostics: {
261
- candidate_count: searchResult.candidate_count,
262
- stdout: searchResult.stdout?.slice(-1200),
263
- stderr: searchResult.stderr?.slice(-1200)
355
+ return buildFailedResponse(
356
+ "BOSS_SEARCH_PAGE_NOT_READY",
357
+ "无法确认 Boss search 页面已就绪。请先确保 Chrome 调试端口可连,并且页面能稳定停留在 https://www.zhipin.com/web/chat/search。",
358
+ {
359
+ search_params: roundSearchParams,
360
+ screen_params: roundScreenParams,
361
+ diagnostics: buildProgressDiagnostics({
362
+ preflight,
363
+ totalProcessedCount,
364
+ totalPassedCount,
365
+ roundCount,
366
+ extra: {
367
+ page_state: pageCheck.page_state
368
+ }
369
+ })
264
370
  }
265
- }
266
- );
267
- }
371
+ );
372
+ }
268
373
 
269
- if (searchResult.candidate_count === 0) {
270
- return buildFailedResponse(
271
- "SEARCH_EMPTY_RESULT",
272
- "搜索结果为空,已停止后续筛选。请调整搜索条件后重试。",
273
- {
374
+ const searchResult = await searchCli({
375
+ workspaceRoot,
376
+ searchParams: roundSearchParams
377
+ });
378
+
379
+ if (!searchResult.ok) {
380
+ const failure = classifySearchFailure(searchResult);
381
+ return buildFailedResponse(
382
+ failure.code,
383
+ failure.message,
384
+ {
385
+ search_params: roundSearchParams,
386
+ screen_params: roundScreenParams,
387
+ diagnostics: buildProgressDiagnostics({
388
+ preflight,
389
+ totalProcessedCount,
390
+ totalPassedCount,
391
+ roundCount,
392
+ extra: {
393
+ exit_code: searchResult.exit_code,
394
+ error_code: searchResult.error_code,
395
+ stderr: searchResult.stderr?.slice(0, 1200)
396
+ }
397
+ })
398
+ }
399
+ );
400
+ }
401
+
402
+ if (!Number.isInteger(searchResult.candidate_count)) {
403
+ return buildFailedResponse(
404
+ "SEARCH_RESULT_UNVERIFIED",
405
+ "搜索流程未能确认候选人数量,说明搜索步骤可能没有真正完成,已停止后续筛选。",
406
+ {
407
+ search_params: roundSearchParams,
408
+ screen_params: roundScreenParams,
409
+ diagnostics: buildProgressDiagnostics({
410
+ preflight,
411
+ totalProcessedCount,
412
+ totalPassedCount,
413
+ roundCount,
414
+ extra: {
415
+ candidate_count: searchResult.candidate_count,
416
+ stdout: searchResult.stdout?.slice(-1200),
417
+ stderr: searchResult.stderr?.slice(-1200)
418
+ }
419
+ })
420
+ }
421
+ );
422
+ }
423
+
424
+ const exhaustedByTipNoData = searchResult.no_data_tip_present === true;
425
+ if (exhaustedByTipNoData || searchResult.candidate_count === 0) {
426
+ const durationSec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
427
+ const mergedCsvPath = mergeRoundCsvFiles(roundOutputCsvPaths);
428
+ return {
429
+ status: "COMPLETED",
274
430
  search_params: parsed.searchParams,
275
431
  screen_params: parsed.screenParams,
276
- diagnostics: {
277
- candidate_count: 0
432
+ result: {
433
+ target_count: initialTargetCount,
434
+ processed_count: totalProcessedCount,
435
+ passed_count: totalPassedCount,
436
+ duration_sec: durationSec,
437
+ output_csv: mergedCsvPath,
438
+ round_count: roundCount,
439
+ completion_reason: "search_exhausted_no_candidates",
440
+ exhausted_by_tip_nodata: exhaustedByTipNoData,
441
+ target_count_semantics: "target_count means processed candidate count, not passed candidate count"
442
+ },
443
+ message: exhaustedByTipNoData
444
+ ? "流水线已完成。检测到页面出现 tip-nodata(i.tip-nodata),判定候选池已耗尽并结束。"
445
+ : "流水线已完成。累计处理人数未达到目标前,会自动重跑搜索和筛选;当新一轮搜索无可筛选人选时,按候选池耗尽结束。"
446
+ };
447
+ }
448
+
449
+ const screenResult = await screenCli({
450
+ workspaceRoot,
451
+ screenParams: roundScreenParams
452
+ });
453
+
454
+ if (!screenResult.ok) {
455
+ const failure = classifyScreenFailure(screenResult);
456
+ return buildFailedResponse(failure.code, failure.message, {
457
+ search_params: roundSearchParams,
458
+ screen_params: roundScreenParams,
459
+ diagnostics: buildProgressDiagnostics({
460
+ preflight,
461
+ totalProcessedCount,
462
+ totalPassedCount,
463
+ roundCount,
464
+ extra: {
465
+ exit_code: screenResult.exit_code,
466
+ error_code: screenResult.error_code,
467
+ stderr: screenResult.stderr?.slice(0, 1200)
468
+ }
469
+ })
470
+ });
471
+ }
472
+
473
+ const summary = screenResult.summary || {};
474
+ const roundOutputCsvPath = normalizeCsvPath(summary.output_csv);
475
+ const roundProcessedCount = summary.processed_count;
476
+
477
+ if (!Number.isInteger(roundProcessedCount) || roundProcessedCount <= 0) {
478
+ const mergedCsvPath = mergeRoundCsvFiles([
479
+ ...roundOutputCsvPaths,
480
+ roundOutputCsvPath
481
+ ]);
482
+ return buildFailedResponse(
483
+ "SCREEN_NO_PROGRESS",
484
+ "本轮搜索返回了可筛选候选人,但筛选流程未产生有效处理进度。已先导出当前累计 CSV 结果,请检查页面状态或筛选工具日志后重试。",
485
+ {
486
+ search_params: roundSearchParams,
487
+ screen_params: roundScreenParams,
488
+ diagnostics: buildProgressDiagnostics({
489
+ preflight,
490
+ totalProcessedCount,
491
+ totalPassedCount,
492
+ roundCount,
493
+ extra: {
494
+ candidate_count: searchResult.candidate_count,
495
+ round_processed_count: roundProcessedCount ?? null,
496
+ round_passed_count: summary.passed_count ?? null,
497
+ round_output_csv: roundOutputCsvPath,
498
+ output_csv: mergedCsvPath
499
+ }
500
+ })
278
501
  }
279
- }
280
- );
281
- }
502
+ );
503
+ }
282
504
 
283
- const screenResult = await runScreenCli({
284
- workspaceRoot,
285
- screenParams: parsed.screenParams
286
- });
505
+ if (roundOutputCsvPath && isReadableFile(roundOutputCsvPath)) {
506
+ roundOutputCsvPaths.push(roundOutputCsvPath);
507
+ }
287
508
 
288
- if (!screenResult.ok) {
289
- const failure = classifyScreenFailure(screenResult);
290
- return buildFailedResponse(failure.code, failure.message, {
291
- search_params: parsed.searchParams,
292
- screen_params: parsed.screenParams,
293
- diagnostics: {
294
- exit_code: screenResult.exit_code,
295
- error_code: screenResult.error_code,
296
- stderr: screenResult.stderr?.slice(0, 1200)
297
- }
298
- });
509
+ const roundPassedCount =
510
+ Number.isInteger(summary.passed_count) && summary.passed_count >= 0
511
+ ? summary.passed_count
512
+ : 0;
513
+ totalProcessedCount += roundProcessedCount;
514
+ totalPassedCount += roundPassedCount;
299
515
  }
300
516
 
301
517
  const durationSec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
302
- const summary = screenResult.summary || {};
518
+ const mergedCsvPath = mergeRoundCsvFiles(roundOutputCsvPaths);
303
519
  return {
304
520
  status: "COMPLETED",
305
521
  search_params: parsed.searchParams,
306
522
  screen_params: parsed.screenParams,
307
523
  result: {
308
- target_count: summary.target_count ?? parsed.screenParams.target_count,
309
- processed_count: summary.processed_count ?? null,
310
- passed_count: summary.passed_count ?? null,
524
+ target_count: initialTargetCount,
525
+ processed_count: totalProcessedCount,
526
+ passed_count: totalPassedCount,
311
527
  duration_sec: durationSec,
312
- output_csv: summary.output_csv,
528
+ output_csv: mergedCsvPath,
529
+ round_count: roundCount,
313
530
  completion_reason: "processed_target_reached",
314
531
  target_count_semantics: "target_count means processed candidate count, not passed candidate count"
315
532
  },
316
- message: "流水线已完成。target_count 表示处理人数目标,而不是通过人数目标;即使通过人数小于 target_count,只要已处理达到目标人数,也应视为本轮完成。"
533
+ message: "流水线已完成。target_count 表示处理人数目标,而不是通过人数目标;当累计处理人数仍不足时会自动多轮执行,且从第2轮起会强制过滤近14天查看过的人选。"
317
534
  };
318
535
  }
@@ -0,0 +1,434 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { runRecruitPipeline } from "./pipeline.js";
6
+
7
+ const CSV_HEADER = "姓名,最高学历学校,最高学历专业,最近工作公司,最近工作职位,评估通过详细原因";
8
+
9
+ function createTempDir(tag) {
10
+ return fs.mkdtempSync(path.join(os.tmpdir(), `boss-pipeline-${tag}-`));
11
+ }
12
+
13
+ function writeCsv(filePath, rows) {
14
+ const body = rows.length > 0 ? `${rows.join("\n")}\n` : "";
15
+ fs.writeFileSync(filePath, `\uFEFF${CSV_HEADER}\n${body}`, "utf8");
16
+ }
17
+
18
+ function readCsvLines(filePath) {
19
+ return fs
20
+ .readFileSync(filePath, "utf8")
21
+ .replace(/^\uFEFF/, "")
22
+ .split(/\r?\n/)
23
+ .filter((line) => line.trim() !== "");
24
+ }
25
+
26
+ function createParsed(overrides = {}) {
27
+ return {
28
+ has_unresolved_missing_fields: false,
29
+ needs_keyword_confirmation: false,
30
+ needs_search_params_confirmation: false,
31
+ needs_recent_viewed_filter_confirmation: false,
32
+ needs_criteria_confirmation: false,
33
+ missing_fields: [],
34
+ proposed_keyword: null,
35
+ pending_questions: [],
36
+ review: {},
37
+ searchParams: {
38
+ city: "杭州",
39
+ degree: "本科",
40
+ schools: ["985院校"],
41
+ filter_recent_viewed: false,
42
+ keyword: "AI infra"
43
+ },
44
+ screenParams: {
45
+ criteria: "候选人需有 AI infra 相关经历",
46
+ target_count: 100
47
+ },
48
+ ...overrides
49
+ };
50
+ }
51
+
52
+ function buildSearchOk(candidateCount) {
53
+ return {
54
+ ok: true,
55
+ candidate_count: candidateCount,
56
+ no_data_tip_present: false,
57
+ no_data_tip_check: { ok: true, details: { exhausted: false } },
58
+ stdout: "",
59
+ stderr: "",
60
+ error_code: null
61
+ };
62
+ }
63
+
64
+ function buildScreenOk({ processedCount, passedCount = 0, outputCsv = null }) {
65
+ return {
66
+ ok: true,
67
+ summary: {
68
+ processed_count: processedCount,
69
+ passed_count: passedCount,
70
+ output_csv: outputCsv
71
+ },
72
+ stdout: "",
73
+ stderr: "",
74
+ error_code: null
75
+ };
76
+ }
77
+
78
+ function createDependencies({
79
+ parsed,
80
+ searchResults,
81
+ screenResults,
82
+ pageStates
83
+ }) {
84
+ const searchQueue = [...searchResults];
85
+ const screenQueue = [...screenResults];
86
+ const pageQueue = pageStates ? [...pageStates] : [];
87
+ const calls = {
88
+ searchParams: [],
89
+ screenParams: [],
90
+ pageChecks: 0,
91
+ preflightChecks: 0
92
+ };
93
+
94
+ return {
95
+ calls,
96
+ deps: {
97
+ parseRecruitInstruction: () => parsed,
98
+ runPipelinePreflight: () => {
99
+ calls.preflightChecks += 1;
100
+ return {
101
+ ok: true,
102
+ checks: [],
103
+ debug_port: 9222,
104
+ calibration_path: null
105
+ };
106
+ },
107
+ ensureBossSearchPageReady: async () => {
108
+ calls.pageChecks += 1;
109
+ if (pageQueue.length > 0) {
110
+ return pageQueue.shift();
111
+ }
112
+ return {
113
+ ok: true,
114
+ state: "SEARCH_READY",
115
+ debug_port: 9222,
116
+ page_state: {}
117
+ };
118
+ },
119
+ runSearchCli: async ({ searchParams }) => {
120
+ calls.searchParams.push({ ...searchParams });
121
+ if (searchQueue.length === 0) {
122
+ throw new Error("runSearchCli called more times than expected");
123
+ }
124
+ return searchQueue.shift();
125
+ },
126
+ runScreenCli: async ({ screenParams }) => {
127
+ calls.screenParams.push({ ...screenParams });
128
+ if (screenQueue.length === 0) {
129
+ throw new Error("runScreenCli called more times than expected");
130
+ }
131
+ return screenQueue.shift();
132
+ }
133
+ }
134
+ };
135
+ }
136
+
137
+ async function testSearchExhaustedCompletesAndMergesCsv() {
138
+ const tempDir = createTempDir("exhausted");
139
+ const round1Csv = path.join(tempDir, "round-1.csv");
140
+ writeCsv(round1Csv, [
141
+ "张三,清华大学,计算机,甲公司,算法工程师,理由A"
142
+ ]);
143
+
144
+ const parsed = createParsed({
145
+ screenParams: {
146
+ criteria: "候选人需有 AI infra 相关经历",
147
+ target_count: 100
148
+ }
149
+ });
150
+ const { deps, calls } = createDependencies({
151
+ parsed,
152
+ searchResults: [buildSearchOk(160), buildSearchOk(0)],
153
+ screenResults: [
154
+ buildScreenOk({
155
+ processedCount: 80,
156
+ passedCount: 12,
157
+ outputCsv: round1Csv
158
+ })
159
+ ]
160
+ });
161
+
162
+ const result = await runRecruitPipeline(
163
+ {
164
+ workspaceRoot: tempDir,
165
+ instruction: "test",
166
+ confirmation: {},
167
+ overrides: {}
168
+ },
169
+ deps
170
+ );
171
+
172
+ assert.equal(result.status, "COMPLETED");
173
+ assert.equal(result.result.completion_reason, "search_exhausted_no_candidates");
174
+ assert.equal(result.result.processed_count, 80);
175
+ assert.equal(result.result.passed_count, 12);
176
+ assert.equal(result.result.round_count, 2);
177
+ assert.equal(calls.searchParams[0].filter_recent_viewed, false);
178
+ assert.equal(calls.searchParams[1].filter_recent_viewed, true);
179
+ assert.ok(result.result.output_csv && fs.existsSync(result.result.output_csv));
180
+ const mergedLines = readCsvLines(result.result.output_csv);
181
+ assert.equal(mergedLines.length, 2);
182
+ }
183
+
184
+ async function testSearchExhaustedByTipNodataEvenWhenCandidateCountPositive() {
185
+ const tempDir = createTempDir("exhausted-tip");
186
+ const round1Csv = path.join(tempDir, "round-1.csv");
187
+ writeCsv(round1Csv, [
188
+ "赵六,浙大,电子信息,戊公司,工程师,理由D"
189
+ ]);
190
+
191
+ const parsed = createParsed({
192
+ screenParams: {
193
+ criteria: "候选人需有 AI infra 相关经历",
194
+ target_count: 100
195
+ }
196
+ });
197
+ const { deps, calls } = createDependencies({
198
+ parsed,
199
+ searchResults: [
200
+ buildSearchOk(120),
201
+ {
202
+ ...buildSearchOk(87),
203
+ no_data_tip_present: true,
204
+ no_data_tip_check: {
205
+ ok: true,
206
+ details: {
207
+ exhausted: true,
208
+ selector: "i.tip-nodata"
209
+ }
210
+ }
211
+ }
212
+ ],
213
+ screenResults: [
214
+ buildScreenOk({
215
+ processedCount: 80,
216
+ passedCount: 4,
217
+ outputCsv: round1Csv
218
+ })
219
+ ]
220
+ });
221
+
222
+ const result = await runRecruitPipeline(
223
+ {
224
+ workspaceRoot: tempDir,
225
+ instruction: "test",
226
+ confirmation: {},
227
+ overrides: {}
228
+ },
229
+ deps
230
+ );
231
+
232
+ assert.equal(result.status, "COMPLETED");
233
+ assert.equal(result.result.completion_reason, "search_exhausted_no_candidates");
234
+ assert.equal(result.result.exhausted_by_tip_nodata, true);
235
+ assert.equal(result.result.processed_count, 80);
236
+ assert.equal(result.result.round_count, 2);
237
+ assert.equal(calls.searchParams[1].filter_recent_viewed, true);
238
+ }
239
+
240
+ async function testTargetReachedAcrossRoundsAndDuplicateRowsKept() {
241
+ const tempDir = createTempDir("target");
242
+ const round1Csv = path.join(tempDir, "round-1.csv");
243
+ const round2Csv = path.join(tempDir, "round-2.csv");
244
+ const dupRow = "重复候选人,北大,计算机,乙公司,后端工程师,重复原因";
245
+ writeCsv(round1Csv, [
246
+ "张三,清华大学,计算机,甲公司,算法工程师,理由A",
247
+ dupRow
248
+ ]);
249
+ writeCsv(round2Csv, [
250
+ dupRow,
251
+ "李四,复旦大学,软件工程,丙公司,推荐算法工程师,理由B"
252
+ ]);
253
+
254
+ const parsed = createParsed({
255
+ screenParams: {
256
+ criteria: "候选人需有 AI infra 相关经历",
257
+ target_count: 100
258
+ }
259
+ });
260
+ const { deps, calls } = createDependencies({
261
+ parsed,
262
+ searchResults: [buildSearchOk(120), buildSearchOk(120)],
263
+ screenResults: [
264
+ buildScreenOk({
265
+ processedCount: 60,
266
+ passedCount: 5,
267
+ outputCsv: round1Csv
268
+ }),
269
+ buildScreenOk({
270
+ processedCount: 40,
271
+ passedCount: 8,
272
+ outputCsv: round2Csv
273
+ })
274
+ ]
275
+ });
276
+
277
+ const result = await runRecruitPipeline(
278
+ {
279
+ workspaceRoot: tempDir,
280
+ instruction: "test",
281
+ confirmation: {},
282
+ overrides: {}
283
+ },
284
+ deps
285
+ );
286
+
287
+ assert.equal(result.status, "COMPLETED");
288
+ assert.equal(result.result.completion_reason, "processed_target_reached");
289
+ assert.equal(result.result.processed_count, 100);
290
+ assert.equal(result.result.passed_count, 13);
291
+ assert.equal(result.result.round_count, 2);
292
+ assert.equal(calls.searchParams[0].filter_recent_viewed, false);
293
+ assert.equal(calls.searchParams[1].filter_recent_viewed, true);
294
+ assert.equal(calls.screenParams[1].target_count, 40);
295
+
296
+ const mergedLines = readCsvLines(result.result.output_csv);
297
+ assert.equal(mergedLines.length, 5);
298
+ const duplicateCount = mergedLines.filter((line) => line === dupRow).length;
299
+ assert.equal(duplicateCount, 2);
300
+ }
301
+
302
+ async function testScreenNoProgressWithZeroProcessedExportsBeforeFail() {
303
+ const tempDir = createTempDir("no-progress-zero");
304
+ const round1Csv = path.join(tempDir, "round-1.csv");
305
+ const round2Csv = path.join(tempDir, "round-2.csv");
306
+ writeCsv(round1Csv, [
307
+ "张三,清华大学,计算机,甲公司,算法工程师,理由A"
308
+ ]);
309
+ writeCsv(round2Csv, [
310
+ "王五,浙大,软件工程,丁公司,算法工程师,理由C"
311
+ ]);
312
+
313
+ const parsed = createParsed({
314
+ screenParams: {
315
+ criteria: "候选人需有 AI infra 相关经历",
316
+ target_count: 100
317
+ }
318
+ });
319
+ const { deps } = createDependencies({
320
+ parsed,
321
+ searchResults: [buildSearchOk(120), buildSearchOk(120)],
322
+ screenResults: [
323
+ buildScreenOk({
324
+ processedCount: 80,
325
+ passedCount: 9,
326
+ outputCsv: round1Csv
327
+ }),
328
+ buildScreenOk({
329
+ processedCount: 0,
330
+ passedCount: 0,
331
+ outputCsv: round2Csv
332
+ })
333
+ ]
334
+ });
335
+
336
+ const result = await runRecruitPipeline(
337
+ {
338
+ workspaceRoot: tempDir,
339
+ instruction: "test",
340
+ confirmation: {},
341
+ overrides: {}
342
+ },
343
+ deps
344
+ );
345
+
346
+ assert.equal(result.status, "FAILED");
347
+ assert.equal(result.error.code, "SCREEN_NO_PROGRESS");
348
+ assert.equal(result.diagnostics.total_processed_count, 80);
349
+ assert.equal(result.diagnostics.round_count, 2);
350
+ assert.ok(result.diagnostics.output_csv && fs.existsSync(result.diagnostics.output_csv));
351
+ const mergedLines = readCsvLines(result.diagnostics.output_csv);
352
+ assert.equal(mergedLines.length, 3);
353
+ }
354
+
355
+ async function testScreenNoProgressWithInvalidProcessedAndNoCsv() {
356
+ const tempDir = createTempDir("no-progress-invalid");
357
+ const parsed = createParsed({
358
+ screenParams: {
359
+ criteria: "候选人需有 AI infra 相关经历",
360
+ target_count: 60
361
+ }
362
+ });
363
+ const { deps } = createDependencies({
364
+ parsed,
365
+ searchResults: [buildSearchOk(88)],
366
+ screenResults: [
367
+ buildScreenOk({
368
+ processedCount: null,
369
+ passedCount: 0,
370
+ outputCsv: null
371
+ })
372
+ ]
373
+ });
374
+
375
+ const result = await runRecruitPipeline(
376
+ {
377
+ workspaceRoot: tempDir,
378
+ instruction: "test",
379
+ confirmation: {},
380
+ overrides: {}
381
+ },
382
+ deps
383
+ );
384
+
385
+ assert.equal(result.status, "FAILED");
386
+ assert.equal(result.error.code, "SCREEN_NO_PROGRESS");
387
+ assert.equal(result.diagnostics.round_count, 1);
388
+ assert.equal(result.diagnostics.output_csv, null);
389
+ }
390
+
391
+ async function testNeedInputGateStillWorks() {
392
+ const tempDir = createTempDir("need-input");
393
+ const parsed = createParsed({
394
+ has_unresolved_missing_fields: true,
395
+ missing_fields: ["city"]
396
+ });
397
+ let preflightCalled = false;
398
+ const deps = {
399
+ parseRecruitInstruction: () => parsed,
400
+ runPipelinePreflight: () => {
401
+ preflightCalled = true;
402
+ return { ok: true, checks: [], debug_port: 9222, calibration_path: null };
403
+ },
404
+ ensureBossSearchPageReady: async () => ({ ok: true, state: "SEARCH_READY", debug_port: 9222, page_state: {} }),
405
+ runSearchCli: async () => buildSearchOk(0),
406
+ runScreenCli: async () => buildScreenOk({ processedCount: 0, passedCount: 0, outputCsv: null })
407
+ };
408
+
409
+ const result = await runRecruitPipeline(
410
+ {
411
+ workspaceRoot: tempDir,
412
+ instruction: "test",
413
+ confirmation: {},
414
+ overrides: {}
415
+ },
416
+ deps
417
+ );
418
+
419
+ assert.equal(result.status, "NEED_INPUT");
420
+ assert.equal(preflightCalled, false);
421
+ }
422
+
423
+ async function main() {
424
+ await testSearchExhaustedCompletesAndMergesCsv();
425
+ await testSearchExhaustedByTipNodataEvenWhenCandidateCountPositive();
426
+ await testTargetReachedAcrossRoundsAndDuplicateRowsKept();
427
+ await testScreenNoProgressWithZeroProcessedExportsBeforeFail();
428
+ await testScreenNoProgressWithInvalidProcessedAndNoCsv();
429
+ await testNeedInputGateStillWorks();
430
+ // eslint-disable-next-line no-console
431
+ console.log("pipeline tests passed");
432
+ }
433
+
434
+ await main();