@reconcrap/boss-recommend-mcp 1.3.16 → 1.3.18

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;
@@ -636,6 +652,101 @@ function formatEducationSchoolTags(edu) {
636
652
  return tags.join("、");
637
653
  }
638
654
 
655
+ function inferDegreeRank(degreeText) {
656
+ const normalized = normalizeText(degreeText).toLowerCase();
657
+ if (!normalized) return 0;
658
+ if (/博士|phd|doctor/.test(normalized)) return 7;
659
+ if (/硕士|master/.test(normalized)) return 6;
660
+ if (/本科|学士|bachelor/.test(normalized)) return 5;
661
+ if (/大专|专科|junior/.test(normalized)) return 4;
662
+ if (/高中/.test(normalized)) return 3;
663
+ if (/中专|中技/.test(normalized)) return 2;
664
+ if (/初中|小学|及以下/.test(normalized)) return 1;
665
+ return 0;
666
+ }
667
+
668
+ function normalizeResumeDateToken(value) {
669
+ const raw = normalizeText(value);
670
+ if (!raw) return "";
671
+ const digits = raw.replace(/[^\d]/g, "");
672
+ if (/^\d{8}$/.test(digits)) {
673
+ return `${digits.slice(0, 4)}.${digits.slice(4, 6)}`;
674
+ }
675
+ if (/^\d{6}$/.test(digits)) {
676
+ return `${digits.slice(0, 4)}.${digits.slice(4, 6)}`;
677
+ }
678
+ if (/^\d{4}$/.test(digits)) {
679
+ return digits;
680
+ }
681
+ return raw;
682
+ }
683
+
684
+ function formatResumeTimeRange(startRaw, endRaw, fallbackEnd = "") {
685
+ const start = normalizeResumeDateToken(startRaw);
686
+ const end = normalizeResumeDateToken(endRaw) || normalizeText(fallbackEnd);
687
+ if (start && end) return `${start} ~ ${end}`;
688
+ if (start) return `${start} ~`;
689
+ if (end) return `~ ${end}`;
690
+ return "";
691
+ }
692
+
693
+ function formatResumeTimeRangeFromFields(source, startFields = [], endFields = [], fallbackEnd = "") {
694
+ const startRaw = startFields
695
+ .map((field) => source?.[field])
696
+ .find((value) => normalizeText(value));
697
+ const endRaw = endFields
698
+ .map((field) => source?.[field])
699
+ .find((value) => normalizeText(value));
700
+ return formatResumeTimeRange(startRaw, endRaw, fallbackEnd);
701
+ }
702
+
703
+ function formatNamedListText(items = []) {
704
+ if (!Array.isArray(items) || items.length <= 0) return "";
705
+ return items
706
+ .map((item) => normalizeText(item?.name || item?.subjectName || item?.title || item))
707
+ .filter(Boolean)
708
+ .join("、");
709
+ }
710
+
711
+ function extractYearFromResumeDate(value) {
712
+ const token = normalizeResumeDateToken(value);
713
+ if (!token) return "";
714
+ const match = token.match(/(19|20)\d{2}/);
715
+ return match ? match[0] : "";
716
+ }
717
+
718
+ function deriveHighestEducation(eduExpList = []) {
719
+ const list = Array.isArray(eduExpList) ? eduExpList : [];
720
+ let selected = null;
721
+ for (const edu of list) {
722
+ const degree = formatEducationDegree(edu);
723
+ const rank = inferDegreeRank(degree);
724
+ const endYear = extractYearFromResumeDate(
725
+ edu?.endYearMonStr || edu?.endYearStr || edu?.endDateDesc || edu?.endDate || ""
726
+ );
727
+ const candidate = {
728
+ school: normalizeText(edu?.school || edu?.schoolName || ""),
729
+ degree,
730
+ rank,
731
+ endYear
732
+ };
733
+ if (!selected) {
734
+ selected = candidate;
735
+ continue;
736
+ }
737
+ const selectedYear = Number(selected.endYear || 0);
738
+ const candidateYear = Number(candidate.endYear || 0);
739
+ if (candidate.rank > selected.rank) {
740
+ selected = candidate;
741
+ continue;
742
+ }
743
+ if (candidate.rank === selected.rank && candidateYear > selectedYear) {
744
+ selected = candidate;
745
+ }
746
+ }
747
+ return selected || { school: "", degree: "", rank: 0, endYear: "" };
748
+ }
749
+
639
750
  function splitTextByChunks(text, chunkSize, overlap, maxChunks) {
640
751
  const source = String(text || "");
641
752
  if (!source) return [];
@@ -962,12 +1073,69 @@ function shouldBringChromeToFront() {
962
1073
  }
963
1074
 
964
1075
  const SHOULD_BRING_TO_FRONT = shouldBringChromeToFront();
1076
+ const LLM_THINKING_ENV_KEYS = [
1077
+ "BOSS_RECOMMEND_LLM_THINKING_LEVEL",
1078
+ "BOSS_LLM_THINKING_LEVEL",
1079
+ "LLM_THINKING_LEVEL"
1080
+ ];
1081
+
1082
+ function normalizeLlmThinkingLevel(value) {
1083
+ const normalized = normalizeText(value).toLowerCase().replace(/[_\s]+/g, "-");
1084
+ if (!normalized) return "";
1085
+ if (["off", "disabled", "disable", "minimal", "none", "false", "0"].includes(normalized)) return "off";
1086
+ if (["low", "medium", "high", "auto", "current", "default", "provider-default", "unchanged", "inherit"].includes(normalized)) {
1087
+ return normalized;
1088
+ }
1089
+ return "";
1090
+ }
1091
+
1092
+ function getEnvLlmThinkingLevel() {
1093
+ for (const key of LLM_THINKING_ENV_KEYS) {
1094
+ const normalized = normalizeLlmThinkingLevel(process.env[key]);
1095
+ if (normalized) return normalized;
1096
+ }
1097
+ return "";
1098
+ }
1099
+
1100
+ function resolveLlmThinkingLevel(value) {
1101
+ return normalizeLlmThinkingLevel(value) || getEnvLlmThinkingLevel() || "off";
1102
+ }
1103
+
1104
+ function isVolcengineModel(baseUrl, model) {
1105
+ const combined = `${baseUrl || ""} ${model || ""}`;
1106
+ return /volces\.com|volcengine|ark\.cn-|doubao|seed/i.test(combined);
1107
+ }
1108
+
1109
+ function applyChatCompletionThinking(payload, { baseUrl = "", model = "", thinkingLevel = "" } = {}) {
1110
+ const level = resolveLlmThinkingLevel(thinkingLevel);
1111
+ if (["current", "default", "provider-default", "unchanged", "inherit"].includes(level)) return payload;
1112
+ const isVolc = isVolcengineModel(baseUrl, model);
1113
+ if (isVolc) {
1114
+ if (level === "auto") {
1115
+ payload.thinking = { type: "auto" };
1116
+ return payload;
1117
+ }
1118
+ if (level === "off") {
1119
+ payload.thinking = { type: "disabled" };
1120
+ payload.reasoning_effort = "minimal";
1121
+ return payload;
1122
+ }
1123
+ payload.thinking = { type: "enabled" };
1124
+ payload.reasoning_effort = level;
1125
+ return payload;
1126
+ }
1127
+ if (level !== "auto") {
1128
+ payload.reasoning_effort = level === "off" ? "minimal" : level;
1129
+ }
1130
+ return payload;
1131
+ }
965
1132
 
966
1133
  function parseArgs(argv) {
967
1134
  const parsed = {
968
1135
  baseUrl: null,
969
1136
  apiKey: null,
970
1137
  model: null,
1138
+ thinkingLevel: null,
971
1139
  openaiOrganization: null,
972
1140
  openaiProject: null,
973
1141
  criteria: null,
@@ -988,6 +1156,7 @@ function parseArgs(argv) {
988
1156
  baseUrl: false,
989
1157
  apiKey: false,
990
1158
  model: false,
1159
+ thinkingLevel: false,
991
1160
  criteria: false,
992
1161
  targetCount: false,
993
1162
  maxGreetCount: false,
@@ -1016,6 +1185,10 @@ function parseArgs(argv) {
1016
1185
  parsed.model = inlineValue || next;
1017
1186
  parsed.__provided.model = true;
1018
1187
  if (!inlineValue) index += 1;
1188
+ } else if ((token === "--thinking-level" || token === "--thinkingLevel" || token === "--llm-thinking-level" || token === "--reasoning-effort") && (inlineValue || next)) {
1189
+ parsed.thinkingLevel = inlineValue || next;
1190
+ parsed.__provided.thinkingLevel = true;
1191
+ if (!inlineValue) index += 1;
1019
1192
  } else if (token === "--openai-organization" && (inlineValue || next)) {
1020
1193
  parsed.openaiOrganization = inlineValue || next;
1021
1194
  if (!inlineValue) index += 1;
@@ -1208,6 +1381,30 @@ function csvEscape(value) {
1208
1381
  return `"${String(value || "").replace(/"/g, '""')}"`;
1209
1382
  }
1210
1383
 
1384
+ function normalizeTimingMs(value) {
1385
+ const parsed = Number(value);
1386
+ if (!Number.isFinite(parsed) || parsed < 0) return null;
1387
+ return Math.round(parsed);
1388
+ }
1389
+
1390
+ function sanitizeTimingBreakdown(value) {
1391
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
1392
+ const result = {};
1393
+ for (const [key, raw] of Object.entries(value)) {
1394
+ const normalizedKey = normalizeText(key);
1395
+ if (!normalizedKey) continue;
1396
+ const normalizedValue = normalizeTimingMs(raw);
1397
+ if (normalizedValue === null) continue;
1398
+ result[normalizedKey] = normalizedValue;
1399
+ }
1400
+ return result;
1401
+ }
1402
+
1403
+ function getTimingMs(timing, key) {
1404
+ const normalized = normalizeTimingMs(timing?.[key]);
1405
+ return normalized === null ? "" : normalized;
1406
+ }
1407
+
1211
1408
  function stringifyInputSummaryValue(value) {
1212
1409
  if (value === null) return "null";
1213
1410
  if (value === undefined) return "";
@@ -1434,12 +1631,21 @@ function formatResumeApiData(data) {
1434
1631
  const parts = [];
1435
1632
  const geekDetail = data?.geekDetail || data?.geekDetailInfo || data || {};
1436
1633
  const baseInfo = geekDetail.geekBaseInfo || {};
1437
- const expectList = geekDetail.geekExpectList || [];
1634
+ const expectList = Array.isArray(geekDetail.geekExpectList) && geekDetail.geekExpectList.length > 0
1635
+ ? geekDetail.geekExpectList
1636
+ : Array.isArray(geekDetail.geekExpPosList) && geekDetail.geekExpPosList.length > 0
1637
+ ? geekDetail.geekExpPosList
1638
+ : geekDetail.showExpectPosition
1639
+ ? [geekDetail.showExpectPosition]
1640
+ : [];
1438
1641
  const workExpList = geekDetail.geekWorkExpList || [];
1439
1642
  const projExpList = geekDetail.geekProjExpList || [];
1440
1643
  const eduExpList = geekDetail.geekEduExpList || geekDetail.geekEducationList || [];
1441
1644
  const advantage = geekDetail.geekAdvantage || baseInfo.userDesc || baseInfo.userDescription || "";
1442
1645
  const skillList = geekDetail.geekSkillList || geekDetail.skillList || [];
1646
+ const certificationList = geekDetail.geekCertificationList || [];
1647
+ const workExpCheckRes = Array.isArray(geekDetail.workExpCheckRes) ? geekDetail.workExpCheckRes : [];
1648
+ const highestEdu = deriveHighestEducation(eduExpList);
1443
1649
 
1444
1650
  parts.push("=== 基本信息 ===");
1445
1651
  if (baseInfo.name) parts.push(`姓名: ${baseInfo.name}`);
@@ -1447,6 +1653,9 @@ function formatResumeApiData(data) {
1447
1653
  if (baseInfo.gender !== undefined) parts.push(`性别: ${baseInfo.gender === 1 ? "男" : "女"}`);
1448
1654
  if (baseInfo.degreeCategory) parts.push(`学历: ${baseInfo.degreeCategory}`);
1449
1655
  if (baseInfo.workYearDesc) parts.push(`工作经验: ${baseInfo.workYearDesc}`);
1656
+ if (typeof baseInfo.freshGraduate === "number") parts.push(`应届状态: ${baseInfo.freshGraduate === 1 ? "应届生" : "非应届生"}`);
1657
+ const workDate = normalizeResumeDateToken(baseInfo.workDate8);
1658
+ if (workDate) parts.push(`参加工作时间: ${workDate}`);
1450
1659
  if (baseInfo.activeTimeDesc) parts.push(`活跃状态: ${baseInfo.activeTimeDesc}`);
1451
1660
  if (baseInfo.applyStatusContent) parts.push(`求职状态: ${baseInfo.applyStatusContent}`);
1452
1661
 
@@ -1471,13 +1680,27 @@ function formatResumeApiData(data) {
1471
1680
  const company = exp.company || "";
1472
1681
  const position = stripHtml(exp.positionName || "");
1473
1682
  parts.push(`${index + 1}. ${company} - ${position}`);
1474
- if (exp.startYearMonStr) {
1475
- parts.push(` 时间: ${exp.startYearMonStr} ~ ${exp.endYearMonStr || "至今"}`);
1683
+ const workTime = formatResumeTimeRangeFromFields(
1684
+ exp,
1685
+ ["startYearMonStr", "startYearStr", "startDateDesc", "startDate"],
1686
+ ["endYearMonStr", "endYearStr", "endDateDesc", "endDate"],
1687
+ "至今"
1688
+ );
1689
+ if (workTime) {
1690
+ parts.push(` 时间: ${workTime}`);
1476
1691
  }
1477
1692
  const workContent = exp.responsibility || exp.workContent || "";
1478
1693
  if (workContent) {
1479
1694
  parts.push(` 职责: ${stripHtml(workContent)}`);
1480
1695
  }
1696
+ const workPerformance = exp.workPerformance || exp.performance || "";
1697
+ if (workPerformance) {
1698
+ parts.push(` 成果: ${stripHtml(workPerformance)}`);
1699
+ }
1700
+ const workEmphasis = exp.workEmphasis || "";
1701
+ if (workEmphasis) {
1702
+ parts.push(` 补充: ${stripHtml(workEmphasis)}`);
1703
+ }
1481
1704
  });
1482
1705
  }
1483
1706
 
@@ -1486,8 +1709,14 @@ function formatResumeApiData(data) {
1486
1709
  projExpList.forEach((proj, index) => {
1487
1710
  parts.push(`${index + 1}. ${proj.name || proj.projectName || "未知项目"}`);
1488
1711
  if (proj.roleName) parts.push(` 角色: ${proj.roleName}`);
1489
- if (proj.startYearMonStr) {
1490
- parts.push(` 时间: ${proj.startYearMonStr} ~ ${proj.endYearMonStr || "至今"}`);
1712
+ const projTime = formatResumeTimeRangeFromFields(
1713
+ proj,
1714
+ ["startYearMonStr", "startYearStr", "startDateDesc", "startDate"],
1715
+ ["endYearMonStr", "endYearStr", "endDateDesc", "endDate"],
1716
+ "至今"
1717
+ );
1718
+ if (projTime) {
1719
+ parts.push(` 时间: ${projTime}`);
1491
1720
  }
1492
1721
  const projectDescription = proj.description || proj.projectDescription || "";
1493
1722
  if (projectDescription) parts.push(` 描述: ${stripHtml(projectDescription)}`);
@@ -1503,15 +1732,35 @@ function formatResumeApiData(data) {
1503
1732
  if (edu.major || edu.majorName) parts.push(` 专业: ${edu.major || edu.majorName}`);
1504
1733
  const eduDegree = formatEducationDegree(edu);
1505
1734
  if (eduDegree) parts.push(` 学历: ${eduDegree}`);
1506
- const eduStart = edu.startYearMonStr || edu.startYearStr;
1507
- if (eduStart) {
1508
- const eduEnd = edu.endYearMonStr || edu.endYearStr || "";
1509
- parts.push(` 时间: ${eduStart} ~ ${eduEnd}`);
1735
+ const eduTime = formatResumeTimeRangeFromFields(
1736
+ edu,
1737
+ ["startYearMonStr", "startYearStr", "startDateDesc", "startDate"],
1738
+ ["endYearMonStr", "endYearStr", "endDateDesc", "endDate"]
1739
+ );
1740
+ if (eduTime) {
1741
+ parts.push(` 时间: ${eduTime}`);
1510
1742
  }
1511
1743
  const schoolTags = formatEducationSchoolTags(edu);
1512
1744
  if (schoolTags) {
1513
1745
  parts.push(` 学校标签: ${schoolTags}`);
1514
1746
  }
1747
+ const eduDescription = stripHtml(edu.eduDescription || edu.description || "");
1748
+ if (eduDescription) {
1749
+ parts.push(` 描述: ${eduDescription}`);
1750
+ }
1751
+ const courseDesc = stripHtml(edu.courseDesc || "");
1752
+ if (courseDesc) {
1753
+ parts.push(` 课程/研究: ${courseDesc}`);
1754
+ }
1755
+ const keySubjects = formatNamedListText(edu.keySubjectList || []);
1756
+ if (keySubjects) {
1757
+ parts.push(` 核心课程: ${keySubjects}`);
1758
+ }
1759
+ const thesisTitle = normalizeText(edu.thesisTitle || "");
1760
+ const thesisDesc = stripHtml(edu.thesisDesc || "");
1761
+ if (thesisTitle || thesisDesc) {
1762
+ parts.push(` 论文: ${thesisTitle}${thesisDesc ? ` - ${thesisDesc}` : ""}`);
1763
+ }
1515
1764
  });
1516
1765
  }
1517
1766
 
@@ -1524,6 +1773,33 @@ function formatResumeApiData(data) {
1524
1773
  });
1525
1774
  }
1526
1775
 
1776
+ if (certificationList.length > 0) {
1777
+ parts.push("\n=== 资格证书 ===");
1778
+ certificationList.forEach((cert) => {
1779
+ const certName = normalizeText(cert?.certName || cert?.name || "");
1780
+ if (certName) parts.push(`- ${certName}`);
1781
+ });
1782
+ }
1783
+
1784
+ parts.push("\n=== 结构化判定线索 ===");
1785
+ if (highestEdu.degree) parts.push(`最高学历: ${highestEdu.degree}`);
1786
+ if (highestEdu.school) parts.push(`最高学历学校: ${highestEdu.school}`);
1787
+ if (highestEdu.endYear) parts.push(`最高学历毕业年份: ${highestEdu.endYear}`);
1788
+ parts.push(`是否有工作经历: ${workExpList.length > 0 ? "是" : "否"}`);
1789
+ parts.push(`是否有项目经历: ${projExpList.length > 0 ? "是" : "否"}`);
1790
+ parts.push("相关经验硬判口径: 仅工作经历/项目经历可作为“相关经验”硬性证据;教育/课程/技能仅作补充。");
1791
+ if (workExpCheckRes.length > 0) {
1792
+ const riskText = workExpCheckRes
1793
+ .map((item) => normalizeText(item?.desc || item?.firstTip || item?.chatDesc || ""))
1794
+ .filter(Boolean)
1795
+ .slice(0, 3)
1796
+ .join(";");
1797
+ if (riskText) {
1798
+ parts.push(`软风险提示(需追问,不直接淘汰): ${riskText}`);
1799
+ }
1800
+ }
1801
+ parts.push("判定忽略项: 活跃度/沟通热度/受欢迎度等运营指标不参与通过判定。");
1802
+
1527
1803
  return parts.join("\n");
1528
1804
  }
1529
1805
 
@@ -2844,6 +3120,8 @@ class RecommendScreenCli {
2844
3120
  dom_fallback: 0,
2845
3121
  image_fallback: 0
2846
3122
  };
3123
+ this.resumeAcquisitionMode = "unknown";
3124
+ this.resumeAcquisitionModeReason = "";
2847
3125
  this.lastActiveTabStatus = PAGE_SCOPE_TAB_STATUS[this.args.pageScope] || null;
2848
3126
  this.featuredCalibration = this.args.pageScope === "featured"
2849
3127
  ? loadCalibrationPosition(this.args.calibrationPath)
@@ -2890,6 +3168,8 @@ class RecommendScreenCli {
2890
3168
  skipped_count: this.skippedCount,
2891
3169
  greet_count: this.greetCount,
2892
3170
  greet_limit_fallback_count: this.greetLimitFallbackCount,
3171
+ resume_acquisition_mode: this.resumeAcquisitionMode,
3172
+ resume_acquisition_mode_reason: this.resumeAcquisitionModeReason,
2893
3173
  processed_keys: Array.from(this.processedKeys),
2894
3174
  passed_candidates: this.passedCandidates.map((item) => ({
2895
3175
  name: item?.name || "",
@@ -2926,7 +3206,8 @@ class RecommendScreenCli {
2926
3206
  error_code: item?.error_code || "",
2927
3207
  error_message: item?.error_message || "",
2928
3208
  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
3209
+ chunk_total: Number.isFinite(Number(item?.chunk_total)) ? Number(item.chunk_total) : null,
3210
+ timing_ms: sanitizeTimingBreakdown(item?.timing_ms)
2930
3211
  })),
2931
3212
  input_summary: sanitizeInputSummary(this.inputSummary)
2932
3213
  };
@@ -2942,6 +3223,7 @@ class RecommendScreenCli {
2942
3223
  checkpoint_path: this.checkpointPath,
2943
3224
  selected_page: this.args.pageScope || "recommend",
2944
3225
  active_tab_status: this.lastActiveTabStatus || PAGE_SCOPE_TAB_STATUS[this.args.pageScope] || null,
3226
+ resume_acquisition_mode: this.resumeAcquisitionMode,
2945
3227
  resume_source: this.resumeSourceStats.image_fallback > 0
2946
3228
  ? "image_fallback"
2947
3229
  : this.resumeSourceStats.dom_fallback > 0
@@ -3092,7 +3374,8 @@ class RecommendScreenCli {
3092
3374
  error_code: normalizeText(entry?.error_code || "") || "",
3093
3375
  error_message: normalizeText(entry?.error_message || "") || "",
3094
3376
  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
3377
+ chunk_total: Number.isFinite(Number(entry?.chunk_total)) ? Number(entry.chunk_total) : null,
3378
+ timing_ms: sanitizeTimingBreakdown(entry?.timing_ms)
3096
3379
  };
3097
3380
  this.candidateAudits.push(normalized);
3098
3381
  const maxItems = parsePositiveInteger(process.env.BOSS_RECOMMEND_MAX_CANDIDATE_AUDITS);
@@ -3101,6 +3384,22 @@ class RecommendScreenCli {
3101
3384
  }
3102
3385
  }
3103
3386
 
3387
+ updateCandidateAuditTiming(candidateKey, timing = {}) {
3388
+ const normalizedKey = normalizeText(candidateKey || "");
3389
+ if (!normalizedKey) return;
3390
+ const timingMs = sanitizeTimingBreakdown(timing);
3391
+ for (let index = this.candidateAudits.length - 1; index >= 0; index -= 1) {
3392
+ const audit = this.candidateAudits[index];
3393
+ if (
3394
+ normalizeText(audit?.candidate_key || "") === normalizedKey
3395
+ || normalizeText(audit?.geek_id || "") === normalizedKey
3396
+ ) {
3397
+ audit.timing_ms = timingMs;
3398
+ return;
3399
+ }
3400
+ }
3401
+ }
3402
+
3104
3403
  logResumeNetworkMissDiagnostics(candidate, options = {}) {
3105
3404
  const candidateKey = normalizeText(candidate?.key || candidate?.geek_id || "");
3106
3405
  const candidateName = normalizeText(candidate?.name || "");
@@ -3240,6 +3539,60 @@ class RecommendScreenCli {
3240
3539
  return null;
3241
3540
  }
3242
3541
 
3542
+ setResumeAcquisitionMode(mode, reason = "") {
3543
+ if (!["unknown", "network", "image"].includes(mode)) return;
3544
+ if (this.resumeAcquisitionMode === mode) return;
3545
+ this.resumeAcquisitionMode = mode;
3546
+ this.resumeAcquisitionModeReason = normalizeText(reason || "");
3547
+ log(`[简历获取模式] mode=${mode}${this.resumeAcquisitionModeReason ? ` reason=${this.resumeAcquisitionModeReason}` : ""}`);
3548
+ }
3549
+
3550
+ async waitForResumeNetworkByMode(candidate, options = {}) {
3551
+ const minTs = Number.isFinite(Number(options?.minTs)) ? Number(options.minTs) : 0;
3552
+ const mode = this.resumeAcquisitionMode || "unknown";
3553
+ const firstWaitMs = mode === "image" ? NETWORK_RESUME_IMAGE_MODE_GRACE_MS : NETWORK_RESUME_WAIT_MS;
3554
+ const waitStartedAt = Date.now();
3555
+ let networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(candidate, firstWaitMs, { minTs });
3556
+ if (normalizeText(networkCandidateInfo?.resumeText)) {
3557
+ this.setResumeAcquisitionMode("network", "network_resume_hit");
3558
+ return networkCandidateInfo;
3559
+ }
3560
+ if (typeof this.logResumeNetworkMissDiagnostics === "function") {
3561
+ this.logResumeNetworkMissDiagnostics(candidate, {
3562
+ timeoutMs: firstWaitMs,
3563
+ waitStartedAt
3564
+ });
3565
+ }
3566
+ if (mode === "image") {
3567
+ return null;
3568
+ }
3569
+ await sleep(NETWORK_RESUME_RETRY_WAIT_MS);
3570
+ networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(
3571
+ candidate,
3572
+ NETWORK_RESUME_RETRY_WAIT_MS,
3573
+ { minTs }
3574
+ );
3575
+ if (normalizeText(networkCandidateInfo?.resumeText)) {
3576
+ this.setResumeAcquisitionMode("network", "network_resume_retry_hit");
3577
+ return networkCandidateInfo;
3578
+ }
3579
+ return null;
3580
+ }
3581
+
3582
+ async waitForLateNetworkResumeCandidateInfo(candidate, options = {}) {
3583
+ const minTs = Number.isFinite(Number(options?.minTs)) ? Number(options.minTs) : 0;
3584
+ const networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(
3585
+ candidate,
3586
+ NETWORK_RESUME_LATE_RETRY_MS,
3587
+ { minTs }
3588
+ );
3589
+ if (normalizeText(networkCandidateInfo?.resumeText)) {
3590
+ this.setResumeAcquisitionMode("network", "late_network_resume_hit");
3591
+ return networkCandidateInfo;
3592
+ }
3593
+ return null;
3594
+ }
3595
+
3243
3596
  async extractResumeTextFromDom(candidate) {
3244
3597
  const candidateKey = normalizeText(candidate?.key || candidate?.geek_id || "");
3245
3598
  const candidateLabel = normalizeText(candidate?.name || candidateKey || "unknown");
@@ -3637,7 +3990,8 @@ class RecommendScreenCli {
3637
3990
  error_code: normalizeText(item?.error_code || "") || "",
3638
3991
  error_message: normalizeText(item?.error_message || "") || "",
3639
3992
  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
3993
+ chunk_total: Number.isFinite(Number(item?.chunk_total)) ? Number(item.chunk_total) : null,
3994
+ timing_ms: sanitizeTimingBreakdown(item?.timing_ms)
3641
3995
  }))
3642
3996
  : [];
3643
3997
  if (!this.inputSummary) {
@@ -3665,6 +4019,17 @@ class RecommendScreenCli {
3665
4019
  this.resumeSourceStats.image_fallback = 1;
3666
4020
  }
3667
4021
  }
4022
+ const checkpointMode = normalizeText(parsed.resume_acquisition_mode || "").toLowerCase();
4023
+ if (["network", "image"].includes(checkpointMode)) {
4024
+ this.resumeAcquisitionMode = checkpointMode;
4025
+ this.resumeAcquisitionModeReason = normalizeText(parsed.resume_acquisition_mode_reason || "checkpoint");
4026
+ } else if (this.resumeSourceStats.network > 0) {
4027
+ this.resumeAcquisitionMode = "network";
4028
+ this.resumeAcquisitionModeReason = "checkpoint_source_stats";
4029
+ } else if (this.resumeSourceStats.image_fallback > 0) {
4030
+ this.resumeAcquisitionMode = "image";
4031
+ this.resumeAcquisitionModeReason = "checkpoint_source_stats";
4032
+ }
3668
4033
 
3669
4034
  return true;
3670
4035
  }
@@ -4191,7 +4556,8 @@ class RecommendScreenCli {
4191
4556
  outPrefix,
4192
4557
  targetPattern: RECOMMEND_URL_FRAGMENT,
4193
4558
  waitResumeMs: RESUME_CAPTURE_WAIT_MS,
4194
- scrollSettleMs: 500
4559
+ scrollSettleMs: 500,
4560
+ stitchFullImage: false
4195
4561
  });
4196
4562
  } catch (error) {
4197
4563
  lastError = error;
@@ -4218,7 +4584,7 @@ class RecommendScreenCli {
4218
4584
  DEFAULT_VISION_MAX_IMAGE_PIXELS
4219
4585
  );
4220
4586
  const retryLimit = resolveVisionRetryPixelLimit(primaryLimit);
4221
- const preparedPrimary = await this.prepareVisionImageSegmentsForModel(imagePath, primaryLimit, "primary");
4587
+ const preparedPrimary = await this.prepareVisionInputsForModel(imagePath, primaryLimit, "primary");
4222
4588
  try {
4223
4589
  const primaryResult = await this.requestVisionModel(preparedPrimary.imagePaths);
4224
4590
  return this.applyVisionEvidenceGate(primaryResult);
@@ -4233,7 +4599,7 @@ class RecommendScreenCli {
4233
4599
  `segments=${preparedPrimary.imagePaths?.length || 1}`
4234
4600
  );
4235
4601
  }
4236
- const preparedRetry = await this.prepareVisionImageSegmentsForModel(imagePath, retryLimit, "retry");
4602
+ const preparedRetry = await this.prepareVisionInputsForModel(imagePath, retryLimit, "retry");
4237
4603
  try {
4238
4604
  const retryResult = await this.requestVisionModel(preparedRetry.imagePaths);
4239
4605
  return this.applyVisionEvidenceGate(retryResult);
@@ -4253,6 +4619,37 @@ class RecommendScreenCli {
4253
4619
  }
4254
4620
  }
4255
4621
 
4622
+ async prepareVisionInputsForModel(imageInput, maxPixels, attemptTag = "primary") {
4623
+ const sourcePaths = Array.isArray(imageInput) ? imageInput.filter(Boolean) : [imageInput].filter(Boolean);
4624
+ if (sourcePaths.length <= 0) {
4625
+ return {
4626
+ imagePaths: [],
4627
+ source: "empty",
4628
+ sourcePixels: null,
4629
+ currentPixels: null
4630
+ };
4631
+ }
4632
+ const preparedItems = [];
4633
+ for (let index = 0; index < sourcePaths.length; index += 1) {
4634
+ const prepared = await this.prepareVisionImageSegmentsForModel(
4635
+ sourcePaths[index],
4636
+ maxPixels,
4637
+ `${attemptTag}.input${String(index + 1).padStart(3, "0")}`
4638
+ );
4639
+ preparedItems.push(prepared);
4640
+ }
4641
+ return {
4642
+ imagePaths: preparedItems.flatMap((item) => item.imagePaths || []),
4643
+ source: sourcePaths.length > 1 ? "ordered_chunks" : (preparedItems[0]?.source || "single"),
4644
+ sourcePixels: preparedItems.reduce((acc, item) => (
4645
+ Number.isFinite(Number(item?.sourcePixels)) ? acc + Number(item.sourcePixels) : acc
4646
+ ), 0) || null,
4647
+ currentPixels: preparedItems.reduce((acc, item) => (
4648
+ Number.isFinite(Number(item?.currentPixels)) ? acc + Number(item.currentPixels) : acc
4649
+ ), 0) || null
4650
+ };
4651
+ }
4652
+
4256
4653
  applyVisionEvidenceGate(result) {
4257
4654
  const parsed = result && typeof result === "object" ? result : {};
4258
4655
  const rawPassed = parsed?.rawPassed === true || parsed?.passed === true;
@@ -4474,7 +4871,13 @@ class RecommendScreenCli {
4474
4871
  "请根据以下标准判断候选人是否通过筛选。\n\n" +
4475
4872
  `筛选标准:\n${this.args.criteria}\n\n` +
4476
4873
  "你将收到候选人完整简历的一个或多个顺序分段图片。必须完整阅读全部分段后再判断," +
4477
- "严禁编造任何不存在的经历、项目、学校、公司或时间线;证据不足时必须判定为不通过。\n\n" +
4874
+ "不能只根据前几段下结论;后续分段中的教育、项目、经历或否定信息必须纳入最终判断。" +
4875
+ "严禁编造任何不存在的经历、项目、学校、公司或时间线;证据不足时必须判定为不通过。\n" +
4876
+ "当筛选条件涉及应届/毕业年份时,必须以最高学历毕业年份作为主依据;若简历中出现教育起止时间、毕业时间或可推断年份信息,必须先推断再判断," +
4877
+ "只有完全不存在可推断时间信息时才可以写“无法判断”。\n" +
4878
+ "当筛选条件提及“相关经验”时,必须以工作经历或项目经历作为硬性证据;教育/课程/论文/技能/个人优势只能作为补充,不能单独判定满足。\n" +
4879
+ "workExpCheckRes 等经历校验项仅作为“需追问”软风险,不得直接据此判定不通过。\n" +
4880
+ "活跃度、沟通热度、受欢迎度等运营指标不参与筛选通过判定。\n\n" +
4478
4881
  "要求:\n" +
4479
4882
  "1) reason 必须写出可审计的判定依据,至少包含 2 条与筛选标准直接相关的事实。\n" +
4480
4883
  "2) 禁止只写“候选人同时满足全部筛选条件/不满足筛选条件”等泛化句。\n" +
@@ -4518,6 +4921,11 @@ class RecommendScreenCli {
4518
4921
  }
4519
4922
  ]
4520
4923
  };
4924
+ applyChatCompletionThinking(payload, {
4925
+ baseUrl,
4926
+ model: this.args.model,
4927
+ thinkingLevel: this.args.thinkingLevel
4928
+ });
4521
4929
  const headers = {
4522
4930
  "Content-Type": "application/json",
4523
4931
  Authorization: `Bearer ${this.args.apiKey}`
@@ -4657,14 +5065,24 @@ class RecommendScreenCli {
4657
5065
  "1) 必须完整阅读上面的全部简历文本。\n" +
4658
5066
  "2) 只能依据简历中真实出现的信息判断,严禁编造不存在的经历/项目/学历/公司。\n" +
4659
5067
  "3) 若证据不足,必须返回 passed=false。\n\n" +
4660
- "4) reason 必须写出可审计依据,至少包含 2 条与筛选标准直接相关的事实。\n" +
4661
- "5) 禁止只写“候选人同时满足全部筛选条件/不满足筛选条件”等泛化句。\n" +
4662
- "6) evidence 至少给出 2 条可在简历原文定位的证据短句。\n\n" +
5068
+ "4) 当筛选条件涉及应届/毕业年份时,必须以最高学历毕业年份作为主依据;若简历中存在教育时间、毕业时间或可推断年份信息,必须先推断再判断;" +
5069
+ "只有完全不存在时间线信息时才可写“无法判断”。\n" +
5070
+ "5) 当筛选条件提及“相关经验”时,必须以工作经历或项目经历作为硬性证据;教育/课程/论文/技能/个人优势只能作为补充,不能单独判定满足。\n" +
5071
+ "6) workExpCheckRes 等经历校验项仅作为“需追问”软风险,不得直接据此判定不通过。\n" +
5072
+ "7) 活跃度、沟通热度、受欢迎度等运营指标不参与筛选通过判定。\n" +
5073
+ "8) reason 必须写出可审计依据,至少包含 2 条与筛选标准直接相关的事实。\n" +
5074
+ "9) 禁止只写“候选人同时满足全部筛选条件/不满足筛选条件”等泛化句。\n" +
5075
+ "10) evidence 至少给出 2 条可在简历原文定位的证据短句。\n\n" +
4663
5076
  "请返回严格 JSON: " +
4664
5077
  "{\"passed\": true/false, \"reason\": \"...\", \"summary\": \"...\", \"evidence\": [\"证据原文1\", \"证据原文2\"]}"
4665
5078
  }
4666
5079
  ]
4667
5080
  };
5081
+ applyChatCompletionThinking(payload, {
5082
+ baseUrl,
5083
+ model: this.args.model,
5084
+ thinkingLevel: this.args.thinkingLevel
5085
+ });
4668
5086
  const headers = {
4669
5087
  "Content-Type": "application/json",
4670
5088
  Authorization: `Bearer ${this.args.apiKey}`
@@ -5044,6 +5462,7 @@ class RecommendScreenCli {
5044
5462
  const finalPassed = audit?.final_passed === true || normalizeText(audit?.outcome || "") === "passed";
5045
5463
  const screeningReason = normalizeText(audit?.screening_reason || passedItem?.reason || "");
5046
5464
  const passReason = finalPassed ? screeningReason : "";
5465
+ const timing = sanitizeTimingBreakdown(audit?.timing_ms);
5047
5466
  lines.push([
5048
5467
  csvEscape(audit?.candidate_name || passedItem?.name || ""),
5049
5468
  csvEscape(audit?.school || passedItem?.school || ""),
@@ -5062,7 +5481,21 @@ class RecommendScreenCli {
5062
5481
  csvEscape(audit?.evidence_gate_demoted === true ? "true" : "false"),
5063
5482
  csvEscape(audit?.error_code || ""),
5064
5483
  csvEscape(audit?.error_message || ""),
5065
- csvEscape(auditGeekId || passedItem?.geekId || "")
5484
+ csvEscape(auditGeekId || passedItem?.geekId || ""),
5485
+ csvEscape(getTimingMs(timing, "total_ms")),
5486
+ csvEscape(getTimingMs(timing, "card_profile_ms")),
5487
+ csvEscape(getTimingMs(timing, "click_candidate_ms")),
5488
+ csvEscape(getTimingMs(timing, "detail_open_ms")),
5489
+ csvEscape(getTimingMs(timing, "network_resume_wait_ms")),
5490
+ csvEscape(getTimingMs(timing, "text_model_ms")),
5491
+ csvEscape(getTimingMs(timing, "image_capture_ms")),
5492
+ csvEscape(getTimingMs(timing, "vision_model_ms")),
5493
+ csvEscape(getTimingMs(timing, "late_network_retry_ms")),
5494
+ csvEscape(getTimingMs(timing, "dom_fallback_ms")),
5495
+ csvEscape(getTimingMs(timing, "post_action_ms")),
5496
+ csvEscape(getTimingMs(timing, "close_detail_ms")),
5497
+ csvEscape(getTimingMs(timing, "rest_ms")),
5498
+ csvEscape(getTimingMs(timing, "checkpoint_save_ms"))
5066
5499
  ].join(","));
5067
5500
  }
5068
5501
  fs.mkdirSync(path.dirname(this.args.output), { recursive: true });
@@ -5218,6 +5651,29 @@ class RecommendScreenCli {
5218
5651
  this.scrollRetryCount = 0;
5219
5652
  this.processedCount += 1;
5220
5653
  log(`处理第 ${this.processedCount} 位候选人: ${nextCandidate.name || nextCandidate.geek_id}`);
5654
+ const candidateStartedAt = Date.now();
5655
+ const candidateTiming = {};
5656
+ const candidateKeyForTiming = nextCandidate.key || nextCandidate.geek_id || "";
5657
+ const addCandidateTiming = (key, startedAt) => {
5658
+ const elapsed = Math.max(0, Date.now() - startedAt);
5659
+ candidateTiming[key] = Math.round((Number(candidateTiming[key]) || 0) + elapsed);
5660
+ };
5661
+ const timeCandidateStage = async (key, fn) => {
5662
+ const startedAt = Date.now();
5663
+ try {
5664
+ return await fn();
5665
+ } finally {
5666
+ addCandidateTiming(key, startedAt);
5667
+ }
5668
+ };
5669
+ const timeCandidateStageSync = (key, fn) => {
5670
+ const startedAt = Date.now();
5671
+ try {
5672
+ return fn();
5673
+ } finally {
5674
+ addCandidateTiming(key, startedAt);
5675
+ }
5676
+ };
5221
5677
  let shouldMarkProcessed = true;
5222
5678
  let resumeSource = "";
5223
5679
  let resumeTextLength = null;
@@ -5235,7 +5691,10 @@ class RecommendScreenCli {
5235
5691
 
5236
5692
  try {
5237
5693
  this.currentCandidateKey = nextCandidate.key || nextCandidate.geek_id || null;
5238
- const cardProfile = await this.extractCandidateProfileFromCard(nextCandidate);
5694
+ const cardProfile = await timeCandidateStage(
5695
+ "card_profile_ms",
5696
+ () => this.extractCandidateProfileFromCard(nextCandidate)
5697
+ );
5239
5698
  candidateProfile = mergeCandidateProfiles(
5240
5699
  cardProfile || null,
5241
5700
  {
@@ -5247,38 +5706,26 @@ class RecommendScreenCli {
5247
5706
  }
5248
5707
  );
5249
5708
  const candidateCaptureStartedAt = Date.now();
5250
- await this.clickCandidate(nextCandidate);
5251
- const detailOpen = await this.ensureDetailOpen();
5709
+ await timeCandidateStage("click_candidate_ms", () => this.clickCandidate(nextCandidate));
5710
+ const detailOpen = await timeCandidateStage("detail_open_ms", () => this.ensureDetailOpen());
5252
5711
  if (!detailOpen) {
5253
5712
  throw this.buildError("DETAIL_OPEN_FAILED", "详情页打开超时");
5254
5713
  }
5255
5714
 
5256
5715
  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
- });
5716
+ let networkCandidateInfo = await timeCandidateStage(
5717
+ "network_resume_wait_ms",
5718
+ () => this.waitForResumeNetworkByMode(nextCandidate, {
5719
+ minTs: candidateCaptureStartedAt
5720
+ })
5721
+ );
5262
5722
  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
5723
 
5280
5724
  if (networkCandidateInfo?.resumeText) {
5281
- screening = await this.callTextModel(networkCandidateInfo.resumeText);
5725
+ screening = await timeCandidateStage(
5726
+ "text_model_ms",
5727
+ () => this.callTextModel(networkCandidateInfo.resumeText)
5728
+ );
5282
5729
  resumeSource = "network";
5283
5730
  resumeTextLength = normalizeText(networkCandidateInfo.resumeText).length;
5284
5731
  this.resumeSourceStats.network += 1;
@@ -5296,14 +5743,29 @@ class RecommendScreenCli {
5296
5743
  } else {
5297
5744
  try {
5298
5745
  resumeSource = "image_fallback";
5299
- capture = await this.captureResumeImage(nextCandidate);
5300
- screening = await this.callVisionModel(capture.stitchedImage);
5746
+ capture = await timeCandidateStage(
5747
+ "image_capture_ms",
5748
+ () => this.captureResumeImage(nextCandidate)
5749
+ );
5750
+ this.setResumeAcquisitionMode("image", "image_capture_success");
5751
+ screening = await timeCandidateStage(
5752
+ "vision_model_ms",
5753
+ () => this.callVisionModel(capture.modelImagePaths || capture.stitchedImage)
5754
+ );
5301
5755
  this.resumeSourceStats.image_fallback += 1;
5302
5756
  } 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);
5757
+ const lateNetworkCandidateInfo = await timeCandidateStage(
5758
+ "late_network_retry_ms",
5759
+ () => this.waitForLateNetworkResumeCandidateInfo(nextCandidate, {
5760
+ minTs: candidateCaptureStartedAt
5761
+ })
5762
+ );
5763
+ if (lateNetworkCandidateInfo?.resumeText) {
5764
+ networkCandidateInfo = lateNetworkCandidateInfo;
5765
+ screening = await timeCandidateStage(
5766
+ "text_model_ms",
5767
+ () => this.callTextModel(networkCandidateInfo.resumeText)
5768
+ );
5307
5769
  resumeSource = "network";
5308
5770
  resumeTextLength = normalizeText(networkCandidateInfo.resumeText).length;
5309
5771
  this.resumeSourceStats.network += 1;
@@ -5318,25 +5780,54 @@ class RecommendScreenCli {
5318
5780
  position: nextCandidate.last_position || ""
5319
5781
  }
5320
5782
  );
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
5783
  } else {
5339
- throw imageFallbackError;
5784
+ const domFallback = await timeCandidateStage(
5785
+ "dom_fallback_ms",
5786
+ () => this.resolveDomResumeFallback(nextCandidate, cardProfile || null)
5787
+ );
5788
+ if (domFallback?.networkCandidateInfo?.resumeText) {
5789
+ networkCandidateInfo = domFallback.networkCandidateInfo;
5790
+ screening = await timeCandidateStage(
5791
+ "text_model_ms",
5792
+ () => this.callTextModel(networkCandidateInfo.resumeText)
5793
+ );
5794
+ resumeSource = "network";
5795
+ resumeTextLength = normalizeText(networkCandidateInfo.resumeText).length;
5796
+ this.resumeSourceStats.network += 1;
5797
+ candidateProfile = mergeCandidateProfiles(
5798
+ networkCandidateInfo || null,
5799
+ cardProfile || null,
5800
+ {
5801
+ name: nextCandidate.name || "",
5802
+ school: nextCandidate.school || "",
5803
+ major: nextCandidate.major || "",
5804
+ company: nextCandidate.last_company || "",
5805
+ position: nextCandidate.last_position || ""
5806
+ }
5807
+ );
5808
+ } else if (domFallback?.domCandidateInfo?.resumeText) {
5809
+ domCandidateInfo = domFallback.domCandidateInfo;
5810
+ screening = await timeCandidateStage(
5811
+ "text_model_ms",
5812
+ () => this.callTextModel(domCandidateInfo.resumeText)
5813
+ );
5814
+ resumeSource = "dom_fallback";
5815
+ resumeTextLength = normalizeText(domCandidateInfo.resumeText).length;
5816
+ this.resumeSourceStats.dom_fallback += 1;
5817
+ candidateProfile = mergeCandidateProfiles(
5818
+ domCandidateInfo || null,
5819
+ cardProfile || null,
5820
+ {
5821
+ name: nextCandidate.name || "",
5822
+ school: nextCandidate.school || "",
5823
+ major: nextCandidate.major || "",
5824
+ company: nextCandidate.last_company || "",
5825
+ position: nextCandidate.last_position || ""
5826
+ }
5827
+ );
5828
+ } else {
5829
+ throw imageFallbackError;
5830
+ }
5340
5831
  }
5341
5832
  }
5342
5833
  }
@@ -5356,13 +5847,16 @@ class RecommendScreenCli {
5356
5847
  }
5357
5848
  let actionResult = { actionTaken: "none" };
5358
5849
  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" };
5850
+ actionResult = await timeCandidateStage(
5851
+ "post_action_ms",
5852
+ () => effectiveAction === "favorite"
5853
+ ? this.favoriteCandidate({
5854
+ alreadyInterested: networkCandidateInfo?.alreadyInterested === true
5855
+ })
5856
+ : effectiveAction === "greet"
5857
+ ? this.greetCandidate()
5858
+ : Promise.resolve({ actionTaken: "none" })
5859
+ );
5366
5860
  } catch (postActionError) {
5367
5861
  if (!isRecoverablePostActionError(postActionError, effectiveAction)) {
5368
5862
  throw postActionError;
@@ -5395,7 +5889,7 @@ class RecommendScreenCli {
5395
5889
  action: actionResult.actionTaken,
5396
5890
  geekId: nextCandidate.geek_id,
5397
5891
  summary: screening.summary,
5398
- imagePath: capture?.stitchedImage || "",
5892
+ imagePath: capture?.stitchedImage || capture?.modelImagePaths?.[0] || capture?.chunkFiles?.[0] || "",
5399
5893
  resumeSource
5400
5894
  });
5401
5895
  this.recordCandidateAudit({
@@ -5527,7 +6021,7 @@ class RecommendScreenCli {
5527
6021
  );
5528
6022
  }
5529
6023
  } finally {
5530
- const closed = await this.closeDetailPage();
6024
+ const closed = await timeCandidateStage("close_detail_ms", () => this.closeDetailPage());
5531
6025
  if (!closed) {
5532
6026
  if (allowDetailCloseFailure) {
5533
6027
  log("[详情关闭兜底] 本候选人 post_action 失败后详情页关闭未确认,已记录错误并继续下一位候选人。");
@@ -5540,12 +6034,31 @@ class RecommendScreenCli {
5540
6034
  }
5541
6035
  }
5542
6036
 
5543
- await this.takeBreakIfNeeded();
6037
+ await timeCandidateStage("rest_ms", () => this.takeBreakIfNeeded());
6038
+ candidateTiming.total_ms = Math.max(0, Date.now() - candidateStartedAt);
6039
+ this.updateCandidateAuditTiming(candidateKeyForTiming, candidateTiming);
5544
6040
  try {
5545
- this.saveCheckpoint();
6041
+ timeCandidateStageSync("checkpoint_save_ms", () => this.saveCheckpoint());
6042
+ candidateTiming.total_ms = Math.max(0, Date.now() - candidateStartedAt);
6043
+ this.updateCandidateAuditTiming(candidateKeyForTiming, candidateTiming);
5546
6044
  } catch (checkpointError) {
5547
6045
  log(`[保存checkpoint失败] ${checkpointError.message || checkpointError}`);
5548
6046
  }
6047
+ try {
6048
+ this.saveCsv();
6049
+ } catch (csvError) {
6050
+ log(`[增量保存CSV失败] ${csvError.message || csvError}`);
6051
+ }
6052
+ log(
6053
+ `[TIMING] candidate=${candidateKeyForTiming || nextCandidate.name || "unknown"} ` +
6054
+ `total_ms=${candidateTiming.total_ms ?? ""} ` +
6055
+ `network_ms=${candidateTiming.network_resume_wait_ms ?? 0} ` +
6056
+ `text_model_ms=${candidateTiming.text_model_ms ?? 0} ` +
6057
+ `image_capture_ms=${candidateTiming.image_capture_ms ?? 0} ` +
6058
+ `vision_model_ms=${candidateTiming.vision_model_ms ?? 0} ` +
6059
+ `post_action_ms=${candidateTiming.post_action_ms ?? 0} ` +
6060
+ `close_ms=${candidateTiming.close_detail_ms ?? 0}`
6061
+ );
5549
6062
  }
5550
6063
 
5551
6064
  if (this.args.targetCount && this.passedCandidates.length < this.args.targetCount) {
@@ -5606,7 +6119,7 @@ async function main() {
5606
6119
  console.log(JSON.stringify({
5607
6120
  status: "COMPLETED",
5608
6121
  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]"
6122
+ 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
6123
  }
5611
6124
  }));
5612
6125
  return;
@@ -5646,6 +6159,8 @@ if (require.main === module) {
5646
6159
  MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES,
5647
6160
  RESUME_CAPTURE_MAX_ATTEMPTS,
5648
6161
  RESUME_CAPTURE_WAIT_MS,
6162
+ NETWORK_RESUME_IMAGE_MODE_GRACE_MS,
6163
+ NETWORK_RESUME_LATE_RETRY_MS,
5649
6164
  parseFavoriteActionFromPostData,
5650
6165
  parseFavoriteActionFromRequest,
5651
6166
  parseFavoriteActionFromKnownRequest,