@reconcrap/boss-recommend-mcp 1.1.3 → 1.1.4

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/src/adapters.js CHANGED
@@ -3,1838 +3,1908 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawn, spawnSync } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
-
7
- const currentFilePath = fileURLToPath(import.meta.url);
8
- const packagedMcpDir = path.resolve(path.dirname(currentFilePath), "..");
9
- const bossRecommendUrl = "https://www.zhipin.com/web/chat/recommend";
10
- const bossLoginUrl = "https://www.zhipin.com/web/user/?ka=bticket";
11
- const chromeOnboardingUrlPattern = /^chrome:\/\/(welcome|intro|newtab|signin|history-sync|settings\/syncSetup)/i;
12
- const bossLoginUrlPattern = /(?:zhipin\.com\/web\/user(?:\/|\?|$)|passport\.zhipin\.com)/i;
13
- const bossLoginTitlePattern = /登录|signin|扫码登录|BOSS直聘登录/i;
14
- const screenConfigTemplateDefaults = {
15
- baseUrl: "https://api.openai.com/v1",
16
- apiKey: "replace-with-openai-api-key",
17
- model: "gpt-4.1-mini"
18
- };
19
- const DEFAULT_RECOMMEND_SCREEN_TIMEOUT_MS = 24 * 60 * 60 * 1000;
20
-
21
- function getCodexHome() {
22
- return process.env.CODEX_HOME
23
- ? path.resolve(process.env.CODEX_HOME)
24
- : path.join(os.homedir(), ".codex");
25
- }
26
-
27
- function getStateHome() {
28
- return process.env.BOSS_RECOMMEND_HOME
29
- ? path.resolve(process.env.BOSS_RECOMMEND_HOME)
30
- : path.join(os.homedir(), ".boss-recommend-mcp");
31
- }
32
-
33
- function getUserConfigPath() {
34
- return path.join(getStateHome(), "screening-config.json");
35
- }
36
-
37
- function getLegacyUserConfigPath() {
38
- return path.join(getCodexHome(), "boss-recommend-mcp", "screening-config.json");
39
- }
40
-
41
- function getDesktopDir() {
42
- return path.join(os.homedir(), "Desktop");
43
- }
44
-
45
- function ensureDir(targetPath) {
46
- fs.mkdirSync(targetPath, { recursive: true });
47
- }
48
-
49
- function pathExists(targetPath) {
50
- try {
51
- return fs.existsSync(targetPath);
52
- } catch {
53
- return false;
54
- }
55
- }
56
-
57
- function parsePositiveInteger(raw) {
58
- const value = Number.parseInt(String(raw || ""), 10);
59
- return Number.isFinite(value) && value > 0 ? value : null;
60
- }
61
-
62
- function normalizeText(value) {
63
- return String(value || "").replace(/\s+/g, " ").trim();
64
- }
65
-
66
- function isRootDirectory(targetPath) {
67
- const resolved = path.resolve(String(targetPath || ""));
68
- const parsed = path.parse(resolved);
69
- return resolved.toLowerCase() === String(parsed.root || "").toLowerCase();
70
- }
71
-
72
- function isSystemDirectoryWorkspaceRoot(workspaceRoot) {
73
- const root = path.resolve(String(workspaceRoot || ""));
74
- const normalized = root.replace(/\\/g, "/").toLowerCase();
75
- if (process.platform === "win32") {
76
- return (
77
- normalized.endsWith("/windows")
78
- || normalized.endsWith("/windows/system32")
79
- || normalized.endsWith("/windows/syswow64")
80
- || normalized.endsWith("/program files")
81
- || normalized.endsWith("/program files (x86)")
82
- );
83
- }
84
- return (
85
- normalized === "/system"
86
- || normalized.startsWith("/system/")
87
- || normalized === "/usr"
88
- || normalized.startsWith("/usr/")
89
- || normalized === "/bin"
90
- || normalized.startsWith("/bin/")
91
- || normalized === "/sbin"
92
- || normalized.startsWith("/sbin/")
93
- );
94
- }
95
-
96
- function shouldIgnoreWorkspaceConfigRoot(workspaceRoot) {
97
- const root = path.resolve(String(workspaceRoot || process.cwd()));
98
- const home = path.resolve(os.homedir());
99
- return (
100
- isEphemeralNpxWorkspaceRoot(root)
101
- || isRootDirectory(root)
102
- || root.toLowerCase() === home.toLowerCase()
103
- || isSystemDirectoryWorkspaceRoot(root)
104
- );
105
- }
106
-
107
- function resolveWorkspaceConfigCandidates(workspaceRoot) {
108
- const root = path.resolve(String(workspaceRoot || process.cwd()));
109
- if (shouldIgnoreWorkspaceConfigRoot(root)) {
110
- return [];
111
- }
112
- const directPath = path.join(root, "config", "screening-config.json");
113
- const nestedPath = path.join(root, "boss-recommend-mcp", "config", "screening-config.json");
114
- const candidates = [directPath];
115
- if (path.basename(root).toLowerCase() !== "boss-recommend-mcp") {
116
- candidates.push(nestedPath);
117
- }
118
- return Array.from(new Set(candidates));
119
- }
120
-
121
- function serializeDegreeSelection(value) {
122
- if (Array.isArray(value)) {
123
- const normalized = value.map((item) => String(item || "").trim()).filter(Boolean);
124
- return normalized.length ? normalized.join(",") : "不限";
125
- }
126
- const normalized = String(value || "").trim();
127
- return normalized || "不限";
128
- }
129
-
130
- function serializeSchoolTagSelection(value) {
131
- if (Array.isArray(value)) {
132
- const normalized = value.map((item) => String(item || "").trim()).filter(Boolean);
133
- if (!normalized.length) return "不限";
134
- if (normalized.includes("不限")) {
135
- return normalized.length === 1
136
- ? "不限"
137
- : normalized.filter((item) => item !== "不限").join(",");
138
- }
139
- return normalized.join(",");
140
- }
141
- const normalized = String(value || "").trim();
142
- return normalized || "不限";
143
- }
144
-
145
- function isEphemeralNpxWorkspaceRoot(workspaceRoot) {
146
- const root = path.resolve(String(workspaceRoot || ""));
147
- const normalized = root.replace(/\\/g, "/").toLowerCase();
148
- return (
149
- normalized.includes("/appdata/local/npm-cache/_npx/")
150
- || normalized.includes("/node_modules/@reconcrap/boss-recommend-mcp")
151
- );
152
- }
153
-
154
- function buildScreenConfigCandidateMap(workspaceRoot) {
155
- return {
156
- env_path: process.env.BOSS_RECOMMEND_SCREEN_CONFIG
157
- ? path.resolve(process.env.BOSS_RECOMMEND_SCREEN_CONFIG)
158
- : null,
159
- workspace_paths: resolveWorkspaceConfigCandidates(workspaceRoot),
160
- user_path: getUserConfigPath(),
161
- legacy_path: getLegacyUserConfigPath()
162
- };
163
- }
164
-
165
- function resolveScreenConfigCandidates(workspaceRoot) {
166
- const candidateMap = buildScreenConfigCandidateMap(workspaceRoot);
167
- return [
168
- candidateMap.env_path,
169
- candidateMap.user_path,
170
- ...candidateMap.workspace_paths,
171
- candidateMap.legacy_path
172
- ].filter(Boolean);
173
- }
174
-
175
- function canWriteDirectory(targetDir) {
176
- try {
177
- ensureDir(targetDir);
178
- fs.accessSync(targetDir, fs.constants.W_OK);
179
- return true;
180
- } catch {
181
- return false;
182
- }
183
- }
184
-
185
- function resolveWritableScreenConfigPath(workspaceRoot) {
186
- const candidateMap = buildScreenConfigCandidateMap(workspaceRoot);
187
- const workspacePreferred = candidateMap.workspace_paths?.[0] || null;
188
- if (candidateMap.env_path) {
189
- return candidateMap.env_path;
190
- }
191
- if (candidateMap.user_path && canWriteDirectory(path.dirname(candidateMap.user_path))) {
192
- return candidateMap.user_path;
193
- }
194
- if (workspacePreferred && canWriteDirectory(path.dirname(workspacePreferred))) {
195
- return workspacePreferred;
196
- }
197
- if (workspacePreferred) {
198
- return workspacePreferred;
199
- }
200
- return candidateMap.user_path || candidateMap.legacy_path;
201
- }
202
-
203
- function resolveScreenConfigPath(workspaceRoot) {
204
- const candidateMap = buildScreenConfigCandidateMap(workspaceRoot);
205
- if (candidateMap.env_path) {
206
- return candidateMap.env_path;
207
- }
208
- if (candidateMap.user_path && pathExists(candidateMap.user_path)) {
209
- return candidateMap.user_path;
210
- }
211
- const existingWorkspacePath = candidateMap.workspace_paths.find((item) => pathExists(item));
212
- if (existingWorkspacePath) {
213
- return existingWorkspacePath;
214
- }
215
- const writablePath = resolveWritableScreenConfigPath(workspaceRoot);
216
- if (writablePath) {
217
- return writablePath;
218
- }
219
- return candidateMap.legacy_path;
220
- }
221
-
222
- export function getScreenConfigResolution(workspaceRoot) {
223
- const candidateMap = buildScreenConfigCandidateMap(workspaceRoot);
224
- const candidate_paths = resolveScreenConfigCandidates(workspaceRoot);
225
- const resolved_path = resolveScreenConfigPath(workspaceRoot) || null;
226
- const workspace_root = path.resolve(String(workspaceRoot || process.cwd()));
227
- return {
228
- resolved_path,
229
- candidate_paths,
230
- workspace_root,
231
- workspace_ephemeral: isEphemeralNpxWorkspaceRoot(workspaceRoot),
232
- workspace_ignored_for_config: shouldIgnoreWorkspaceConfigRoot(workspace_root),
233
- writable_path: resolveWritableScreenConfigPath(workspaceRoot),
234
- legacy_path: candidateMap.legacy_path
235
- };
236
- }
237
-
238
- function readJsonFile(filePath) {
239
- if (!filePath || !pathExists(filePath)) return null;
240
- try {
241
- const raw = fs.readFileSync(filePath, "utf8");
242
- return JSON.parse(raw);
243
- } catch {
244
- return null;
245
- }
246
- }
247
-
248
- function validateScreenConfig(config) {
249
- if (!config || typeof config !== "object" || Array.isArray(config)) {
250
- return {
251
- ok: false,
252
- reason: "INVALID_OR_MISSING_CONFIG",
253
- message: "screening-config.json 缺失或格式无效。请填写 baseUrl、apiKey、model。"
254
- };
255
- }
256
- const baseUrl = String(config.baseUrl || "").trim();
257
- const apiKey = String(config.apiKey || "").trim();
258
- const model = String(config.model || "").trim();
259
- const missing = [];
260
- if (!baseUrl) missing.push("baseUrl");
261
- if (!apiKey) missing.push("apiKey");
262
- if (!model) missing.push("model");
263
- if (missing.length > 0) {
264
- return {
265
- ok: false,
266
- reason: "MISSING_REQUIRED_FIELDS",
267
- message: `screening-config.json 缺少必填字段:${missing.join(", ")}。`
268
- };
269
- }
270
- if (/^replace-with/i.test(apiKey) || apiKey === screenConfigTemplateDefaults.apiKey) {
271
- return {
272
- ok: false,
273
- reason: "PLACEHOLDER_API_KEY",
274
- message: "screening-config.json 的 apiKey 仍是模板占位符,请填写真实 API Key。"
275
- };
276
- }
277
- if (
278
- baseUrl === screenConfigTemplateDefaults.baseUrl
279
- && apiKey === screenConfigTemplateDefaults.apiKey
280
- && model === screenConfigTemplateDefaults.model
281
- ) {
282
- return {
283
- ok: false,
284
- reason: "PLACEHOLDER_TEMPLATE_VALUES",
285
- message: "screening-config.json 仍是默认模板值,请填写 baseUrl、apiKey、model。"
286
- };
287
- }
288
- return { ok: true, reason: "OK", message: "screening-config.json 校验通过。" };
289
- }
290
-
291
- function resolveWorkspaceDebugPort(workspaceRoot) {
292
- const fromEnv = parsePositiveInteger(process.env.BOSS_RECOMMEND_CHROME_PORT);
293
- if (fromEnv) return fromEnv;
294
- const config = readJsonFile(resolveScreenConfigPath(workspaceRoot));
295
- return parsePositiveInteger(config?.debugPort) || 9222;
296
- }
297
-
298
- function getDefaultChromeExecutableCandidates() {
299
- const candidates = [process.env.BOSS_RECOMMEND_CHROME_PATH].filter(Boolean);
300
- if (process.platform === "win32") {
301
- candidates.push(
302
- path.join(process.env.LOCALAPPDATA || "", "Google", "Chrome", "Application", "chrome.exe"),
303
- path.join(process.env.ProgramFiles || "", "Google", "Chrome", "Application", "chrome.exe"),
304
- path.join(process.env["ProgramFiles(x86)"] || "", "Google", "Chrome", "Application", "chrome.exe")
305
- );
306
- } else if (process.platform === "darwin") {
307
- candidates.push(
308
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
309
- path.join(os.homedir(), "Applications", "Google Chrome.app", "Contents", "MacOS", "Google Chrome"),
310
- "/Applications/Chromium.app/Contents/MacOS/Chromium"
311
- );
312
- } else {
313
- candidates.push(
314
- "/usr/bin/google-chrome",
315
- "/usr/bin/google-chrome-stable",
316
- "/usr/bin/chromium-browser",
317
- "/usr/bin/chromium",
318
- "/snap/bin/chromium"
319
- );
320
- }
321
- return Array.from(new Set(candidates.filter(Boolean)));
322
- }
323
-
324
- function getChromeExecutable() {
325
- const candidates = getDefaultChromeExecutableCandidates();
326
- return candidates.find((candidate) => pathExists(candidate)) || null;
327
- }
328
-
329
- function getChromeUserDataDir(port) {
330
- const profileDir = resolveDefaultChromeUserDataDir(port);
331
- ensureDir(profileDir);
332
- return profileDir;
333
- }
334
-
335
- function getSharedChromeUserDataDir(port) {
336
- return path.join(getCodexHome(), "boss-mcp", `chrome-profile-${port}`);
337
- }
338
-
339
- function getLegacyRecruitChromeUserDataDir(port) {
340
- return path.join(getCodexHome(), "boss-recruit-mcp", `chrome-profile-${port}`);
341
- }
342
-
343
- function getLegacyRecommendChromeUserDataDir(port) {
344
- return path.join(getStateHome(), `chrome-profile-${port}`);
345
- }
346
-
347
- function resolveDefaultChromeUserDataDir(port) {
348
- const sharedPath = getSharedChromeUserDataDir(port);
349
- if (pathExists(sharedPath)) {
350
- return sharedPath;
351
- }
352
- const legacyPaths = [
353
- getLegacyRecruitChromeUserDataDir(port),
354
- getLegacyRecommendChromeUserDataDir(port)
355
- ];
356
- const legacyExisting = legacyPaths.find((candidate) => pathExists(candidate));
357
- return legacyExisting || sharedPath;
358
- }
359
-
360
- function launchChromeWithDebugPort(port) {
361
- const chromePath = getChromeExecutable();
362
- if (!chromePath) {
363
- return {
364
- ok: false,
365
- code: "CHROME_EXECUTABLE_NOT_FOUND",
366
- message: "未找到 Chrome 可执行文件,请安装 Chrome 或设置 BOSS_RECOMMEND_CHROME_PATH。"
367
- };
368
- }
369
- const userDataDir = getChromeUserDataDir(port);
370
- const args = [
371
- `--remote-debugging-port=${port}`,
372
- `--user-data-dir=${userDataDir}`,
373
- "--no-first-run",
374
- "--no-default-browser-check",
375
- "--new-window",
376
- bossRecommendUrl
377
- ];
378
-
379
- try {
380
- const child = spawn(chromePath, args, {
381
- detached: true,
382
- stdio: "ignore",
383
- windowsHide: false
384
- });
385
- child.unref();
386
- return {
387
- ok: true,
388
- code: "CHROME_LAUNCHED",
389
- chrome_path: chromePath,
390
- user_data_dir: userDataDir
391
- };
392
- } catch (error) {
393
- return {
394
- ok: false,
395
- code: "CHROME_LAUNCH_FAILED",
396
- message: error.message || "Chrome 启动失败。"
397
- };
398
- }
399
- }
400
-
401
- function resolveRecommendSearchCliDir(workspaceRoot) {
402
- const localDir = path.join(workspaceRoot, "boss-recommend-search-cli");
403
- if (pathExists(localDir)) return localDir;
404
- const vendoredDir = path.join(packagedMcpDir, "vendor", "boss-recommend-search-cli");
405
- if (pathExists(vendoredDir)) return vendoredDir;
406
- return null;
407
- }
408
-
409
- function resolveRecommendScreenCliDir(workspaceRoot) {
410
- const localDir = path.join(workspaceRoot, "boss-recommend-screen-cli");
411
- if (pathExists(localDir)) return localDir;
412
- const vendoredDir = path.join(packagedMcpDir, "vendor", "boss-recommend-screen-cli");
413
- if (pathExists(vendoredDir)) return vendoredDir;
414
- return null;
415
- }
416
-
417
- function resolveRecommendScreenCliEntry(screenDir) {
418
- const candidates = [
419
- path.join(screenDir, "boss-recommend-screen-cli.cjs"),
420
- path.join(screenDir, "boss-recommend-screen-cli.js")
421
- ];
422
- return candidates.find((candidate) => pathExists(candidate)) || candidates[0];
423
- }
424
-
425
- function resolveRecommendSearchCliEntry(searchDir) {
426
- const candidates = [
427
- path.join(searchDir, "src", "cli.js"),
428
- path.join(searchDir, "src", "cli.cjs")
429
- ];
430
- return candidates.find((candidate) => pathExists(candidate)) || candidates[0];
431
- }
432
-
433
- function safeInvokeCallback(callback, payload) {
434
- if (typeof callback !== "function") return;
435
- try {
436
- callback(payload);
437
- } catch {
438
- // Ignore callback errors to keep pipeline runtime stable.
439
- }
440
- }
441
-
442
- function runProcess({
443
- command,
444
- args,
445
- cwd,
446
- timeoutMs,
447
- onOutput,
448
- onLine,
449
- onHeartbeat,
450
- heartbeatIntervalMs = 10_000,
451
- signal
452
- }) {
453
- return new Promise((resolve) => {
454
- let stdout = "";
455
- let stderr = "";
456
- let stdoutLineBuffer = "";
457
- let stderrLineBuffer = "";
458
- let settled = false;
459
- let timer = null;
460
- let heartbeatTimer = null;
461
- let abortedBySignal = Boolean(signal?.aborted);
462
- let abortListener = null;
463
-
464
- function notifyHeartbeat(source) {
465
- safeInvokeCallback(onHeartbeat, {
466
- source,
467
- command,
468
- args,
469
- cwd,
470
- at: new Date().toISOString()
471
- });
472
- }
473
-
474
- function emitLine(stream, line) {
475
- const normalized = String(line ?? "").replace(/\r$/, "");
476
- if (!normalized) return;
477
- safeInvokeCallback(onLine, {
478
- stream,
479
- line: normalized,
480
- at: new Date().toISOString()
481
- });
482
- }
483
-
484
- function pushLineBuffer(stream, chunkText) {
485
- if (stream === "stdout") {
486
- stdoutLineBuffer += chunkText;
487
- } else {
488
- stderrLineBuffer += chunkText;
489
- }
490
- let buffer = stream === "stdout" ? stdoutLineBuffer : stderrLineBuffer;
491
- let newlineIndex = buffer.indexOf("\n");
492
- while (newlineIndex !== -1) {
493
- emitLine(stream, buffer.slice(0, newlineIndex));
494
- buffer = buffer.slice(newlineIndex + 1);
495
- newlineIndex = buffer.indexOf("\n");
496
- }
497
- if (stream === "stdout") {
498
- stdoutLineBuffer = buffer;
499
- } else {
500
- stderrLineBuffer = buffer;
501
- }
502
- }
503
-
504
- function finish(payload) {
505
- if (settled) return;
506
- settled = true;
507
- if (timer) clearTimeout(timer);
508
- if (heartbeatTimer) clearInterval(heartbeatTimer);
509
- if (signal && typeof signal.removeEventListener === "function" && abortListener) {
510
- signal.removeEventListener("abort", abortListener);
511
- }
512
- emitLine("stdout", stdoutLineBuffer);
513
- emitLine("stderr", stderrLineBuffer);
514
- stdoutLineBuffer = "";
515
- stderrLineBuffer = "";
516
- resolve(payload);
517
- }
518
-
519
- if (abortedBySignal) {
520
- finish({
521
- code: -1,
522
- stdout,
523
- stderr: "Process aborted before spawn",
524
- error_code: "ABORTED"
525
- });
526
- return;
527
- }
528
-
529
- let child;
530
- try {
531
- child = spawn(command, args, {
532
- cwd,
533
- windowsHide: true,
534
- shell: false,
535
- env: process.env
536
- });
537
- } catch (error) {
538
- finish({
539
- code: -1,
540
- stdout,
541
- stderr: error.message,
542
- error_code: error.code || "SPAWN_FAILED"
543
- });
544
- return;
545
- }
546
-
547
- if (signal && typeof signal.addEventListener === "function") {
548
- abortListener = () => {
549
- abortedBySignal = true;
550
- try {
551
- child.kill();
552
- } catch {}
553
- };
554
- signal.addEventListener("abort", abortListener, { once: true });
555
- }
556
-
557
- if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) {
558
- timer = setTimeout(() => {
559
- try {
560
- child.kill();
561
- } catch {}
562
- finish({
563
- code: -1,
564
- stdout,
565
- stderr: `${stderr}\nProcess timed out after ${timeoutMs}ms`.trim(),
566
- error_code: "TIMEOUT"
567
- });
568
- }, timeoutMs);
569
- }
570
-
571
- if (Number.isFinite(heartbeatIntervalMs) && heartbeatIntervalMs > 0) {
572
- heartbeatTimer = setInterval(() => {
573
- notifyHeartbeat("timer");
574
- }, heartbeatIntervalMs);
575
- }
576
-
577
- child.stdout.on("data", (chunk) => {
578
- const text = chunk.toString();
579
- stdout += text;
580
- pushLineBuffer("stdout", text);
581
- safeInvokeCallback(onOutput, {
582
- stream: "stdout",
583
- text,
584
- at: new Date().toISOString()
585
- });
586
- notifyHeartbeat("stdout");
587
- });
588
- child.stderr.on("data", (chunk) => {
589
- const text = chunk.toString();
590
- stderr += text;
591
- pushLineBuffer("stderr", text);
592
- safeInvokeCallback(onOutput, {
593
- stream: "stderr",
594
- text,
595
- at: new Date().toISOString()
596
- });
597
- notifyHeartbeat("stderr");
598
- });
599
- child.on("close", (code) => {
600
- if (abortedBySignal) {
601
- finish({
602
- code: -1,
603
- stdout,
604
- stderr: `${stderr}\nProcess aborted by signal`.trim(),
605
- error_code: "ABORTED"
606
- });
607
- return;
608
- }
609
- finish({ code, stdout, stderr });
610
- });
611
- child.on("error", (error) => {
612
- finish({
613
- code: -1,
614
- stdout,
615
- stderr: `${stderr}\n${error.message}`.trim(),
616
- error_code: error.code || "SPAWN_FAILED"
617
- });
618
- });
619
- });
620
- }
621
-
622
- function runProcessSync({ command, args, cwd }) {
623
- try {
624
- const result = spawnSync(command, args, {
625
- cwd,
626
- windowsHide: true,
627
- shell: false,
628
- env: process.env,
629
- encoding: "utf8"
630
- });
631
- const stdout = String(result.stdout || "").trim();
632
- const stderr = String(result.stderr || "").trim();
633
- return {
634
- ok: result.status === 0,
635
- status: result.status,
636
- stdout,
637
- stderr,
638
- output: [stdout, stderr].filter(Boolean).join("\n").trim(),
639
- error_code: result.error?.code || null,
640
- error_message: result.error?.message || null
641
- };
642
- } catch (error) {
643
- return {
644
- ok: false,
645
- status: -1,
646
- stdout: "",
647
- stderr: "",
648
- output: "",
649
- error_code: error.code || "SPAWN_FAILED",
650
- error_message: error.message || String(error)
651
- };
652
- }
653
- }
654
-
655
- function parseMajorVersion(raw) {
656
- const match = String(raw || "").match(/v?(\d+)(?:\.\d+){0,2}/);
657
- if (!match) return null;
658
- const major = Number.parseInt(match[1], 10);
659
- return Number.isFinite(major) ? major : null;
660
- }
661
-
662
- function buildNodeCommandCheck() {
663
- const probe = runProcessSync({
664
- command: "node",
665
- args: ["--version"]
666
- });
667
- const major = parseMajorVersion(probe.output);
668
- const versionOk = Number.isInteger(major) && major >= 18;
669
- return {
670
- key: "node_cli",
671
- ok: probe.ok && versionOk,
672
- path: "node --version",
673
- message: probe.ok
674
- ? (versionOk
675
- ? `Node 命令可用 (${probe.output || "unknown version"})`
676
- : `Node 版本过低 (${probe.output || "unknown version"}),要求 >= 18`)
677
- : `未找到 node 命令,请先安装 Node.js >= 18。${probe.error_message ? ` (${probe.error_message})` : ""}`
678
- };
679
- }
680
-
681
- function detectPythonCommand() {
682
- const python = runProcessSync({
683
- command: "python",
684
- args: ["--version"]
685
- });
686
- if (python.ok) {
687
- return {
688
- ok: true,
689
- command: "python",
690
- probe: python
691
- };
692
- }
693
- const python3 = runProcessSync({
694
- command: "python3",
695
- args: ["--version"]
696
- });
697
- if (python3.ok) {
698
- return {
699
- ok: false,
700
- command: null,
701
- probe: python,
702
- fallback: python3
703
- };
704
- }
705
- return {
706
- ok: false,
707
- command: null,
708
- probe: python,
709
- fallback: null
710
- };
711
- }
712
-
713
- function buildPythonCommandCheck() {
714
- const detected = detectPythonCommand();
715
- if (detected.ok) {
716
- return {
717
- key: "python_cli",
718
- ok: true,
719
- path: "python --version",
720
- message: `Python 命令可用 (${detected.probe.output || "unknown version"})`
721
- };
722
- }
723
- if (detected.fallback) {
724
- return {
725
- key: "python_cli",
726
- ok: false,
727
- path: "python --version",
728
- message: `检测到 ${detected.fallback.output || "python3"},但当前流程依赖 python 命令;请创建 python 别名后重试。`
729
- };
730
- }
731
- return {
732
- key: "python_cli",
733
- ok: false,
734
- path: "python --version",
735
- message: "未找到 python 命令,请安装 Python 并确保 python 在 PATH 中。"
736
- };
737
- }
738
-
739
- function buildPillowCheck() {
740
- const detected = detectPythonCommand();
741
- if (!detected.ok || !detected.command) {
742
- return {
743
- key: "python_pillow",
744
- ok: false,
745
- path: "python -c \"import PIL\"",
746
- message: "无法校验 Pillow:python 命令不可用。"
747
- };
748
- }
749
- const probe = runProcessSync({
750
- command: detected.command,
751
- args: ["-c", "import PIL, PIL.Image; print(PIL.__version__)"]
752
- });
753
- return {
754
- key: "python_pillow",
755
- ok: probe.ok,
756
- path: `${detected.command} -c "import PIL"`,
757
- message: probe.ok
758
- ? `Pillow 可用 (${probe.output || "version unknown"})`
759
- : "Pillow 未安装。请执行 `python -m pip install pillow`。"
760
- };
761
- }
762
-
763
- function buildNodePackageCheck({ key, moduleName, cwd, missingMessage }) {
764
- if (!cwd || !pathExists(cwd)) {
765
- return {
766
- key,
767
- ok: false,
768
- path: moduleName,
769
- module: moduleName,
770
- install_cwd: null,
771
- message: missingMessage
772
- };
773
- }
774
- const probe = runProcessSync({
775
- command: "node",
776
- args: ["-e", `require.resolve(${JSON.stringify(moduleName)});`],
777
- cwd
778
- });
779
- return {
780
- key,
781
- ok: probe.ok,
782
- path: moduleName,
783
- module: moduleName,
784
- install_cwd: cwd,
785
- message: probe.ok
786
- ? `${moduleName} npm 依赖可用`
787
- : `缺少 npm 依赖 ${moduleName},请在 boss-recommend-mcp 目录执行 npm install。`
788
- };
789
- }
790
-
791
- function buildRuntimeDependencyChecks({ searchDir, screenDir }) {
792
- return [
793
- buildNodeCommandCheck(),
794
- buildPythonCommandCheck(),
795
- buildPillowCheck(),
796
- buildNodePackageCheck({
797
- key: "npm_dep_chrome_remote_interface_search",
798
- moduleName: "chrome-remote-interface",
799
- cwd: searchDir,
800
- missingMessage: "无法校验 chrome-remote-interface:boss-recommend-search-cli 目录不存在。"
801
- }),
802
- buildNodePackageCheck({
803
- key: "npm_dep_chrome_remote_interface_screen",
804
- moduleName: "chrome-remote-interface",
805
- cwd: screenDir,
806
- missingMessage: "无法校验 chrome-remote-interface:boss-recommend-screen-cli 目录不存在。"
807
- }),
808
- buildNodePackageCheck({
809
- key: "npm_dep_ws",
810
- moduleName: "ws",
811
- cwd: screenDir,
812
- missingMessage: "无法校验 ws:boss-recommend-screen-cli 目录不存在。"
813
- })
814
- ];
815
- }
816
-
817
- function parseJsonOutput(text) {
818
- const trimmed = String(text || "").trim();
819
- if (!trimmed) return null;
820
- try {
821
- return JSON.parse(trimmed);
822
- } catch {}
823
- const lines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
824
- for (let index = lines.length - 1; index >= 0; index -= 1) {
825
- try {
826
- return JSON.parse(lines[index]);
827
- } catch {
828
- continue;
829
- }
830
- }
831
- return null;
832
- }
833
-
834
- function createScreenProgressTracker(currentTracker = {}) {
835
- const outcome = String(currentTracker.outcome || "").trim();
836
- return {
837
- candidate_index: Number.isInteger(currentTracker.candidate_index) ? currentTracker.candidate_index : null,
838
- outcome: outcome === "pass" || outcome === "skip" ? outcome : null,
839
- action_failed: currentTracker.action_failed === true
840
- };
841
- }
842
-
843
- function finalizeCandidateProgress(progress, tracker) {
844
- if (!Number.isInteger(tracker.candidate_index)) {
845
- return false;
846
- }
847
-
848
- let changed = false;
849
- if (tracker.action_failed === true) {
850
- progress.skipped += 1;
851
- changed = true;
852
- } else if (tracker.outcome === "pass") {
853
- progress.passed += 1;
854
- changed = true;
855
- } else if (tracker.outcome === "skip") {
856
- progress.skipped += 1;
857
- changed = true;
858
- }
859
-
860
- tracker.candidate_index = null;
861
- tracker.outcome = null;
862
- tracker.action_failed = false;
863
- return changed;
864
- }
865
-
866
- function parseScreenProgressLine(line, currentProgress = {}, currentTracker = {}) {
867
- const normalizedLine = String(line || "").replace(/\s+/g, " ").trim();
868
- if (!normalizedLine) return null;
869
-
870
- const nextProgress = {
871
- processed: Number.isInteger(currentProgress.processed) ? currentProgress.processed : 0,
872
- passed: Number.isInteger(currentProgress.passed) ? currentProgress.passed : 0,
873
- skipped: Number.isInteger(currentProgress.skipped) ? currentProgress.skipped : 0,
874
- greet_count: Number.isInteger(currentProgress.greet_count) ? currentProgress.greet_count : 0
875
- };
876
- const nextTracker = createScreenProgressTracker(currentTracker);
877
-
878
- let changed = false;
879
- const processedMatch = normalizedLine.match(/处理第\s*(\d+)\s*位候选人/u);
880
- if (processedMatch) {
881
- if (finalizeCandidateProgress(nextProgress, nextTracker)) {
882
- changed = true;
883
- }
884
- const processed = Number.parseInt(processedMatch[1], 10);
885
- if (Number.isInteger(processed) && processed >= 0 && processed !== nextProgress.processed) {
886
- nextProgress.processed = processed;
887
- changed = true;
888
- }
889
- nextTracker.candidate_index = processed;
890
- nextTracker.outcome = null;
891
- nextTracker.action_failed = false;
892
- }
893
-
894
- if (/筛选结果:\s*通过/u.test(normalizedLine)) {
895
- if (nextTracker.outcome !== "pass" || nextTracker.action_failed) {
896
- changed = true;
897
- }
898
- nextTracker.outcome = "pass";
899
- nextTracker.action_failed = false;
900
- } else if (/筛选结果:\s*不通过/u.test(normalizedLine)) {
901
- if (nextTracker.outcome !== "skip" || nextTracker.action_failed) {
902
- changed = true;
903
- }
904
- nextTracker.outcome = "skip";
905
- nextTracker.action_failed = false;
906
- }
907
-
908
- if (/候选人处理失败\s*:/u.test(normalizedLine)) {
909
- if (!nextTracker.action_failed) {
910
- changed = true;
911
- }
912
- nextTracker.action_failed = true;
913
- }
914
-
915
- if (/^\[关闭详情\].*成功/u.test(normalizedLine)) {
916
- if (finalizeCandidateProgress(nextProgress, nextTracker)) {
917
- changed = true;
918
- }
919
- }
920
-
921
- const finalStateLine = /Process timed out after|status"\s*:\s*"(?:COMPLETED|PAUSED|FAILED)"/iu.test(normalizedLine);
922
- if (finalStateLine) {
923
- if (finalizeCandidateProgress(nextProgress, nextTracker)) {
924
- changed = true;
925
- }
926
- }
927
-
928
- const greetMatch = normalizedLine.match(/greet[_\s-]*count\s*[:=]\s*(\d+)/iu);
929
- if (greetMatch) {
930
- const greetCount = Number.parseInt(greetMatch[1], 10);
931
- if (Number.isInteger(greetCount) && greetCount >= 0 && greetCount !== nextProgress.greet_count) {
932
- nextProgress.greet_count = greetCount;
933
- changed = true;
934
- }
935
- }
936
-
937
- if (!changed) return null;
938
- return {
939
- line: normalizedLine,
940
- progress: nextProgress,
941
- tracker: nextTracker
942
- };
943
- }
944
-
945
- function resolveRecommendScreenTimeoutMs(runtime = null) {
946
- const runtimeTimeoutMs = parsePositiveInteger(runtime?.timeoutMs);
947
- const envTimeoutMs = parsePositiveInteger(process.env.BOSS_RECOMMEND_SCREEN_TIMEOUT_MS);
948
- return runtimeTimeoutMs || envTimeoutMs || DEFAULT_RECOMMEND_SCREEN_TIMEOUT_MS;
949
- }
950
-
951
- function buildRecommendScreenProcessError(result, screenTimeoutMs) {
952
- if (result.code === 0) return null;
953
- if (result.error_code === "TIMEOUT") {
954
- return {
955
- code: "TIMEOUT",
956
- message: `推荐页筛选命令执行超时(${screenTimeoutMs}ms)。`
957
- };
958
- }
959
- if (result.error_code === "ABORTED") {
960
- return {
961
- code: "PROCESS_ABORTED",
962
- message: "推荐页筛选命令已取消。"
963
- };
964
- }
965
- return {
966
- code: "RECOMMEND_SCREEN_FAILED",
967
- message: "推荐页筛选命令执行失败。"
968
- };
969
- }
970
-
971
- function loadScreenConfig(configPath) {
972
- const parsed = readJsonFile(configPath);
973
- const validation = validateScreenConfig(parsed);
974
- if (!validation.ok) {
975
- return {
976
- ok: false,
977
- error: `${validation.message} (path: ${configPath})`
978
- };
979
- }
980
- return { ok: true, config: parsed };
981
- }
982
-
983
- function localDirHint(workspaceRoot, dirName) {
984
- return path.join(workspaceRoot, dirName);
985
- }
986
-
987
- export function runPipelinePreflight(workspaceRoot) {
988
- const searchDir = resolveRecommendSearchCliDir(workspaceRoot);
989
- const screenDir = resolveRecommendScreenCliDir(workspaceRoot);
990
- const searchDirExists = Boolean(searchDir && pathExists(searchDir));
991
- const searchEntryPath = searchDir
992
- ? resolveRecommendSearchCliEntry(searchDir)
993
- : path.join(localDirHint(workspaceRoot, "boss-recommend-search-cli"), "src", "cli.js");
994
- const searchEntryExists = Boolean(searchDir && pathExists(searchEntryPath));
995
- const screenDirExists = Boolean(screenDir && pathExists(screenDir));
996
- const screenEntryPath = screenDir
997
- ? resolveRecommendScreenCliEntry(screenDir)
998
- : path.join(localDirHint(workspaceRoot, "boss-recommend-screen-cli"), "boss-recommend-screen-cli.cjs");
999
- const screenEntryExists = Boolean(screenDir && pathExists(screenEntryPath));
1000
- const configResolution = getScreenConfigResolution(workspaceRoot);
1001
- const screenConfigPath = configResolution.resolved_path;
1002
- const screenConfigParsed = readJsonFile(screenConfigPath);
1003
- const screenConfigValidation = validateScreenConfig(screenConfigParsed);
1004
- const checks = [
1005
- {
1006
- key: "recommend_search_cli_dir",
1007
- ok: searchDirExists,
1008
- path: searchDir || localDirHint(workspaceRoot, "boss-recommend-search-cli"),
1009
- message: searchDirExists
1010
- ? "boss-recommend-search-cli 目录可用"
1011
- : "boss-recommend-search-cli 目录不存在"
1012
- },
1013
- {
1014
- key: "recommend_search_cli_entry",
1015
- ok: searchEntryExists,
1016
- path: searchEntryPath,
1017
- message: searchEntryExists
1018
- ? "boss-recommend-search-cli 入口文件可用"
1019
- : "boss-recommend-search-cli 入口文件缺失"
1020
- },
1021
- {
1022
- key: "recommend_screen_cli_dir",
1023
- ok: screenDirExists,
1024
- path: screenDir || localDirHint(workspaceRoot, "boss-recommend-screen-cli"),
1025
- message: screenDirExists
1026
- ? "boss-recommend-screen-cli 目录可用"
1027
- : "boss-recommend-screen-cli 目录不存在"
1028
- },
1029
- {
1030
- key: "recommend_screen_cli_entry",
1031
- ok: screenEntryExists,
1032
- path: screenEntryPath,
1033
- message: screenEntryExists
1034
- ? "boss-recommend-screen-cli 入口文件可用"
1035
- : "boss-recommend-screen-cli 入口文件缺失"
1036
- },
1037
- {
1038
- key: "screen_config",
1039
- ok: screenConfigValidation.ok,
1040
- path: screenConfigPath,
1041
- reason: screenConfigValidation.reason || null,
1042
- message: screenConfigValidation.ok ? "screening-config.json 可用" : screenConfigValidation.message
1043
- }
1044
- ];
1045
- checks.push(...buildRuntimeDependencyChecks({ searchDir, screenDir }));
1046
-
1047
- return {
1048
- ok: checks.every((item) => item.ok),
1049
- checks,
1050
- debug_port: resolveWorkspaceDebugPort(workspaceRoot),
1051
- config_resolution: configResolution
1052
- };
1053
- }
1054
-
1055
- function collectFailedCheckKeys(checks = []) {
1056
- return new Set(
1057
- checks
1058
- .filter((item) => item && item.ok === false && typeof item.key === "string")
1059
- .map((item) => item.key)
1060
- );
1061
- }
1062
-
1063
- function collectNpmInstallDirsFromChecks(checks = [], workspaceRoot) {
1064
- const npmKeys = new Set([
1065
- "npm_dep_chrome_remote_interface_search",
1066
- "npm_dep_chrome_remote_interface_screen",
1067
- "npm_dep_ws"
1068
- ]);
1069
- const dirs = checks
1070
- .filter((item) => item && item.ok === false && npmKeys.has(item.key))
1071
- .map((item) => item.install_cwd)
1072
- .filter((item) => typeof item === "string" && item.trim())
1073
- .map((item) => path.resolve(item));
1074
- if (dirs.length > 0) {
1075
- return [...new Set(dirs)];
1076
- }
1077
- return [path.resolve(workspaceRoot)];
1078
- }
1079
-
1080
- function installNpmDependencies(checks, workspaceRoot) {
1081
- const dirs = collectNpmInstallDirsFromChecks(checks, workspaceRoot);
1082
- const commandResults = [];
1083
- let allOk = true;
1084
- for (const cwd of dirs) {
1085
- const result = runProcessSync({
1086
- command: "npm",
1087
- args: ["install"],
1088
- cwd
1089
- });
1090
- commandResults.push({
1091
- cwd,
1092
- ok: result.ok,
1093
- output: result.output || result.error_message || ""
1094
- });
1095
- if (!result.ok) allOk = false;
1096
- }
1097
- return {
1098
- ok: allOk,
1099
- action: "install_npm_dependencies",
1100
- changed: true,
1101
- command_results: commandResults,
1102
- message: allOk ? "npm 依赖自动安装完成。" : "npm 依赖自动安装失败。"
1103
- };
1104
- }
1105
-
1106
- function installPillowIfPossible() {
1107
- const detected = detectPythonCommand();
1108
- if (!detected.ok || !detected.command) {
1109
- return {
1110
- ok: false,
1111
- action: "install_pillow",
1112
- changed: false,
1113
- message: "未检测到可用 python 命令,无法自动安装 Pillow。"
1114
- };
1115
- }
1116
- const install = runProcessSync({
1117
- command: detected.command,
1118
- args: ["-m", "pip", "install", "pillow"]
1119
- });
1120
- return {
1121
- ok: install.ok,
1122
- action: "install_pillow",
1123
- changed: install.ok,
1124
- message: install.ok ? "Pillow 自动安装完成。" : `Pillow 自动安装失败:${install.output || install.error_message || "unknown"}`
1125
- };
1126
- }
1127
-
1128
- export function attemptPipelineAutoRepair(workspaceRoot, preflight = {}) {
1129
- const checks = Array.isArray(preflight.checks) ? preflight.checks : [];
1130
- const failed = collectFailedCheckKeys(checks);
1131
- const actions = [];
1132
-
1133
- if (
1134
- failed.has("npm_dep_chrome_remote_interface_search")
1135
- || failed.has("npm_dep_chrome_remote_interface_screen")
1136
- || failed.has("npm_dep_ws")
1137
- ) {
1138
- if (!failed.has("node_cli")) {
1139
- actions.push(installNpmDependencies(checks, workspaceRoot));
1140
- } else {
1141
- actions.push({
1142
- ok: false,
1143
- action: "install_npm_dependencies",
1144
- changed: false,
1145
- message: "Node 命令不可用,跳过 npm 自动安装。"
1146
- });
1147
- }
1148
- }
1149
-
1150
- if (failed.has("python_pillow")) {
1151
- if (!failed.has("python_cli")) {
1152
- actions.push(installPillowIfPossible());
1153
- } else {
1154
- actions.push({
1155
- ok: false,
1156
- action: "install_pillow",
1157
- changed: false,
1158
- message: "python 命令不可用,跳过 Pillow 自动安装。"
1159
- });
1160
- }
1161
- }
1162
-
1163
- const attempted = actions.length > 0;
1164
- const nextPreflight = runPipelinePreflight(workspaceRoot);
1165
- return {
1166
- attempted,
1167
- actions,
1168
- preflight: nextPreflight
1169
- };
1170
- }
1171
-
1172
- function sleep(ms) {
1173
- return new Promise((resolve) => setTimeout(resolve, ms));
1174
- }
1175
-
1176
- async function listChromeTabs(port) {
1177
- const response = await fetch(`http://127.0.0.1:${port}/json/list`);
1178
- if (!response.ok) {
1179
- throw new Error(`DevTools endpoint returned ${response.status}`);
1180
- }
1181
- const data = await response.json();
1182
- return Array.isArray(data) ? data : [];
1183
- }
1184
-
1185
- function buildBossPageState(payload) {
1186
- return {
1187
- key: "boss_page_state",
1188
- ...payload
1189
- };
1190
- }
1191
-
1192
- function extractSampleUrls(tabs, limit = 5) {
1193
- return tabs
1194
- .map((tab) => tab?.url)
1195
- .filter(Boolean)
1196
- .slice(0, limit);
1197
- }
1198
-
1199
- function findChromeOnboardingUrl(tabs) {
1200
- for (const tab of tabs) {
1201
- if (typeof tab?.url === "string" && chromeOnboardingUrlPattern.test(tab.url)) {
1202
- return tab.url;
1203
- }
1204
- }
1205
- return null;
1206
- }
1207
-
1208
- function isBossLoginTab(tab) {
1209
- const url = String(tab?.url || "");
1210
- const title = String(tab?.title || "");
1211
- return (
1212
- url === bossLoginUrl
1213
- || bossLoginUrlPattern.test(url)
1214
- || bossLoginTitlePattern.test(title)
1215
- );
1216
- }
1217
-
1218
- export async function inspectBossRecommendPageState(port, options = {}) {
1219
- const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 6000;
1220
- const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : 1000;
1221
- const expectedUrl = options.expectedUrl || bossRecommendUrl;
1222
- const deadline = Date.now() + timeoutMs;
1223
- let lastError = null;
1224
- let lastTabs = [];
1225
-
1226
- while (Date.now() < deadline) {
1227
- try {
1228
- const tabs = await listChromeTabs(port);
1229
- lastTabs = tabs;
1230
- const exactTab = tabs.find(
1231
- (tab) => typeof tab?.url === "string" && tab.url.includes("/web/chat/recommend")
1232
- );
1233
- if (exactTab) {
1234
- if (isBossLoginTab(exactTab)) {
1235
- return buildBossPageState({
1236
- ok: false,
1237
- state: "LOGIN_REQUIRED",
1238
- path: exactTab.url || bossLoginUrl,
1239
- current_url: exactTab.url || bossLoginUrl,
1240
- title: exactTab.title || null,
1241
- requires_login: true,
1242
- expected_url: expectedUrl,
1243
- login_url: bossLoginUrl,
1244
- message: "当前标签页虽在 recommend 路径,但检测到登录态页面特征,请先完成 Boss 登录。"
1245
- });
1246
- }
1247
- return buildBossPageState({
1248
- ok: true,
1249
- state: "RECOMMEND_READY",
1250
- path: exactTab.url,
1251
- current_url: exactTab.url,
1252
- title: exactTab.title || null,
1253
- requires_login: false,
1254
- message: "Boss 推荐页已打开,且当前仍停留在 recommend 页面。"
1255
- });
1256
- }
1257
-
1258
- const loginTab = tabs.find((tab) => isBossLoginTab(tab));
1259
- if (loginTab) {
1260
- return buildBossPageState({
1261
- ok: false,
1262
- state: "LOGIN_REQUIRED",
1263
- path: loginTab.url || bossLoginUrl,
1264
- current_url: loginTab.url || bossLoginUrl,
1265
- title: loginTab.title || null,
1266
- requires_login: true,
1267
- expected_url: expectedUrl,
1268
- login_url: bossLoginUrl,
1269
- message: "Boss 页面未登录,需先完成登录后再进入 recommend 页面。"
1270
- });
1271
- }
1272
-
1273
- const bossTab = tabs.find(
1274
- (tab) => typeof tab?.url === "string" && tab.url.includes("zhipin.com")
1275
- );
1276
- if (bossTab) {
1277
- const requiresLogin = bossLoginUrlPattern.test(bossTab.url);
1278
- return buildBossPageState({
1279
- ok: false,
1280
- state: requiresLogin ? "LOGIN_REQUIRED" : "BOSS_NOT_ON_RECOMMEND",
1281
- path: bossTab.url,
1282
- current_url: bossTab.url,
1283
- title: bossTab.title || null,
1284
- requires_login: requiresLogin,
1285
- expected_url: expectedUrl,
1286
- message: requiresLogin
1287
- ? "Boss 页面未登录,需先完成登录后再进入 recommend 页面。"
1288
- : "Boss 已登录但当前不在 recommend 页面,将尝试自动跳转。"
1289
- });
1290
- }
1291
- } catch (error) {
1292
- lastError = error;
1293
- }
1294
-
1295
- await sleep(pollMs);
1296
- }
1297
-
1298
- if (lastError) {
1299
- return buildBossPageState({
1300
- ok: false,
1301
- state: "DEBUG_PORT_UNREACHABLE",
1302
- path: `http://127.0.0.1:${port}`,
1303
- current_url: null,
1304
- title: null,
1305
- requires_login: false,
1306
- expected_url: expectedUrl,
1307
- message: `无法连接到 Chrome DevTools 端口 ${port}。请确认 Chrome 已以远程调试模式启动。`,
1308
- error: lastError.message
1309
- });
1310
- }
1311
-
1312
- const onboardingUrl = findChromeOnboardingUrl(lastTabs);
1313
- if (onboardingUrl) {
1314
- return buildBossPageState({
1315
- ok: false,
1316
- state: "CHROME_ONBOARDING_INTERCEPTED",
1317
- path: onboardingUrl,
1318
- current_url: onboardingUrl,
1319
- title: null,
1320
- requires_login: false,
1321
- expected_url: expectedUrl,
1322
- message: "Chrome 当前停留在登录或引导页,尚未稳定到 Boss 推荐页。",
1323
- sample_urls: extractSampleUrls(lastTabs)
1324
- });
1325
- }
1326
-
1327
- return buildBossPageState({
1328
- ok: false,
1329
- state: "BOSS_TAB_NOT_FOUND",
1330
- path: expectedUrl,
1331
- current_url: null,
1332
- title: null,
1333
- requires_login: false,
1334
- expected_url,
1335
- message: "未检测到 Boss 推荐页标签页。",
1336
- sample_urls: extractSampleUrls(lastTabs)
1337
- });
1338
- }
1339
-
1340
- async function openBossRecommendTab(port) {
1341
- const endpoint = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(bossRecommendUrl)}`;
1342
- const attempts = ["PUT", "GET"];
1343
- let lastError = null;
1344
-
1345
- for (const method of attempts) {
1346
- try {
1347
- const response = await fetch(endpoint, { method });
1348
- if (response.ok) {
1349
- return { ok: true, method };
1350
- }
1351
- lastError = new Error(`DevTools /json/new returned ${response.status}`);
1352
- } catch (error) {
1353
- lastError = error;
1354
- }
1355
- }
1356
-
1357
- return {
1358
- ok: false,
1359
- error: lastError?.message || "Failed to open Boss recommend tab via DevTools /json/new"
1360
- };
1361
- }
1362
-
6
+ import CDP from "chrome-remote-interface";
7
+
8
+ const currentFilePath = fileURLToPath(import.meta.url);
9
+ const packagedMcpDir = path.resolve(path.dirname(currentFilePath), "..");
10
+ const bossRecommendUrl = "https://www.zhipin.com/web/chat/recommend";
11
+ const bossLoginUrl = "https://www.zhipin.com/web/user/?ka=bticket";
12
+ const chromeOnboardingUrlPattern = /^chrome:\/\/(welcome|intro|newtab|signin|history-sync|settings\/syncSetup)/i;
13
+ const bossLoginUrlPattern = /(?:zhipin\.com\/web\/user(?:\/|\?|$)|passport\.zhipin\.com)/i;
14
+ const bossLoginTitlePattern = /登录|signin|扫码登录|BOSS直聘登录/i;
15
+ const screenConfigTemplateDefaults = {
16
+ baseUrl: "https://api.openai.com/v1",
17
+ apiKey: "replace-with-openai-api-key",
18
+ model: "gpt-4.1-mini"
19
+ };
20
+ const DEFAULT_RECOMMEND_SCREEN_TIMEOUT_MS = 24 * 60 * 60 * 1000;
21
+
22
+ function getCodexHome() {
23
+ return process.env.CODEX_HOME
24
+ ? path.resolve(process.env.CODEX_HOME)
25
+ : path.join(os.homedir(), ".codex");
26
+ }
27
+
28
+ function getStateHome() {
29
+ return process.env.BOSS_RECOMMEND_HOME
30
+ ? path.resolve(process.env.BOSS_RECOMMEND_HOME)
31
+ : path.join(os.homedir(), ".boss-recommend-mcp");
32
+ }
33
+
34
+ function getUserConfigPath() {
35
+ return path.join(getStateHome(), "screening-config.json");
36
+ }
37
+
38
+ function getLegacyUserConfigPath() {
39
+ return path.join(getCodexHome(), "boss-recommend-mcp", "screening-config.json");
40
+ }
41
+
42
+ function getDesktopDir() {
43
+ return path.join(os.homedir(), "Desktop");
44
+ }
45
+
46
+ function ensureDir(targetPath) {
47
+ fs.mkdirSync(targetPath, { recursive: true });
48
+ }
49
+
50
+ function pathExists(targetPath) {
51
+ try {
52
+ return fs.existsSync(targetPath);
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ function parsePositiveInteger(raw) {
59
+ const value = Number.parseInt(String(raw || ""), 10);
60
+ return Number.isFinite(value) && value > 0 ? value : null;
61
+ }
62
+
63
+ function normalizeText(value) {
64
+ return String(value || "").replace(/\s+/g, " ").trim();
65
+ }
66
+
67
+ function isRootDirectory(targetPath) {
68
+ const resolved = path.resolve(String(targetPath || ""));
69
+ const parsed = path.parse(resolved);
70
+ return resolved.toLowerCase() === String(parsed.root || "").toLowerCase();
71
+ }
72
+
73
+ function isSystemDirectoryWorkspaceRoot(workspaceRoot) {
74
+ const root = path.resolve(String(workspaceRoot || ""));
75
+ const normalized = root.replace(/\\/g, "/").toLowerCase();
76
+ if (process.platform === "win32") {
77
+ return (
78
+ normalized.endsWith("/windows")
79
+ || normalized.endsWith("/windows/system32")
80
+ || normalized.endsWith("/windows/syswow64")
81
+ || normalized.endsWith("/program files")
82
+ || normalized.endsWith("/program files (x86)")
83
+ );
84
+ }
85
+ return (
86
+ normalized === "/system"
87
+ || normalized.startsWith("/system/")
88
+ || normalized === "/usr"
89
+ || normalized.startsWith("/usr/")
90
+ || normalized === "/bin"
91
+ || normalized.startsWith("/bin/")
92
+ || normalized === "/sbin"
93
+ || normalized.startsWith("/sbin/")
94
+ );
95
+ }
96
+
97
+ function shouldIgnoreWorkspaceConfigRoot(workspaceRoot) {
98
+ const root = path.resolve(String(workspaceRoot || process.cwd()));
99
+ const home = path.resolve(os.homedir());
100
+ return (
101
+ isEphemeralNpxWorkspaceRoot(root)
102
+ || isRootDirectory(root)
103
+ || root.toLowerCase() === home.toLowerCase()
104
+ || isSystemDirectoryWorkspaceRoot(root)
105
+ );
106
+ }
107
+
108
+ function resolveWorkspaceConfigCandidates(workspaceRoot) {
109
+ const root = path.resolve(String(workspaceRoot || process.cwd()));
110
+ if (shouldIgnoreWorkspaceConfigRoot(root)) {
111
+ return [];
112
+ }
113
+ const directPath = path.join(root, "config", "screening-config.json");
114
+ const nestedPath = path.join(root, "boss-recommend-mcp", "config", "screening-config.json");
115
+ const candidates = [directPath];
116
+ if (path.basename(root).toLowerCase() !== "boss-recommend-mcp") {
117
+ candidates.push(nestedPath);
118
+ }
119
+ return Array.from(new Set(candidates));
120
+ }
121
+
122
+ function serializeDegreeSelection(value) {
123
+ if (Array.isArray(value)) {
124
+ const normalized = value.map((item) => String(item || "").trim()).filter(Boolean);
125
+ return normalized.length ? normalized.join(",") : "不限";
126
+ }
127
+ const normalized = String(value || "").trim();
128
+ return normalized || "不限";
129
+ }
130
+
131
+ function serializeSchoolTagSelection(value) {
132
+ if (Array.isArray(value)) {
133
+ const normalized = value.map((item) => String(item || "").trim()).filter(Boolean);
134
+ if (!normalized.length) return "不限";
135
+ if (normalized.includes("不限")) {
136
+ return normalized.length === 1
137
+ ? "不限"
138
+ : normalized.filter((item) => item !== "不限").join(",");
139
+ }
140
+ return normalized.join(",");
141
+ }
142
+ const normalized = String(value || "").trim();
143
+ return normalized || "不限";
144
+ }
145
+
146
+ function isEphemeralNpxWorkspaceRoot(workspaceRoot) {
147
+ const root = path.resolve(String(workspaceRoot || ""));
148
+ const normalized = root.replace(/\\/g, "/").toLowerCase();
149
+ return (
150
+ normalized.includes("/appdata/local/npm-cache/_npx/")
151
+ || normalized.includes("/node_modules/@reconcrap/boss-recommend-mcp")
152
+ );
153
+ }
154
+
155
+ function buildScreenConfigCandidateMap(workspaceRoot) {
156
+ return {
157
+ env_path: process.env.BOSS_RECOMMEND_SCREEN_CONFIG
158
+ ? path.resolve(process.env.BOSS_RECOMMEND_SCREEN_CONFIG)
159
+ : null,
160
+ workspace_paths: resolveWorkspaceConfigCandidates(workspaceRoot),
161
+ user_path: getUserConfigPath(),
162
+ legacy_path: getLegacyUserConfigPath()
163
+ };
164
+ }
165
+
166
+ function resolveScreenConfigCandidates(workspaceRoot) {
167
+ const candidateMap = buildScreenConfigCandidateMap(workspaceRoot);
168
+ return [
169
+ candidateMap.env_path,
170
+ candidateMap.user_path,
171
+ ...candidateMap.workspace_paths,
172
+ candidateMap.legacy_path
173
+ ].filter(Boolean);
174
+ }
175
+
176
+ function canWriteDirectory(targetDir) {
177
+ try {
178
+ ensureDir(targetDir);
179
+ fs.accessSync(targetDir, fs.constants.W_OK);
180
+ return true;
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+
186
+ function resolveWritableScreenConfigPath(workspaceRoot) {
187
+ const candidateMap = buildScreenConfigCandidateMap(workspaceRoot);
188
+ const workspacePreferred = candidateMap.workspace_paths?.[0] || null;
189
+ if (candidateMap.env_path) {
190
+ return candidateMap.env_path;
191
+ }
192
+ if (candidateMap.user_path && canWriteDirectory(path.dirname(candidateMap.user_path))) {
193
+ return candidateMap.user_path;
194
+ }
195
+ if (workspacePreferred && canWriteDirectory(path.dirname(workspacePreferred))) {
196
+ return workspacePreferred;
197
+ }
198
+ if (workspacePreferred) {
199
+ return workspacePreferred;
200
+ }
201
+ return candidateMap.user_path || candidateMap.legacy_path;
202
+ }
203
+
204
+ function resolveScreenConfigPath(workspaceRoot) {
205
+ const candidateMap = buildScreenConfigCandidateMap(workspaceRoot);
206
+ if (candidateMap.env_path) {
207
+ return candidateMap.env_path;
208
+ }
209
+ if (candidateMap.user_path && pathExists(candidateMap.user_path)) {
210
+ return candidateMap.user_path;
211
+ }
212
+ const existingWorkspacePath = candidateMap.workspace_paths.find((item) => pathExists(item));
213
+ if (existingWorkspacePath) {
214
+ return existingWorkspacePath;
215
+ }
216
+ const writablePath = resolveWritableScreenConfigPath(workspaceRoot);
217
+ if (writablePath) {
218
+ return writablePath;
219
+ }
220
+ return candidateMap.legacy_path;
221
+ }
222
+
223
+ export function getScreenConfigResolution(workspaceRoot) {
224
+ const candidateMap = buildScreenConfigCandidateMap(workspaceRoot);
225
+ const candidate_paths = resolveScreenConfigCandidates(workspaceRoot);
226
+ const resolved_path = resolveScreenConfigPath(workspaceRoot) || null;
227
+ const workspace_root = path.resolve(String(workspaceRoot || process.cwd()));
228
+ return {
229
+ resolved_path,
230
+ candidate_paths,
231
+ workspace_root,
232
+ workspace_ephemeral: isEphemeralNpxWorkspaceRoot(workspaceRoot),
233
+ workspace_ignored_for_config: shouldIgnoreWorkspaceConfigRoot(workspace_root),
234
+ writable_path: resolveWritableScreenConfigPath(workspaceRoot),
235
+ legacy_path: candidateMap.legacy_path
236
+ };
237
+ }
238
+
239
+ function readJsonFile(filePath) {
240
+ if (!filePath || !pathExists(filePath)) return null;
241
+ try {
242
+ const raw = fs.readFileSync(filePath, "utf8");
243
+ return JSON.parse(raw);
244
+ } catch {
245
+ return null;
246
+ }
247
+ }
248
+
249
+ function validateScreenConfig(config) {
250
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
251
+ return {
252
+ ok: false,
253
+ reason: "INVALID_OR_MISSING_CONFIG",
254
+ message: "screening-config.json 缺失或格式无效。请填写 baseUrl、apiKey、model。"
255
+ };
256
+ }
257
+ const baseUrl = String(config.baseUrl || "").trim();
258
+ const apiKey = String(config.apiKey || "").trim();
259
+ const model = String(config.model || "").trim();
260
+ const missing = [];
261
+ if (!baseUrl) missing.push("baseUrl");
262
+ if (!apiKey) missing.push("apiKey");
263
+ if (!model) missing.push("model");
264
+ if (missing.length > 0) {
265
+ return {
266
+ ok: false,
267
+ reason: "MISSING_REQUIRED_FIELDS",
268
+ message: `screening-config.json 缺少必填字段:${missing.join(", ")}。`
269
+ };
270
+ }
271
+ if (/^replace-with/i.test(apiKey) || apiKey === screenConfigTemplateDefaults.apiKey) {
272
+ return {
273
+ ok: false,
274
+ reason: "PLACEHOLDER_API_KEY",
275
+ message: "screening-config.json 的 apiKey 仍是模板占位符,请填写真实 API Key。"
276
+ };
277
+ }
278
+ if (
279
+ baseUrl === screenConfigTemplateDefaults.baseUrl
280
+ && apiKey === screenConfigTemplateDefaults.apiKey
281
+ && model === screenConfigTemplateDefaults.model
282
+ ) {
283
+ return {
284
+ ok: false,
285
+ reason: "PLACEHOLDER_TEMPLATE_VALUES",
286
+ message: "screening-config.json 仍是默认模板值,请填写 baseUrl、apiKey、model。"
287
+ };
288
+ }
289
+ return { ok: true, reason: "OK", message: "screening-config.json 校验通过。" };
290
+ }
291
+
292
+ function resolveWorkspaceDebugPort(workspaceRoot) {
293
+ const fromEnv = parsePositiveInteger(process.env.BOSS_RECOMMEND_CHROME_PORT);
294
+ if (fromEnv) return fromEnv;
295
+ const config = readJsonFile(resolveScreenConfigPath(workspaceRoot));
296
+ return parsePositiveInteger(config?.debugPort) || 9222;
297
+ }
298
+
299
+ function getDefaultChromeExecutableCandidates() {
300
+ const candidates = [process.env.BOSS_RECOMMEND_CHROME_PATH].filter(Boolean);
301
+ if (process.platform === "win32") {
302
+ candidates.push(
303
+ path.join(process.env.LOCALAPPDATA || "", "Google", "Chrome", "Application", "chrome.exe"),
304
+ path.join(process.env.ProgramFiles || "", "Google", "Chrome", "Application", "chrome.exe"),
305
+ path.join(process.env["ProgramFiles(x86)"] || "", "Google", "Chrome", "Application", "chrome.exe")
306
+ );
307
+ } else if (process.platform === "darwin") {
308
+ candidates.push(
309
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
310
+ path.join(os.homedir(), "Applications", "Google Chrome.app", "Contents", "MacOS", "Google Chrome"),
311
+ "/Applications/Chromium.app/Contents/MacOS/Chromium"
312
+ );
313
+ } else {
314
+ candidates.push(
315
+ "/usr/bin/google-chrome",
316
+ "/usr/bin/google-chrome-stable",
317
+ "/usr/bin/chromium-browser",
318
+ "/usr/bin/chromium",
319
+ "/snap/bin/chromium"
320
+ );
321
+ }
322
+ return Array.from(new Set(candidates.filter(Boolean)));
323
+ }
324
+
325
+ function getChromeExecutable() {
326
+ const candidates = getDefaultChromeExecutableCandidates();
327
+ return candidates.find((candidate) => pathExists(candidate)) || null;
328
+ }
329
+
330
+ function getChromeUserDataDir(port) {
331
+ const profileDir = resolveDefaultChromeUserDataDir(port);
332
+ ensureDir(profileDir);
333
+ return profileDir;
334
+ }
335
+
336
+ function getSharedChromeUserDataDir(port) {
337
+ return path.join(getCodexHome(), "boss-mcp", `chrome-profile-${port}`);
338
+ }
339
+
340
+ function getLegacyRecruitChromeUserDataDir(port) {
341
+ return path.join(getCodexHome(), "boss-recruit-mcp", `chrome-profile-${port}`);
342
+ }
343
+
344
+ function getLegacyRecommendChromeUserDataDir(port) {
345
+ return path.join(getStateHome(), `chrome-profile-${port}`);
346
+ }
347
+
348
+ function resolveDefaultChromeUserDataDir(port) {
349
+ const sharedPath = getSharedChromeUserDataDir(port);
350
+ if (pathExists(sharedPath)) {
351
+ return sharedPath;
352
+ }
353
+ const legacyPaths = [
354
+ getLegacyRecruitChromeUserDataDir(port),
355
+ getLegacyRecommendChromeUserDataDir(port)
356
+ ];
357
+ const legacyExisting = legacyPaths.find((candidate) => pathExists(candidate));
358
+ return legacyExisting || sharedPath;
359
+ }
360
+
361
+ function launchChromeWithDebugPort(port) {
362
+ const chromePath = getChromeExecutable();
363
+ if (!chromePath) {
364
+ return {
365
+ ok: false,
366
+ code: "CHROME_EXECUTABLE_NOT_FOUND",
367
+ message: "未找到 Chrome 可执行文件,请安装 Chrome 或设置 BOSS_RECOMMEND_CHROME_PATH。"
368
+ };
369
+ }
370
+ const userDataDir = getChromeUserDataDir(port);
371
+ const args = [
372
+ `--remote-debugging-port=${port}`,
373
+ `--user-data-dir=${userDataDir}`,
374
+ "--no-first-run",
375
+ "--no-default-browser-check",
376
+ "--new-window",
377
+ bossRecommendUrl
378
+ ];
379
+
380
+ try {
381
+ const child = spawn(chromePath, args, {
382
+ detached: true,
383
+ stdio: "ignore",
384
+ windowsHide: false
385
+ });
386
+ child.unref();
387
+ return {
388
+ ok: true,
389
+ code: "CHROME_LAUNCHED",
390
+ chrome_path: chromePath,
391
+ user_data_dir: userDataDir
392
+ };
393
+ } catch (error) {
394
+ return {
395
+ ok: false,
396
+ code: "CHROME_LAUNCH_FAILED",
397
+ message: error.message || "Chrome 启动失败。"
398
+ };
399
+ }
400
+ }
401
+
402
+ function resolveRecommendSearchCliDir(workspaceRoot) {
403
+ const localDir = path.join(workspaceRoot, "boss-recommend-search-cli");
404
+ if (pathExists(localDir)) return localDir;
405
+ const vendoredDir = path.join(packagedMcpDir, "vendor", "boss-recommend-search-cli");
406
+ if (pathExists(vendoredDir)) return vendoredDir;
407
+ return null;
408
+ }
409
+
410
+ function resolveRecommendScreenCliDir(workspaceRoot) {
411
+ const localDir = path.join(workspaceRoot, "boss-recommend-screen-cli");
412
+ if (pathExists(localDir)) return localDir;
413
+ const vendoredDir = path.join(packagedMcpDir, "vendor", "boss-recommend-screen-cli");
414
+ if (pathExists(vendoredDir)) return vendoredDir;
415
+ return null;
416
+ }
417
+
418
+ function resolveRecommendScreenCliEntry(screenDir) {
419
+ const candidates = [
420
+ path.join(screenDir, "boss-recommend-screen-cli.cjs"),
421
+ path.join(screenDir, "boss-recommend-screen-cli.js")
422
+ ];
423
+ return candidates.find((candidate) => pathExists(candidate)) || candidates[0];
424
+ }
425
+
426
+ function resolveRecommendSearchCliEntry(searchDir) {
427
+ const candidates = [
428
+ path.join(searchDir, "src", "cli.js"),
429
+ path.join(searchDir, "src", "cli.cjs")
430
+ ];
431
+ return candidates.find((candidate) => pathExists(candidate)) || candidates[0];
432
+ }
433
+
434
+ function safeInvokeCallback(callback, payload) {
435
+ if (typeof callback !== "function") return;
436
+ try {
437
+ callback(payload);
438
+ } catch {
439
+ // Ignore callback errors to keep pipeline runtime stable.
440
+ }
441
+ }
442
+
443
+ function runProcess({
444
+ command,
445
+ args,
446
+ cwd,
447
+ timeoutMs,
448
+ onOutput,
449
+ onLine,
450
+ onHeartbeat,
451
+ heartbeatIntervalMs = 10_000,
452
+ signal
453
+ }) {
454
+ return new Promise((resolve) => {
455
+ let stdout = "";
456
+ let stderr = "";
457
+ let stdoutLineBuffer = "";
458
+ let stderrLineBuffer = "";
459
+ let settled = false;
460
+ let timer = null;
461
+ let heartbeatTimer = null;
462
+ let abortedBySignal = Boolean(signal?.aborted);
463
+ let abortListener = null;
464
+
465
+ function notifyHeartbeat(source) {
466
+ safeInvokeCallback(onHeartbeat, {
467
+ source,
468
+ command,
469
+ args,
470
+ cwd,
471
+ at: new Date().toISOString()
472
+ });
473
+ }
474
+
475
+ function emitLine(stream, line) {
476
+ const normalized = String(line ?? "").replace(/\r$/, "");
477
+ if (!normalized) return;
478
+ safeInvokeCallback(onLine, {
479
+ stream,
480
+ line: normalized,
481
+ at: new Date().toISOString()
482
+ });
483
+ }
484
+
485
+ function pushLineBuffer(stream, chunkText) {
486
+ if (stream === "stdout") {
487
+ stdoutLineBuffer += chunkText;
488
+ } else {
489
+ stderrLineBuffer += chunkText;
490
+ }
491
+ let buffer = stream === "stdout" ? stdoutLineBuffer : stderrLineBuffer;
492
+ let newlineIndex = buffer.indexOf("\n");
493
+ while (newlineIndex !== -1) {
494
+ emitLine(stream, buffer.slice(0, newlineIndex));
495
+ buffer = buffer.slice(newlineIndex + 1);
496
+ newlineIndex = buffer.indexOf("\n");
497
+ }
498
+ if (stream === "stdout") {
499
+ stdoutLineBuffer = buffer;
500
+ } else {
501
+ stderrLineBuffer = buffer;
502
+ }
503
+ }
504
+
505
+ function finish(payload) {
506
+ if (settled) return;
507
+ settled = true;
508
+ if (timer) clearTimeout(timer);
509
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
510
+ if (signal && typeof signal.removeEventListener === "function" && abortListener) {
511
+ signal.removeEventListener("abort", abortListener);
512
+ }
513
+ emitLine("stdout", stdoutLineBuffer);
514
+ emitLine("stderr", stderrLineBuffer);
515
+ stdoutLineBuffer = "";
516
+ stderrLineBuffer = "";
517
+ resolve(payload);
518
+ }
519
+
520
+ if (abortedBySignal) {
521
+ finish({
522
+ code: -1,
523
+ stdout,
524
+ stderr: "Process aborted before spawn",
525
+ error_code: "ABORTED"
526
+ });
527
+ return;
528
+ }
529
+
530
+ let child;
531
+ try {
532
+ child = spawn(command, args, {
533
+ cwd,
534
+ windowsHide: true,
535
+ shell: false,
536
+ env: process.env
537
+ });
538
+ } catch (error) {
539
+ finish({
540
+ code: -1,
541
+ stdout,
542
+ stderr: error.message,
543
+ error_code: error.code || "SPAWN_FAILED"
544
+ });
545
+ return;
546
+ }
547
+
548
+ if (signal && typeof signal.addEventListener === "function") {
549
+ abortListener = () => {
550
+ abortedBySignal = true;
551
+ try {
552
+ child.kill();
553
+ } catch {}
554
+ };
555
+ signal.addEventListener("abort", abortListener, { once: true });
556
+ }
557
+
558
+ if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) {
559
+ timer = setTimeout(() => {
560
+ try {
561
+ child.kill();
562
+ } catch {}
563
+ finish({
564
+ code: -1,
565
+ stdout,
566
+ stderr: `${stderr}\nProcess timed out after ${timeoutMs}ms`.trim(),
567
+ error_code: "TIMEOUT"
568
+ });
569
+ }, timeoutMs);
570
+ }
571
+
572
+ if (Number.isFinite(heartbeatIntervalMs) && heartbeatIntervalMs > 0) {
573
+ heartbeatTimer = setInterval(() => {
574
+ notifyHeartbeat("timer");
575
+ }, heartbeatIntervalMs);
576
+ }
577
+
578
+ child.stdout.on("data", (chunk) => {
579
+ const text = chunk.toString();
580
+ stdout += text;
581
+ pushLineBuffer("stdout", text);
582
+ safeInvokeCallback(onOutput, {
583
+ stream: "stdout",
584
+ text,
585
+ at: new Date().toISOString()
586
+ });
587
+ notifyHeartbeat("stdout");
588
+ });
589
+ child.stderr.on("data", (chunk) => {
590
+ const text = chunk.toString();
591
+ stderr += text;
592
+ pushLineBuffer("stderr", text);
593
+ safeInvokeCallback(onOutput, {
594
+ stream: "stderr",
595
+ text,
596
+ at: new Date().toISOString()
597
+ });
598
+ notifyHeartbeat("stderr");
599
+ });
600
+ child.on("close", (code) => {
601
+ if (abortedBySignal) {
602
+ finish({
603
+ code: -1,
604
+ stdout,
605
+ stderr: `${stderr}\nProcess aborted by signal`.trim(),
606
+ error_code: "ABORTED"
607
+ });
608
+ return;
609
+ }
610
+ finish({ code, stdout, stderr });
611
+ });
612
+ child.on("error", (error) => {
613
+ finish({
614
+ code: -1,
615
+ stdout,
616
+ stderr: `${stderr}\n${error.message}`.trim(),
617
+ error_code: error.code || "SPAWN_FAILED"
618
+ });
619
+ });
620
+ });
621
+ }
622
+
623
+ function runProcessSync({ command, args, cwd }) {
624
+ try {
625
+ const result = spawnSync(command, args, {
626
+ cwd,
627
+ windowsHide: true,
628
+ shell: false,
629
+ env: process.env,
630
+ encoding: "utf8"
631
+ });
632
+ const stdout = String(result.stdout || "").trim();
633
+ const stderr = String(result.stderr || "").trim();
634
+ return {
635
+ ok: result.status === 0,
636
+ status: result.status,
637
+ stdout,
638
+ stderr,
639
+ output: [stdout, stderr].filter(Boolean).join("\n").trim(),
640
+ error_code: result.error?.code || null,
641
+ error_message: result.error?.message || null
642
+ };
643
+ } catch (error) {
644
+ return {
645
+ ok: false,
646
+ status: -1,
647
+ stdout: "",
648
+ stderr: "",
649
+ output: "",
650
+ error_code: error.code || "SPAWN_FAILED",
651
+ error_message: error.message || String(error)
652
+ };
653
+ }
654
+ }
655
+
656
+ function parseMajorVersion(raw) {
657
+ const match = String(raw || "").match(/v?(\d+)(?:\.\d+){0,2}/);
658
+ if (!match) return null;
659
+ const major = Number.parseInt(match[1], 10);
660
+ return Number.isFinite(major) ? major : null;
661
+ }
662
+
663
+ function buildNodeCommandCheck() {
664
+ const probe = runProcessSync({
665
+ command: "node",
666
+ args: ["--version"]
667
+ });
668
+ const major = parseMajorVersion(probe.output);
669
+ const versionOk = Number.isInteger(major) && major >= 18;
670
+ return {
671
+ key: "node_cli",
672
+ ok: probe.ok && versionOk,
673
+ path: "node --version",
674
+ message: probe.ok
675
+ ? (versionOk
676
+ ? `Node 命令可用 (${probe.output || "unknown version"})`
677
+ : `Node 版本过低 (${probe.output || "unknown version"}),要求 >= 18`)
678
+ : `未找到 node 命令,请先安装 Node.js >= 18。${probe.error_message ? ` (${probe.error_message})` : ""}`
679
+ };
680
+ }
681
+
682
+ function detectPythonCommand() {
683
+ const python = runProcessSync({
684
+ command: "python",
685
+ args: ["--version"]
686
+ });
687
+ if (python.ok) {
688
+ return {
689
+ ok: true,
690
+ command: "python",
691
+ probe: python
692
+ };
693
+ }
694
+ const python3 = runProcessSync({
695
+ command: "python3",
696
+ args: ["--version"]
697
+ });
698
+ if (python3.ok) {
699
+ return {
700
+ ok: false,
701
+ command: null,
702
+ probe: python,
703
+ fallback: python3
704
+ };
705
+ }
706
+ return {
707
+ ok: false,
708
+ command: null,
709
+ probe: python,
710
+ fallback: null
711
+ };
712
+ }
713
+
714
+ function buildPythonCommandCheck() {
715
+ const detected = detectPythonCommand();
716
+ if (detected.ok) {
717
+ return {
718
+ key: "python_cli",
719
+ ok: true,
720
+ path: "python --version",
721
+ message: `Python 命令可用 (${detected.probe.output || "unknown version"})`
722
+ };
723
+ }
724
+ if (detected.fallback) {
725
+ return {
726
+ key: "python_cli",
727
+ ok: false,
728
+ path: "python --version",
729
+ message: `检测到 ${detected.fallback.output || "python3"},但当前流程依赖 python 命令;请创建 python 别名后重试。`
730
+ };
731
+ }
732
+ return {
733
+ key: "python_cli",
734
+ ok: false,
735
+ path: "python --version",
736
+ message: "未找到 python 命令,请安装 Python 并确保 python 在 PATH 中。"
737
+ };
738
+ }
739
+
740
+ function buildPillowCheck() {
741
+ const detected = detectPythonCommand();
742
+ if (!detected.ok || !detected.command) {
743
+ return {
744
+ key: "python_pillow",
745
+ ok: false,
746
+ path: "python -c \"import PIL\"",
747
+ message: "无法校验 Pillow:python 命令不可用。"
748
+ };
749
+ }
750
+ const probe = runProcessSync({
751
+ command: detected.command,
752
+ args: ["-c", "import PIL, PIL.Image; print(PIL.__version__)"]
753
+ });
754
+ return {
755
+ key: "python_pillow",
756
+ ok: probe.ok,
757
+ path: `${detected.command} -c "import PIL"`,
758
+ message: probe.ok
759
+ ? `Pillow 可用 (${probe.output || "version unknown"})`
760
+ : "Pillow 未安装。请执行 `python -m pip install pillow`。"
761
+ };
762
+ }
763
+
764
+ function buildNodePackageCheck({ key, moduleName, cwd, missingMessage }) {
765
+ if (!cwd || !pathExists(cwd)) {
766
+ return {
767
+ key,
768
+ ok: false,
769
+ path: moduleName,
770
+ module: moduleName,
771
+ install_cwd: null,
772
+ message: missingMessage
773
+ };
774
+ }
775
+ const probe = runProcessSync({
776
+ command: "node",
777
+ args: ["-e", `require.resolve(${JSON.stringify(moduleName)});`],
778
+ cwd
779
+ });
780
+ return {
781
+ key,
782
+ ok: probe.ok,
783
+ path: moduleName,
784
+ module: moduleName,
785
+ install_cwd: cwd,
786
+ message: probe.ok
787
+ ? `${moduleName} npm 依赖可用`
788
+ : `缺少 npm 依赖 ${moduleName},请在 boss-recommend-mcp 目录执行 npm install。`
789
+ };
790
+ }
791
+
792
+ function buildRuntimeDependencyChecks({ searchDir, screenDir }) {
793
+ return [
794
+ buildNodeCommandCheck(),
795
+ buildPythonCommandCheck(),
796
+ buildPillowCheck(),
797
+ buildNodePackageCheck({
798
+ key: "npm_dep_chrome_remote_interface_search",
799
+ moduleName: "chrome-remote-interface",
800
+ cwd: searchDir,
801
+ missingMessage: "无法校验 chrome-remote-interface:boss-recommend-search-cli 目录不存在。"
802
+ }),
803
+ buildNodePackageCheck({
804
+ key: "npm_dep_chrome_remote_interface_screen",
805
+ moduleName: "chrome-remote-interface",
806
+ cwd: screenDir,
807
+ missingMessage: "无法校验 chrome-remote-interface:boss-recommend-screen-cli 目录不存在。"
808
+ }),
809
+ buildNodePackageCheck({
810
+ key: "npm_dep_ws",
811
+ moduleName: "ws",
812
+ cwd: screenDir,
813
+ missingMessage: "无法校验 ws:boss-recommend-screen-cli 目录不存在。"
814
+ })
815
+ ];
816
+ }
817
+
818
+ function parseJsonOutput(text) {
819
+ const trimmed = String(text || "").trim();
820
+ if (!trimmed) return null;
821
+ try {
822
+ return JSON.parse(trimmed);
823
+ } catch {}
824
+ const lines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
825
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
826
+ try {
827
+ return JSON.parse(lines[index]);
828
+ } catch {
829
+ continue;
830
+ }
831
+ }
832
+ return null;
833
+ }
834
+
835
+ function createScreenProgressTracker(currentTracker = {}) {
836
+ const outcome = String(currentTracker.outcome || "").trim();
837
+ return {
838
+ candidate_index: Number.isInteger(currentTracker.candidate_index) ? currentTracker.candidate_index : null,
839
+ outcome: outcome === "pass" || outcome === "skip" ? outcome : null,
840
+ action_failed: currentTracker.action_failed === true
841
+ };
842
+ }
843
+
844
+ function finalizeCandidateProgress(progress, tracker) {
845
+ if (!Number.isInteger(tracker.candidate_index)) {
846
+ return false;
847
+ }
848
+
849
+ let changed = false;
850
+ if (tracker.action_failed === true) {
851
+ progress.skipped += 1;
852
+ changed = true;
853
+ } else if (tracker.outcome === "pass") {
854
+ progress.passed += 1;
855
+ changed = true;
856
+ } else if (tracker.outcome === "skip") {
857
+ progress.skipped += 1;
858
+ changed = true;
859
+ }
860
+
861
+ tracker.candidate_index = null;
862
+ tracker.outcome = null;
863
+ tracker.action_failed = false;
864
+ return changed;
865
+ }
866
+
867
+ function parseScreenProgressLine(line, currentProgress = {}, currentTracker = {}) {
868
+ const normalizedLine = String(line || "").replace(/\s+/g, " ").trim();
869
+ if (!normalizedLine) return null;
870
+
871
+ const nextProgress = {
872
+ processed: Number.isInteger(currentProgress.processed) ? currentProgress.processed : 0,
873
+ passed: Number.isInteger(currentProgress.passed) ? currentProgress.passed : 0,
874
+ skipped: Number.isInteger(currentProgress.skipped) ? currentProgress.skipped : 0,
875
+ greet_count: Number.isInteger(currentProgress.greet_count) ? currentProgress.greet_count : 0
876
+ };
877
+ const nextTracker = createScreenProgressTracker(currentTracker);
878
+
879
+ let changed = false;
880
+ const processedMatch = normalizedLine.match(/处理第\s*(\d+)\s*位候选人/u);
881
+ if (processedMatch) {
882
+ if (finalizeCandidateProgress(nextProgress, nextTracker)) {
883
+ changed = true;
884
+ }
885
+ const processed = Number.parseInt(processedMatch[1], 10);
886
+ if (Number.isInteger(processed) && processed >= 0 && processed !== nextProgress.processed) {
887
+ nextProgress.processed = processed;
888
+ changed = true;
889
+ }
890
+ nextTracker.candidate_index = processed;
891
+ nextTracker.outcome = null;
892
+ nextTracker.action_failed = false;
893
+ }
894
+
895
+ if (/筛选结果:\s*通过/u.test(normalizedLine)) {
896
+ if (nextTracker.outcome !== "pass" || nextTracker.action_failed) {
897
+ changed = true;
898
+ }
899
+ nextTracker.outcome = "pass";
900
+ nextTracker.action_failed = false;
901
+ } else if (/筛选结果:\s*不通过/u.test(normalizedLine)) {
902
+ if (nextTracker.outcome !== "skip" || nextTracker.action_failed) {
903
+ changed = true;
904
+ }
905
+ nextTracker.outcome = "skip";
906
+ nextTracker.action_failed = false;
907
+ }
908
+
909
+ if (/候选人处理失败\s*:/u.test(normalizedLine)) {
910
+ if (!nextTracker.action_failed) {
911
+ changed = true;
912
+ }
913
+ nextTracker.action_failed = true;
914
+ }
915
+
916
+ if (/^\[关闭详情\].*成功/u.test(normalizedLine)) {
917
+ if (finalizeCandidateProgress(nextProgress, nextTracker)) {
918
+ changed = true;
919
+ }
920
+ }
921
+
922
+ const finalStateLine = /Process timed out after|status"\s*:\s*"(?:COMPLETED|PAUSED|FAILED)"/iu.test(normalizedLine);
923
+ if (finalStateLine) {
924
+ if (finalizeCandidateProgress(nextProgress, nextTracker)) {
925
+ changed = true;
926
+ }
927
+ }
928
+
929
+ const greetMatch = normalizedLine.match(/greet[_\s-]*count\s*[:=]\s*(\d+)/iu);
930
+ if (greetMatch) {
931
+ const greetCount = Number.parseInt(greetMatch[1], 10);
932
+ if (Number.isInteger(greetCount) && greetCount >= 0 && greetCount !== nextProgress.greet_count) {
933
+ nextProgress.greet_count = greetCount;
934
+ changed = true;
935
+ }
936
+ }
937
+
938
+ if (!changed) return null;
939
+ return {
940
+ line: normalizedLine,
941
+ progress: nextProgress,
942
+ tracker: nextTracker
943
+ };
944
+ }
945
+
946
+ function resolveRecommendScreenTimeoutMs(runtime = null) {
947
+ const runtimeTimeoutMs = parsePositiveInteger(runtime?.timeoutMs);
948
+ const envTimeoutMs = parsePositiveInteger(process.env.BOSS_RECOMMEND_SCREEN_TIMEOUT_MS);
949
+ return runtimeTimeoutMs || envTimeoutMs || DEFAULT_RECOMMEND_SCREEN_TIMEOUT_MS;
950
+ }
951
+
952
+ function buildRecommendScreenProcessError(result, screenTimeoutMs) {
953
+ if (result.code === 0) return null;
954
+ if (result.error_code === "TIMEOUT") {
955
+ return {
956
+ code: "TIMEOUT",
957
+ message: `推荐页筛选命令执行超时(${screenTimeoutMs}ms)。`
958
+ };
959
+ }
960
+ if (result.error_code === "ABORTED") {
961
+ return {
962
+ code: "PROCESS_ABORTED",
963
+ message: "推荐页筛选命令已取消。"
964
+ };
965
+ }
966
+ return {
967
+ code: "RECOMMEND_SCREEN_FAILED",
968
+ message: "推荐页筛选命令执行失败。"
969
+ };
970
+ }
971
+
972
+ function loadScreenConfig(configPath) {
973
+ const parsed = readJsonFile(configPath);
974
+ const validation = validateScreenConfig(parsed);
975
+ if (!validation.ok) {
976
+ return {
977
+ ok: false,
978
+ error: `${validation.message} (path: ${configPath})`
979
+ };
980
+ }
981
+ return { ok: true, config: parsed };
982
+ }
983
+
984
+ function localDirHint(workspaceRoot, dirName) {
985
+ return path.join(workspaceRoot, dirName);
986
+ }
987
+
988
+ export function runPipelinePreflight(workspaceRoot) {
989
+ const searchDir = resolveRecommendSearchCliDir(workspaceRoot);
990
+ const screenDir = resolveRecommendScreenCliDir(workspaceRoot);
991
+ const searchDirExists = Boolean(searchDir && pathExists(searchDir));
992
+ const searchEntryPath = searchDir
993
+ ? resolveRecommendSearchCliEntry(searchDir)
994
+ : path.join(localDirHint(workspaceRoot, "boss-recommend-search-cli"), "src", "cli.js");
995
+ const searchEntryExists = Boolean(searchDir && pathExists(searchEntryPath));
996
+ const screenDirExists = Boolean(screenDir && pathExists(screenDir));
997
+ const screenEntryPath = screenDir
998
+ ? resolveRecommendScreenCliEntry(screenDir)
999
+ : path.join(localDirHint(workspaceRoot, "boss-recommend-screen-cli"), "boss-recommend-screen-cli.cjs");
1000
+ const screenEntryExists = Boolean(screenDir && pathExists(screenEntryPath));
1001
+ const configResolution = getScreenConfigResolution(workspaceRoot);
1002
+ const screenConfigPath = configResolution.resolved_path;
1003
+ const screenConfigParsed = readJsonFile(screenConfigPath);
1004
+ const screenConfigValidation = validateScreenConfig(screenConfigParsed);
1005
+ const checks = [
1006
+ {
1007
+ key: "recommend_search_cli_dir",
1008
+ ok: searchDirExists,
1009
+ path: searchDir || localDirHint(workspaceRoot, "boss-recommend-search-cli"),
1010
+ message: searchDirExists
1011
+ ? "boss-recommend-search-cli 目录可用"
1012
+ : "boss-recommend-search-cli 目录不存在"
1013
+ },
1014
+ {
1015
+ key: "recommend_search_cli_entry",
1016
+ ok: searchEntryExists,
1017
+ path: searchEntryPath,
1018
+ message: searchEntryExists
1019
+ ? "boss-recommend-search-cli 入口文件可用"
1020
+ : "boss-recommend-search-cli 入口文件缺失"
1021
+ },
1022
+ {
1023
+ key: "recommend_screen_cli_dir",
1024
+ ok: screenDirExists,
1025
+ path: screenDir || localDirHint(workspaceRoot, "boss-recommend-screen-cli"),
1026
+ message: screenDirExists
1027
+ ? "boss-recommend-screen-cli 目录可用"
1028
+ : "boss-recommend-screen-cli 目录不存在"
1029
+ },
1030
+ {
1031
+ key: "recommend_screen_cli_entry",
1032
+ ok: screenEntryExists,
1033
+ path: screenEntryPath,
1034
+ message: screenEntryExists
1035
+ ? "boss-recommend-screen-cli 入口文件可用"
1036
+ : "boss-recommend-screen-cli 入口文件缺失"
1037
+ },
1038
+ {
1039
+ key: "screen_config",
1040
+ ok: screenConfigValidation.ok,
1041
+ path: screenConfigPath,
1042
+ reason: screenConfigValidation.reason || null,
1043
+ message: screenConfigValidation.ok ? "screening-config.json 可用" : screenConfigValidation.message
1044
+ }
1045
+ ];
1046
+ checks.push(...buildRuntimeDependencyChecks({ searchDir, screenDir }));
1047
+
1048
+ return {
1049
+ ok: checks.every((item) => item.ok),
1050
+ checks,
1051
+ debug_port: resolveWorkspaceDebugPort(workspaceRoot),
1052
+ config_resolution: configResolution
1053
+ };
1054
+ }
1055
+
1056
+ function collectFailedCheckKeys(checks = []) {
1057
+ return new Set(
1058
+ checks
1059
+ .filter((item) => item && item.ok === false && typeof item.key === "string")
1060
+ .map((item) => item.key)
1061
+ );
1062
+ }
1063
+
1064
+ function collectNpmInstallDirsFromChecks(checks = [], workspaceRoot) {
1065
+ const npmKeys = new Set([
1066
+ "npm_dep_chrome_remote_interface_search",
1067
+ "npm_dep_chrome_remote_interface_screen",
1068
+ "npm_dep_ws"
1069
+ ]);
1070
+ const dirs = checks
1071
+ .filter((item) => item && item.ok === false && npmKeys.has(item.key))
1072
+ .map((item) => item.install_cwd)
1073
+ .filter((item) => typeof item === "string" && item.trim())
1074
+ .map((item) => path.resolve(item));
1075
+ if (dirs.length > 0) {
1076
+ return [...new Set(dirs)];
1077
+ }
1078
+ return [path.resolve(workspaceRoot)];
1079
+ }
1080
+
1081
+ function installNpmDependencies(checks, workspaceRoot) {
1082
+ const dirs = collectNpmInstallDirsFromChecks(checks, workspaceRoot);
1083
+ const commandResults = [];
1084
+ let allOk = true;
1085
+ for (const cwd of dirs) {
1086
+ const result = runProcessSync({
1087
+ command: "npm",
1088
+ args: ["install"],
1089
+ cwd
1090
+ });
1091
+ commandResults.push({
1092
+ cwd,
1093
+ ok: result.ok,
1094
+ output: result.output || result.error_message || ""
1095
+ });
1096
+ if (!result.ok) allOk = false;
1097
+ }
1098
+ return {
1099
+ ok: allOk,
1100
+ action: "install_npm_dependencies",
1101
+ changed: true,
1102
+ command_results: commandResults,
1103
+ message: allOk ? "npm 依赖自动安装完成。" : "npm 依赖自动安装失败。"
1104
+ };
1105
+ }
1106
+
1107
+ function installPillowIfPossible() {
1108
+ const detected = detectPythonCommand();
1109
+ if (!detected.ok || !detected.command) {
1110
+ return {
1111
+ ok: false,
1112
+ action: "install_pillow",
1113
+ changed: false,
1114
+ message: "未检测到可用 python 命令,无法自动安装 Pillow。"
1115
+ };
1116
+ }
1117
+ const install = runProcessSync({
1118
+ command: detected.command,
1119
+ args: ["-m", "pip", "install", "pillow"]
1120
+ });
1121
+ return {
1122
+ ok: install.ok,
1123
+ action: "install_pillow",
1124
+ changed: install.ok,
1125
+ message: install.ok ? "Pillow 自动安装完成。" : `Pillow 自动安装失败:${install.output || install.error_message || "unknown"}`
1126
+ };
1127
+ }
1128
+
1129
+ export function attemptPipelineAutoRepair(workspaceRoot, preflight = {}) {
1130
+ const checks = Array.isArray(preflight.checks) ? preflight.checks : [];
1131
+ const failed = collectFailedCheckKeys(checks);
1132
+ const actions = [];
1133
+
1134
+ if (
1135
+ failed.has("npm_dep_chrome_remote_interface_search")
1136
+ || failed.has("npm_dep_chrome_remote_interface_screen")
1137
+ || failed.has("npm_dep_ws")
1138
+ ) {
1139
+ if (!failed.has("node_cli")) {
1140
+ actions.push(installNpmDependencies(checks, workspaceRoot));
1141
+ } else {
1142
+ actions.push({
1143
+ ok: false,
1144
+ action: "install_npm_dependencies",
1145
+ changed: false,
1146
+ message: "Node 命令不可用,跳过 npm 自动安装。"
1147
+ });
1148
+ }
1149
+ }
1150
+
1151
+ if (failed.has("python_pillow")) {
1152
+ if (!failed.has("python_cli")) {
1153
+ actions.push(installPillowIfPossible());
1154
+ } else {
1155
+ actions.push({
1156
+ ok: false,
1157
+ action: "install_pillow",
1158
+ changed: false,
1159
+ message: "python 命令不可用,跳过 Pillow 自动安装。"
1160
+ });
1161
+ }
1162
+ }
1163
+
1164
+ const attempted = actions.length > 0;
1165
+ const nextPreflight = runPipelinePreflight(workspaceRoot);
1166
+ return {
1167
+ attempted,
1168
+ actions,
1169
+ preflight: nextPreflight
1170
+ };
1171
+ }
1172
+
1173
+ function sleep(ms) {
1174
+ return new Promise((resolve) => setTimeout(resolve, ms));
1175
+ }
1176
+
1177
+ async function listChromeTabs(port) {
1178
+ const response = await fetch(`http://127.0.0.1:${port}/json/list`);
1179
+ if (!response.ok) {
1180
+ throw new Error(`DevTools endpoint returned ${response.status}`);
1181
+ }
1182
+ const data = await response.json();
1183
+ return Array.isArray(data) ? data : [];
1184
+ }
1185
+
1186
+ function buildBossPageState(payload) {
1187
+ return {
1188
+ key: "boss_page_state",
1189
+ ...payload
1190
+ };
1191
+ }
1192
+
1193
+ function extractSampleUrls(tabs, limit = 5) {
1194
+ return tabs
1195
+ .map((tab) => tab?.url)
1196
+ .filter(Boolean)
1197
+ .slice(0, limit);
1198
+ }
1199
+
1200
+ function findChromeOnboardingUrl(tabs) {
1201
+ for (const tab of tabs) {
1202
+ if (typeof tab?.url === "string" && chromeOnboardingUrlPattern.test(tab.url)) {
1203
+ return tab.url;
1204
+ }
1205
+ }
1206
+ return null;
1207
+ }
1208
+
1209
+ function isBossLoginTab(tab) {
1210
+ const url = String(tab?.url || "");
1211
+ const title = String(tab?.title || "");
1212
+ return (
1213
+ url === bossLoginUrl
1214
+ || bossLoginUrlPattern.test(url)
1215
+ || bossLoginTitlePattern.test(title)
1216
+ );
1217
+ }
1218
+
1219
+ export async function inspectBossRecommendPageState(port, options = {}) {
1220
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 6000;
1221
+ const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : 1000;
1222
+ const expectedUrl = options.expectedUrl || bossRecommendUrl;
1223
+ const deadline = Date.now() + timeoutMs;
1224
+ let lastError = null;
1225
+ let lastTabs = [];
1226
+
1227
+ while (Date.now() < deadline) {
1228
+ try {
1229
+ const tabs = await listChromeTabs(port);
1230
+ lastTabs = tabs;
1231
+ const exactTab = tabs.find(
1232
+ (tab) => typeof tab?.url === "string" && tab.url.includes("/web/chat/recommend")
1233
+ );
1234
+ if (exactTab) {
1235
+ if (isBossLoginTab(exactTab)) {
1236
+ return buildBossPageState({
1237
+ ok: false,
1238
+ state: "LOGIN_REQUIRED",
1239
+ path: exactTab.url || bossLoginUrl,
1240
+ current_url: exactTab.url || bossLoginUrl,
1241
+ title: exactTab.title || null,
1242
+ requires_login: true,
1243
+ expected_url: expectedUrl,
1244
+ login_url: bossLoginUrl,
1245
+ message: "当前标签页虽在 recommend 路径,但检测到登录态页面特征,请先完成 Boss 登录。"
1246
+ });
1247
+ }
1248
+ return buildBossPageState({
1249
+ ok: true,
1250
+ state: "RECOMMEND_READY",
1251
+ path: exactTab.url,
1252
+ current_url: exactTab.url,
1253
+ title: exactTab.title || null,
1254
+ requires_login: false,
1255
+ message: "Boss 推荐页已打开,且当前仍停留在 recommend 页面。"
1256
+ });
1257
+ }
1258
+
1259
+ const loginTab = tabs.find((tab) => isBossLoginTab(tab));
1260
+ if (loginTab) {
1261
+ return buildBossPageState({
1262
+ ok: false,
1263
+ state: "LOGIN_REQUIRED",
1264
+ path: loginTab.url || bossLoginUrl,
1265
+ current_url: loginTab.url || bossLoginUrl,
1266
+ title: loginTab.title || null,
1267
+ requires_login: true,
1268
+ expected_url: expectedUrl,
1269
+ login_url: bossLoginUrl,
1270
+ message: "Boss 页面未登录,需先完成登录后再进入 recommend 页面。"
1271
+ });
1272
+ }
1273
+
1274
+ const bossTab = tabs.find(
1275
+ (tab) => typeof tab?.url === "string" && tab.url.includes("zhipin.com")
1276
+ );
1277
+ if (bossTab) {
1278
+ const requiresLogin = bossLoginUrlPattern.test(bossTab.url);
1279
+ return buildBossPageState({
1280
+ ok: false,
1281
+ state: requiresLogin ? "LOGIN_REQUIRED" : "BOSS_NOT_ON_RECOMMEND",
1282
+ path: bossTab.url,
1283
+ current_url: bossTab.url,
1284
+ title: bossTab.title || null,
1285
+ requires_login: requiresLogin,
1286
+ expected_url: expectedUrl,
1287
+ message: requiresLogin
1288
+ ? "Boss 页面未登录,需先完成登录后再进入 recommend 页面。"
1289
+ : "Boss 已登录但当前不在 recommend 页面,将尝试自动跳转。"
1290
+ });
1291
+ }
1292
+ } catch (error) {
1293
+ lastError = error;
1294
+ }
1295
+
1296
+ await sleep(pollMs);
1297
+ }
1298
+
1299
+ if (lastError) {
1300
+ return buildBossPageState({
1301
+ ok: false,
1302
+ state: "DEBUG_PORT_UNREACHABLE",
1303
+ path: `http://127.0.0.1:${port}`,
1304
+ current_url: null,
1305
+ title: null,
1306
+ requires_login: false,
1307
+ expected_url: expectedUrl,
1308
+ message: `无法连接到 Chrome DevTools 端口 ${port}。请确认 Chrome 已以远程调试模式启动。`,
1309
+ error: lastError.message
1310
+ });
1311
+ }
1312
+
1313
+ const onboardingUrl = findChromeOnboardingUrl(lastTabs);
1314
+ if (onboardingUrl) {
1315
+ return buildBossPageState({
1316
+ ok: false,
1317
+ state: "CHROME_ONBOARDING_INTERCEPTED",
1318
+ path: onboardingUrl,
1319
+ current_url: onboardingUrl,
1320
+ title: null,
1321
+ requires_login: false,
1322
+ expected_url: expectedUrl,
1323
+ message: "Chrome 当前停留在登录或引导页,尚未稳定到 Boss 推荐页。",
1324
+ sample_urls: extractSampleUrls(lastTabs)
1325
+ });
1326
+ }
1327
+
1328
+ return buildBossPageState({
1329
+ ok: false,
1330
+ state: "BOSS_TAB_NOT_FOUND",
1331
+ path: expectedUrl,
1332
+ current_url: null,
1333
+ title: null,
1334
+ requires_login: false,
1335
+ expected_url,
1336
+ message: "未检测到 Boss 推荐页标签页。",
1337
+ sample_urls: extractSampleUrls(lastTabs)
1338
+ });
1339
+ }
1340
+
1341
+ async function openBossRecommendTab(port) {
1342
+ const endpoint = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(bossRecommendUrl)}`;
1343
+ const attempts = ["PUT", "GET"];
1344
+ let lastError = null;
1345
+
1346
+ for (const method of attempts) {
1347
+ try {
1348
+ const response = await fetch(endpoint, { method });
1349
+ if (response.ok) {
1350
+ return { ok: true, method };
1351
+ }
1352
+ lastError = new Error(`DevTools /json/new returned ${response.status}`);
1353
+ } catch (error) {
1354
+ lastError = error;
1355
+ }
1356
+ }
1357
+
1358
+ return {
1359
+ ok: false,
1360
+ error: lastError?.message || "Failed to open Boss recommend tab via DevTools /json/new"
1361
+ };
1362
+ }
1363
+
1363
1364
  async function verifyRecommendPageStable(port, options = {}) {
1364
1365
  const settleMs = Number.isFinite(options.settleMs) ? options.settleMs : 1500;
1365
1366
  const recheckTimeoutMs = Number.isFinite(options.recheckTimeoutMs) ? options.recheckTimeoutMs : 2500;
1366
1367
  const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : 600;
1367
-
1368
- await sleep(settleMs);
1369
- const recheck = await inspectBossRecommendPageState(port, {
1370
- timeoutMs: recheckTimeoutMs,
1371
- pollMs
1372
- });
1373
- if (recheck.state === "RECOMMEND_READY") {
1374
- return recheck;
1375
- }
1376
- if (recheck.state === "LOGIN_REQUIRED") {
1377
- return buildBossPageState({
1378
- ...recheck,
1379
- state: "LOGIN_REQUIRED_AFTER_REDIRECT",
1380
- message: "Boss 页面曾进入 recommend 但随后跳转到其他页面,通常表示登录态失效。"
1381
- });
1382
- }
1368
+
1369
+ await sleep(settleMs);
1370
+ const recheck = await inspectBossRecommendPageState(port, {
1371
+ timeoutMs: recheckTimeoutMs,
1372
+ pollMs
1373
+ });
1374
+ if (recheck.state === "RECOMMEND_READY") {
1375
+ return recheck;
1376
+ }
1377
+ if (recheck.state === "LOGIN_REQUIRED") {
1378
+ return buildBossPageState({
1379
+ ...recheck,
1380
+ state: "LOGIN_REQUIRED_AFTER_REDIRECT",
1381
+ message: "Boss 页面曾进入 recommend 但随后跳转到其他页面,通常表示登录态失效。"
1382
+ });
1383
+ }
1383
1384
  return recheck;
1384
1385
  }
1385
1386
 
1386
- export async function ensureBossRecommendPageReady(workspaceRoot, options = {}) {
1387
+ function pickBossRecommendReloadTarget(tabs = []) {
1388
+ return tabs.find(
1389
+ (tab) => typeof tab?.url === "string" && tab.url.includes("/web/chat/recommend")
1390
+ ) || tabs.find(
1391
+ (tab) => typeof tab?.url === "string" && tab.url.includes("zhipin.com")
1392
+ ) || null;
1393
+ }
1394
+
1395
+ export async function reloadBossRecommendPage(workspaceRoot, options = {}) {
1387
1396
  const debugPort = Number.isFinite(options.port)
1388
1397
  ? options.port
1389
1398
  : resolveWorkspaceDebugPort(workspaceRoot);
1390
- const attempts = Number.isFinite(options.attempts) ? Math.max(0, options.attempts) : 3;
1391
- const inspectTimeoutMs = Number.isFinite(options.inspectTimeoutMs) ? options.inspectTimeoutMs : 6000;
1392
- const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : 800;
1393
- const settleMs = Number.isFinite(options.settleMs) ? options.settleMs : 800;
1394
-
1395
- let pageState = await inspectBossRecommendPageState(debugPort, {
1396
- timeoutMs: inspectTimeoutMs,
1397
- pollMs
1398
- });
1399
- if (pageState.state === "RECOMMEND_READY") {
1400
- const stableState = await verifyRecommendPageStable(debugPort, { settleMs, pollMs });
1401
- return {
1402
- ok: stableState.state === "RECOMMEND_READY",
1403
- debug_port: debugPort,
1404
- state: stableState.state,
1405
- page_state: stableState
1406
- };
1407
- }
1399
+ const settleMs = Number.isFinite(options.settleMs) ? options.settleMs : 1200;
1400
+ const recheckTimeoutMs = Number.isFinite(options.recheckTimeoutMs) ? options.recheckTimeoutMs : 4000;
1401
+ const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : 600;
1408
1402
 
1409
- let launchAttempt = null;
1410
- if (pageState.state === "LOGIN_REQUIRED" || pageState.state === "LOGIN_REQUIRED_AFTER_REDIRECT") {
1411
- return {
1412
- ok: false,
1413
- debug_port: debugPort,
1414
- state: pageState.state,
1415
- page_state: {
1416
- ...pageState,
1417
- launch_attempt: launchAttempt
1418
- }
1419
- };
1420
- }
1421
- if (pageState.state === "DEBUG_PORT_UNREACHABLE") {
1422
- launchAttempt = launchChromeWithDebugPort(debugPort);
1423
- if (launchAttempt.ok) {
1424
- await sleep(settleMs + 1200);
1425
- pageState = await inspectBossRecommendPageState(debugPort, {
1426
- timeoutMs: inspectTimeoutMs,
1427
- pollMs
1428
- });
1429
- if (pageState.state === "LOGIN_REQUIRED" || pageState.state === "LOGIN_REQUIRED_AFTER_REDIRECT") {
1430
- return {
1431
- ok: false,
1432
- debug_port: debugPort,
1433
- state: pageState.state,
1434
- page_state: {
1435
- ...pageState,
1436
- launch_attempt: launchAttempt
1437
- }
1438
- };
1439
- }
1440
- if (pageState.state === "RECOMMEND_READY") {
1441
- const stableState = await verifyRecommendPageStable(debugPort, { settleMs, pollMs });
1442
- return {
1443
- ok: stableState.state === "RECOMMEND_READY",
1444
- debug_port: debugPort,
1445
- state: stableState.state,
1446
- page_state: {
1447
- ...stableState,
1448
- launch_attempt: launchAttempt
1449
- }
1450
- };
1451
- }
1452
- } else {
1403
+ let client = null;
1404
+ try {
1405
+ const tabs = await listChromeTabs(debugPort);
1406
+ const target = pickBossRecommendReloadTarget(tabs);
1407
+ if (!target) {
1453
1408
  return {
1454
1409
  ok: false,
1455
1410
  debug_port: debugPort,
1456
- state: pageState.state,
1457
- page_state: {
1458
- ...pageState,
1459
- launch_attempt: launchAttempt
1460
- }
1461
- };
1462
- }
1463
- }
1464
-
1465
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
1466
- if (
1467
- pageState.state === "DEBUG_PORT_UNREACHABLE"
1468
- || pageState.state === "LOGIN_REQUIRED"
1469
- || pageState.state === "LOGIN_REQUIRED_AFTER_REDIRECT"
1470
- ) {
1471
- break;
1472
- }
1473
- await openBossRecommendTab(debugPort);
1474
- await sleep(settleMs);
1475
- pageState = await inspectBossRecommendPageState(debugPort, {
1476
- timeoutMs: inspectTimeoutMs,
1477
- pollMs
1478
- });
1479
- if (pageState.state === "RECOMMEND_READY") {
1480
- const stableState = await verifyRecommendPageStable(debugPort, { settleMs, pollMs });
1481
- return {
1482
- ok: stableState.state === "RECOMMEND_READY",
1483
- debug_port: debugPort,
1484
- state: stableState.state,
1485
- page_state: {
1486
- ...stableState,
1487
- launch_attempt: launchAttempt
1488
- }
1411
+ state: "BOSS_TAB_NOT_FOUND",
1412
+ page_state: null,
1413
+ message: "未找到可刷新的 Boss 标签页。"
1489
1414
  };
1490
1415
  }
1491
- }
1492
-
1493
- return {
1494
- ok: false,
1495
- debug_port: debugPort,
1496
- state: pageState.state || "UNKNOWN",
1497
- page_state: {
1498
- ...pageState,
1499
- launch_attempt: launchAttempt
1500
- }
1501
- };
1502
- }
1503
1416
 
1504
- export async function listRecommendJobs({ workspaceRoot, port, runtime = null }) {
1505
- const searchDir = resolveRecommendSearchCliDir(workspaceRoot);
1506
- if (!searchDir) {
1507
- return {
1508
- ok: false,
1509
- stdout: "",
1510
- stderr: "boss-recommend-search-cli package not found",
1511
- error: {
1512
- code: "RECOMMEND_SEARCH_CLI_MISSING",
1513
- message: "boss-recommend-search-cli 目录不存在。"
1514
- }
1515
- };
1516
- }
1517
- const cliPath = resolveRecommendSearchCliEntry(searchDir);
1518
- const args = [
1519
- cliPath,
1520
- "--list-jobs",
1521
- "--port",
1522
- String(parsePositiveInteger(port) || resolveWorkspaceDebugPort(workspaceRoot))
1523
- ];
1524
- const result = await runProcess({
1525
- command: "node",
1526
- args,
1527
- cwd: searchDir,
1528
- timeoutMs: 180000,
1529
- heartbeatIntervalMs: runtime?.heartbeatIntervalMs,
1530
- signal: runtime?.signal,
1531
- onOutput: (event) => {
1532
- safeInvokeCallback(runtime?.onOutput, event);
1533
- },
1534
- onHeartbeat: (event) => {
1535
- safeInvokeCallback(runtime?.onHeartbeat, event);
1417
+ client = await CDP({ port: debugPort, target });
1418
+ const { Page } = client;
1419
+ if (Page && typeof Page.enable === "function") {
1420
+ await Page.enable();
1536
1421
  }
1537
- });
1538
- const structured = parseJsonOutput(result.stdout) || parseJsonOutput(result.stderr);
1539
- const jobs = Array.isArray(structured?.result?.jobs) ? structured.result.jobs : [];
1540
- const missingOutputError = result.code === 0 && !structured
1541
- ? {
1542
- code: "RECOMMEND_JOB_LIST_NO_OUTPUT",
1543
- message: "岗位列表读取完成但未返回可解析结果。"
1544
- }
1545
- : null;
1546
- return {
1547
- ok: result.code === 0 && structured?.status === "COMPLETED" && jobs.length > 0,
1548
- stdout: result.stdout,
1549
- stderr: result.stderr,
1550
- structured,
1551
- jobs,
1552
- error: structured?.error || missingOutputError || (
1553
- result.code === 0
1554
- ? {
1555
- code: "RECOMMEND_JOB_LIST_EMPTY",
1556
- message: "未读取到可选岗位。"
1557
- }
1558
- : result.error_code === "ABORTED"
1559
- ? {
1560
- code: "PROCESS_ABORTED",
1561
- message: "岗位列表读取已取消。"
1562
- }
1563
- : {
1564
- code: "RECOMMEND_JOB_LIST_FAILED",
1565
- message: "岗位列表读取失败。"
1566
- }
1567
- )
1568
- };
1569
- }
1570
-
1571
- export async function runRecommendSearchCli({ workspaceRoot, searchParams, selectedJob, runtime = null }) {
1572
- const searchDir = resolveRecommendSearchCliDir(workspaceRoot);
1573
- if (!searchDir) {
1574
- return {
1575
- ok: false,
1576
- stdout: "",
1577
- stderr: "boss-recommend-search-cli package not found",
1578
- error: {
1579
- code: "RECOMMEND_SEARCH_CLI_MISSING",
1580
- message: "boss-recommend-search-cli 目录不存在。"
1581
- }
1582
- };
1583
- }
1584
- const cliPath = resolveRecommendSearchCliEntry(searchDir);
1585
- const args = [
1586
- cliPath,
1587
- "--school-tag",
1588
- serializeSchoolTagSelection(searchParams.school_tag),
1589
- "--degree",
1590
- serializeDegreeSelection(searchParams.degree),
1591
- "--gender",
1592
- searchParams.gender,
1593
- "--recent-not-view",
1594
- searchParams.recent_not_view,
1595
- "--port",
1596
- String(resolveWorkspaceDebugPort(workspaceRoot))
1597
- ];
1598
- const normalizedSelectedJob = String(selectedJob || "").trim();
1599
- if (normalizedSelectedJob) {
1600
- args.push("--job", normalizedSelectedJob);
1601
- }
1602
- const result = await runProcess({
1603
- command: "node",
1604
- args,
1605
- cwd: searchDir,
1606
- timeoutMs: 180000,
1607
- heartbeatIntervalMs: runtime?.heartbeatIntervalMs,
1608
- signal: runtime?.signal,
1609
- onOutput: (event) => {
1610
- safeInvokeCallback(runtime?.onOutput, event);
1611
- },
1612
- onHeartbeat: (event) => {
1613
- safeInvokeCallback(runtime?.onHeartbeat, event);
1422
+ if (Page && typeof Page.bringToFront === "function") {
1423
+ await Page.bringToFront();
1614
1424
  }
1615
- });
1616
- const structured = parseJsonOutput(result.stdout) || parseJsonOutput(result.stderr);
1617
- const missingOutputError = result.code === 0 && !structured
1618
- ? {
1619
- code: "RECOMMEND_SEARCH_NO_OUTPUT",
1620
- message: "推荐页筛选命令执行结束但未返回可解析结果。"
1621
- }
1622
- : null;
1623
- return {
1624
- ok: result.code === 0 && structured?.status === "COMPLETED",
1625
- stdout: result.stdout,
1626
- stderr: result.stderr,
1627
- structured,
1628
- summary: structured?.result || null,
1629
- error: structured?.error || missingOutputError || (
1630
- result.code === 0
1631
- ? null
1632
- : result.error_code === "ABORTED"
1633
- ? {
1634
- code: "PROCESS_ABORTED",
1635
- message: "推荐页筛选命令已取消。"
1636
- }
1637
- : {
1638
- code: "RECOMMEND_SEARCH_FAILED",
1639
- message: "推荐页筛选命令执行失败。"
1640
- }
1641
- )
1642
- };
1643
- }
1425
+ await Page.reload({ ignoreCache: true });
1644
1426
 
1645
- export async function runRecommendScreenCli({ workspaceRoot, screenParams, resume = null, runtime = null }) {
1646
- const screenDir = resolveRecommendScreenCliDir(workspaceRoot);
1647
- if (!screenDir) {
1427
+ const stableState = await verifyRecommendPageStable(debugPort, {
1428
+ settleMs,
1429
+ recheckTimeoutMs,
1430
+ pollMs
1431
+ });
1648
1432
  return {
1649
- ok: false,
1650
- stdout: "",
1651
- stderr: "boss-recommend-screen-cli package not found",
1652
- error: {
1653
- code: "RECOMMEND_SCREEN_CLI_MISSING",
1654
- message: "boss-recommend-screen-cli 目录不存在。"
1655
- }
1433
+ ok: stableState.state === "RECOMMEND_READY",
1434
+ debug_port: debugPort,
1435
+ state: stableState.state,
1436
+ page_state: stableState,
1437
+ reloaded_url: target.url || null
1656
1438
  };
1657
- }
1658
- const configPath = resolveScreenConfigPath(workspaceRoot);
1659
- const loaded = loadScreenConfig(configPath);
1660
- if (!loaded.ok) {
1439
+ } catch (error) {
1661
1440
  return {
1662
1441
  ok: false,
1663
- stdout: "",
1664
- stderr: loaded.error,
1665
- error: {
1666
- code: "SCREEN_CONFIG_ERROR",
1667
- message: loaded.error
1668
- }
1442
+ debug_port: debugPort,
1443
+ state: "RELOAD_FAILED",
1444
+ page_state: null,
1445
+ message: error?.message || "刷新 Boss recommend 页面失败。"
1669
1446
  };
1670
- }
1671
-
1672
- const fixedOutput = normalizeText(resume?.output_csv || "");
1673
- const outputName = `recommend_screen_result_${Date.now()}.csv`;
1674
- let outputPath = fixedOutput ? path.resolve(fixedOutput) : outputName;
1675
- if (!fixedOutput) {
1676
- if (loaded.config.outputDir) {
1677
- const resolvedOutputDir = path.resolve(path.dirname(configPath), loaded.config.outputDir);
1678
- fs.mkdirSync(resolvedOutputDir, { recursive: true });
1679
- outputPath = path.join(resolvedOutputDir, outputName);
1680
- } else {
1681
- const desktopDir = getDesktopDir();
1682
- fs.mkdirSync(desktopDir, { recursive: true });
1683
- outputPath = path.join(desktopDir, outputName);
1447
+ } finally {
1448
+ if (client) {
1449
+ try {
1450
+ await client.close();
1451
+ } catch {}
1684
1452
  }
1685
- } else {
1686
- fs.mkdirSync(path.dirname(outputPath), { recursive: true });
1687
1453
  }
1688
-
1689
- const checkpointPath = normalizeText(resume?.checkpoint_path || "")
1690
- ? path.resolve(String(resume.checkpoint_path))
1691
- : null;
1692
- const pauseControlPath = normalizeText(resume?.pause_control_path || "")
1693
- ? path.resolve(String(resume.pause_control_path))
1694
- : null;
1695
- const resumeRequested = resume?.resume === true;
1696
- const requireCheckpoint = resume?.require_checkpoint === true;
1697
- if (resumeRequested && requireCheckpoint) {
1698
- if (!checkpointPath) {
1699
- return {
1700
- ok: false,
1701
- paused: false,
1702
- stdout: "",
1703
- stderr: "",
1704
- structured: null,
1705
- summary: null,
1706
- error: {
1707
- code: "RESUME_CHECKPOINT_MISSING",
1708
- message: "恢复执行缺少 checkpoint_path,无法从上次进度继续。"
1709
- }
1710
- };
1711
- }
1712
- if (!fs.existsSync(checkpointPath)) {
1713
- return {
1714
- ok: false,
1715
- paused: false,
1716
- stdout: "",
1717
- stderr: "",
1718
- structured: null,
1719
- summary: null,
1720
- error: {
1721
- code: "RESUME_CHECKPOINT_MISSING",
1722
- message: `恢复执行未找到 checkpoint 文件:${checkpointPath}`
1723
- }
1724
- };
1725
- }
1726
- }
1727
-
1728
- const cliPath = resolveRecommendScreenCliEntry(screenDir);
1729
- const args = [
1730
- cliPath,
1731
- "--baseurl",
1732
- loaded.config.baseUrl,
1733
- "--apikey",
1734
- loaded.config.apiKey,
1735
- "--model",
1736
- loaded.config.model,
1737
- "--port",
1738
- String(resolveWorkspaceDebugPort(workspaceRoot)),
1739
- "--criteria",
1740
- screenParams.criteria,
1741
- "--post-action",
1742
- screenParams.post_action,
1743
- "--post-action-confirmed",
1744
- "true",
1745
- "--output",
1746
- outputPath
1747
- ];
1748
-
1749
- if (loaded.config.openaiOrganization) {
1750
- args.push("--openai-organization", loaded.config.openaiOrganization);
1751
- }
1752
- if (loaded.config.openaiProject) {
1753
- args.push("--openai-project", loaded.config.openaiProject);
1754
- }
1755
- if (Number.isInteger(screenParams.target_count) && screenParams.target_count > 0) {
1756
- args.push("--targetCount", String(screenParams.target_count));
1757
- }
1758
- if (screenParams.post_action === "greet"
1759
- && Number.isInteger(screenParams.max_greet_count)
1760
- && screenParams.max_greet_count > 0) {
1761
- args.push("--max-greet-count", String(screenParams.max_greet_count));
1762
- }
1763
- if (checkpointPath) {
1764
- args.push("--checkpoint-path", checkpointPath);
1765
- }
1766
- if (pauseControlPath) {
1767
- args.push("--pause-control-path", pauseControlPath);
1768
- }
1769
- if (resumeRequested) {
1770
- args.push("--resume");
1771
- }
1772
-
1773
- let inferredProgress = {
1774
- processed: 0,
1775
- passed: 0,
1776
- skipped: 0,
1777
- greet_count: 0
1778
- };
1779
- let inferredTracker = createScreenProgressTracker();
1780
- const screenTimeoutMs = resolveRecommendScreenTimeoutMs(runtime);
1781
-
1782
- const result = await runProcess({
1783
- command: "node",
1784
- args,
1785
- cwd: screenDir,
1786
- timeoutMs: screenTimeoutMs,
1787
- heartbeatIntervalMs: runtime?.heartbeatIntervalMs,
1788
- signal: runtime?.signal,
1789
- onOutput: (event) => {
1790
- safeInvokeCallback(runtime?.onOutput, event);
1791
- },
1792
- onLine: (event) => {
1793
- const parsed = parseScreenProgressLine(event?.line, inferredProgress, inferredTracker);
1794
- if (!parsed) return;
1795
- inferredProgress = parsed.progress;
1796
- inferredTracker = parsed.tracker;
1797
- safeInvokeCallback(runtime?.onProgress, {
1798
- ...inferredProgress,
1799
- line: parsed.line
1800
- });
1801
- },
1802
- onHeartbeat: (event) => {
1803
- safeInvokeCallback(runtime?.onHeartbeat, event);
1804
- }
1805
- });
1806
- const structured = parseJsonOutput(result.stdout) || parseJsonOutput(result.stderr);
1807
- const status = normalizeText(structured?.status || "").toUpperCase();
1808
- const summary = structured?.result || null;
1809
- if (summary) {
1810
- safeInvokeCallback(runtime?.onProgress, {
1811
- processed: Number.isInteger(summary.processed_count) ? summary.processed_count : inferredProgress.processed,
1812
- passed: Number.isInteger(summary.passed_count) ? summary.passed_count : inferredProgress.passed,
1813
- skipped: Number.isInteger(summary.skipped_count) ? summary.skipped_count : inferredProgress.skipped,
1814
- greet_count: Number.isInteger(summary.greet_count) ? summary.greet_count : inferredProgress.greet_count
1815
- });
1816
- }
1817
- const missingOutputError = result.code === 0 && !structured
1818
- ? {
1819
- code: "RECOMMEND_SCREEN_NO_OUTPUT",
1820
- message: "推荐页筛选命令执行结束但未返回可解析结果。"
1821
- }
1822
- : null;
1823
- return {
1824
- ok: result.code === 0 && status === "COMPLETED",
1825
- paused: result.code === 0 && status === "PAUSED",
1826
- stdout: result.stdout,
1827
- stderr: result.stderr,
1828
- structured,
1829
- summary,
1830
- error: structured?.error || missingOutputError || buildRecommendScreenProcessError(result, screenTimeoutMs)
1831
- };
1832
1454
  }
1833
1455
 
1834
- export const __testables = {
1835
- runProcess,
1836
- parseJsonOutput,
1837
- parseScreenProgressLine,
1838
- resolveRecommendScreenTimeoutMs,
1839
- buildRecommendScreenProcessError
1840
- };
1456
+ export async function ensureBossRecommendPageReady(workspaceRoot, options = {}) {
1457
+ const debugPort = Number.isFinite(options.port)
1458
+ ? options.port
1459
+ : resolveWorkspaceDebugPort(workspaceRoot);
1460
+ const attempts = Number.isFinite(options.attempts) ? Math.max(0, options.attempts) : 3;
1461
+ const inspectTimeoutMs = Number.isFinite(options.inspectTimeoutMs) ? options.inspectTimeoutMs : 6000;
1462
+ const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : 800;
1463
+ const settleMs = Number.isFinite(options.settleMs) ? options.settleMs : 800;
1464
+
1465
+ let pageState = await inspectBossRecommendPageState(debugPort, {
1466
+ timeoutMs: inspectTimeoutMs,
1467
+ pollMs
1468
+ });
1469
+ if (pageState.state === "RECOMMEND_READY") {
1470
+ const stableState = await verifyRecommendPageStable(debugPort, { settleMs, pollMs });
1471
+ return {
1472
+ ok: stableState.state === "RECOMMEND_READY",
1473
+ debug_port: debugPort,
1474
+ state: stableState.state,
1475
+ page_state: stableState
1476
+ };
1477
+ }
1478
+
1479
+ let launchAttempt = null;
1480
+ if (pageState.state === "LOGIN_REQUIRED" || pageState.state === "LOGIN_REQUIRED_AFTER_REDIRECT") {
1481
+ return {
1482
+ ok: false,
1483
+ debug_port: debugPort,
1484
+ state: pageState.state,
1485
+ page_state: {
1486
+ ...pageState,
1487
+ launch_attempt: launchAttempt
1488
+ }
1489
+ };
1490
+ }
1491
+ if (pageState.state === "DEBUG_PORT_UNREACHABLE") {
1492
+ launchAttempt = launchChromeWithDebugPort(debugPort);
1493
+ if (launchAttempt.ok) {
1494
+ await sleep(settleMs + 1200);
1495
+ pageState = await inspectBossRecommendPageState(debugPort, {
1496
+ timeoutMs: inspectTimeoutMs,
1497
+ pollMs
1498
+ });
1499
+ if (pageState.state === "LOGIN_REQUIRED" || pageState.state === "LOGIN_REQUIRED_AFTER_REDIRECT") {
1500
+ return {
1501
+ ok: false,
1502
+ debug_port: debugPort,
1503
+ state: pageState.state,
1504
+ page_state: {
1505
+ ...pageState,
1506
+ launch_attempt: launchAttempt
1507
+ }
1508
+ };
1509
+ }
1510
+ if (pageState.state === "RECOMMEND_READY") {
1511
+ const stableState = await verifyRecommendPageStable(debugPort, { settleMs, pollMs });
1512
+ return {
1513
+ ok: stableState.state === "RECOMMEND_READY",
1514
+ debug_port: debugPort,
1515
+ state: stableState.state,
1516
+ page_state: {
1517
+ ...stableState,
1518
+ launch_attempt: launchAttempt
1519
+ }
1520
+ };
1521
+ }
1522
+ } else {
1523
+ return {
1524
+ ok: false,
1525
+ debug_port: debugPort,
1526
+ state: pageState.state,
1527
+ page_state: {
1528
+ ...pageState,
1529
+ launch_attempt: launchAttempt
1530
+ }
1531
+ };
1532
+ }
1533
+ }
1534
+
1535
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
1536
+ if (
1537
+ pageState.state === "DEBUG_PORT_UNREACHABLE"
1538
+ || pageState.state === "LOGIN_REQUIRED"
1539
+ || pageState.state === "LOGIN_REQUIRED_AFTER_REDIRECT"
1540
+ ) {
1541
+ break;
1542
+ }
1543
+ await openBossRecommendTab(debugPort);
1544
+ await sleep(settleMs);
1545
+ pageState = await inspectBossRecommendPageState(debugPort, {
1546
+ timeoutMs: inspectTimeoutMs,
1547
+ pollMs
1548
+ });
1549
+ if (pageState.state === "RECOMMEND_READY") {
1550
+ const stableState = await verifyRecommendPageStable(debugPort, { settleMs, pollMs });
1551
+ return {
1552
+ ok: stableState.state === "RECOMMEND_READY",
1553
+ debug_port: debugPort,
1554
+ state: stableState.state,
1555
+ page_state: {
1556
+ ...stableState,
1557
+ launch_attempt: launchAttempt
1558
+ }
1559
+ };
1560
+ }
1561
+ }
1562
+
1563
+ return {
1564
+ ok: false,
1565
+ debug_port: debugPort,
1566
+ state: pageState.state || "UNKNOWN",
1567
+ page_state: {
1568
+ ...pageState,
1569
+ launch_attempt: launchAttempt
1570
+ }
1571
+ };
1572
+ }
1573
+
1574
+ export async function listRecommendJobs({ workspaceRoot, port, runtime = null }) {
1575
+ const searchDir = resolveRecommendSearchCliDir(workspaceRoot);
1576
+ if (!searchDir) {
1577
+ return {
1578
+ ok: false,
1579
+ stdout: "",
1580
+ stderr: "boss-recommend-search-cli package not found",
1581
+ error: {
1582
+ code: "RECOMMEND_SEARCH_CLI_MISSING",
1583
+ message: "boss-recommend-search-cli 目录不存在。"
1584
+ }
1585
+ };
1586
+ }
1587
+ const cliPath = resolveRecommendSearchCliEntry(searchDir);
1588
+ const args = [
1589
+ cliPath,
1590
+ "--list-jobs",
1591
+ "--port",
1592
+ String(parsePositiveInteger(port) || resolveWorkspaceDebugPort(workspaceRoot))
1593
+ ];
1594
+ const result = await runProcess({
1595
+ command: "node",
1596
+ args,
1597
+ cwd: searchDir,
1598
+ timeoutMs: 180000,
1599
+ heartbeatIntervalMs: runtime?.heartbeatIntervalMs,
1600
+ signal: runtime?.signal,
1601
+ onOutput: (event) => {
1602
+ safeInvokeCallback(runtime?.onOutput, event);
1603
+ },
1604
+ onHeartbeat: (event) => {
1605
+ safeInvokeCallback(runtime?.onHeartbeat, event);
1606
+ }
1607
+ });
1608
+ const structured = parseJsonOutput(result.stdout) || parseJsonOutput(result.stderr);
1609
+ const jobs = Array.isArray(structured?.result?.jobs) ? structured.result.jobs : [];
1610
+ const missingOutputError = result.code === 0 && !structured
1611
+ ? {
1612
+ code: "RECOMMEND_JOB_LIST_NO_OUTPUT",
1613
+ message: "岗位列表读取完成但未返回可解析结果。"
1614
+ }
1615
+ : null;
1616
+ return {
1617
+ ok: result.code === 0 && structured?.status === "COMPLETED" && jobs.length > 0,
1618
+ stdout: result.stdout,
1619
+ stderr: result.stderr,
1620
+ structured,
1621
+ jobs,
1622
+ error: structured?.error || missingOutputError || (
1623
+ result.code === 0
1624
+ ? {
1625
+ code: "RECOMMEND_JOB_LIST_EMPTY",
1626
+ message: "未读取到可选岗位。"
1627
+ }
1628
+ : result.error_code === "ABORTED"
1629
+ ? {
1630
+ code: "PROCESS_ABORTED",
1631
+ message: "岗位列表读取已取消。"
1632
+ }
1633
+ : {
1634
+ code: "RECOMMEND_JOB_LIST_FAILED",
1635
+ message: "岗位列表读取失败。"
1636
+ }
1637
+ )
1638
+ };
1639
+ }
1640
+
1641
+ export async function runRecommendSearchCli({ workspaceRoot, searchParams, selectedJob, runtime = null }) {
1642
+ const searchDir = resolveRecommendSearchCliDir(workspaceRoot);
1643
+ if (!searchDir) {
1644
+ return {
1645
+ ok: false,
1646
+ stdout: "",
1647
+ stderr: "boss-recommend-search-cli package not found",
1648
+ error: {
1649
+ code: "RECOMMEND_SEARCH_CLI_MISSING",
1650
+ message: "boss-recommend-search-cli 目录不存在。"
1651
+ }
1652
+ };
1653
+ }
1654
+ const cliPath = resolveRecommendSearchCliEntry(searchDir);
1655
+ const args = [
1656
+ cliPath,
1657
+ "--school-tag",
1658
+ serializeSchoolTagSelection(searchParams.school_tag),
1659
+ "--degree",
1660
+ serializeDegreeSelection(searchParams.degree),
1661
+ "--gender",
1662
+ searchParams.gender,
1663
+ "--recent-not-view",
1664
+ searchParams.recent_not_view,
1665
+ "--port",
1666
+ String(resolveWorkspaceDebugPort(workspaceRoot))
1667
+ ];
1668
+ const normalizedSelectedJob = String(selectedJob || "").trim();
1669
+ if (normalizedSelectedJob) {
1670
+ args.push("--job", normalizedSelectedJob);
1671
+ }
1672
+ const result = await runProcess({
1673
+ command: "node",
1674
+ args,
1675
+ cwd: searchDir,
1676
+ timeoutMs: 180000,
1677
+ heartbeatIntervalMs: runtime?.heartbeatIntervalMs,
1678
+ signal: runtime?.signal,
1679
+ onOutput: (event) => {
1680
+ safeInvokeCallback(runtime?.onOutput, event);
1681
+ },
1682
+ onHeartbeat: (event) => {
1683
+ safeInvokeCallback(runtime?.onHeartbeat, event);
1684
+ }
1685
+ });
1686
+ const structured = parseJsonOutput(result.stdout) || parseJsonOutput(result.stderr);
1687
+ const missingOutputError = result.code === 0 && !structured
1688
+ ? {
1689
+ code: "RECOMMEND_SEARCH_NO_OUTPUT",
1690
+ message: "推荐页筛选命令执行结束但未返回可解析结果。"
1691
+ }
1692
+ : null;
1693
+ return {
1694
+ ok: result.code === 0 && structured?.status === "COMPLETED",
1695
+ stdout: result.stdout,
1696
+ stderr: result.stderr,
1697
+ structured,
1698
+ summary: structured?.result || null,
1699
+ error: structured?.error || missingOutputError || (
1700
+ result.code === 0
1701
+ ? null
1702
+ : result.error_code === "ABORTED"
1703
+ ? {
1704
+ code: "PROCESS_ABORTED",
1705
+ message: "推荐页筛选命令已取消。"
1706
+ }
1707
+ : {
1708
+ code: "RECOMMEND_SEARCH_FAILED",
1709
+ message: "推荐页筛选命令执行失败。"
1710
+ }
1711
+ )
1712
+ };
1713
+ }
1714
+
1715
+ export async function runRecommendScreenCli({ workspaceRoot, screenParams, resume = null, runtime = null }) {
1716
+ const screenDir = resolveRecommendScreenCliDir(workspaceRoot);
1717
+ if (!screenDir) {
1718
+ return {
1719
+ ok: false,
1720
+ stdout: "",
1721
+ stderr: "boss-recommend-screen-cli package not found",
1722
+ error: {
1723
+ code: "RECOMMEND_SCREEN_CLI_MISSING",
1724
+ message: "boss-recommend-screen-cli 目录不存在。"
1725
+ }
1726
+ };
1727
+ }
1728
+ const configPath = resolveScreenConfigPath(workspaceRoot);
1729
+ const loaded = loadScreenConfig(configPath);
1730
+ if (!loaded.ok) {
1731
+ return {
1732
+ ok: false,
1733
+ stdout: "",
1734
+ stderr: loaded.error,
1735
+ error: {
1736
+ code: "SCREEN_CONFIG_ERROR",
1737
+ message: loaded.error
1738
+ }
1739
+ };
1740
+ }
1741
+
1742
+ const fixedOutput = normalizeText(resume?.output_csv || "");
1743
+ const outputName = `recommend_screen_result_${Date.now()}.csv`;
1744
+ let outputPath = fixedOutput ? path.resolve(fixedOutput) : outputName;
1745
+ if (!fixedOutput) {
1746
+ if (loaded.config.outputDir) {
1747
+ const resolvedOutputDir = path.resolve(path.dirname(configPath), loaded.config.outputDir);
1748
+ fs.mkdirSync(resolvedOutputDir, { recursive: true });
1749
+ outputPath = path.join(resolvedOutputDir, outputName);
1750
+ } else {
1751
+ const desktopDir = getDesktopDir();
1752
+ fs.mkdirSync(desktopDir, { recursive: true });
1753
+ outputPath = path.join(desktopDir, outputName);
1754
+ }
1755
+ } else {
1756
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
1757
+ }
1758
+
1759
+ const checkpointPath = normalizeText(resume?.checkpoint_path || "")
1760
+ ? path.resolve(String(resume.checkpoint_path))
1761
+ : null;
1762
+ const pauseControlPath = normalizeText(resume?.pause_control_path || "")
1763
+ ? path.resolve(String(resume.pause_control_path))
1764
+ : null;
1765
+ const resumeRequested = resume?.resume === true;
1766
+ const requireCheckpoint = resume?.require_checkpoint === true;
1767
+ if (resumeRequested && requireCheckpoint) {
1768
+ if (!checkpointPath) {
1769
+ return {
1770
+ ok: false,
1771
+ paused: false,
1772
+ stdout: "",
1773
+ stderr: "",
1774
+ structured: null,
1775
+ summary: null,
1776
+ error: {
1777
+ code: "RESUME_CHECKPOINT_MISSING",
1778
+ message: "恢复执行缺少 checkpoint_path,无法从上次进度继续。"
1779
+ }
1780
+ };
1781
+ }
1782
+ if (!fs.existsSync(checkpointPath)) {
1783
+ return {
1784
+ ok: false,
1785
+ paused: false,
1786
+ stdout: "",
1787
+ stderr: "",
1788
+ structured: null,
1789
+ summary: null,
1790
+ error: {
1791
+ code: "RESUME_CHECKPOINT_MISSING",
1792
+ message: `恢复执行未找到 checkpoint 文件:${checkpointPath}`
1793
+ }
1794
+ };
1795
+ }
1796
+ }
1797
+
1798
+ const cliPath = resolveRecommendScreenCliEntry(screenDir);
1799
+ const args = [
1800
+ cliPath,
1801
+ "--baseurl",
1802
+ loaded.config.baseUrl,
1803
+ "--apikey",
1804
+ loaded.config.apiKey,
1805
+ "--model",
1806
+ loaded.config.model,
1807
+ "--port",
1808
+ String(resolveWorkspaceDebugPort(workspaceRoot)),
1809
+ "--criteria",
1810
+ screenParams.criteria,
1811
+ "--post-action",
1812
+ screenParams.post_action,
1813
+ "--post-action-confirmed",
1814
+ "true",
1815
+ "--output",
1816
+ outputPath
1817
+ ];
1818
+
1819
+ if (loaded.config.openaiOrganization) {
1820
+ args.push("--openai-organization", loaded.config.openaiOrganization);
1821
+ }
1822
+ if (loaded.config.openaiProject) {
1823
+ args.push("--openai-project", loaded.config.openaiProject);
1824
+ }
1825
+ if (Number.isInteger(screenParams.target_count) && screenParams.target_count > 0) {
1826
+ args.push("--targetCount", String(screenParams.target_count));
1827
+ }
1828
+ if (screenParams.post_action === "greet"
1829
+ && Number.isInteger(screenParams.max_greet_count)
1830
+ && screenParams.max_greet_count > 0) {
1831
+ args.push("--max-greet-count", String(screenParams.max_greet_count));
1832
+ }
1833
+ if (checkpointPath) {
1834
+ args.push("--checkpoint-path", checkpointPath);
1835
+ }
1836
+ if (pauseControlPath) {
1837
+ args.push("--pause-control-path", pauseControlPath);
1838
+ }
1839
+ if (resumeRequested) {
1840
+ args.push("--resume");
1841
+ }
1842
+
1843
+ let inferredProgress = {
1844
+ processed: 0,
1845
+ passed: 0,
1846
+ skipped: 0,
1847
+ greet_count: 0
1848
+ };
1849
+ let inferredTracker = createScreenProgressTracker();
1850
+ const screenTimeoutMs = resolveRecommendScreenTimeoutMs(runtime);
1851
+
1852
+ const result = await runProcess({
1853
+ command: "node",
1854
+ args,
1855
+ cwd: screenDir,
1856
+ timeoutMs: screenTimeoutMs,
1857
+ heartbeatIntervalMs: runtime?.heartbeatIntervalMs,
1858
+ signal: runtime?.signal,
1859
+ onOutput: (event) => {
1860
+ safeInvokeCallback(runtime?.onOutput, event);
1861
+ },
1862
+ onLine: (event) => {
1863
+ const parsed = parseScreenProgressLine(event?.line, inferredProgress, inferredTracker);
1864
+ if (!parsed) return;
1865
+ inferredProgress = parsed.progress;
1866
+ inferredTracker = parsed.tracker;
1867
+ safeInvokeCallback(runtime?.onProgress, {
1868
+ ...inferredProgress,
1869
+ line: parsed.line
1870
+ });
1871
+ },
1872
+ onHeartbeat: (event) => {
1873
+ safeInvokeCallback(runtime?.onHeartbeat, event);
1874
+ }
1875
+ });
1876
+ const structured = parseJsonOutput(result.stdout) || parseJsonOutput(result.stderr);
1877
+ const status = normalizeText(structured?.status || "").toUpperCase();
1878
+ const summary = structured?.result || null;
1879
+ if (summary) {
1880
+ safeInvokeCallback(runtime?.onProgress, {
1881
+ processed: Number.isInteger(summary.processed_count) ? summary.processed_count : inferredProgress.processed,
1882
+ passed: Number.isInteger(summary.passed_count) ? summary.passed_count : inferredProgress.passed,
1883
+ skipped: Number.isInteger(summary.skipped_count) ? summary.skipped_count : inferredProgress.skipped,
1884
+ greet_count: Number.isInteger(summary.greet_count) ? summary.greet_count : inferredProgress.greet_count
1885
+ });
1886
+ }
1887
+ const missingOutputError = result.code === 0 && !structured
1888
+ ? {
1889
+ code: "RECOMMEND_SCREEN_NO_OUTPUT",
1890
+ message: "推荐页筛选命令执行结束但未返回可解析结果。"
1891
+ }
1892
+ : null;
1893
+ return {
1894
+ ok: result.code === 0 && status === "COMPLETED",
1895
+ paused: result.code === 0 && status === "PAUSED",
1896
+ stdout: result.stdout,
1897
+ stderr: result.stderr,
1898
+ structured,
1899
+ summary,
1900
+ error: structured?.error || missingOutputError || buildRecommendScreenProcessError(result, screenTimeoutMs)
1901
+ };
1902
+ }
1903
+
1904
+ export const __testables = {
1905
+ runProcess,
1906
+ parseJsonOutput,
1907
+ parseScreenProgressLine,
1908
+ resolveRecommendScreenTimeoutMs,
1909
+ buildRecommendScreenProcessError
1910
+ };