@reconcrap/boss-recommend-mcp 1.3.15 → 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");
@@ -3305,6 +3484,60 @@ class RecommendScreenCli {
3305
3484
  return info;
3306
3485
  }
3307
3486
 
3487
+ async resolveDomResumeFallback(candidate, cardProfile) {
3488
+ let domCandidateInfo = await this.extractResumeTextFromDom(candidate);
3489
+ let networkCandidateInfo = null;
3490
+ if (domCandidateInfo && !isDomProfileConsistentWithCard(cardProfile, domCandidateInfo)) {
3491
+ this.recordResumeNetworkDiagnostic({
3492
+ kind: "dom_profile_mismatch",
3493
+ candidate_key: normalizeText(candidate?.key || candidate?.geek_id || ""),
3494
+ card_name: normalizeText(cardProfile?.name || ""),
3495
+ dom_name: normalizeText(domCandidateInfo?.name || ""),
3496
+ card_school: normalizeText(cardProfile?.school || ""),
3497
+ dom_school: normalizeText(domCandidateInfo?.school || "")
3498
+ });
3499
+ log(
3500
+ `[DOM简历疑似错位] candidate=${candidate?.key || candidate?.geek_id || "unknown"} ` +
3501
+ `card=${normalizeText(cardProfile?.name || "-")} dom=${normalizeText(domCandidateInfo?.name || "-")},尝试重试一次点击+监听。`
3502
+ );
3503
+ try {
3504
+ const retryCaptureStartedAt = Date.now();
3505
+ await this.clickCandidate(candidate);
3506
+ const retryDetailOpen = await this.ensureDetailOpen();
3507
+ if (retryDetailOpen) {
3508
+ networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(
3509
+ candidate,
3510
+ NETWORK_RESUME_RETRY_WAIT_MS,
3511
+ { minTs: retryCaptureStartedAt }
3512
+ );
3513
+ if (!normalizeText(networkCandidateInfo?.resumeText)) {
3514
+ const retryDomCandidateInfo = await this.extractResumeTextFromDom(candidate);
3515
+ if (retryDomCandidateInfo && isDomProfileConsistentWithCard(cardProfile, retryDomCandidateInfo)) {
3516
+ domCandidateInfo = retryDomCandidateInfo;
3517
+ } else {
3518
+ domCandidateInfo = null;
3519
+ }
3520
+ } else {
3521
+ domCandidateInfo = null;
3522
+ }
3523
+ } else {
3524
+ domCandidateInfo = null;
3525
+ }
3526
+ } catch (retryError) {
3527
+ domCandidateInfo = null;
3528
+ this.recordResumeNetworkDiagnostic({
3529
+ kind: "dom_profile_mismatch_retry_failed",
3530
+ candidate_key: normalizeText(candidate?.key || candidate?.geek_id || ""),
3531
+ error: normalizeText(retryError?.message || retryError)
3532
+ });
3533
+ }
3534
+ }
3535
+ return {
3536
+ domCandidateInfo,
3537
+ networkCandidateInfo
3538
+ };
3539
+ }
3540
+
3308
3541
  handleNetworkRequestWillBeSent(params) {
3309
3542
  const url = normalizeText(params?.request?.url || "");
3310
3543
  const postData = params?.request?.postData || "";
@@ -3583,7 +3816,8 @@ class RecommendScreenCli {
3583
3816
  error_code: normalizeText(item?.error_code || "") || "",
3584
3817
  error_message: normalizeText(item?.error_message || "") || "",
3585
3818
  chunk_index: Number.isFinite(Number(item?.chunk_index)) ? Number(item.chunk_index) : null,
3586
- 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)
3587
3821
  }))
3588
3822
  : [];
3589
3823
  if (!this.inputSummary) {
@@ -3611,6 +3845,17 @@ class RecommendScreenCli {
3611
3845
  this.resumeSourceStats.image_fallback = 1;
3612
3846
  }
3613
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
+ }
3614
3859
 
3615
3860
  return true;
3616
3861
  }
@@ -4137,7 +4382,8 @@ class RecommendScreenCli {
4137
4382
  outPrefix,
4138
4383
  targetPattern: RECOMMEND_URL_FRAGMENT,
4139
4384
  waitResumeMs: RESUME_CAPTURE_WAIT_MS,
4140
- scrollSettleMs: 500
4385
+ scrollSettleMs: 500,
4386
+ stitchFullImage: false
4141
4387
  });
4142
4388
  } catch (error) {
4143
4389
  lastError = error;
@@ -4164,7 +4410,7 @@ class RecommendScreenCli {
4164
4410
  DEFAULT_VISION_MAX_IMAGE_PIXELS
4165
4411
  );
4166
4412
  const retryLimit = resolveVisionRetryPixelLimit(primaryLimit);
4167
- const preparedPrimary = await this.prepareVisionImageSegmentsForModel(imagePath, primaryLimit, "primary");
4413
+ const preparedPrimary = await this.prepareVisionInputsForModel(imagePath, primaryLimit, "primary");
4168
4414
  try {
4169
4415
  const primaryResult = await this.requestVisionModel(preparedPrimary.imagePaths);
4170
4416
  return this.applyVisionEvidenceGate(primaryResult);
@@ -4179,7 +4425,7 @@ class RecommendScreenCli {
4179
4425
  `segments=${preparedPrimary.imagePaths?.length || 1}`
4180
4426
  );
4181
4427
  }
4182
- const preparedRetry = await this.prepareVisionImageSegmentsForModel(imagePath, retryLimit, "retry");
4428
+ const preparedRetry = await this.prepareVisionInputsForModel(imagePath, retryLimit, "retry");
4183
4429
  try {
4184
4430
  const retryResult = await this.requestVisionModel(preparedRetry.imagePaths);
4185
4431
  return this.applyVisionEvidenceGate(retryResult);
@@ -4199,6 +4445,37 @@ class RecommendScreenCli {
4199
4445
  }
4200
4446
  }
4201
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
+
4202
4479
  applyVisionEvidenceGate(result) {
4203
4480
  const parsed = result && typeof result === "object" ? result : {};
4204
4481
  const rawPassed = parsed?.rawPassed === true || parsed?.passed === true;
@@ -4420,6 +4697,7 @@ class RecommendScreenCli {
4420
4697
  "请根据以下标准判断候选人是否通过筛选。\n\n" +
4421
4698
  `筛选标准:\n${this.args.criteria}\n\n` +
4422
4699
  "你将收到候选人完整简历的一个或多个顺序分段图片。必须完整阅读全部分段后再判断," +
4700
+ "不能只根据前几段下结论;后续分段中的教育、项目、经历或否定信息必须纳入最终判断。" +
4423
4701
  "严禁编造任何不存在的经历、项目、学校、公司或时间线;证据不足时必须判定为不通过。\n\n" +
4424
4702
  "要求:\n" +
4425
4703
  "1) reason 必须写出可审计的判定依据,至少包含 2 条与筛选标准直接相关的事实。\n" +
@@ -4464,6 +4742,11 @@ class RecommendScreenCli {
4464
4742
  }
4465
4743
  ]
4466
4744
  };
4745
+ applyChatCompletionThinking(payload, {
4746
+ baseUrl,
4747
+ model: this.args.model,
4748
+ thinkingLevel: this.args.thinkingLevel
4749
+ });
4467
4750
  const headers = {
4468
4751
  "Content-Type": "application/json",
4469
4752
  Authorization: `Bearer ${this.args.apiKey}`
@@ -4611,6 +4894,11 @@ class RecommendScreenCli {
4611
4894
  }
4612
4895
  ]
4613
4896
  };
4897
+ applyChatCompletionThinking(payload, {
4898
+ baseUrl,
4899
+ model: this.args.model,
4900
+ thinkingLevel: this.args.thinkingLevel
4901
+ });
4614
4902
  const headers = {
4615
4903
  "Content-Type": "application/json",
4616
4904
  Authorization: `Bearer ${this.args.apiKey}`
@@ -4990,6 +5278,7 @@ class RecommendScreenCli {
4990
5278
  const finalPassed = audit?.final_passed === true || normalizeText(audit?.outcome || "") === "passed";
4991
5279
  const screeningReason = normalizeText(audit?.screening_reason || passedItem?.reason || "");
4992
5280
  const passReason = finalPassed ? screeningReason : "";
5281
+ const timing = sanitizeTimingBreakdown(audit?.timing_ms);
4993
5282
  lines.push([
4994
5283
  csvEscape(audit?.candidate_name || passedItem?.name || ""),
4995
5284
  csvEscape(audit?.school || passedItem?.school || ""),
@@ -5008,7 +5297,21 @@ class RecommendScreenCli {
5008
5297
  csvEscape(audit?.evidence_gate_demoted === true ? "true" : "false"),
5009
5298
  csvEscape(audit?.error_code || ""),
5010
5299
  csvEscape(audit?.error_message || ""),
5011
- 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"))
5012
5315
  ].join(","));
5013
5316
  }
5014
5317
  fs.mkdirSync(path.dirname(this.args.output), { recursive: true });
@@ -5164,6 +5467,29 @@ class RecommendScreenCli {
5164
5467
  this.scrollRetryCount = 0;
5165
5468
  this.processedCount += 1;
5166
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
+ };
5167
5493
  let shouldMarkProcessed = true;
5168
5494
  let resumeSource = "";
5169
5495
  let resumeTextLength = null;
@@ -5181,7 +5507,10 @@ class RecommendScreenCli {
5181
5507
 
5182
5508
  try {
5183
5509
  this.currentCandidateKey = nextCandidate.key || nextCandidate.geek_id || null;
5184
- const cardProfile = await this.extractCandidateProfileFromCard(nextCandidate);
5510
+ const cardProfile = await timeCandidateStage(
5511
+ "card_profile_ms",
5512
+ () => this.extractCandidateProfileFromCard(nextCandidate)
5513
+ );
5185
5514
  candidateProfile = mergeCandidateProfiles(
5186
5515
  cardProfile || null,
5187
5516
  {
@@ -5193,112 +5522,131 @@ class RecommendScreenCli {
5193
5522
  }
5194
5523
  );
5195
5524
  const candidateCaptureStartedAt = Date.now();
5196
- await this.clickCandidate(nextCandidate);
5197
- const detailOpen = await this.ensureDetailOpen();
5525
+ await timeCandidateStage("click_candidate_ms", () => this.clickCandidate(nextCandidate));
5526
+ const detailOpen = await timeCandidateStage("detail_open_ms", () => this.ensureDetailOpen());
5198
5527
  if (!detailOpen) {
5199
5528
  throw this.buildError("DETAIL_OPEN_FAILED", "详情页打开超时");
5200
5529
  }
5201
5530
 
5202
5531
  let capture = null;
5203
- const networkWaitMs = NETWORK_RESUME_WAIT_MS;
5204
- const networkWaitStartedAt = Date.now();
5205
- let networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(nextCandidate, networkWaitMs, {
5206
- minTs: candidateCaptureStartedAt
5207
- });
5532
+ let networkCandidateInfo = await timeCandidateStage(
5533
+ "network_resume_wait_ms",
5534
+ () => this.waitForResumeNetworkByMode(nextCandidate, {
5535
+ minTs: candidateCaptureStartedAt
5536
+ })
5537
+ );
5208
5538
  let domCandidateInfo = null;
5209
- if (!normalizeText(networkCandidateInfo?.resumeText)) {
5210
- if (typeof this.logResumeNetworkMissDiagnostics === "function") {
5211
- this.logResumeNetworkMissDiagnostics(nextCandidate, {
5212
- timeoutMs: networkWaitMs,
5213
- waitStartedAt: networkWaitStartedAt
5214
- });
5215
- }
5216
- await sleep(NETWORK_RESUME_RETRY_WAIT_MS);
5217
- networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(
5218
- nextCandidate,
5219
- NETWORK_RESUME_RETRY_WAIT_MS,
5539
+
5540
+ if (networkCandidateInfo?.resumeText) {
5541
+ screening = await timeCandidateStage(
5542
+ "text_model_ms",
5543
+ () => this.callTextModel(networkCandidateInfo.resumeText)
5544
+ );
5545
+ resumeSource = "network";
5546
+ resumeTextLength = normalizeText(networkCandidateInfo.resumeText).length;
5547
+ this.resumeSourceStats.network += 1;
5548
+ candidateProfile = mergeCandidateProfiles(
5549
+ networkCandidateInfo || null,
5550
+ cardProfile || null,
5220
5551
  {
5221
- minTs: candidateCaptureStartedAt
5552
+ name: nextCandidate.name || "",
5553
+ school: nextCandidate.school || "",
5554
+ major: nextCandidate.major || "",
5555
+ company: nextCandidate.last_company || "",
5556
+ position: nextCandidate.last_position || ""
5222
5557
  }
5223
5558
  );
5224
- }
5225
- if (!normalizeText(networkCandidateInfo?.resumeText)) {
5226
- domCandidateInfo = await this.extractResumeTextFromDom(nextCandidate);
5227
- if (domCandidateInfo && !isDomProfileConsistentWithCard(cardProfile, domCandidateInfo)) {
5228
- this.recordResumeNetworkDiagnostic({
5229
- kind: "dom_profile_mismatch",
5230
- candidate_key: normalizeText(nextCandidate?.key || nextCandidate?.geek_id || ""),
5231
- card_name: normalizeText(cardProfile?.name || ""),
5232
- dom_name: normalizeText(domCandidateInfo?.name || ""),
5233
- card_school: normalizeText(cardProfile?.school || ""),
5234
- dom_school: normalizeText(domCandidateInfo?.school || "")
5235
- });
5236
- log(
5237
- `[DOM简历疑似错位] candidate=${nextCandidate?.key || nextCandidate?.geek_id || "unknown"} ` +
5238
- `card=${normalizeText(cardProfile?.name || "-")} dom=${normalizeText(domCandidateInfo?.name || "-")},尝试重试一次点击+监听。`
5559
+ } else {
5560
+ try {
5561
+ resumeSource = "image_fallback";
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)
5239
5570
  );
5240
- try {
5241
- const retryCaptureStartedAt = Date.now();
5242
- await this.clickCandidate(nextCandidate);
5243
- const retryDetailOpen = await this.ensureDetailOpen();
5244
- if (retryDetailOpen) {
5245
- networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(
5246
- nextCandidate,
5247
- NETWORK_RESUME_RETRY_WAIT_MS,
5248
- { minTs: retryCaptureStartedAt }
5571
+ this.resumeSourceStats.image_fallback += 1;
5572
+ } catch (imageFallbackError) {
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
+ );
5585
+ resumeSource = "network";
5586
+ resumeTextLength = normalizeText(networkCandidateInfo.resumeText).length;
5587
+ this.resumeSourceStats.network += 1;
5588
+ candidateProfile = mergeCandidateProfiles(
5589
+ networkCandidateInfo || null,
5590
+ cardProfile || null,
5591
+ {
5592
+ name: nextCandidate.name || "",
5593
+ school: nextCandidate.school || "",
5594
+ major: nextCandidate.major || "",
5595
+ company: nextCandidate.last_company || "",
5596
+ position: nextCandidate.last_position || ""
5597
+ }
5598
+ );
5599
+ } else {
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)
5249
5609
  );
5250
- if (!normalizeText(networkCandidateInfo?.resumeText)) {
5251
- const retryDomCandidateInfo = await this.extractResumeTextFromDom(nextCandidate);
5252
- if (retryDomCandidateInfo && isDomProfileConsistentWithCard(cardProfile, retryDomCandidateInfo)) {
5253
- domCandidateInfo = retryDomCandidateInfo;
5254
- } else {
5255
- domCandidateInfo = null;
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 || ""
5256
5622
  }
5257
- } else {
5258
- domCandidateInfo = null;
5259
- }
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
+ );
5260
5644
  } else {
5261
- domCandidateInfo = null;
5645
+ throw imageFallbackError;
5262
5646
  }
5263
- } catch (retryError) {
5264
- domCandidateInfo = null;
5265
- this.recordResumeNetworkDiagnostic({
5266
- kind: "dom_profile_mismatch_retry_failed",
5267
- candidate_key: normalizeText(nextCandidate?.key || nextCandidate?.geek_id || ""),
5268
- error: normalizeText(retryError?.message || retryError)
5269
- });
5270
5647
  }
5271
5648
  }
5272
5649
  }
5273
- const resumeCandidateInfo = networkCandidateInfo?.resumeText ? networkCandidateInfo : domCandidateInfo;
5274
- candidateProfile = mergeCandidateProfiles(
5275
- resumeCandidateInfo || null,
5276
- cardProfile || null,
5277
- {
5278
- name: nextCandidate.name || "",
5279
- school: nextCandidate.school || "",
5280
- major: nextCandidate.major || "",
5281
- company: nextCandidate.last_company || "",
5282
- position: nextCandidate.last_position || ""
5283
- }
5284
- );
5285
-
5286
- if (networkCandidateInfo?.resumeText) {
5287
- screening = await this.callTextModel(networkCandidateInfo.resumeText);
5288
- resumeSource = "network";
5289
- resumeTextLength = normalizeText(networkCandidateInfo.resumeText).length;
5290
- this.resumeSourceStats.network += 1;
5291
- } else if (domCandidateInfo?.resumeText) {
5292
- screening = await this.callTextModel(domCandidateInfo.resumeText);
5293
- resumeSource = "dom_fallback";
5294
- resumeTextLength = normalizeText(domCandidateInfo.resumeText).length;
5295
- this.resumeSourceStats.dom_fallback += 1;
5296
- } else {
5297
- resumeSource = "image_fallback";
5298
- capture = await this.captureResumeImage(nextCandidate);
5299
- screening = await this.callVisionModel(capture.stitchedImage);
5300
- this.resumeSourceStats.image_fallback += 1;
5301
- }
5302
5650
  this.resetResumeCaptureFailureStreak();
5303
5651
  log(`筛选结果: ${screening.passed ? "通过" : "不通过"}`);
5304
5652
 
@@ -5315,13 +5663,16 @@ class RecommendScreenCli {
5315
5663
  }
5316
5664
  let actionResult = { actionTaken: "none" };
5317
5665
  try {
5318
- actionResult = effectiveAction === "favorite"
5319
- ? await this.favoriteCandidate({
5320
- alreadyInterested: networkCandidateInfo?.alreadyInterested === true
5321
- })
5322
- : effectiveAction === "greet"
5323
- ? await this.greetCandidate()
5324
- : { 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
+ );
5325
5676
  } catch (postActionError) {
5326
5677
  if (!isRecoverablePostActionError(postActionError, effectiveAction)) {
5327
5678
  throw postActionError;
@@ -5354,7 +5705,7 @@ class RecommendScreenCli {
5354
5705
  action: actionResult.actionTaken,
5355
5706
  geekId: nextCandidate.geek_id,
5356
5707
  summary: screening.summary,
5357
- imagePath: capture?.stitchedImage || "",
5708
+ imagePath: capture?.stitchedImage || capture?.modelImagePaths?.[0] || capture?.chunkFiles?.[0] || "",
5358
5709
  resumeSource
5359
5710
  });
5360
5711
  this.recordCandidateAudit({
@@ -5486,7 +5837,7 @@ class RecommendScreenCli {
5486
5837
  );
5487
5838
  }
5488
5839
  } finally {
5489
- const closed = await this.closeDetailPage();
5840
+ const closed = await timeCandidateStage("close_detail_ms", () => this.closeDetailPage());
5490
5841
  if (!closed) {
5491
5842
  if (allowDetailCloseFailure) {
5492
5843
  log("[详情关闭兜底] 本候选人 post_action 失败后详情页关闭未确认,已记录错误并继续下一位候选人。");
@@ -5499,12 +5850,31 @@ class RecommendScreenCli {
5499
5850
  }
5500
5851
  }
5501
5852
 
5502
- 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);
5503
5856
  try {
5504
- this.saveCheckpoint();
5857
+ timeCandidateStageSync("checkpoint_save_ms", () => this.saveCheckpoint());
5858
+ candidateTiming.total_ms = Math.max(0, Date.now() - candidateStartedAt);
5859
+ this.updateCandidateAuditTiming(candidateKeyForTiming, candidateTiming);
5505
5860
  } catch (checkpointError) {
5506
5861
  log(`[保存checkpoint失败] ${checkpointError.message || checkpointError}`);
5507
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
+ );
5508
5878
  }
5509
5879
 
5510
5880
  if (this.args.targetCount && this.passedCandidates.length < this.args.targetCount) {
@@ -5565,7 +5935,7 @@ async function main() {
5565
5935
  console.log(JSON.stringify({
5566
5936
  status: "COMPLETED",
5567
5937
  result: {
5568
- 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]"
5569
5939
  }
5570
5940
  }));
5571
5941
  return;
@@ -5605,6 +5975,8 @@ if (require.main === module) {
5605
5975
  MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES,
5606
5976
  RESUME_CAPTURE_MAX_ATTEMPTS,
5607
5977
  RESUME_CAPTURE_WAIT_MS,
5978
+ NETWORK_RESUME_IMAGE_MODE_GRACE_MS,
5979
+ NETWORK_RESUME_LATE_RETRY_MS,
5608
5980
  parseFavoriteActionFromPostData,
5609
5981
  parseFavoriteActionFromRequest,
5610
5982
  parseFavoriteActionFromKnownRequest,