@reconcrap/boss-recruit-mcp 1.0.13 → 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 +5 -2
- package/package.json +3 -1
- package/skills/boss-recruit-pipeline/SKILL.md +9 -3
- package/src/adapters.js +319 -0
- package/src/pipeline.js +322 -64
- package/src/test-pipeline.js +434 -0
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`
|
|
222
|
-
-
|
|
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.
|
|
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": [
|
|
@@ -43,7 +43,11 @@ description: "Use when users ask to recruit candidates on Boss Zhipin via the bo
|
|
|
43
43
|
- 若仍在 search 页面,可继续;
|
|
44
44
|
- 若跳转到登录页、首页或其他 Boss 页面,视为“需要重新登录”;
|
|
45
45
|
- 必须明确提示用户手动登录 Boss,并等待用户回复“已登录/可以继续”后,才能继续后续动作。
|
|
46
|
-
8.
|
|
46
|
+
8. 无论是复用已有实例,还是新开实例并导航到 search 页面,只要曾经到达过 search,都必须再做一次短延时复查(例如 1-2 秒):
|
|
47
|
+
- 若复查时仍在 search 页面,才允许继续;
|
|
48
|
+
- 若从 search 自动跳转到其他 Boss 页面,优先判定为“登录态失效/未登录”,提示用户先登录;
|
|
49
|
+
- 在用户明确回复“已登录,可以继续”前,不得执行搜索和筛选。
|
|
50
|
+
9. 只有在以上条件满足后,才继续调用流水线。
|
|
47
51
|
|
|
48
52
|
## Calibration Requirement
|
|
49
53
|
|
|
@@ -140,8 +144,8 @@ description: "Use when users ask to recruit candidates on Boss Zhipin via the bo
|
|
|
140
144
|
- MCP 是否可用
|
|
141
145
|
- 若 MCP 不可用,CLI fallback 是否可用
|
|
142
146
|
- Chrome 调试端口是否已确认
|
|
143
|
-
- Chrome
|
|
144
|
-
-
|
|
147
|
+
- Chrome 是否能被导航到 Boss 搜索页面
|
|
148
|
+
- 导航到 search 后 1-2 秒是否仍稳定停留在 search(而不是自动跳转)
|
|
145
149
|
- `favorite-calibration.json` 是否存在
|
|
146
150
|
2. 若缺少依赖或 MCP 未启动:
|
|
147
151
|
- 自动安装依赖并帮助用户启动 MCP;
|
|
@@ -207,6 +211,8 @@ description: "Use when users ask to recruit candidates on Boss Zhipin via the bo
|
|
|
207
211
|
12. 若返回 `FAILED`:
|
|
208
212
|
- 先提炼 `error.code`、`error.message`、`diagnostics`;
|
|
209
213
|
- 如果是 `PIPELINE_PREFLIGHT_FAILED`,明确指出缺失的本地目录 / 文件;
|
|
214
|
+
- 如果是 `BOSS_LOGIN_REQUIRED`,明确告诉用户“当前页面被跳转,疑似未登录/登录态失效”,并要求先登录再继续;
|
|
215
|
+
- 如果是 `BOSS_SEARCH_PAGE_NOT_READY`,明确告诉用户先修复 Chrome 调试连接和 search 页面导航问题;
|
|
210
216
|
- 如果是 `CALIBRATION_REQUIRED`,明确提醒用户执行校准,并给出校准步骤;
|
|
211
217
|
- 如果是 `SEARCH_PROCESS_PERMISSION_DENIED` 或 `SCREEN_PROCESS_PERMISSION_DENIED`,明确说明“当前环境拒绝创建子进程”,建议用户在本地终端直接运行 MCP;
|
|
212
218
|
- 如果是 `SEARCH_CLI_MISSING` / `SCREEN_CLI_MISSING` / `SCREEN_CONFIG_ERROR`,直接告诉用户缺什么,不要只说“重试”;
|
package/src/adapters.js
CHANGED
|
@@ -3,8 +3,11 @@ 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), "..");
|
|
9
|
+
const bossSearchUrl = "https://www.zhipin.com/web/chat/search";
|
|
10
|
+
const chromeOnboardingUrlPattern = /^chrome:\/\/(welcome|intro|newtab|signin|history-sync|settings\/syncSetup)/i;
|
|
8
11
|
|
|
9
12
|
function getCodexHome() {
|
|
10
13
|
return process.env.CODEX_HOME
|
|
@@ -159,6 +162,82 @@ function parseSearchCount(output) {
|
|
|
159
162
|
return Number.parseInt(m[1], 10);
|
|
160
163
|
}
|
|
161
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
|
+
|
|
162
241
|
function parseScreenSummary(output) {
|
|
163
242
|
const processed = output.match(/已处理:\s*(\d+)\s*人/);
|
|
164
243
|
const passed = output.match(/通过筛选:\s*(\d+)\s*人/);
|
|
@@ -293,6 +372,239 @@ function localDirHint(workspaceRoot, dirName) {
|
|
|
293
372
|
return path.join(workspaceRoot, dirName);
|
|
294
373
|
}
|
|
295
374
|
|
|
375
|
+
function sleep(ms) {
|
|
376
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function listChromeTabs(port) {
|
|
380
|
+
const response = await fetch(`http://127.0.0.1:${port}/json/list`);
|
|
381
|
+
if (!response.ok) {
|
|
382
|
+
throw new Error(`DevTools endpoint returned ${response.status}`);
|
|
383
|
+
}
|
|
384
|
+
const data = await response.json();
|
|
385
|
+
return Array.isArray(data) ? data : [];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function buildBossPageState(payload) {
|
|
389
|
+
return {
|
|
390
|
+
key: "boss_page_state",
|
|
391
|
+
...payload
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function extractSampleUrls(tabs, limit = 5) {
|
|
396
|
+
return tabs
|
|
397
|
+
.map((tab) => tab?.url)
|
|
398
|
+
.filter(Boolean)
|
|
399
|
+
.slice(0, limit);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function findChromeOnboardingUrl(tabs) {
|
|
403
|
+
for (const tab of tabs) {
|
|
404
|
+
if (typeof tab?.url === "string" && chromeOnboardingUrlPattern.test(tab.url)) {
|
|
405
|
+
return tab.url;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function inspectBossPageState(port, options = {}) {
|
|
412
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 6000;
|
|
413
|
+
const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : 1000;
|
|
414
|
+
const expectedUrl = options.expectedUrl || bossSearchUrl;
|
|
415
|
+
const deadline = Date.now() + timeoutMs;
|
|
416
|
+
let lastError = null;
|
|
417
|
+
let lastTabs = [];
|
|
418
|
+
|
|
419
|
+
while (Date.now() < deadline) {
|
|
420
|
+
try {
|
|
421
|
+
const tabs = await listChromeTabs(port);
|
|
422
|
+
lastTabs = tabs;
|
|
423
|
+
|
|
424
|
+
const exactSearchTab = tabs.find(
|
|
425
|
+
(tab) => typeof tab?.url === "string" && tab.url.includes("/web/chat/search")
|
|
426
|
+
);
|
|
427
|
+
if (exactSearchTab) {
|
|
428
|
+
return buildBossPageState({
|
|
429
|
+
ok: true,
|
|
430
|
+
state: "SEARCH_READY",
|
|
431
|
+
path: exactSearchTab.url,
|
|
432
|
+
current_url: exactSearchTab.url,
|
|
433
|
+
title: exactSearchTab.title || null,
|
|
434
|
+
requires_login: false,
|
|
435
|
+
message: "Boss 搜索页已打开,且当前仍停留在 search 页面。"
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const bossTab = tabs.find(
|
|
440
|
+
(tab) => typeof tab?.url === "string" && tab.url.includes("zhipin.com")
|
|
441
|
+
);
|
|
442
|
+
if (bossTab) {
|
|
443
|
+
return buildBossPageState({
|
|
444
|
+
ok: false,
|
|
445
|
+
state: "LOGIN_REQUIRED",
|
|
446
|
+
path: bossTab.url,
|
|
447
|
+
current_url: bossTab.url,
|
|
448
|
+
title: bossTab.title || null,
|
|
449
|
+
requires_login: true,
|
|
450
|
+
expected_url: expectedUrl,
|
|
451
|
+
message: "Boss 页面没有停留在 search 页面,通常表示需要重新登录。请手动登录 Boss 后再继续。"
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
} catch (error) {
|
|
455
|
+
lastError = error;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
await sleep(pollMs);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (lastError) {
|
|
462
|
+
return buildBossPageState({
|
|
463
|
+
ok: false,
|
|
464
|
+
state: "DEBUG_PORT_UNREACHABLE",
|
|
465
|
+
path: `http://127.0.0.1:${port}`,
|
|
466
|
+
current_url: null,
|
|
467
|
+
title: null,
|
|
468
|
+
requires_login: false,
|
|
469
|
+
expected_url: expectedUrl,
|
|
470
|
+
message: `无法连接到 Chrome DevTools 端口 ${port}。请确认 Chrome 已以远程调试模式启动。`,
|
|
471
|
+
error: lastError.message
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const onboardingUrl = findChromeOnboardingUrl(lastTabs);
|
|
476
|
+
if (onboardingUrl) {
|
|
477
|
+
return buildBossPageState({
|
|
478
|
+
ok: false,
|
|
479
|
+
state: "CHROME_ONBOARDING_INTERCEPTED",
|
|
480
|
+
path: onboardingUrl,
|
|
481
|
+
current_url: onboardingUrl,
|
|
482
|
+
title: null,
|
|
483
|
+
requires_login: false,
|
|
484
|
+
expected_url: expectedUrl,
|
|
485
|
+
message: "Chrome 当前停留在登录/引导页,尚未稳定到 Boss 搜索页。",
|
|
486
|
+
sample_urls: extractSampleUrls(lastTabs)
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return buildBossPageState({
|
|
491
|
+
ok: false,
|
|
492
|
+
state: "BOSS_TAB_NOT_FOUND",
|
|
493
|
+
path: expectedUrl,
|
|
494
|
+
current_url: null,
|
|
495
|
+
title: null,
|
|
496
|
+
requires_login: false,
|
|
497
|
+
expected_url: expectedUrl,
|
|
498
|
+
message: "未检测到 Boss 页面标签页。请确认 Chrome 已打开 Boss 搜索页。",
|
|
499
|
+
sample_urls: extractSampleUrls(lastTabs)
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function openBossSearchTab(port) {
|
|
504
|
+
const endpoint = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(bossSearchUrl)}`;
|
|
505
|
+
const attempts = ["PUT", "GET"];
|
|
506
|
+
let lastError = null;
|
|
507
|
+
|
|
508
|
+
for (const method of attempts) {
|
|
509
|
+
try {
|
|
510
|
+
const response = await fetch(endpoint, { method });
|
|
511
|
+
if (response.ok) {
|
|
512
|
+
return { ok: true, method };
|
|
513
|
+
}
|
|
514
|
+
lastError = new Error(`DevTools /json/new returned ${response.status}`);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
lastError = error;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
ok: false,
|
|
522
|
+
error: lastError?.message || "Failed to open Boss search tab via DevTools /json/new"
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function verifySearchPageStable(port, options = {}) {
|
|
527
|
+
const settleMs = Number.isFinite(options.settleMs) ? options.settleMs : 1500;
|
|
528
|
+
const recheckTimeoutMs = Number.isFinite(options.recheckTimeoutMs) ? options.recheckTimeoutMs : 2500;
|
|
529
|
+
const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : 600;
|
|
530
|
+
|
|
531
|
+
await sleep(settleMs);
|
|
532
|
+
const recheck = await inspectBossPageState(port, { timeoutMs: recheckTimeoutMs, pollMs });
|
|
533
|
+
if (recheck.state === "SEARCH_READY") {
|
|
534
|
+
return recheck;
|
|
535
|
+
}
|
|
536
|
+
if (recheck.state === "LOGIN_REQUIRED") {
|
|
537
|
+
return buildBossPageState({
|
|
538
|
+
...recheck,
|
|
539
|
+
state: "LOGIN_REQUIRED_AFTER_REDIRECT",
|
|
540
|
+
message: "Boss 页面曾进入 search 但随后跳转到其他页面,通常表示登录态失效。请先手动登录后再继续搜索和筛选。"
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
return recheck;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export async function ensureBossSearchPageReady(workspaceRoot, options = {}) {
|
|
547
|
+
const debugPort = Number.isFinite(options.port)
|
|
548
|
+
? options.port
|
|
549
|
+
: resolveWorkspaceDebugPort(workspaceRoot);
|
|
550
|
+
const attempts = Number.isFinite(options.attempts) ? Math.max(1, options.attempts) : 3;
|
|
551
|
+
const inspectTimeoutMs = Number.isFinite(options.inspectTimeoutMs) ? options.inspectTimeoutMs : 6000;
|
|
552
|
+
const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : 800;
|
|
553
|
+
const settleMs = Number.isFinite(options.settleMs) ? options.settleMs : 800;
|
|
554
|
+
|
|
555
|
+
let pageState = await inspectBossPageState(debugPort, { timeoutMs: inspectTimeoutMs, pollMs });
|
|
556
|
+
if (pageState.state === "SEARCH_READY") {
|
|
557
|
+
const stableState = await verifySearchPageStable(debugPort, { settleMs, pollMs });
|
|
558
|
+
return {
|
|
559
|
+
ok: stableState.state === "SEARCH_READY",
|
|
560
|
+
debug_port: debugPort,
|
|
561
|
+
state: stableState.state,
|
|
562
|
+
page_state: stableState
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
if (pageState.state === "LOGIN_REQUIRED") {
|
|
566
|
+
return {
|
|
567
|
+
ok: false,
|
|
568
|
+
debug_port: debugPort,
|
|
569
|
+
state: pageState.state,
|
|
570
|
+
page_state: pageState
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
575
|
+
if (pageState.state === "DEBUG_PORT_UNREACHABLE") {
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
await openBossSearchTab(debugPort);
|
|
579
|
+
await sleep(settleMs);
|
|
580
|
+
pageState = await inspectBossPageState(debugPort, { timeoutMs: inspectTimeoutMs, pollMs });
|
|
581
|
+
if (pageState.state === "SEARCH_READY") {
|
|
582
|
+
const stableState = await verifySearchPageStable(debugPort, { settleMs, pollMs });
|
|
583
|
+
return {
|
|
584
|
+
ok: stableState.state === "SEARCH_READY",
|
|
585
|
+
debug_port: debugPort,
|
|
586
|
+
state: stableState.state,
|
|
587
|
+
page_state: stableState
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
if (pageState.state === "LOGIN_REQUIRED") {
|
|
591
|
+
return {
|
|
592
|
+
ok: false,
|
|
593
|
+
debug_port: debugPort,
|
|
594
|
+
state: pageState.state,
|
|
595
|
+
page_state: pageState
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
ok: false,
|
|
602
|
+
debug_port: debugPort,
|
|
603
|
+
state: pageState.state || "UNKNOWN",
|
|
604
|
+
page_state: pageState
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
296
608
|
export async function runSearchCli({ workspaceRoot, searchParams }) {
|
|
297
609
|
const searchDir = resolveSearchCliDir(workspaceRoot);
|
|
298
610
|
const debugPort = resolveWorkspaceDebugPort(workspaceRoot);
|
|
@@ -334,11 +646,18 @@ export async function runSearchCli({ workspaceRoot, searchParams }) {
|
|
|
334
646
|
|
|
335
647
|
const combined = `${result.stdout}\n${result.stderr}`;
|
|
336
648
|
const candidateCount = parseSearchCount(combined);
|
|
649
|
+
const tipCheck = result.code === 0
|
|
650
|
+
? await detectSearchNoDataTip(debugPort)
|
|
651
|
+
: { ok: false, exhausted: null, error: null };
|
|
337
652
|
|
|
338
653
|
return {
|
|
339
654
|
ok: result.code === 0,
|
|
340
655
|
exit_code: result.code,
|
|
341
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 },
|
|
342
661
|
stdout: result.stdout,
|
|
343
662
|
stderr: result.stderr,
|
|
344
663
|
error_code: result.error_code || null
|
package/src/pipeline.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { parseRecruitInstruction } from "./parser.js";
|
|
2
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ensureBossSearchPageReady,
|
|
6
|
+
runPipelinePreflight,
|
|
7
|
+
runSearchCli,
|
|
8
|
+
runScreenCli
|
|
9
|
+
} from "./adapters.js";
|
|
3
10
|
|
|
4
11
|
function buildRequiredConfirmations(parsedResult) {
|
|
5
12
|
const confirmations = [];
|
|
@@ -68,6 +75,92 @@ function buildFailedResponse(code, message, extra = {}) {
|
|
|
68
75
|
};
|
|
69
76
|
}
|
|
70
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
|
+
|
|
71
164
|
function classifySearchFailure(searchResult) {
|
|
72
165
|
const stderr = searchResult.stderr || "";
|
|
73
166
|
const errorCode = searchResult.error_code || "";
|
|
@@ -144,14 +237,29 @@ function classifyScreenFailure(screenResult) {
|
|
|
144
237
|
};
|
|
145
238
|
}
|
|
146
239
|
|
|
240
|
+
const defaultDependencies = {
|
|
241
|
+
parseRecruitInstruction,
|
|
242
|
+
ensureBossSearchPageReady,
|
|
243
|
+
runPipelinePreflight,
|
|
244
|
+
runSearchCli,
|
|
245
|
+
runScreenCli
|
|
246
|
+
};
|
|
247
|
+
|
|
147
248
|
export async function runRecruitPipeline({
|
|
148
249
|
workspaceRoot,
|
|
149
250
|
instruction,
|
|
150
251
|
confirmation,
|
|
151
252
|
overrides
|
|
152
|
-
}) {
|
|
253
|
+
}, dependencies = defaultDependencies) {
|
|
254
|
+
const {
|
|
255
|
+
parseRecruitInstruction: parseInstruction,
|
|
256
|
+
ensureBossSearchPageReady: ensureSearchPageReady,
|
|
257
|
+
runPipelinePreflight: runPreflight,
|
|
258
|
+
runSearchCli: searchCli,
|
|
259
|
+
runScreenCli: screenCli
|
|
260
|
+
} = dependencies;
|
|
153
261
|
const startedAt = Date.now();
|
|
154
|
-
const parsed =
|
|
262
|
+
const parsed = parseInstruction({
|
|
155
263
|
instruction,
|
|
156
264
|
confirmation,
|
|
157
265
|
overrides
|
|
@@ -170,7 +278,7 @@ export async function runRecruitPipeline({
|
|
|
170
278
|
return buildNeedConfirmationResponse(parsed);
|
|
171
279
|
}
|
|
172
280
|
|
|
173
|
-
const preflight =
|
|
281
|
+
const preflight = runPreflight(workspaceRoot);
|
|
174
282
|
if (!preflight.ok) {
|
|
175
283
|
return buildFailedResponse(
|
|
176
284
|
"PIPELINE_PREFLIGHT_FAILED",
|
|
@@ -187,91 +295,241 @@ export async function runRecruitPipeline({
|
|
|
187
295
|
);
|
|
188
296
|
}
|
|
189
297
|
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
searchParams: parsed.searchParams
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
if (!searchResult.ok) {
|
|
196
|
-
const failure = classifySearchFailure(searchResult);
|
|
298
|
+
const initialTargetCount = parsed.screenParams.target_count;
|
|
299
|
+
if (!Number.isInteger(initialTargetCount) || initialTargetCount <= 0) {
|
|
197
300
|
return buildFailedResponse(
|
|
198
|
-
|
|
199
|
-
|
|
301
|
+
"INVALID_TARGET_COUNT",
|
|
302
|
+
"目标处理人数无效,请确认 target_count 为正整数后重试。",
|
|
200
303
|
{
|
|
201
304
|
search_params: parsed.searchParams,
|
|
202
|
-
screen_params: parsed.screenParams
|
|
203
|
-
diagnostics: {
|
|
204
|
-
exit_code: searchResult.exit_code,
|
|
205
|
-
error_code: searchResult.error_code,
|
|
206
|
-
stderr: searchResult.stderr?.slice(0, 1200)
|
|
207
|
-
}
|
|
305
|
+
screen_params: parsed.screenParams
|
|
208
306
|
}
|
|
209
307
|
);
|
|
210
308
|
}
|
|
211
309
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
310
|
+
let totalProcessedCount = 0;
|
|
311
|
+
let totalPassedCount = 0;
|
|
312
|
+
let roundCount = 0;
|
|
313
|
+
const roundOutputCsvPaths = [];
|
|
314
|
+
|
|
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
|
+
);
|
|
224
353
|
}
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
354
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
+
})
|
|
370
|
+
}
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
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",
|
|
233
430
|
search_params: parsed.searchParams,
|
|
234
431
|
screen_params: parsed.screenParams,
|
|
235
|
-
|
|
236
|
-
|
|
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
|
+
})
|
|
237
501
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
502
|
+
);
|
|
503
|
+
}
|
|
241
504
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
});
|
|
505
|
+
if (roundOutputCsvPath && isReadableFile(roundOutputCsvPath)) {
|
|
506
|
+
roundOutputCsvPaths.push(roundOutputCsvPath);
|
|
507
|
+
}
|
|
246
508
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
exit_code: screenResult.exit_code,
|
|
254
|
-
error_code: screenResult.error_code,
|
|
255
|
-
stderr: screenResult.stderr?.slice(0, 1200)
|
|
256
|
-
}
|
|
257
|
-
});
|
|
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;
|
|
258
515
|
}
|
|
259
516
|
|
|
260
517
|
const durationSec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
|
261
|
-
const
|
|
518
|
+
const mergedCsvPath = mergeRoundCsvFiles(roundOutputCsvPaths);
|
|
262
519
|
return {
|
|
263
520
|
status: "COMPLETED",
|
|
264
521
|
search_params: parsed.searchParams,
|
|
265
522
|
screen_params: parsed.screenParams,
|
|
266
523
|
result: {
|
|
267
|
-
target_count:
|
|
268
|
-
processed_count:
|
|
269
|
-
passed_count:
|
|
524
|
+
target_count: initialTargetCount,
|
|
525
|
+
processed_count: totalProcessedCount,
|
|
526
|
+
passed_count: totalPassedCount,
|
|
270
527
|
duration_sec: durationSec,
|
|
271
|
-
output_csv:
|
|
528
|
+
output_csv: mergedCsvPath,
|
|
529
|
+
round_count: roundCount,
|
|
272
530
|
completion_reason: "processed_target_reached",
|
|
273
531
|
target_count_semantics: "target_count means processed candidate count, not passed candidate count"
|
|
274
532
|
},
|
|
275
|
-
message: "流水线已完成。target_count
|
|
533
|
+
message: "流水线已完成。target_count 表示处理人数目标,而不是通过人数目标;当累计处理人数仍不足时会自动多轮执行,且从第2轮起会强制过滤近14天查看过的人选。"
|
|
276
534
|
};
|
|
277
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();
|