@reconcrap/boss-recommend-mcp 1.3.16 → 1.3.17

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.
@@ -26,7 +26,21 @@ const CSV_HEADER = [
26
26
  "证据门控降级",
27
27
  "错误码",
28
28
  "错误信息",
29
- "候选人ID"
29
+ "候选人ID",
30
+ "总耗时ms",
31
+ "候选卡片读取ms",
32
+ "点击候选人ms",
33
+ "详情打开ms",
34
+ "network简历等待ms",
35
+ "文本模型ms",
36
+ "截图获取ms",
37
+ "视觉模型ms",
38
+ "late network retry ms",
39
+ "DOM fallback ms",
40
+ "通过后动作ms",
41
+ "关闭详情ms",
42
+ "休息ms",
43
+ "checkpoint保存ms"
30
44
  ].join(",");
31
45
  const INPUT_SUMMARY_HEADER = ["运行输入字段", "运行输入值"].join(",");
32
46
  const RESUME_CAPTURE_WAIT_MS = 60000;
@@ -34,6 +48,8 @@ const RESUME_CAPTURE_MAX_ATTEMPTS = 4;
34
48
  const RESUME_CAPTURE_RETRY_DELAY_MS = 1200;
35
49
  const NETWORK_RESUME_WAIT_MS = 4200;
36
50
  const NETWORK_RESUME_RETRY_WAIT_MS = 2000;
51
+ const NETWORK_RESUME_IMAGE_MODE_GRACE_MS = 1000;
52
+ const NETWORK_RESUME_LATE_RETRY_MS = 3000;
37
53
  const MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES = 10;
38
54
  const DEFAULT_VISION_MAX_IMAGE_PIXELS = 36000000;
39
55
  const DEFAULT_VISION_RETRY_MAX_IMAGE_PIXELS = 30000000;
@@ -962,12 +978,69 @@ function shouldBringChromeToFront() {
962
978
  }
963
979
 
964
980
  const SHOULD_BRING_TO_FRONT = shouldBringChromeToFront();
981
+ const LLM_THINKING_ENV_KEYS = [
982
+ "BOSS_RECOMMEND_LLM_THINKING_LEVEL",
983
+ "BOSS_LLM_THINKING_LEVEL",
984
+ "LLM_THINKING_LEVEL"
985
+ ];
986
+
987
+ function normalizeLlmThinkingLevel(value) {
988
+ const normalized = normalizeText(value).toLowerCase().replace(/[_\s]+/g, "-");
989
+ if (!normalized) return "";
990
+ if (["off", "disabled", "disable", "minimal", "none", "false", "0"].includes(normalized)) return "off";
991
+ if (["low", "medium", "high", "auto", "current", "default", "provider-default", "unchanged", "inherit"].includes(normalized)) {
992
+ return normalized;
993
+ }
994
+ return "";
995
+ }
996
+
997
+ function getEnvLlmThinkingLevel() {
998
+ for (const key of LLM_THINKING_ENV_KEYS) {
999
+ const normalized = normalizeLlmThinkingLevel(process.env[key]);
1000
+ if (normalized) return normalized;
1001
+ }
1002
+ return "";
1003
+ }
1004
+
1005
+ function resolveLlmThinkingLevel(value) {
1006
+ return normalizeLlmThinkingLevel(value) || getEnvLlmThinkingLevel() || "off";
1007
+ }
1008
+
1009
+ function isVolcengineModel(baseUrl, model) {
1010
+ const combined = `${baseUrl || ""} ${model || ""}`;
1011
+ return /volces\.com|volcengine|ark\.cn-|doubao|seed/i.test(combined);
1012
+ }
1013
+
1014
+ function applyChatCompletionThinking(payload, { baseUrl = "", model = "", thinkingLevel = "" } = {}) {
1015
+ const level = resolveLlmThinkingLevel(thinkingLevel);
1016
+ if (["current", "default", "provider-default", "unchanged", "inherit"].includes(level)) return payload;
1017
+ const isVolc = isVolcengineModel(baseUrl, model);
1018
+ if (isVolc) {
1019
+ if (level === "auto") {
1020
+ payload.thinking = { type: "auto" };
1021
+ return payload;
1022
+ }
1023
+ if (level === "off") {
1024
+ payload.thinking = { type: "disabled" };
1025
+ payload.reasoning_effort = "minimal";
1026
+ return payload;
1027
+ }
1028
+ payload.thinking = { type: "enabled" };
1029
+ payload.reasoning_effort = level;
1030
+ return payload;
1031
+ }
1032
+ if (level !== "auto") {
1033
+ payload.reasoning_effort = level === "off" ? "minimal" : level;
1034
+ }
1035
+ return payload;
1036
+ }
965
1037
 
966
1038
  function parseArgs(argv) {
967
1039
  const parsed = {
968
1040
  baseUrl: null,
969
1041
  apiKey: null,
970
1042
  model: null,
1043
+ thinkingLevel: null,
971
1044
  openaiOrganization: null,
972
1045
  openaiProject: null,
973
1046
  criteria: null,
@@ -988,6 +1061,7 @@ function parseArgs(argv) {
988
1061
  baseUrl: false,
989
1062
  apiKey: false,
990
1063
  model: false,
1064
+ thinkingLevel: false,
991
1065
  criteria: false,
992
1066
  targetCount: false,
993
1067
  maxGreetCount: false,
@@ -1016,6 +1090,10 @@ function parseArgs(argv) {
1016
1090
  parsed.model = inlineValue || next;
1017
1091
  parsed.__provided.model = true;
1018
1092
  if (!inlineValue) index += 1;
1093
+ } else if ((token === "--thinking-level" || token === "--thinkingLevel" || token === "--llm-thinking-level" || token === "--reasoning-effort") && (inlineValue || next)) {
1094
+ parsed.thinkingLevel = inlineValue || next;
1095
+ parsed.__provided.thinkingLevel = true;
1096
+ if (!inlineValue) index += 1;
1019
1097
  } else if (token === "--openai-organization" && (inlineValue || next)) {
1020
1098
  parsed.openaiOrganization = inlineValue || next;
1021
1099
  if (!inlineValue) index += 1;
@@ -1208,6 +1286,30 @@ function csvEscape(value) {
1208
1286
  return `"${String(value || "").replace(/"/g, '""')}"`;
1209
1287
  }
1210
1288
 
1289
+ function normalizeTimingMs(value) {
1290
+ const parsed = Number(value);
1291
+ if (!Number.isFinite(parsed) || parsed < 0) return null;
1292
+ return Math.round(parsed);
1293
+ }
1294
+
1295
+ function sanitizeTimingBreakdown(value) {
1296
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
1297
+ const result = {};
1298
+ for (const [key, raw] of Object.entries(value)) {
1299
+ const normalizedKey = normalizeText(key);
1300
+ if (!normalizedKey) continue;
1301
+ const normalizedValue = normalizeTimingMs(raw);
1302
+ if (normalizedValue === null) continue;
1303
+ result[normalizedKey] = normalizedValue;
1304
+ }
1305
+ return result;
1306
+ }
1307
+
1308
+ function getTimingMs(timing, key) {
1309
+ const normalized = normalizeTimingMs(timing?.[key]);
1310
+ return normalized === null ? "" : normalized;
1311
+ }
1312
+
1211
1313
  function stringifyInputSummaryValue(value) {
1212
1314
  if (value === null) return "null";
1213
1315
  if (value === undefined) return "";
@@ -2844,6 +2946,8 @@ class RecommendScreenCli {
2844
2946
  dom_fallback: 0,
2845
2947
  image_fallback: 0
2846
2948
  };
2949
+ this.resumeAcquisitionMode = "unknown";
2950
+ this.resumeAcquisitionModeReason = "";
2847
2951
  this.lastActiveTabStatus = PAGE_SCOPE_TAB_STATUS[this.args.pageScope] || null;
2848
2952
  this.featuredCalibration = this.args.pageScope === "featured"
2849
2953
  ? loadCalibrationPosition(this.args.calibrationPath)
@@ -2890,6 +2994,8 @@ class RecommendScreenCli {
2890
2994
  skipped_count: this.skippedCount,
2891
2995
  greet_count: this.greetCount,
2892
2996
  greet_limit_fallback_count: this.greetLimitFallbackCount,
2997
+ resume_acquisition_mode: this.resumeAcquisitionMode,
2998
+ resume_acquisition_mode_reason: this.resumeAcquisitionModeReason,
2893
2999
  processed_keys: Array.from(this.processedKeys),
2894
3000
  passed_candidates: this.passedCandidates.map((item) => ({
2895
3001
  name: item?.name || "",
@@ -2926,7 +3032,8 @@ class RecommendScreenCli {
2926
3032
  error_code: item?.error_code || "",
2927
3033
  error_message: item?.error_message || "",
2928
3034
  chunk_index: Number.isFinite(Number(item?.chunk_index)) ? Number(item.chunk_index) : null,
2929
- chunk_total: Number.isFinite(Number(item?.chunk_total)) ? Number(item.chunk_total) : null
3035
+ chunk_total: Number.isFinite(Number(item?.chunk_total)) ? Number(item.chunk_total) : null,
3036
+ timing_ms: sanitizeTimingBreakdown(item?.timing_ms)
2930
3037
  })),
2931
3038
  input_summary: sanitizeInputSummary(this.inputSummary)
2932
3039
  };
@@ -2942,6 +3049,7 @@ class RecommendScreenCli {
2942
3049
  checkpoint_path: this.checkpointPath,
2943
3050
  selected_page: this.args.pageScope || "recommend",
2944
3051
  active_tab_status: this.lastActiveTabStatus || PAGE_SCOPE_TAB_STATUS[this.args.pageScope] || null,
3052
+ resume_acquisition_mode: this.resumeAcquisitionMode,
2945
3053
  resume_source: this.resumeSourceStats.image_fallback > 0
2946
3054
  ? "image_fallback"
2947
3055
  : this.resumeSourceStats.dom_fallback > 0
@@ -3092,7 +3200,8 @@ class RecommendScreenCli {
3092
3200
  error_code: normalizeText(entry?.error_code || "") || "",
3093
3201
  error_message: normalizeText(entry?.error_message || "") || "",
3094
3202
  chunk_index: Number.isFinite(Number(entry?.chunk_index)) ? Number(entry.chunk_index) : null,
3095
- chunk_total: Number.isFinite(Number(entry?.chunk_total)) ? Number(entry.chunk_total) : null
3203
+ chunk_total: Number.isFinite(Number(entry?.chunk_total)) ? Number(entry.chunk_total) : null,
3204
+ timing_ms: sanitizeTimingBreakdown(entry?.timing_ms)
3096
3205
  };
3097
3206
  this.candidateAudits.push(normalized);
3098
3207
  const maxItems = parsePositiveInteger(process.env.BOSS_RECOMMEND_MAX_CANDIDATE_AUDITS);
@@ -3101,6 +3210,22 @@ class RecommendScreenCli {
3101
3210
  }
3102
3211
  }
3103
3212
 
3213
+ updateCandidateAuditTiming(candidateKey, timing = {}) {
3214
+ const normalizedKey = normalizeText(candidateKey || "");
3215
+ if (!normalizedKey) return;
3216
+ const timingMs = sanitizeTimingBreakdown(timing);
3217
+ for (let index = this.candidateAudits.length - 1; index >= 0; index -= 1) {
3218
+ const audit = this.candidateAudits[index];
3219
+ if (
3220
+ normalizeText(audit?.candidate_key || "") === normalizedKey
3221
+ || normalizeText(audit?.geek_id || "") === normalizedKey
3222
+ ) {
3223
+ audit.timing_ms = timingMs;
3224
+ return;
3225
+ }
3226
+ }
3227
+ }
3228
+
3104
3229
  logResumeNetworkMissDiagnostics(candidate, options = {}) {
3105
3230
  const candidateKey = normalizeText(candidate?.key || candidate?.geek_id || "");
3106
3231
  const candidateName = normalizeText(candidate?.name || "");
@@ -3240,6 +3365,60 @@ class RecommendScreenCli {
3240
3365
  return null;
3241
3366
  }
3242
3367
 
3368
+ setResumeAcquisitionMode(mode, reason = "") {
3369
+ if (!["unknown", "network", "image"].includes(mode)) return;
3370
+ if (this.resumeAcquisitionMode === mode) return;
3371
+ this.resumeAcquisitionMode = mode;
3372
+ this.resumeAcquisitionModeReason = normalizeText(reason || "");
3373
+ log(`[简历获取模式] mode=${mode}${this.resumeAcquisitionModeReason ? ` reason=${this.resumeAcquisitionModeReason}` : ""}`);
3374
+ }
3375
+
3376
+ async waitForResumeNetworkByMode(candidate, options = {}) {
3377
+ const minTs = Number.isFinite(Number(options?.minTs)) ? Number(options.minTs) : 0;
3378
+ const mode = this.resumeAcquisitionMode || "unknown";
3379
+ const firstWaitMs = mode === "image" ? NETWORK_RESUME_IMAGE_MODE_GRACE_MS : NETWORK_RESUME_WAIT_MS;
3380
+ const waitStartedAt = Date.now();
3381
+ let networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(candidate, firstWaitMs, { minTs });
3382
+ if (normalizeText(networkCandidateInfo?.resumeText)) {
3383
+ this.setResumeAcquisitionMode("network", "network_resume_hit");
3384
+ return networkCandidateInfo;
3385
+ }
3386
+ if (typeof this.logResumeNetworkMissDiagnostics === "function") {
3387
+ this.logResumeNetworkMissDiagnostics(candidate, {
3388
+ timeoutMs: firstWaitMs,
3389
+ waitStartedAt
3390
+ });
3391
+ }
3392
+ if (mode === "image") {
3393
+ return null;
3394
+ }
3395
+ await sleep(NETWORK_RESUME_RETRY_WAIT_MS);
3396
+ networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(
3397
+ candidate,
3398
+ NETWORK_RESUME_RETRY_WAIT_MS,
3399
+ { minTs }
3400
+ );
3401
+ if (normalizeText(networkCandidateInfo?.resumeText)) {
3402
+ this.setResumeAcquisitionMode("network", "network_resume_retry_hit");
3403
+ return networkCandidateInfo;
3404
+ }
3405
+ return null;
3406
+ }
3407
+
3408
+ async waitForLateNetworkResumeCandidateInfo(candidate, options = {}) {
3409
+ const minTs = Number.isFinite(Number(options?.minTs)) ? Number(options.minTs) : 0;
3410
+ const networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(
3411
+ candidate,
3412
+ NETWORK_RESUME_LATE_RETRY_MS,
3413
+ { minTs }
3414
+ );
3415
+ if (normalizeText(networkCandidateInfo?.resumeText)) {
3416
+ this.setResumeAcquisitionMode("network", "late_network_resume_hit");
3417
+ return networkCandidateInfo;
3418
+ }
3419
+ return null;
3420
+ }
3421
+
3243
3422
  async extractResumeTextFromDom(candidate) {
3244
3423
  const candidateKey = normalizeText(candidate?.key || candidate?.geek_id || "");
3245
3424
  const candidateLabel = normalizeText(candidate?.name || candidateKey || "unknown");
@@ -3637,7 +3816,8 @@ class RecommendScreenCli {
3637
3816
  error_code: normalizeText(item?.error_code || "") || "",
3638
3817
  error_message: normalizeText(item?.error_message || "") || "",
3639
3818
  chunk_index: Number.isFinite(Number(item?.chunk_index)) ? Number(item.chunk_index) : null,
3640
- chunk_total: Number.isFinite(Number(item?.chunk_total)) ? Number(item.chunk_total) : null
3819
+ chunk_total: Number.isFinite(Number(item?.chunk_total)) ? Number(item.chunk_total) : null,
3820
+ timing_ms: sanitizeTimingBreakdown(item?.timing_ms)
3641
3821
  }))
3642
3822
  : [];
3643
3823
  if (!this.inputSummary) {
@@ -3665,6 +3845,17 @@ class RecommendScreenCli {
3665
3845
  this.resumeSourceStats.image_fallback = 1;
3666
3846
  }
3667
3847
  }
3848
+ const checkpointMode = normalizeText(parsed.resume_acquisition_mode || "").toLowerCase();
3849
+ if (["network", "image"].includes(checkpointMode)) {
3850
+ this.resumeAcquisitionMode = checkpointMode;
3851
+ this.resumeAcquisitionModeReason = normalizeText(parsed.resume_acquisition_mode_reason || "checkpoint");
3852
+ } else if (this.resumeSourceStats.network > 0) {
3853
+ this.resumeAcquisitionMode = "network";
3854
+ this.resumeAcquisitionModeReason = "checkpoint_source_stats";
3855
+ } else if (this.resumeSourceStats.image_fallback > 0) {
3856
+ this.resumeAcquisitionMode = "image";
3857
+ this.resumeAcquisitionModeReason = "checkpoint_source_stats";
3858
+ }
3668
3859
 
3669
3860
  return true;
3670
3861
  }
@@ -4191,7 +4382,8 @@ class RecommendScreenCli {
4191
4382
  outPrefix,
4192
4383
  targetPattern: RECOMMEND_URL_FRAGMENT,
4193
4384
  waitResumeMs: RESUME_CAPTURE_WAIT_MS,
4194
- scrollSettleMs: 500
4385
+ scrollSettleMs: 500,
4386
+ stitchFullImage: false
4195
4387
  });
4196
4388
  } catch (error) {
4197
4389
  lastError = error;
@@ -4218,7 +4410,7 @@ class RecommendScreenCli {
4218
4410
  DEFAULT_VISION_MAX_IMAGE_PIXELS
4219
4411
  );
4220
4412
  const retryLimit = resolveVisionRetryPixelLimit(primaryLimit);
4221
- const preparedPrimary = await this.prepareVisionImageSegmentsForModel(imagePath, primaryLimit, "primary");
4413
+ const preparedPrimary = await this.prepareVisionInputsForModel(imagePath, primaryLimit, "primary");
4222
4414
  try {
4223
4415
  const primaryResult = await this.requestVisionModel(preparedPrimary.imagePaths);
4224
4416
  return this.applyVisionEvidenceGate(primaryResult);
@@ -4233,7 +4425,7 @@ class RecommendScreenCli {
4233
4425
  `segments=${preparedPrimary.imagePaths?.length || 1}`
4234
4426
  );
4235
4427
  }
4236
- const preparedRetry = await this.prepareVisionImageSegmentsForModel(imagePath, retryLimit, "retry");
4428
+ const preparedRetry = await this.prepareVisionInputsForModel(imagePath, retryLimit, "retry");
4237
4429
  try {
4238
4430
  const retryResult = await this.requestVisionModel(preparedRetry.imagePaths);
4239
4431
  return this.applyVisionEvidenceGate(retryResult);
@@ -4253,6 +4445,37 @@ class RecommendScreenCli {
4253
4445
  }
4254
4446
  }
4255
4447
 
4448
+ async prepareVisionInputsForModel(imageInput, maxPixels, attemptTag = "primary") {
4449
+ const sourcePaths = Array.isArray(imageInput) ? imageInput.filter(Boolean) : [imageInput].filter(Boolean);
4450
+ if (sourcePaths.length <= 0) {
4451
+ return {
4452
+ imagePaths: [],
4453
+ source: "empty",
4454
+ sourcePixels: null,
4455
+ currentPixels: null
4456
+ };
4457
+ }
4458
+ const preparedItems = [];
4459
+ for (let index = 0; index < sourcePaths.length; index += 1) {
4460
+ const prepared = await this.prepareVisionImageSegmentsForModel(
4461
+ sourcePaths[index],
4462
+ maxPixels,
4463
+ `${attemptTag}.input${String(index + 1).padStart(3, "0")}`
4464
+ );
4465
+ preparedItems.push(prepared);
4466
+ }
4467
+ return {
4468
+ imagePaths: preparedItems.flatMap((item) => item.imagePaths || []),
4469
+ source: sourcePaths.length > 1 ? "ordered_chunks" : (preparedItems[0]?.source || "single"),
4470
+ sourcePixels: preparedItems.reduce((acc, item) => (
4471
+ Number.isFinite(Number(item?.sourcePixels)) ? acc + Number(item.sourcePixels) : acc
4472
+ ), 0) || null,
4473
+ currentPixels: preparedItems.reduce((acc, item) => (
4474
+ Number.isFinite(Number(item?.currentPixels)) ? acc + Number(item.currentPixels) : acc
4475
+ ), 0) || null
4476
+ };
4477
+ }
4478
+
4256
4479
  applyVisionEvidenceGate(result) {
4257
4480
  const parsed = result && typeof result === "object" ? result : {};
4258
4481
  const rawPassed = parsed?.rawPassed === true || parsed?.passed === true;
@@ -4474,6 +4697,7 @@ class RecommendScreenCli {
4474
4697
  "请根据以下标准判断候选人是否通过筛选。\n\n" +
4475
4698
  `筛选标准:\n${this.args.criteria}\n\n` +
4476
4699
  "你将收到候选人完整简历的一个或多个顺序分段图片。必须完整阅读全部分段后再判断," +
4700
+ "不能只根据前几段下结论;后续分段中的教育、项目、经历或否定信息必须纳入最终判断。" +
4477
4701
  "严禁编造任何不存在的经历、项目、学校、公司或时间线;证据不足时必须判定为不通过。\n\n" +
4478
4702
  "要求:\n" +
4479
4703
  "1) reason 必须写出可审计的判定依据,至少包含 2 条与筛选标准直接相关的事实。\n" +
@@ -4518,6 +4742,11 @@ class RecommendScreenCli {
4518
4742
  }
4519
4743
  ]
4520
4744
  };
4745
+ applyChatCompletionThinking(payload, {
4746
+ baseUrl,
4747
+ model: this.args.model,
4748
+ thinkingLevel: this.args.thinkingLevel
4749
+ });
4521
4750
  const headers = {
4522
4751
  "Content-Type": "application/json",
4523
4752
  Authorization: `Bearer ${this.args.apiKey}`
@@ -4665,6 +4894,11 @@ class RecommendScreenCli {
4665
4894
  }
4666
4895
  ]
4667
4896
  };
4897
+ applyChatCompletionThinking(payload, {
4898
+ baseUrl,
4899
+ model: this.args.model,
4900
+ thinkingLevel: this.args.thinkingLevel
4901
+ });
4668
4902
  const headers = {
4669
4903
  "Content-Type": "application/json",
4670
4904
  Authorization: `Bearer ${this.args.apiKey}`
@@ -5044,6 +5278,7 @@ class RecommendScreenCli {
5044
5278
  const finalPassed = audit?.final_passed === true || normalizeText(audit?.outcome || "") === "passed";
5045
5279
  const screeningReason = normalizeText(audit?.screening_reason || passedItem?.reason || "");
5046
5280
  const passReason = finalPassed ? screeningReason : "";
5281
+ const timing = sanitizeTimingBreakdown(audit?.timing_ms);
5047
5282
  lines.push([
5048
5283
  csvEscape(audit?.candidate_name || passedItem?.name || ""),
5049
5284
  csvEscape(audit?.school || passedItem?.school || ""),
@@ -5062,7 +5297,21 @@ class RecommendScreenCli {
5062
5297
  csvEscape(audit?.evidence_gate_demoted === true ? "true" : "false"),
5063
5298
  csvEscape(audit?.error_code || ""),
5064
5299
  csvEscape(audit?.error_message || ""),
5065
- csvEscape(auditGeekId || passedItem?.geekId || "")
5300
+ csvEscape(auditGeekId || passedItem?.geekId || ""),
5301
+ csvEscape(getTimingMs(timing, "total_ms")),
5302
+ csvEscape(getTimingMs(timing, "card_profile_ms")),
5303
+ csvEscape(getTimingMs(timing, "click_candidate_ms")),
5304
+ csvEscape(getTimingMs(timing, "detail_open_ms")),
5305
+ csvEscape(getTimingMs(timing, "network_resume_wait_ms")),
5306
+ csvEscape(getTimingMs(timing, "text_model_ms")),
5307
+ csvEscape(getTimingMs(timing, "image_capture_ms")),
5308
+ csvEscape(getTimingMs(timing, "vision_model_ms")),
5309
+ csvEscape(getTimingMs(timing, "late_network_retry_ms")),
5310
+ csvEscape(getTimingMs(timing, "dom_fallback_ms")),
5311
+ csvEscape(getTimingMs(timing, "post_action_ms")),
5312
+ csvEscape(getTimingMs(timing, "close_detail_ms")),
5313
+ csvEscape(getTimingMs(timing, "rest_ms")),
5314
+ csvEscape(getTimingMs(timing, "checkpoint_save_ms"))
5066
5315
  ].join(","));
5067
5316
  }
5068
5317
  fs.mkdirSync(path.dirname(this.args.output), { recursive: true });
@@ -5218,6 +5467,29 @@ class RecommendScreenCli {
5218
5467
  this.scrollRetryCount = 0;
5219
5468
  this.processedCount += 1;
5220
5469
  log(`处理第 ${this.processedCount} 位候选人: ${nextCandidate.name || nextCandidate.geek_id}`);
5470
+ const candidateStartedAt = Date.now();
5471
+ const candidateTiming = {};
5472
+ const candidateKeyForTiming = nextCandidate.key || nextCandidate.geek_id || "";
5473
+ const addCandidateTiming = (key, startedAt) => {
5474
+ const elapsed = Math.max(0, Date.now() - startedAt);
5475
+ candidateTiming[key] = Math.round((Number(candidateTiming[key]) || 0) + elapsed);
5476
+ };
5477
+ const timeCandidateStage = async (key, fn) => {
5478
+ const startedAt = Date.now();
5479
+ try {
5480
+ return await fn();
5481
+ } finally {
5482
+ addCandidateTiming(key, startedAt);
5483
+ }
5484
+ };
5485
+ const timeCandidateStageSync = (key, fn) => {
5486
+ const startedAt = Date.now();
5487
+ try {
5488
+ return fn();
5489
+ } finally {
5490
+ addCandidateTiming(key, startedAt);
5491
+ }
5492
+ };
5221
5493
  let shouldMarkProcessed = true;
5222
5494
  let resumeSource = "";
5223
5495
  let resumeTextLength = null;
@@ -5235,7 +5507,10 @@ class RecommendScreenCli {
5235
5507
 
5236
5508
  try {
5237
5509
  this.currentCandidateKey = nextCandidate.key || nextCandidate.geek_id || null;
5238
- const cardProfile = await this.extractCandidateProfileFromCard(nextCandidate);
5510
+ const cardProfile = await timeCandidateStage(
5511
+ "card_profile_ms",
5512
+ () => this.extractCandidateProfileFromCard(nextCandidate)
5513
+ );
5239
5514
  candidateProfile = mergeCandidateProfiles(
5240
5515
  cardProfile || null,
5241
5516
  {
@@ -5247,38 +5522,26 @@ class RecommendScreenCli {
5247
5522
  }
5248
5523
  );
5249
5524
  const candidateCaptureStartedAt = Date.now();
5250
- await this.clickCandidate(nextCandidate);
5251
- const detailOpen = await this.ensureDetailOpen();
5525
+ await timeCandidateStage("click_candidate_ms", () => this.clickCandidate(nextCandidate));
5526
+ const detailOpen = await timeCandidateStage("detail_open_ms", () => this.ensureDetailOpen());
5252
5527
  if (!detailOpen) {
5253
5528
  throw this.buildError("DETAIL_OPEN_FAILED", "详情页打开超时");
5254
5529
  }
5255
5530
 
5256
5531
  let capture = null;
5257
- const networkWaitMs = NETWORK_RESUME_WAIT_MS;
5258
- const networkWaitStartedAt = Date.now();
5259
- let networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(nextCandidate, networkWaitMs, {
5260
- minTs: candidateCaptureStartedAt
5261
- });
5532
+ let networkCandidateInfo = await timeCandidateStage(
5533
+ "network_resume_wait_ms",
5534
+ () => this.waitForResumeNetworkByMode(nextCandidate, {
5535
+ minTs: candidateCaptureStartedAt
5536
+ })
5537
+ );
5262
5538
  let domCandidateInfo = null;
5263
- if (!normalizeText(networkCandidateInfo?.resumeText)) {
5264
- if (typeof this.logResumeNetworkMissDiagnostics === "function") {
5265
- this.logResumeNetworkMissDiagnostics(nextCandidate, {
5266
- timeoutMs: networkWaitMs,
5267
- waitStartedAt: networkWaitStartedAt
5268
- });
5269
- }
5270
- await sleep(NETWORK_RESUME_RETRY_WAIT_MS);
5271
- networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(
5272
- nextCandidate,
5273
- NETWORK_RESUME_RETRY_WAIT_MS,
5274
- {
5275
- minTs: candidateCaptureStartedAt
5276
- }
5277
- );
5278
- }
5279
5539
 
5280
5540
  if (networkCandidateInfo?.resumeText) {
5281
- screening = await this.callTextModel(networkCandidateInfo.resumeText);
5541
+ screening = await timeCandidateStage(
5542
+ "text_model_ms",
5543
+ () => this.callTextModel(networkCandidateInfo.resumeText)
5544
+ );
5282
5545
  resumeSource = "network";
5283
5546
  resumeTextLength = normalizeText(networkCandidateInfo.resumeText).length;
5284
5547
  this.resumeSourceStats.network += 1;
@@ -5296,14 +5559,29 @@ class RecommendScreenCli {
5296
5559
  } else {
5297
5560
  try {
5298
5561
  resumeSource = "image_fallback";
5299
- capture = await this.captureResumeImage(nextCandidate);
5300
- screening = await this.callVisionModel(capture.stitchedImage);
5562
+ capture = await timeCandidateStage(
5563
+ "image_capture_ms",
5564
+ () => this.captureResumeImage(nextCandidate)
5565
+ );
5566
+ this.setResumeAcquisitionMode("image", "image_capture_success");
5567
+ screening = await timeCandidateStage(
5568
+ "vision_model_ms",
5569
+ () => this.callVisionModel(capture.modelImagePaths || capture.stitchedImage)
5570
+ );
5301
5571
  this.resumeSourceStats.image_fallback += 1;
5302
5572
  } catch (imageFallbackError) {
5303
- const domFallback = await this.resolveDomResumeFallback(nextCandidate, cardProfile || null);
5304
- if (domFallback?.networkCandidateInfo?.resumeText) {
5305
- networkCandidateInfo = domFallback.networkCandidateInfo;
5306
- screening = await this.callTextModel(networkCandidateInfo.resumeText);
5573
+ const lateNetworkCandidateInfo = await timeCandidateStage(
5574
+ "late_network_retry_ms",
5575
+ () => this.waitForLateNetworkResumeCandidateInfo(nextCandidate, {
5576
+ minTs: candidateCaptureStartedAt
5577
+ })
5578
+ );
5579
+ if (lateNetworkCandidateInfo?.resumeText) {
5580
+ networkCandidateInfo = lateNetworkCandidateInfo;
5581
+ screening = await timeCandidateStage(
5582
+ "text_model_ms",
5583
+ () => this.callTextModel(networkCandidateInfo.resumeText)
5584
+ );
5307
5585
  resumeSource = "network";
5308
5586
  resumeTextLength = normalizeText(networkCandidateInfo.resumeText).length;
5309
5587
  this.resumeSourceStats.network += 1;
@@ -5318,25 +5596,54 @@ class RecommendScreenCli {
5318
5596
  position: nextCandidate.last_position || ""
5319
5597
  }
5320
5598
  );
5321
- } else if (domFallback?.domCandidateInfo?.resumeText) {
5322
- domCandidateInfo = domFallback.domCandidateInfo;
5323
- screening = await this.callTextModel(domCandidateInfo.resumeText);
5324
- resumeSource = "dom_fallback";
5325
- resumeTextLength = normalizeText(domCandidateInfo.resumeText).length;
5326
- this.resumeSourceStats.dom_fallback += 1;
5327
- candidateProfile = mergeCandidateProfiles(
5328
- domCandidateInfo || null,
5329
- cardProfile || null,
5330
- {
5331
- name: nextCandidate.name || "",
5332
- school: nextCandidate.school || "",
5333
- major: nextCandidate.major || "",
5334
- company: nextCandidate.last_company || "",
5335
- position: nextCandidate.last_position || ""
5336
- }
5337
- );
5338
5599
  } else {
5339
- throw imageFallbackError;
5600
+ const domFallback = await timeCandidateStage(
5601
+ "dom_fallback_ms",
5602
+ () => this.resolveDomResumeFallback(nextCandidate, cardProfile || null)
5603
+ );
5604
+ if (domFallback?.networkCandidateInfo?.resumeText) {
5605
+ networkCandidateInfo = domFallback.networkCandidateInfo;
5606
+ screening = await timeCandidateStage(
5607
+ "text_model_ms",
5608
+ () => this.callTextModel(networkCandidateInfo.resumeText)
5609
+ );
5610
+ resumeSource = "network";
5611
+ resumeTextLength = normalizeText(networkCandidateInfo.resumeText).length;
5612
+ this.resumeSourceStats.network += 1;
5613
+ candidateProfile = mergeCandidateProfiles(
5614
+ networkCandidateInfo || null,
5615
+ cardProfile || null,
5616
+ {
5617
+ name: nextCandidate.name || "",
5618
+ school: nextCandidate.school || "",
5619
+ major: nextCandidate.major || "",
5620
+ company: nextCandidate.last_company || "",
5621
+ position: nextCandidate.last_position || ""
5622
+ }
5623
+ );
5624
+ } else if (domFallback?.domCandidateInfo?.resumeText) {
5625
+ domCandidateInfo = domFallback.domCandidateInfo;
5626
+ screening = await timeCandidateStage(
5627
+ "text_model_ms",
5628
+ () => this.callTextModel(domCandidateInfo.resumeText)
5629
+ );
5630
+ resumeSource = "dom_fallback";
5631
+ resumeTextLength = normalizeText(domCandidateInfo.resumeText).length;
5632
+ this.resumeSourceStats.dom_fallback += 1;
5633
+ candidateProfile = mergeCandidateProfiles(
5634
+ domCandidateInfo || null,
5635
+ cardProfile || null,
5636
+ {
5637
+ name: nextCandidate.name || "",
5638
+ school: nextCandidate.school || "",
5639
+ major: nextCandidate.major || "",
5640
+ company: nextCandidate.last_company || "",
5641
+ position: nextCandidate.last_position || ""
5642
+ }
5643
+ );
5644
+ } else {
5645
+ throw imageFallbackError;
5646
+ }
5340
5647
  }
5341
5648
  }
5342
5649
  }
@@ -5356,13 +5663,16 @@ class RecommendScreenCli {
5356
5663
  }
5357
5664
  let actionResult = { actionTaken: "none" };
5358
5665
  try {
5359
- actionResult = effectiveAction === "favorite"
5360
- ? await this.favoriteCandidate({
5361
- alreadyInterested: networkCandidateInfo?.alreadyInterested === true
5362
- })
5363
- : effectiveAction === "greet"
5364
- ? await this.greetCandidate()
5365
- : { actionTaken: "none" };
5666
+ actionResult = await timeCandidateStage(
5667
+ "post_action_ms",
5668
+ () => effectiveAction === "favorite"
5669
+ ? this.favoriteCandidate({
5670
+ alreadyInterested: networkCandidateInfo?.alreadyInterested === true
5671
+ })
5672
+ : effectiveAction === "greet"
5673
+ ? this.greetCandidate()
5674
+ : Promise.resolve({ actionTaken: "none" })
5675
+ );
5366
5676
  } catch (postActionError) {
5367
5677
  if (!isRecoverablePostActionError(postActionError, effectiveAction)) {
5368
5678
  throw postActionError;
@@ -5395,7 +5705,7 @@ class RecommendScreenCli {
5395
5705
  action: actionResult.actionTaken,
5396
5706
  geekId: nextCandidate.geek_id,
5397
5707
  summary: screening.summary,
5398
- imagePath: capture?.stitchedImage || "",
5708
+ imagePath: capture?.stitchedImage || capture?.modelImagePaths?.[0] || capture?.chunkFiles?.[0] || "",
5399
5709
  resumeSource
5400
5710
  });
5401
5711
  this.recordCandidateAudit({
@@ -5527,7 +5837,7 @@ class RecommendScreenCli {
5527
5837
  );
5528
5838
  }
5529
5839
  } finally {
5530
- const closed = await this.closeDetailPage();
5840
+ const closed = await timeCandidateStage("close_detail_ms", () => this.closeDetailPage());
5531
5841
  if (!closed) {
5532
5842
  if (allowDetailCloseFailure) {
5533
5843
  log("[详情关闭兜底] 本候选人 post_action 失败后详情页关闭未确认,已记录错误并继续下一位候选人。");
@@ -5540,12 +5850,31 @@ class RecommendScreenCli {
5540
5850
  }
5541
5851
  }
5542
5852
 
5543
- await this.takeBreakIfNeeded();
5853
+ await timeCandidateStage("rest_ms", () => this.takeBreakIfNeeded());
5854
+ candidateTiming.total_ms = Math.max(0, Date.now() - candidateStartedAt);
5855
+ this.updateCandidateAuditTiming(candidateKeyForTiming, candidateTiming);
5544
5856
  try {
5545
- this.saveCheckpoint();
5857
+ timeCandidateStageSync("checkpoint_save_ms", () => this.saveCheckpoint());
5858
+ candidateTiming.total_ms = Math.max(0, Date.now() - candidateStartedAt);
5859
+ this.updateCandidateAuditTiming(candidateKeyForTiming, candidateTiming);
5546
5860
  } catch (checkpointError) {
5547
5861
  log(`[保存checkpoint失败] ${checkpointError.message || checkpointError}`);
5548
5862
  }
5863
+ try {
5864
+ this.saveCsv();
5865
+ } catch (csvError) {
5866
+ log(`[增量保存CSV失败] ${csvError.message || csvError}`);
5867
+ }
5868
+ log(
5869
+ `[TIMING] candidate=${candidateKeyForTiming || nextCandidate.name || "unknown"} ` +
5870
+ `total_ms=${candidateTiming.total_ms ?? ""} ` +
5871
+ `network_ms=${candidateTiming.network_resume_wait_ms ?? 0} ` +
5872
+ `text_model_ms=${candidateTiming.text_model_ms ?? 0} ` +
5873
+ `image_capture_ms=${candidateTiming.image_capture_ms ?? 0} ` +
5874
+ `vision_model_ms=${candidateTiming.vision_model_ms ?? 0} ` +
5875
+ `post_action_ms=${candidateTiming.post_action_ms ?? 0} ` +
5876
+ `close_ms=${candidateTiming.close_detail_ms ?? 0}`
5877
+ );
5549
5878
  }
5550
5879
 
5551
5880
  if (this.args.targetCount && this.passedCandidates.length < this.args.targetCount) {
@@ -5606,7 +5935,7 @@ async function main() {
5606
5935
  console.log(JSON.stringify({
5607
5936
  status: "COMPLETED",
5608
5937
  result: {
5609
- usage: "node boss-recommend-screen-cli.cjs --criteria \"有 MCP 开发经验\" --post-action <favorite|greet|none> --max-greet-count 10 --post-action-confirmed true --baseurl <url> --apikey <key> --model <model> --page-scope recommend|latest|featured --calibration <favorite-calibration.json> --port 9222 --output <csv-path> [--input-summary-json <json>] --checkpoint-path <checkpoint.json> --pause-control-path <pause-control.json> [--resume]"
5938
+ usage: "node boss-recommend-screen-cli.cjs --criteria \"有 MCP 开发经验\" --post-action <favorite|greet|none> --max-greet-count 10 --post-action-confirmed true --baseurl <url> --apikey <key> --model <model> --thinking-level off|low|medium|high|current --page-scope recommend|latest|featured --calibration <favorite-calibration.json> --port 9222 --output <csv-path> [--input-summary-json <json>] --checkpoint-path <checkpoint.json> --pause-control-path <pause-control.json> [--resume]"
5610
5939
  }
5611
5940
  }));
5612
5941
  return;
@@ -5646,6 +5975,8 @@ if (require.main === module) {
5646
5975
  MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES,
5647
5976
  RESUME_CAPTURE_MAX_ATTEMPTS,
5648
5977
  RESUME_CAPTURE_WAIT_MS,
5978
+ NETWORK_RESUME_IMAGE_MODE_GRACE_MS,
5979
+ NETWORK_RESUME_LATE_RETRY_MS,
5649
5980
  parseFavoriteActionFromPostData,
5650
5981
  parseFavoriteActionFromRequest,
5651
5982
  parseFavoriteActionFromKnownRequest,