@reconcrap/boss-recommend-mcp 1.2.3 → 1.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
package/src/index.js CHANGED
@@ -4,7 +4,11 @@ import { spawn } from "node:child_process";
4
4
  import { createRequire } from "node:module";
5
5
  import process from "node:process";
6
6
  import { fileURLToPath } from "node:url";
7
- import { runRecommendPipeline } from "./pipeline.js";
7
+ import {
8
+ getFeaturedCalibrationResolution,
9
+ runRecommendCalibration
10
+ } from "./adapters.js";
11
+ import { runRecommendPipeline } from "./pipeline.js";
8
12
  import {
9
13
  RUN_MODE_ASYNC,
10
14
  RUN_STAGE_PREFLIGHT,
@@ -28,11 +32,13 @@ import {
28
32
  const require = createRequire(import.meta.url);
29
33
  const { version: SERVER_VERSION } = require("../package.json");
30
34
 
31
- const TOOL_START_RUN = "start_recommend_pipeline_run";
32
- const TOOL_GET_RUN = "get_recommend_pipeline_run";
33
- const TOOL_CANCEL_RUN = "cancel_recommend_pipeline_run";
34
- const TOOL_PAUSE_RUN = "pause_recommend_pipeline_run";
35
- const TOOL_RESUME_RUN = "resume_recommend_pipeline_run";
35
+ const TOOL_START_RUN = "start_recommend_pipeline_run";
36
+ const TOOL_GET_RUN = "get_recommend_pipeline_run";
37
+ const TOOL_CANCEL_RUN = "cancel_recommend_pipeline_run";
38
+ const TOOL_PAUSE_RUN = "pause_recommend_pipeline_run";
39
+ const TOOL_RESUME_RUN = "resume_recommend_pipeline_run";
40
+ const TOOL_RUN_FEATURED_CALIBRATION = "run_featured_calibration";
41
+ const TOOL_GET_FEATURED_CALIBRATION_STATUS = "get_featured_calibration_status";
36
42
 
37
43
  const SERVER_NAME = "boss-recommend-mcp";
38
44
  const FRAMING_UNKNOWN = "unknown";
@@ -141,7 +147,7 @@ function createJsonRpcError(id, code, message) {
141
147
  };
142
148
  }
143
149
 
144
- function createRunInputSchema() {
150
+ function createRunInputSchema() {
145
151
  return {
146
152
  type: "object",
147
153
  properties: {
@@ -290,10 +296,33 @@ function createRunInputSchema() {
290
296
  required: ["instruction"],
291
297
  additionalProperties: false
292
298
  };
293
- }
294
-
295
- function createToolsSchema() {
296
- return [
299
+ }
300
+
301
+ function createRunFeaturedCalibrationInputSchema() {
302
+ return {
303
+ type: "object",
304
+ properties: {
305
+ port: {
306
+ type: "integer",
307
+ minimum: 1,
308
+ description: "可选,Boss Chrome 远程调试端口(默认读取配置或 9222)"
309
+ },
310
+ timeout_ms: {
311
+ type: "integer",
312
+ minimum: 1000,
313
+ description: "可选,等待收藏点击的超时时间(毫秒)"
314
+ },
315
+ output: {
316
+ type: "string",
317
+ description: "可选,校准文件输出路径(默认 favorite-calibration.json)"
318
+ }
319
+ },
320
+ additionalProperties: false
321
+ };
322
+ }
323
+
324
+ function createToolsSchema() {
325
+ return [
297
326
  {
298
327
  name: TOOL_START_RUN,
299
328
  description: "异步启动 Boss 推荐页流水线(含同步门禁预检);只有在前置确认与页面就绪通过后才返回 run_id。",
@@ -335,20 +364,34 @@ function createToolsSchema() {
335
364
  additionalProperties: false
336
365
  }
337
366
  },
338
- {
339
- name: TOOL_RESUME_RUN,
340
- description: "继续指定 run_id 的 paused 流水线;沿用原 CSV 与 checkpoint 续跑。",
341
- inputSchema: {
367
+ {
368
+ name: TOOL_RESUME_RUN,
369
+ description: "继续指定 run_id 的 paused 流水线;沿用原 CSV 与 checkpoint 续跑。",
370
+ inputSchema: {
342
371
  type: "object",
343
372
  properties: {
344
373
  run_id: { type: "string" }
345
374
  },
346
- required: ["run_id"],
347
- additionalProperties: false
348
- }
349
- }
350
- ];
351
- }
375
+ required: ["run_id"],
376
+ additionalProperties: false
377
+ }
378
+ },
379
+ {
380
+ name: TOOL_RUN_FEATURED_CALIBRATION,
381
+ description: "手动执行精选页收藏按钮校准。执行前请先在 Boss 推荐页切换到精选 tab 并打开任意候选人详情页。",
382
+ inputSchema: createRunFeaturedCalibrationInputSchema()
383
+ },
384
+ {
385
+ name: TOOL_GET_FEATURED_CALIBRATION_STATUS,
386
+ description: "查询精选页收藏校准文件与校准脚本可用性。",
387
+ inputSchema: {
388
+ type: "object",
389
+ properties: {},
390
+ additionalProperties: false
391
+ }
392
+ }
393
+ ];
394
+ }
352
395
 
353
396
  function createToolResultResponse(id, payload, isError = false) {
354
397
  return {
@@ -367,7 +410,7 @@ function createToolResultResponse(id, payload, isError = false) {
367
410
  };
368
411
  }
369
412
 
370
- function validateRunArgs(args) {
413
+ function validateRunArgs(args) {
371
414
  if (!args || typeof args !== "object") {
372
415
  return "arguments must be an object";
373
416
  }
@@ -375,9 +418,37 @@ function validateRunArgs(args) {
375
418
  return "instruction is required and must be a string";
376
419
  }
377
420
  return null;
378
- }
379
-
380
- function getLastOutputLine(text) {
421
+ }
422
+
423
+ function validateRunFeaturedCalibrationArgs(args) {
424
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
425
+ return "arguments must be an object";
426
+ }
427
+
428
+ if (Object.prototype.hasOwnProperty.call(args, "port")) {
429
+ const port = Number.parseInt(String(args.port), 10);
430
+ if (!Number.isFinite(port) || port <= 0) {
431
+ return "port must be a positive integer";
432
+ }
433
+ }
434
+
435
+ if (Object.prototype.hasOwnProperty.call(args, "timeout_ms")) {
436
+ const timeoutMs = Number.parseInt(String(args.timeout_ms), 10);
437
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1000) {
438
+ return "timeout_ms must be an integer >= 1000";
439
+ }
440
+ }
441
+
442
+ if (Object.prototype.hasOwnProperty.call(args, "output")) {
443
+ if (typeof args.output !== "string" || !normalizeText(args.output)) {
444
+ return "output must be a non-empty string when provided";
445
+ }
446
+ }
447
+
448
+ return null;
449
+ }
450
+
451
+ function getLastOutputLine(text) {
381
452
  const lines = String(text || "")
382
453
  .split(/\r?\n/)
383
454
  .map((line) => normalizeText(line))
@@ -1219,8 +1290,57 @@ function handleResumeRunTool(args) {
1219
1290
  message: "已恢复 Recommend 流水线(detached)。默认不自动轮询;如需进度请按需调用 get_recommend_pipeline_run。"
1220
1291
  };
1221
1292
  }
1222
-
1223
- async function handleRequest(message, workspaceRoot) {
1293
+
1294
+ function handleGetFeaturedCalibrationStatusTool(workspaceRoot) {
1295
+ const resolution = getFeaturedCalibrationResolution(workspaceRoot);
1296
+ return {
1297
+ status: "CALIBRATION_STATUS",
1298
+ ready: resolution.calibration_usable === true,
1299
+ calibration_path: resolution.calibration_path,
1300
+ calibration_exists: resolution.calibration_exists,
1301
+ calibration_usable: resolution.calibration_usable,
1302
+ calibration_script_path: resolution.calibration_script_path,
1303
+ message: resolution.calibration_usable
1304
+ ? "精选页收藏校准文件可用。"
1305
+ : "精选页收藏校准文件不存在或无效。"
1306
+ };
1307
+ }
1308
+
1309
+ async function handleRunFeaturedCalibrationTool({ workspaceRoot, args }) {
1310
+ const result = await runRecommendCalibration(workspaceRoot, {
1311
+ port: args.port,
1312
+ timeoutMs: args.timeout_ms,
1313
+ output: args.output
1314
+ });
1315
+
1316
+ if (!result?.ok) {
1317
+ return {
1318
+ status: "FAILED",
1319
+ error: {
1320
+ code: result?.error?.code || "CALIBRATION_REQUIRED",
1321
+ message: result?.error?.message || "精选页收藏校准失败,请在推荐页精选 tab 打开候选人详情后点击收藏按钮再重试。",
1322
+ retryable: true
1323
+ },
1324
+ calibration_path: result?.calibration_path || null,
1325
+ calibration_script_path: result?.calibration_script_path || null,
1326
+ debug_port: result?.debug_port || null,
1327
+ diagnostics: {
1328
+ stdout_last_line: getLastOutputLine(result?.stdout),
1329
+ stderr_last_line: getLastOutputLine(result?.stderr)
1330
+ }
1331
+ };
1332
+ }
1333
+
1334
+ return {
1335
+ status: "CALIBRATED",
1336
+ message: "精选页收藏按钮校准完成,可重新执行 start_recommend_pipeline_run。",
1337
+ calibration_path: result.calibration_path,
1338
+ calibration_script_path: result.calibration_script_path,
1339
+ debug_port: result.debug_port
1340
+ };
1341
+ }
1342
+
1343
+ async function handleRequest(message, workspaceRoot) {
1224
1344
  if (!message || message.jsonrpc !== "2.0") {
1225
1345
  return createJsonRpcError(null, -32600, "Invalid JSON-RPC request");
1226
1346
  }
@@ -1262,34 +1382,45 @@ async function handleRequest(message, workspaceRoot) {
1262
1382
  const toolName = params?.name;
1263
1383
  const args = params?.arguments || {};
1264
1384
 
1265
- if (toolName === TOOL_START_RUN) {
1266
- const inputError = validateRunArgs(args);
1267
- if (inputError) {
1268
- return createJsonRpcError(id, -32602, inputError);
1269
- }
1270
- }
1271
-
1272
- if ([TOOL_GET_RUN, TOOL_CANCEL_RUN, TOOL_PAUSE_RUN, TOOL_RESUME_RUN].includes(toolName)) {
1273
- if (!args || typeof args.run_id !== "string" || !normalizeText(args.run_id)) {
1274
- return createJsonRpcError(id, -32602, "run_id is required and must be a string");
1275
- }
1385
+ if (toolName === TOOL_START_RUN) {
1386
+ const inputError = validateRunArgs(args);
1387
+ if (inputError) {
1388
+ return createJsonRpcError(id, -32602, inputError);
1389
+ }
1390
+ }
1391
+
1392
+ if (toolName === TOOL_RUN_FEATURED_CALIBRATION) {
1393
+ const inputError = validateRunFeaturedCalibrationArgs(args);
1394
+ if (inputError) {
1395
+ return createJsonRpcError(id, -32602, inputError);
1396
+ }
1397
+ }
1398
+
1399
+ if ([TOOL_GET_RUN, TOOL_CANCEL_RUN, TOOL_PAUSE_RUN, TOOL_RESUME_RUN].includes(toolName)) {
1400
+ if (!args || typeof args.run_id !== "string" || !normalizeText(args.run_id)) {
1401
+ return createJsonRpcError(id, -32602, "run_id is required and must be a string");
1402
+ }
1276
1403
  }
1277
1404
 
1278
1405
  try {
1279
1406
  let payload;
1280
- if (toolName === TOOL_START_RUN) {
1281
- payload = await handleStartRunTool({ workspaceRoot, args });
1282
- } else if (toolName === TOOL_GET_RUN) {
1283
- payload = handleGetRunTool(args);
1284
- } else if (toolName === TOOL_CANCEL_RUN) {
1285
- payload = handleCancelRunTool(args);
1286
- } else if (toolName === TOOL_PAUSE_RUN) {
1287
- payload = handlePauseRunTool(args);
1288
- } else if (toolName === TOOL_RESUME_RUN) {
1289
- payload = handleResumeRunTool(args);
1290
- } else {
1291
- return createJsonRpcError(id, -32602, `Unknown tool: ${toolName || ""}`);
1292
- }
1407
+ if (toolName === TOOL_START_RUN) {
1408
+ payload = await handleStartRunTool({ workspaceRoot, args });
1409
+ } else if (toolName === TOOL_GET_RUN) {
1410
+ payload = handleGetRunTool(args);
1411
+ } else if (toolName === TOOL_CANCEL_RUN) {
1412
+ payload = handleCancelRunTool(args);
1413
+ } else if (toolName === TOOL_PAUSE_RUN) {
1414
+ payload = handlePauseRunTool(args);
1415
+ } else if (toolName === TOOL_RESUME_RUN) {
1416
+ payload = handleResumeRunTool(args);
1417
+ } else if (toolName === TOOL_GET_FEATURED_CALIBRATION_STATUS) {
1418
+ payload = handleGetFeaturedCalibrationStatusTool(workspaceRoot);
1419
+ } else if (toolName === TOOL_RUN_FEATURED_CALIBRATION) {
1420
+ payload = await handleRunFeaturedCalibrationTool({ workspaceRoot, args });
1421
+ } else {
1422
+ return createJsonRpcError(id, -32602, `Unknown tool: ${toolName || ""}`);
1423
+ }
1293
1424
  const isError = payload?.status === "FAILED";
1294
1425
  return createToolResultResponse(id, payload, isError);
1295
1426
  } catch (error) {
package/src/pipeline.js CHANGED
@@ -896,15 +896,6 @@ export async function runRecommendPipeline(
896
896
  };
897
897
 
898
898
  const ensureSelectedPageTab = async () => {
899
- if (selectedPage === "recommend") {
900
- activeTabStatus = selectedTabStatus;
901
- return {
902
- ok: true,
903
- switched: false,
904
- before_state: null,
905
- after_state: null
906
- };
907
- }
908
899
  const expectedStatus = selectedTabStatus;
909
900
  let beforeState = null;
910
901
  if (typeof readTabState === "function") {
@@ -1267,11 +1258,11 @@ export async function runRecommendPipeline(
1267
1258
  );
1268
1259
  runtimeHooks.heartbeat("screen_recovery", lastAutoRecovery);
1269
1260
  } else {
1270
- const recoveryFailureText = selectedPage === "featured" ? "network 简历获取失败" : "截图失败";
1271
- runtimeHooks.setStage(
1272
- "screen_recovery",
1273
- `screen 连续${recoveryFailureText},开始自动恢复(第 ${screenAutoRecoveryCount} 次):刷新 recommend 页面并重跑 search。`
1274
- );
1261
+ const recoveryFailureText = "简历获取失败(network + 截图)";
1262
+ runtimeHooks.setStage(
1263
+ "screen_recovery",
1264
+ `screen 连续${recoveryFailureText},开始自动恢复(第 ${screenAutoRecoveryCount} 次):刷新 recommend 页面并重跑 search。`
1265
+ );
1275
1266
  runtimeHooks.heartbeat("screen_recovery", lastAutoRecovery);
1276
1267
  }
1277
1268
 
@@ -1388,11 +1379,9 @@ export async function runRecommendPipeline(
1388
1379
  || tabStatusToPageScope(resolvedActiveTabStatus)
1389
1380
  ) || selectedPage;
1390
1381
  const resolvedResumeSourceRaw = normalizeText(screenSummary.resume_source || "").toLowerCase();
1391
- const resolvedResumeSource = resolvedResumeSourceRaw === "network"
1392
- ? "network"
1393
- : resolvedSelectedPage === "featured"
1394
- ? "network"
1395
- : "image_fallback";
1382
+ const resolvedResumeSource = ["network", "image_fallback"].includes(resolvedResumeSourceRaw)
1383
+ ? resolvedResumeSourceRaw
1384
+ : "network";
1396
1385
  runtimeHooks.progress("finalize", {
1397
1386
  processed: screenSummary.processed_count ?? 0,
1398
1387
  passed: screenSummary.passed_count ?? 0,
@@ -679,13 +679,44 @@ function parseGeekIdFromUrl(url) {
679
679
  if (!raw) return null;
680
680
  try {
681
681
  const parsed = new URL(raw);
682
- const keys = ["geekId", "geek_id", "gid", "encryptGeekId", "securityId"];
682
+ const keys = ["geekId", "geek_id", "gid", "encryptGeekId", "encryptJid", "jid", "securityId"];
683
683
  for (const key of keys) {
684
684
  const value = normalizeText(parsed.searchParams.get(key) || "");
685
685
  if (value) return value;
686
686
  }
687
687
  } catch {}
688
- const matched = raw.match(/[?&](?:geekId|geek_id|gid|encryptGeekId|securityId)=([^&]+)/i);
688
+ const matched = raw.match(/[?&](?:geekId|geek_id|gid|encryptGeekId|encryptJid|jid|securityId)=([^&]+)/i);
689
+ if (matched?.[1]) return decodeURIComponent(matched[1]);
690
+ return null;
691
+ }
692
+
693
+ function parseGeekIdFromPostData(postData) {
694
+ const raw = normalizeText(postData);
695
+ if (!raw) return null;
696
+ const keys = ["geekId", "geek_id", "gid", "encryptGeekId", "encryptJid", "jid", "securityId"];
697
+ try {
698
+ const parsed = JSON.parse(raw);
699
+ if (parsed && typeof parsed === "object") {
700
+ const queue = [parsed];
701
+ let depth = 0;
702
+ while (queue.length > 0 && depth < 5) {
703
+ const current = queue.shift();
704
+ depth += 1;
705
+ if (!current || typeof current !== "object") continue;
706
+ for (const key of keys) {
707
+ const value = normalizeText(current[key] || "");
708
+ if (value) return value;
709
+ }
710
+ for (const value of Object.values(current)) {
711
+ if (value && typeof value === "object") {
712
+ queue.push(value);
713
+ }
714
+ }
715
+ }
716
+ }
717
+ } catch {}
718
+
719
+ const matched = raw.match(/(?:^|[?&,\s"'])?(?:geekId|geek_id|gid|encryptGeekId|encryptJid|jid|securityId)(?:["']?\s*[:=]\s*["']?)([^&,"'\s}]+)/i);
689
720
  if (matched?.[1]) return decodeURIComponent(matched[1]);
690
721
  return null;
691
722
  }
@@ -800,9 +831,22 @@ function isResumeInfoRequestUrl(url) {
800
831
  const normalizedUrl = normalizeText(url).toLowerCase();
801
832
  if (!normalizedUrl || !normalizedUrl.includes("/wapi/")) return false;
802
833
  if (!normalizedUrl.includes("geek") || !normalizedUrl.includes("info")) return false;
834
+ if (/\/wapi\/zpjob\/view\/geek\/info\b/.test(normalizedUrl)) return true;
835
+ if (/\/wapi\/zpitem\/web\/boss\/[^?#]*\/geek\/info\b/.test(normalizedUrl)) return true;
803
836
  if (/\/boss\/[^?#]*\/geek\/info\b/.test(normalizedUrl)) return true;
804
837
  if (/\/geek\/info\b/.test(normalizedUrl)) return true;
805
- return /[?&](?:geekid|geek_id|encryptgeekid|securityid)=/.test(normalizedUrl);
838
+ return /[?&](?:geekid|geek_id|encryptgeekid|encryptjid|jid|securityid)=/.test(normalizedUrl);
839
+ }
840
+
841
+ function isResumeRelatedWapiUrl(url) {
842
+ const normalizedUrl = normalizeText(url).toLowerCase();
843
+ if (!normalizedUrl || !normalizedUrl.includes("/wapi/")) return false;
844
+ return (
845
+ normalizedUrl.includes("geek")
846
+ || normalizedUrl.includes("resume")
847
+ || normalizedUrl.includes("candidate")
848
+ || normalizedUrl.includes("friend")
849
+ );
806
850
  }
807
851
 
808
852
  function formatResumeApiData(data) {
@@ -2044,6 +2088,8 @@ class RecommendScreenCli {
2044
2088
  this.resumeCaptureFailureStreakKeys = [];
2045
2089
  this.currentCandidateKey = null;
2046
2090
  this.resumeNetworkRequests = new Map();
2091
+ this.resumeNetworkRelatedRequests = new Map();
2092
+ this.resumeNetworkDiagnostics = [];
2047
2093
  this.resumeNetworkByGeekId = new Map();
2048
2094
  this.latestResumeNetworkPayload = null;
2049
2095
  this.favoriteActionEvents = [];
@@ -2117,7 +2163,7 @@ class RecommendScreenCli {
2117
2163
  }
2118
2164
 
2119
2165
  buildProgressSnapshot(completionReason = null) {
2120
- const defaultResumeSource = this.args.pageScope === "featured" ? "network" : "image_fallback";
2166
+ const defaultResumeSource = "network";
2121
2167
  const snapshot = {
2122
2168
  processed_count: this.processedCount,
2123
2169
  passed_count: this.passedCandidates.length,
@@ -2185,6 +2231,89 @@ class RecommendScreenCli {
2185
2231
  });
2186
2232
  }
2187
2233
 
2234
+ recordResumeNetworkDiagnostic(entry) {
2235
+ const normalized = {
2236
+ ts: Number.isFinite(Number(entry?.ts)) ? Number(entry.ts) : Date.now(),
2237
+ kind: normalizeText(entry?.kind || "unknown") || "unknown",
2238
+ request_id: normalizeText(entry?.request_id || "") || null,
2239
+ method: normalizeText(entry?.method || "").toUpperCase() || null,
2240
+ url: normalizeText(entry?.url || "") || null,
2241
+ geek_id: normalizeText(entry?.geek_id || "") || null,
2242
+ match: normalizeText(entry?.match || "") || null,
2243
+ reason: normalizeText(entry?.reason || "") || null,
2244
+ error: normalizeText(entry?.error || "") || null,
2245
+ resume_text_len: Number.isFinite(Number(entry?.resume_text_len)) ? Number(entry.resume_text_len) : null,
2246
+ candidate_key: normalizeText(entry?.candidate_key || "") || null,
2247
+ source: normalizeText(entry?.source || "") || null,
2248
+ waited_ms: Number.isFinite(Number(entry?.waited_ms)) ? Number(entry.waited_ms) : null
2249
+ };
2250
+ this.resumeNetworkDiagnostics.push(normalized);
2251
+ if (this.resumeNetworkDiagnostics.length > 240) {
2252
+ this.resumeNetworkDiagnostics = this.resumeNetworkDiagnostics.slice(-240);
2253
+ }
2254
+ }
2255
+
2256
+ summarizeResumeNetworkDiagnostics(since = 0) {
2257
+ const timestamp = Number.isFinite(since) ? since : 0;
2258
+ return this.resumeNetworkDiagnostics
2259
+ .filter((item) => Number(item?.ts || 0) >= timestamp)
2260
+ .slice(-20)
2261
+ .map((item) => {
2262
+ const prefix = `[${item.kind}]`;
2263
+ if (item.kind === "request") {
2264
+ return `${prefix} ${item.method || "GET"} ${item.url || ""} match=${item.match || "none"} geek=${item.geek_id || "-"}`;
2265
+ }
2266
+ if (item.kind === "response_hit") {
2267
+ return `${prefix} ${item.url || ""} geek=${item.geek_id || "-"} resume_len=${item.resume_text_len ?? "?"}`;
2268
+ }
2269
+ if (item.kind === "response_miss") {
2270
+ return `${prefix} ${item.url || ""} reason=${item.reason || "payload_not_found"}`;
2271
+ }
2272
+ if (item.kind === "response_error") {
2273
+ return `${prefix} ${item.url || ""} error=${item.error || "unknown"}`;
2274
+ }
2275
+ if (item.kind === "wait_hit") {
2276
+ return `${prefix} candidate=${item.candidate_key || "-"} source=${item.source || "-"} waited_ms=${item.waited_ms ?? "?"} resume_len=${item.resume_text_len ?? "?"}`;
2277
+ }
2278
+ if (item.kind === "wait_timeout") {
2279
+ return `${prefix} candidate=${item.candidate_key || "-"} waited_ms=${item.waited_ms ?? "?"} reason=${item.reason || "timeout"}`;
2280
+ }
2281
+ return `${prefix} ${item.url || item.reason || "n/a"}`;
2282
+ });
2283
+ }
2284
+
2285
+ logResumeNetworkMissDiagnostics(candidate, options = {}) {
2286
+ const candidateKey = normalizeText(candidate?.key || candidate?.geek_id || "");
2287
+ const candidateName = normalizeText(candidate?.name || "");
2288
+ const waitStartedAt = Number.isFinite(options.waitStartedAt) ? options.waitStartedAt : 0;
2289
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 0;
2290
+ const now = Date.now();
2291
+ const latestPayloadAgeMs = this.latestResumeNetworkPayload
2292
+ ? Math.max(0, now - Number(this.latestResumeNetworkPayload.ts || 0))
2293
+ : null;
2294
+ const latestPayloadGeekIds = Array.isArray(this.latestResumeNetworkPayload?.geekIds)
2295
+ ? this.latestResumeNetworkPayload.geekIds.slice(0, 4)
2296
+ : [];
2297
+ const recentLines = this.summarizeResumeNetworkDiagnostics(waitStartedAt);
2298
+ const trackedResumeRequestCount = this.resumeNetworkRequests.size;
2299
+ const trackedRelatedRequestCount = this.resumeNetworkRelatedRequests.size;
2300
+
2301
+ log(
2302
+ `[network简历未命中] candidate=${candidateKey || candidateName || "unknown"} `
2303
+ + `wait_ms=${timeoutMs || "n/a"} `
2304
+ + `tracked_resume_requests=${trackedResumeRequestCount} `
2305
+ + `tracked_related_requests=${trackedRelatedRequestCount} `
2306
+ + `cached_by_geek=${this.resumeNetworkByGeekId.size} `
2307
+ + `latest_payload_age_ms=${latestPayloadAgeMs ?? "none"} `
2308
+ + `latest_payload_geek_ids=${latestPayloadGeekIds.length ? latestPayloadGeekIds.join("|") : "none"}`
2309
+ );
2310
+ if (recentLines.length > 0) {
2311
+ log(`[network简历未命中][最近网络事件] ${recentLines.join(" || ")}`);
2312
+ } else {
2313
+ log("[network简历未命中][最近网络事件] none");
2314
+ }
2315
+ }
2316
+
2188
2317
  cacheResumeNetworkPayload(payload, fallbackGeekId = null) {
2189
2318
  if (!payload || typeof payload !== "object") return;
2190
2319
  const geekDetail = payload.geekDetail || payload;
@@ -2232,35 +2361,81 @@ class RecommendScreenCli {
2232
2361
  }
2233
2362
 
2234
2363
  async waitForNetworkResumeCandidateInfo(candidate, timeoutMs = 2200) {
2364
+ const waitStartedAt = Date.now();
2365
+ const candidateKey = normalizeText(candidate?.key || candidate?.geek_id || "");
2235
2366
  const deadline = Date.now() + timeoutMs;
2236
2367
  while (Date.now() < deadline) {
2237
2368
  const info = this.tryExtractNetworkResumeForCandidate(candidate);
2238
2369
  if (info && normalizeText(info.resumeText)) {
2370
+ const source = candidateKey && this.resumeNetworkByGeekId.has(candidateKey)
2371
+ ? "geek_id_map"
2372
+ : "latest_payload";
2373
+ this.recordResumeNetworkDiagnostic({
2374
+ kind: "wait_hit",
2375
+ candidate_key: candidateKey,
2376
+ source,
2377
+ waited_ms: Date.now() - waitStartedAt,
2378
+ resume_text_len: normalizeText(info.resumeText).length
2379
+ });
2239
2380
  return info;
2240
2381
  }
2241
2382
  await sleep(120);
2242
2383
  }
2384
+ this.recordResumeNetworkDiagnostic({
2385
+ kind: "wait_timeout",
2386
+ candidate_key: candidateKey,
2387
+ waited_ms: Date.now() - waitStartedAt,
2388
+ reason: "resume_text_not_ready"
2389
+ });
2243
2390
  return null;
2244
2391
  }
2245
2392
 
2246
2393
  handleNetworkRequestWillBeSent(params) {
2247
2394
  const url = normalizeText(params?.request?.url || "");
2395
+ const postData = params?.request?.postData || "";
2248
2396
  if (!url) return;
2249
- if (isResumeInfoRequestUrl(url)) {
2250
- const geekId = parseGeekIdFromUrl(url);
2251
- this.resumeNetworkRequests.set(params.requestId, {
2252
- ts: Date.now(),
2397
+ const requestTs = Date.now();
2398
+ const method = normalizeText(params?.request?.method || "").toUpperCase() || "GET";
2399
+ const isResumeInfo = isResumeInfoRequestUrl(url);
2400
+ const isResumeRelated = isResumeInfo || isResumeRelatedWapiUrl(url);
2401
+ if (isResumeRelated) {
2402
+ const geekId = parseGeekIdFromUrl(url) || parseGeekIdFromPostData(postData);
2403
+ const meta = {
2404
+ ts: requestTs,
2253
2405
  url,
2254
- geekId
2406
+ geekId,
2407
+ method,
2408
+ isResumeInfo
2409
+ };
2410
+ this.resumeNetworkRelatedRequests.set(params.requestId, meta);
2411
+ this.recordResumeNetworkDiagnostic({
2412
+ kind: "request",
2413
+ request_id: params.requestId,
2414
+ method,
2415
+ url: url.slice(0, 280),
2416
+ geek_id: geekId,
2417
+ match: isResumeInfo ? "resume_info_url" : "wapi_related_non_resume_info"
2255
2418
  });
2256
- return;
2419
+ if (this.resumeNetworkRelatedRequests.size > 400) {
2420
+ const oldest = [...this.resumeNetworkRelatedRequests.entries()]
2421
+ .sort((a, b) => Number(a[1]?.ts || 0) - Number(b[1]?.ts || 0))
2422
+ .slice(0, this.resumeNetworkRelatedRequests.size - 320);
2423
+ for (const [requestId] of oldest) {
2424
+ this.resumeNetworkRelatedRequests.delete(requestId);
2425
+ }
2426
+ }
2427
+ if (isResumeInfo) {
2428
+ this.resumeNetworkRequests.set(params.requestId, {
2429
+ ts: requestTs,
2430
+ url,
2431
+ geekId
2432
+ });
2433
+ return;
2434
+ }
2257
2435
  }
2258
2436
 
2259
2437
  if (this.favoriteClickPendingSince <= 0) return;
2260
- const requestTs = Date.now();
2261
2438
  if (requestTs < this.favoriteClickPendingSince) return;
2262
- const method = normalizeText(params?.request?.method || "").toUpperCase() || "GET";
2263
- const postData = params?.request?.postData || "";
2264
2439
  this.recordFavoriteNetworkTrace({
2265
2440
  ts: requestTs,
2266
2441
  kind: "http",
@@ -2310,21 +2485,60 @@ class RecommendScreenCli {
2310
2485
  }
2311
2486
 
2312
2487
  async handleNetworkLoadingFinished(params) {
2313
- const requestMeta = this.resumeNetworkRequests.get(params?.requestId);
2314
- if (!requestMeta) return;
2315
- this.resumeNetworkRequests.delete(params.requestId);
2488
+ const requestId = params?.requestId;
2489
+ const requestMeta = this.resumeNetworkRequests.get(requestId);
2490
+ const relatedMeta = this.resumeNetworkRelatedRequests.get(requestId);
2491
+ if (!requestMeta && !relatedMeta) return;
2492
+ this.resumeNetworkRequests.delete(requestId);
2493
+ this.resumeNetworkRelatedRequests.delete(requestId);
2494
+ const effectiveMeta = requestMeta || relatedMeta || {};
2495
+ const effectiveUrl = normalizeText(effectiveMeta.url || "");
2496
+ const effectiveGeekId = normalizeText(effectiveMeta.geekId || "");
2316
2497
  try {
2317
- const responseBody = await this.Network.getResponseBody({ requestId: params.requestId });
2318
- if (!responseBody?.body) return;
2498
+ const responseBody = await this.Network.getResponseBody({ requestId });
2499
+ if (!responseBody?.body) {
2500
+ this.recordResumeNetworkDiagnostic({
2501
+ kind: "response_miss",
2502
+ request_id: requestId,
2503
+ url: effectiveUrl.slice(0, 280),
2504
+ geek_id: effectiveGeekId,
2505
+ reason: "empty_body"
2506
+ });
2507
+ return;
2508
+ }
2319
2509
  const rawBody = responseBody.base64Encoded
2320
2510
  ? Buffer.from(responseBody.body, "base64").toString("utf8")
2321
2511
  : responseBody.body;
2322
2512
  const parsed = JSON.parse(rawBody);
2323
2513
  const resumePayload = extractResumePayloadFromResponseBody(parsed);
2324
2514
  if (resumePayload) {
2325
- this.cacheResumeNetworkPayload(resumePayload, requestMeta.geekId);
2515
+ this.cacheResumeNetworkPayload(resumePayload, effectiveGeekId);
2516
+ const formattedText = normalizeText(formatResumeApiData(resumePayload));
2517
+ this.recordResumeNetworkDiagnostic({
2518
+ kind: "response_hit",
2519
+ request_id: requestId,
2520
+ url: effectiveUrl.slice(0, 280),
2521
+ geek_id: effectiveGeekId,
2522
+ resume_text_len: formattedText.length
2523
+ });
2524
+ } else {
2525
+ this.recordResumeNetworkDiagnostic({
2526
+ kind: "response_miss",
2527
+ request_id: requestId,
2528
+ url: effectiveUrl.slice(0, 280),
2529
+ geek_id: effectiveGeekId,
2530
+ reason: "payload_not_found"
2531
+ });
2326
2532
  }
2327
- } catch {}
2533
+ } catch (error) {
2534
+ this.recordResumeNetworkDiagnostic({
2535
+ kind: "response_error",
2536
+ request_id: requestId,
2537
+ url: effectiveUrl.slice(0, 280),
2538
+ geek_id: effectiveGeekId,
2539
+ error: normalizeText(error?.message || String(error)).slice(0, 240)
2540
+ });
2541
+ }
2328
2542
  }
2329
2543
 
2330
2544
  resetResumeCaptureFailureStreak() {
@@ -2433,13 +2647,13 @@ class RecommendScreenCli {
2433
2647
  const imageFallbackCount = this.passedCandidates.filter((item) => item?.resumeSource === "image_fallback").length;
2434
2648
  this.resumeSourceStats = {
2435
2649
  network: networkCount,
2436
- image_fallback: this.args.pageScope === "featured" ? 0 : imageFallbackCount
2650
+ image_fallback: imageFallbackCount
2437
2651
  };
2438
2652
  if (this.resumeSourceStats.network <= 0 && this.resumeSourceStats.image_fallback <= 0) {
2439
2653
  const snapshotSource = normalizeText(parsed.resume_source || "").toLowerCase();
2440
2654
  if (snapshotSource === "network") {
2441
2655
  this.resumeSourceStats.network = 1;
2442
- } else if (snapshotSource === "image_fallback" && this.args.pageScope !== "featured") {
2656
+ } else if (snapshotSource === "image_fallback") {
2443
2657
  this.resumeSourceStats.image_fallback = 1;
2444
2658
  }
2445
2659
  }
@@ -3570,12 +3784,18 @@ class RecommendScreenCli {
3570
3784
  throw this.buildError("DETAIL_OPEN_FAILED", "详情页打开超时");
3571
3785
  }
3572
3786
 
3573
- const isFeaturedScope = this.args.pageScope === "featured";
3574
- const allowImageFallback = !isFeaturedScope;
3575
3787
  let capture = null;
3576
3788
  let screening = null;
3577
- let resumeSource = isFeaturedScope ? "network" : "image_fallback";
3578
- const networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(nextCandidate, allowImageFallback ? 4200 : 2400);
3789
+ let resumeSource = "image_fallback";
3790
+ const networkWaitMs = 4200;
3791
+ const networkWaitStartedAt = Date.now();
3792
+ const networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(nextCandidate, networkWaitMs);
3793
+ if (!normalizeText(networkCandidateInfo?.resumeText) && typeof this.logResumeNetworkMissDiagnostics === "function") {
3794
+ this.logResumeNetworkMissDiagnostics(nextCandidate, {
3795
+ timeoutMs: networkWaitMs,
3796
+ waitStartedAt: networkWaitStartedAt
3797
+ });
3798
+ }
3579
3799
  const candidateProfile = {
3580
3800
  name: networkCandidateInfo?.name || nextCandidate.name || "",
3581
3801
  school: networkCandidateInfo?.school || nextCandidate.school || "",
@@ -3584,17 +3804,7 @@ class RecommendScreenCli {
3584
3804
  position: networkCandidateInfo?.position || nextCandidate.last_position || ""
3585
3805
  };
3586
3806
 
3587
- if (isFeaturedScope) {
3588
- if (!networkCandidateInfo?.resumeText) {
3589
- throw this.buildError(
3590
- "RESUME_NETWORK_UNAVAILABLE",
3591
- "精选页未获取到 network 简历数据,已跳过当前候选人。"
3592
- );
3593
- }
3594
- screening = await this.callTextModel(networkCandidateInfo.resumeText);
3595
- resumeSource = "network";
3596
- this.resumeSourceStats.network += 1;
3597
- } else if (networkCandidateInfo?.resumeText) {
3807
+ if (networkCandidateInfo?.resumeText) {
3598
3808
  screening = await this.callTextModel(networkCandidateInfo.resumeText);
3599
3809
  resumeSource = "network";
3600
3810
  this.resumeSourceStats.network += 1;
@@ -3667,7 +3877,9 @@ class RecommendScreenCli {
3667
3877
  log(`候选人处理失败: ${error.code || error.message}`);
3668
3878
  if (["RESUME_CAPTURE_FAILED", "RESUME_NETWORK_UNAVAILABLE"].includes(error.code)) {
3669
3879
  this.recordResumeCaptureFailure(nextCandidate.key);
3670
- const failureLabel = error.code === "RESUME_NETWORK_UNAVAILABLE" ? "简历 network 获取失败" : "简历截图失败";
3880
+ const failureLabel = error.code === "RESUME_NETWORK_UNAVAILABLE"
3881
+ ? "简历 network 获取失败且截图回退未完成"
3882
+ : "简历截图失败";
3671
3883
  log(
3672
3884
  `[候选人跳过] ${nextCandidate.name || nextCandidate.geek_id || "unknown"} ${failureLabel},` +
3673
3885
  `已跳过当前候选人;连续失败 ${this.consecutiveResumeCaptureFailures}/${MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES}`
@@ -3675,7 +3887,7 @@ class RecommendScreenCli {
3675
3887
  if (this.consecutiveResumeCaptureFailures >= MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES) {
3676
3888
  shouldMarkProcessed = false;
3677
3889
  const rollback = this.rollbackResumeCaptureFailureStreak(nextCandidate.key);
3678
- const failureTypeText = this.args.pageScope === "featured" ? "简历 network 获取失败" : "简历捕获失败";
3890
+ const failureTypeText = "简历获取失败(network + 截图)";
3679
3891
  throw this.buildError(
3680
3892
  "RESUME_CAPTURE_FAILED_CONSECUTIVE_LIMIT",
3681
3893
  `连续 ${MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES} 位候选人${failureTypeText},已停止运行以避免错误跳过。` +
@@ -225,7 +225,7 @@ async function testConsecutiveResumeCaptureFailuresStillAbort() {
225
225
  () => cli.run(),
226
226
  (error) => {
227
227
  assert.equal(error.code, "RESUME_CAPTURE_FAILED_CONSECUTIVE_LIMIT");
228
- assert.match(error.message, /连续 .* 位候选人简历捕获失败/);
228
+ assert.match(error.message, /连续 .* 位候选人简历(?:捕获失败|获取失败(network \+ 截图))/);
229
229
  assert.equal(error.rollback?.rollback_count, maxFailures);
230
230
  assert.equal(error.partial_result?.processed_count, 0);
231
231
  assert.equal(error.partial_result?.skipped_count, 0);