@reconcrap/boss-recommend-mcp 1.2.9 → 1.3.0

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.
Files changed (36) hide show
  1. package/README.md +82 -1
  2. package/package.json +2 -1
  3. package/skills/boss-chat/README.md +5 -0
  4. package/skills/boss-chat/SKILL.md +69 -0
  5. package/skills/boss-recommend-pipeline/SKILL.md +40 -4
  6. package/src/adapters.js +19 -5
  7. package/src/boss-chat.js +436 -0
  8. package/src/cli.js +294 -129
  9. package/src/index.js +459 -108
  10. package/src/parser.js +4 -5
  11. package/src/pipeline.js +605 -8
  12. package/src/run-state.js +5 -0
  13. package/src/test-adapters-runtime.js +69 -0
  14. package/src/test-boss-chat.js +399 -0
  15. package/src/test-index-async.js +238 -4
  16. package/src/test-parser.js +33 -6
  17. package/src/test-pipeline.js +408 -1
  18. package/vendor/boss-chat-cli/README.md +134 -0
  19. package/vendor/boss-chat-cli/package.json +53 -0
  20. package/vendor/boss-chat-cli/src/app.js +769 -0
  21. package/vendor/boss-chat-cli/src/browser/chat-page.js +2681 -0
  22. package/vendor/boss-chat-cli/src/cli.js +1350 -0
  23. package/vendor/boss-chat-cli/src/mcp/server.js +149 -0
  24. package/vendor/boss-chat-cli/src/mcp/tool-runtime.js +193 -0
  25. package/vendor/boss-chat-cli/src/runtime/async-run-state.js +260 -0
  26. package/vendor/boss-chat-cli/src/runtime/interaction.js +102 -0
  27. package/vendor/boss-chat-cli/src/runtime/run-control.js +102 -0
  28. package/vendor/boss-chat-cli/src/services/chrome-client.js +97 -0
  29. package/vendor/boss-chat-cli/src/services/llm.js +352 -0
  30. package/vendor/boss-chat-cli/src/services/profile-store.js +157 -0
  31. package/vendor/boss-chat-cli/src/services/report-store.js +19 -0
  32. package/vendor/boss-chat-cli/src/services/resume-capture.js +554 -0
  33. package/vendor/boss-chat-cli/src/services/state-store.js +217 -0
  34. package/vendor/boss-chat-cli/src/utils/customer-key.js +82 -0
  35. package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +902 -56
  36. package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +387 -1
@@ -8,7 +8,27 @@ const { captureFullResumeCanvas } = require("./scripts/capture-full-resume-canva
8
8
 
9
9
  const DEFAULT_PORT = 9222;
10
10
  const RECOMMEND_URL_FRAGMENT = "/web/chat/recommend";
11
- const CSV_HEADER = ["姓名", "最高学历学校", "最高学历专业", "最近工作公司", "最近工作职位", "评估通过详细原因"].join(",");
11
+ const CSV_HEADER = [
12
+ "姓名",
13
+ "最高学历学校",
14
+ "最高学历专业",
15
+ "最近工作公司",
16
+ "最近工作职位",
17
+ "评估通过详细原因",
18
+ "处理结果",
19
+ "筛选原因",
20
+ "动作执行结果",
21
+ "简历来源",
22
+ "原始判定通过",
23
+ "最终判定通过",
24
+ "证据总数",
25
+ "证据命中数",
26
+ "证据门控降级",
27
+ "错误码",
28
+ "错误信息",
29
+ "候选人ID"
30
+ ].join(",");
31
+ const INPUT_SUMMARY_HEADER = ["运行输入字段", "运行输入值"].join(",");
12
32
  const RESUME_CAPTURE_WAIT_MS = 60000;
13
33
  const RESUME_CAPTURE_MAX_ATTEMPTS = 4;
14
34
  const RESUME_CAPTURE_RETRY_DELAY_MS = 1200;
@@ -18,6 +38,7 @@ const DEFAULT_VISION_RETRY_MAX_IMAGE_PIXELS = 30000000;
18
38
  const DEFAULT_TEXT_MODEL_CHUNK_SIZE_CHARS = 24000;
19
39
  const DEFAULT_TEXT_MODEL_CHUNK_OVERLAP_CHARS = 1200;
20
40
  const DEFAULT_TEXT_MODEL_MAX_CHUNKS = 12;
41
+ const MAX_EVIDENCE_TOKENS = 12;
21
42
  let visionSharpFactory = null;
22
43
  const PAGE_SCOPE_TAB_STATUS = {
23
44
  recommend: "0",
@@ -139,6 +160,51 @@ const DETAIL_RESUME_IFRAME_SELECTORS = getHealingValue(
139
160
  ["selectors", "detail", "resume_iframe"],
140
161
  ['iframe[src*="/web/frame/c-resume/"]', 'iframe[name*="resume"]']
141
162
  );
163
+ const RESUME_DOM_ROOT_SELECTORS = getHealingValue(
164
+ HEALING_RULES,
165
+ ["selectors", "detail", "resume_dom_root"],
166
+ [
167
+ ".resume-center-side",
168
+ ".resume-detail-wrap",
169
+ ".resume-item-detail",
170
+ ".resume-section"
171
+ ]
172
+ );
173
+ const RESUME_DOM_BLOCK_SELECTORS = getHealingValue(
174
+ HEALING_RULES,
175
+ ["selectors", "detail", "resume_dom_blocks"],
176
+ [
177
+ ".resume-section .section-title",
178
+ ".resume-section .section-content",
179
+ ".resume-section .item-content",
180
+ ".resume-section .geek-desc",
181
+ ".resume-section .text-item",
182
+ ".resume-warning"
183
+ ]
184
+ );
185
+ const RESUME_DOM_PROFILE_SELECTORS = {
186
+ name: [
187
+ ".resume-section.geek-base-info-wrap .name",
188
+ ".geek-name .name",
189
+ ".name-wrap .name"
190
+ ],
191
+ school: [
192
+ ".geek-education-experience-wrap .school-name",
193
+ ".edu-wrap .school-name"
194
+ ],
195
+ major: [
196
+ ".geek-education-experience-wrap .major",
197
+ ".edu-wrap .major"
198
+ ],
199
+ company: [
200
+ ".geek-work-experience-wrap .company-name-wrap .name",
201
+ ".geek-work-experience-wrap .company-name"
202
+ ],
203
+ position: [
204
+ ".geek-work-experience-wrap .position span",
205
+ ".geek-work-experience-wrap .position"
206
+ ]
207
+ };
142
208
  const DETAIL_CLOSE_SELECTORS = getHealingValue(
143
209
  HEALING_RULES,
144
210
  ["selectors", "detail", "close_button"],
@@ -307,6 +373,41 @@ function parsePositiveInteger(raw) {
307
373
  return Number.isFinite(value) && value > 0 ? value : null;
308
374
  }
309
375
 
376
+ function parseInputSummary(raw) {
377
+ const text = String(raw || "").trim();
378
+ if (!text) return null;
379
+ try {
380
+ const parsed = JSON.parse(text);
381
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
382
+ } catch {
383
+ return null;
384
+ }
385
+ }
386
+
387
+ function isSensitiveInputSummaryKey(key) {
388
+ const normalized = String(key || "").toLowerCase().replace(/[^a-z0-9]/g, "");
389
+ return normalized === "baseurl" || normalized === "apikey" || normalized === "model";
390
+ }
391
+
392
+ function sanitizeInputSummary(value) {
393
+ if (value === null || value === undefined) return null;
394
+ if (Array.isArray(value)) {
395
+ return value.map((item) => sanitizeInputSummary(item));
396
+ }
397
+ if (typeof value === "object") {
398
+ const sanitized = {};
399
+ for (const [key, raw] of Object.entries(value)) {
400
+ if (isSensitiveInputSummaryKey(key)) continue;
401
+ const next = sanitizeInputSummary(raw);
402
+ if (next !== undefined) {
403
+ sanitized[key] = next;
404
+ }
405
+ }
406
+ return sanitized;
407
+ }
408
+ return value;
409
+ }
410
+
310
411
  function resolveVisionPixelLimitFromEnv(envName, fallback) {
311
412
  const parsed = parsePositiveInteger(process.env[envName]);
312
413
  return parsed || fallback;
@@ -356,6 +457,85 @@ function toStringArray(value, maxItems = 8) {
356
457
  return normalized;
357
458
  }
358
459
 
460
+ function toLowerSafe(text) {
461
+ return String(text || "").toLowerCase();
462
+ }
463
+
464
+ function extractEvidenceTokens(text, maxItems = MAX_EVIDENCE_TOKENS) {
465
+ const normalized = normalizeText(text);
466
+ if (!normalized) return [];
467
+ const matched = normalized.match(/[\u4e00-\u9fff]{2,}|[A-Za-z][A-Za-z0-9.+#_-]{2,}|\d{3,}/g) || [];
468
+ const seen = new Set();
469
+ const picked = [];
470
+ const sorted = matched
471
+ .map((item) => normalizeText(item))
472
+ .filter(Boolean)
473
+ .sort((a, b) => b.length - a.length);
474
+ for (const token of sorted) {
475
+ const key = toLowerSafe(token);
476
+ if (seen.has(key)) continue;
477
+ seen.add(key);
478
+ picked.push(token);
479
+ if (picked.length >= maxItems) break;
480
+ }
481
+ return picked;
482
+ }
483
+
484
+ function matchEvidenceAgainstResume(evidenceText, rawResumeText, normalizedResumeText, normalizedResumeLowerText) {
485
+ const normalizedEvidence = normalizeText(evidenceText);
486
+ if (!normalizedEvidence) {
487
+ return {
488
+ matched: false,
489
+ mode: "empty",
490
+ matchedTokens: []
491
+ };
492
+ }
493
+ if (rawResumeText.includes(evidenceText) || normalizedResumeText.includes(normalizedEvidence)) {
494
+ return {
495
+ matched: true,
496
+ mode: "exact",
497
+ matchedTokens: [normalizedEvidence]
498
+ };
499
+ }
500
+ const evidenceTokens = extractEvidenceTokens(normalizedEvidence, MAX_EVIDENCE_TOKENS);
501
+ if (evidenceTokens.length <= 0) {
502
+ return {
503
+ matched: false,
504
+ mode: "token_empty",
505
+ matchedTokens: []
506
+ };
507
+ }
508
+ const matchedTokens = [];
509
+ for (const token of evidenceTokens) {
510
+ if (normalizedResumeLowerText.includes(toLowerSafe(token))) {
511
+ matchedTokens.push(token);
512
+ }
513
+ }
514
+ const requiredHits = evidenceTokens.length >= 4 ? 2 : 1;
515
+ return {
516
+ matched: matchedTokens.length >= requiredHits,
517
+ mode: "token_fuzzy",
518
+ matchedTokens
519
+ };
520
+ }
521
+
522
+ function formatEducationDegree(edu) {
523
+ const degreeName = normalizeText(edu?.degreeName || edu?.degreeCategory || "");
524
+ if (degreeName) return degreeName;
525
+ if (typeof edu?.degree === "string") {
526
+ return normalizeText(edu.degree);
527
+ }
528
+ return "";
529
+ }
530
+
531
+ function formatEducationSchoolTags(edu) {
532
+ if (!Array.isArray(edu?.schoolTags) || edu.schoolTags.length <= 0) return "";
533
+ const tags = edu.schoolTags
534
+ .map((item) => normalizeText(item?.name || item?.tagName || item))
535
+ .filter(Boolean);
536
+ return tags.join("、");
537
+ }
538
+
359
539
  function splitTextByChunks(text, chunkSize, overlap, maxChunks) {
360
540
  const source = String(text || "");
361
541
  if (!source) return [];
@@ -642,7 +822,9 @@ function isRecoverablePostActionError(error, action) {
642
822
  const normalizedCode = normalizeText(error?.code).toUpperCase();
643
823
  if (!normalizedAction || !normalizedCode) return false;
644
824
  if (normalizedAction === "favorite" && normalizedCode === "FAVORITE_BUTTON_FAILED") return true;
645
- if (normalizedAction === "greet" && normalizedCode === "GREET_BUTTON_FAILED") return true;
825
+ if (normalizedAction === "greet" && ["GREET_BUTTON_FAILED", "GREET_BUTTON_NOT_FOUND", "GREET_CONTINUE_BUTTON_FOUND"].includes(normalizedCode)) {
826
+ return true;
827
+ }
646
828
  return false;
647
829
  }
648
830
 
@@ -695,6 +877,7 @@ function parseArgs(argv) {
695
877
  calibrationPath: getDefaultCalibrationPath(),
696
878
  port: DEFAULT_PORT,
697
879
  output: path.resolve(process.cwd(), `筛选结果_${Date.now()}.csv`),
880
+ inputSummary: null,
698
881
  checkpointPath: null,
699
882
  pauseControlPath: null,
700
883
  resume: false,
@@ -766,6 +949,9 @@ function parseArgs(argv) {
766
949
  } else if (token === "--output" && (inlineValue || next)) {
767
950
  parsed.output = path.resolve(inlineValue || next);
768
951
  if (!inlineValue) index += 1;
952
+ } else if ((token === "--input-summary-json" || token === "--inputSummaryJson") && (inlineValue || next)) {
953
+ parsed.inputSummary = parseInputSummary(inlineValue || next);
954
+ if (!inlineValue) index += 1;
769
955
  } else if (token === "--checkpoint-path" && (inlineValue || next)) {
770
956
  parsed.checkpointPath = path.resolve(inlineValue || next);
771
957
  if (!inlineValue) index += 1;
@@ -922,6 +1108,56 @@ function csvEscape(value) {
922
1108
  return `"${String(value || "").replace(/"/g, '""')}"`;
923
1109
  }
924
1110
 
1111
+ function stringifyInputSummaryValue(value) {
1112
+ if (value === null) return "null";
1113
+ if (value === undefined) return "";
1114
+ if (typeof value === "string") return value;
1115
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
1116
+ try {
1117
+ return JSON.stringify(value);
1118
+ } catch {
1119
+ return String(value);
1120
+ }
1121
+ }
1122
+
1123
+ function appendInputSummaryRows(rows, value, prefix = "") {
1124
+ if (value === null || value === undefined) {
1125
+ if (prefix) rows.push([prefix, stringifyInputSummaryValue(value)]);
1126
+ return;
1127
+ }
1128
+ if (Array.isArray(value)) {
1129
+ rows.push([prefix, stringifyInputSummaryValue(value)]);
1130
+ return;
1131
+ }
1132
+ if (typeof value !== "object") {
1133
+ rows.push([prefix, stringifyInputSummaryValue(value)]);
1134
+ return;
1135
+ }
1136
+ const entries = Object.entries(value);
1137
+ if (entries.length === 0) {
1138
+ if (prefix) rows.push([prefix, "{}"]);
1139
+ return;
1140
+ }
1141
+ for (const [key, item] of entries) {
1142
+ const nextPrefix = prefix ? `${prefix}.${key}` : key;
1143
+ if (!nextPrefix) continue;
1144
+ if (item && typeof item === "object" && !Array.isArray(item)) {
1145
+ appendInputSummaryRows(rows, item, nextPrefix);
1146
+ } else {
1147
+ rows.push([nextPrefix, stringifyInputSummaryValue(item)]);
1148
+ }
1149
+ }
1150
+ }
1151
+
1152
+ function buildInputSummaryRows(inputSummary) {
1153
+ if (!inputSummary || typeof inputSummary !== "object" || Array.isArray(inputSummary)) {
1154
+ return [];
1155
+ }
1156
+ const rows = [];
1157
+ appendInputSummaryRows(rows, inputSummary);
1158
+ return rows;
1159
+ }
1160
+
925
1161
  function stripHtml(value) {
926
1162
  return String(value || "")
927
1163
  .replace(/<[^>]+>/g, "")
@@ -1096,7 +1332,7 @@ function isResumeRelatedWapiUrl(url) {
1096
1332
 
1097
1333
  function formatResumeApiData(data) {
1098
1334
  const parts = [];
1099
- const geekDetail = data?.geekDetail || data || {};
1335
+ const geekDetail = data?.geekDetail || data?.geekDetailInfo || data || {};
1100
1336
  const baseInfo = geekDetail.geekBaseInfo || {};
1101
1337
  const expectList = geekDetail.geekExpectList || [];
1102
1338
  const workExpList = geekDetail.geekWorkExpList || [];
@@ -1138,8 +1374,9 @@ function formatResumeApiData(data) {
1138
1374
  if (exp.startYearMonStr) {
1139
1375
  parts.push(` 时间: ${exp.startYearMonStr} ~ ${exp.endYearMonStr || "至今"}`);
1140
1376
  }
1141
- if (exp.responsibility) {
1142
- parts.push(` 职责: ${stripHtml(exp.responsibility)}`);
1377
+ const workContent = exp.responsibility || exp.workContent || "";
1378
+ if (workContent) {
1379
+ parts.push(` 职责: ${stripHtml(workContent)}`);
1143
1380
  }
1144
1381
  });
1145
1382
  }
@@ -1147,13 +1384,15 @@ function formatResumeApiData(data) {
1147
1384
  if (projExpList.length > 0) {
1148
1385
  parts.push("\n=== 项目经历 ===");
1149
1386
  projExpList.forEach((proj, index) => {
1150
- parts.push(`${index + 1}. ${proj.name || "未知项目"}`);
1387
+ parts.push(`${index + 1}. ${proj.name || proj.projectName || "未知项目"}`);
1151
1388
  if (proj.roleName) parts.push(` 角色: ${proj.roleName}`);
1152
1389
  if (proj.startYearMonStr) {
1153
1390
  parts.push(` 时间: ${proj.startYearMonStr} ~ ${proj.endYearMonStr || "至今"}`);
1154
1391
  }
1155
- if (proj.description) parts.push(` 描述: ${stripHtml(proj.description)}`);
1156
- if (proj.performance) parts.push(` 成果: ${stripHtml(proj.performance)}`);
1392
+ const projectDescription = proj.description || proj.projectDescription || "";
1393
+ if (projectDescription) parts.push(` 描述: ${stripHtml(projectDescription)}`);
1394
+ const projectPerformance = proj.performance || proj.projectPerformance || "";
1395
+ if (projectPerformance) parts.push(` 成果: ${stripHtml(projectPerformance)}`);
1157
1396
  });
1158
1397
  }
1159
1398
 
@@ -1162,13 +1401,17 @@ function formatResumeApiData(data) {
1162
1401
  eduExpList.forEach((edu, index) => {
1163
1402
  parts.push(`${index + 1}. ${edu.school || edu.schoolName || "未知学校"}`);
1164
1403
  if (edu.major || edu.majorName) parts.push(` 专业: ${edu.major || edu.majorName}`);
1165
- const eduDegree = edu.degree || edu.degreeCategory || edu.degreeName;
1404
+ const eduDegree = formatEducationDegree(edu);
1166
1405
  if (eduDegree) parts.push(` 学历: ${eduDegree}`);
1167
1406
  const eduStart = edu.startYearMonStr || edu.startYearStr;
1168
1407
  if (eduStart) {
1169
1408
  const eduEnd = edu.endYearMonStr || edu.endYearStr || "";
1170
1409
  parts.push(` 时间: ${eduStart} ~ ${eduEnd}`);
1171
1410
  }
1411
+ const schoolTags = formatEducationSchoolTags(edu);
1412
+ if (schoolTags) {
1413
+ parts.push(` 学校标签: ${schoolTags}`);
1414
+ }
1172
1415
  });
1173
1416
  }
1174
1417
 
@@ -1599,6 +1842,182 @@ const jsWaitForDetail = `(() => {
1599
1842
  return { open, scope: 'frame' };
1600
1843
  })()`;
1601
1844
 
1845
+ const jsExtractResumeTextFromDom = `(() => {
1846
+ const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
1847
+ const rootSelectors = ${JSON.stringify(RESUME_DOM_ROOT_SELECTORS)};
1848
+ const blockSelectors = ${JSON.stringify(RESUME_DOM_BLOCK_SELECTORS)};
1849
+ const profileSelectors = ${JSON.stringify(RESUME_DOM_PROFILE_SELECTORS)};
1850
+
1851
+ const isVisible = (doc, el) => {
1852
+ if (!el) return false;
1853
+ const win = (doc && doc.defaultView) || window;
1854
+ const style = win.getComputedStyle(el);
1855
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.02) {
1856
+ return false;
1857
+ }
1858
+ const rect = el.getBoundingClientRect();
1859
+ return rect.width > 2 && rect.height > 2;
1860
+ };
1861
+
1862
+ const pickFirstText = (doc, scopeRoot, selectors) => {
1863
+ const scopeNode = scopeRoot && typeof scopeRoot.querySelectorAll === 'function' ? scopeRoot : doc;
1864
+ for (const selector of selectors || []) {
1865
+ let nodes = [];
1866
+ try {
1867
+ nodes = Array.from(scopeNode.querySelectorAll(selector)).slice(0, 12);
1868
+ } catch {
1869
+ nodes = [];
1870
+ }
1871
+ for (const node of nodes) {
1872
+ if (!isVisible(doc, node)) continue;
1873
+ const text = normalize(node.textContent || '');
1874
+ if (text) return text;
1875
+ }
1876
+ }
1877
+ return '';
1878
+ };
1879
+
1880
+ const extractProfileFromRoot = (doc, root) => ({
1881
+ name: pickFirstText(doc, root, profileSelectors.name),
1882
+ school: pickFirstText(doc, root, profileSelectors.school),
1883
+ major: pickFirstText(doc, root, profileSelectors.major),
1884
+ company: pickFirstText(doc, root, profileSelectors.company),
1885
+ position: pickFirstText(doc, root, profileSelectors.position)
1886
+ });
1887
+
1888
+ const extractRootText = (doc, root) => {
1889
+ const sectionSelector = '.resume-section';
1890
+ const titleSelector = '.section-title';
1891
+ const contentSelector = '.section-content';
1892
+ const dedup = new Set();
1893
+ const lines = [];
1894
+ const pushLine = (raw) => {
1895
+ const text = normalize(raw);
1896
+ if (!text) return;
1897
+ const key = text.toLowerCase();
1898
+ if (dedup.has(key)) return;
1899
+ dedup.add(key);
1900
+ lines.push(text);
1901
+ };
1902
+
1903
+ let sections = [];
1904
+ try {
1905
+ sections = Array.from(root.querySelectorAll(sectionSelector)).slice(0, 120);
1906
+ } catch {
1907
+ sections = [];
1908
+ }
1909
+ if (sections.length > 0) {
1910
+ for (const section of sections) {
1911
+ if (!isVisible(doc, section)) continue;
1912
+ const title = normalize((section.querySelector(titleSelector)?.textContent) || '');
1913
+ const contentNode = section.querySelector(contentSelector);
1914
+ const content = normalize((contentNode && contentNode.textContent) || section.textContent || '');
1915
+ if (title && content) {
1916
+ pushLine('[' + title + '] ' + content);
1917
+ } else if (content) {
1918
+ pushLine(content);
1919
+ } else if (title) {
1920
+ pushLine('[' + title + ']');
1921
+ }
1922
+ }
1923
+ }
1924
+
1925
+ if (lines.length === 0) {
1926
+ let blocks = [];
1927
+ try {
1928
+ blocks = Array.from(root.querySelectorAll(blockSelectors.join(','))).slice(0, 260);
1929
+ } catch {
1930
+ blocks = [];
1931
+ }
1932
+ if (blocks.length > 0) {
1933
+ for (const node of blocks) {
1934
+ if (!isVisible(doc, node)) continue;
1935
+ pushLine(node.textContent || '');
1936
+ }
1937
+ }
1938
+ }
1939
+
1940
+ if (lines.length === 0) {
1941
+ pushLine(root.textContent || '');
1942
+ }
1943
+ return normalize(lines.join('\\n'));
1944
+ };
1945
+
1946
+ const collectFromDocument = (doc, scope) => {
1947
+ if (!doc) return [];
1948
+ const rows = [];
1949
+ const seen = new Set();
1950
+
1951
+ const pushCandidate = (root, selectorLabel) => {
1952
+ if (!root || seen.has(root)) return;
1953
+ seen.add(root);
1954
+ if (!isVisible(doc, root)) return;
1955
+ const text = extractRootText(doc, root);
1956
+ if (text.length < 120) return;
1957
+ const profile = extractProfileFromRoot(doc, root);
1958
+ rows.push({
1959
+ scope,
1960
+ selector: selectorLabel,
1961
+ text,
1962
+ text_length: text.length,
1963
+ name: profile.name || '',
1964
+ school: profile.school || '',
1965
+ major: profile.major || '',
1966
+ company: profile.company || '',
1967
+ position: profile.position || ''
1968
+ });
1969
+ };
1970
+
1971
+ for (const selector of rootSelectors) {
1972
+ let nodes = [];
1973
+ try {
1974
+ nodes = Array.from(doc.querySelectorAll(selector)).slice(0, 20);
1975
+ } catch {
1976
+ nodes = [];
1977
+ }
1978
+ for (const node of nodes) {
1979
+ pushCandidate(node, selector);
1980
+ }
1981
+ }
1982
+
1983
+ if (rows.length === 0) {
1984
+ const fallbackRoot = doc.querySelector('.resume-center-side')
1985
+ || doc.querySelector('.resume-detail-wrap')
1986
+ || doc.querySelector('.resume-section');
1987
+ if (fallbackRoot) {
1988
+ pushCandidate(fallbackRoot, 'fallback_any_resume_root');
1989
+ }
1990
+ }
1991
+ return rows;
1992
+ };
1993
+
1994
+ const topRows = collectFromDocument(document, 'top');
1995
+ let frameRows = [];
1996
+ try {
1997
+ const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
1998
+ if (frame && frame.contentDocument) {
1999
+ frameRows = collectFromDocument(frame.contentDocument, 'frame');
2000
+ }
2001
+ } catch {}
2002
+
2003
+ const candidates = [...topRows, ...frameRows]
2004
+ .filter((item) => normalize(item?.text || '').length > 0)
2005
+ .sort((a, b) => Number(b?.text_length || 0) - Number(a?.text_length || 0));
2006
+ const best = candidates[0] || null;
2007
+ if (!best) {
2008
+ return {
2009
+ ok: false,
2010
+ reason: 'resume_dom_not_found',
2011
+ candidate_count: 0
2012
+ };
2013
+ }
2014
+ return {
2015
+ ok: true,
2016
+ ...best,
2017
+ candidate_count: candidates.length
2018
+ };
2019
+ })()`;
2020
+
1602
2021
  const jsCloseDetail = `(() => {
1603
2022
  const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim();
1604
2023
  const pickVisibleKnowButton = (rootDoc) => {
@@ -1882,7 +2301,18 @@ const jsGetGreetStateRecommend = `(() => {
1882
2301
  const resolveGreet = (doc, offsetX, offsetY, scope) => {
1883
2302
  if (!doc) return null;
1884
2303
  const candidates = ${buildSelectorCollectionExpression(GREET_BUTTON_RECOMMEND_SELECTORS, "doc")};
1885
- const button = candidates.find((item) => isVisible(doc, item) && /沟通|打招呼|聊一聊/.test(normalize(item.textContent))) || null;
2304
+ const visibleButtons = candidates.filter((item) => isVisible(doc, item));
2305
+ const normalizeLabel = (item) => normalize(item?.textContent || '');
2306
+ const isContinue = (item) => /继续沟通/.test(normalizeLabel(item));
2307
+ const isGreetEntry = (item) => (
2308
+ /打招呼|聊一聊|立即沟通/.test(normalizeLabel(item))
2309
+ || (/沟通/.test(normalizeLabel(item)) && !isContinue(item))
2310
+ );
2311
+ const button = visibleButtons.find((item) => isGreetEntry(item)) || null;
2312
+ const continueButton = visibleButtons.find((item) => isContinue(item)) || null;
2313
+ if (!button && continueButton) {
2314
+ return { ok: false, error: 'GREET_CONTINUE_BUTTON_FOUND', scope };
2315
+ }
1886
2316
  if (!button) return null;
1887
2317
  const rect = button.getBoundingClientRect();
1888
2318
  return {
@@ -1908,7 +2338,12 @@ const jsGetGreetStateRecommend = `(() => {
1908
2338
 
1909
2339
  const jsClickGreetFallbackRecommend = `(() => {
1910
2340
  const topButton = Array.from(document.querySelectorAll('.resume-footer.item-operate button, .resume-footer-wrap button, button.btn-v2.btn-sure-v2'))
1911
- .find((item) => item && item.offsetParent !== null && /沟通|打招呼|聊一聊/.test(String(item.textContent || '').replace(/\\s+/g, ' ')));
2341
+ .find((item) => {
2342
+ if (!item || item.offsetParent === null) return false;
2343
+ const text = String(item.textContent || '').replace(/\\s+/g, ' ').trim();
2344
+ if (/继续沟通/.test(text)) return false;
2345
+ return /打招呼|聊一聊|立即沟通/.test(text) || /沟通/.test(text);
2346
+ });
1912
2347
  if (topButton) {
1913
2348
  topButton.click();
1914
2349
  return { ok: true, scope: 'top' };
@@ -1916,7 +2351,13 @@ const jsClickGreetFallbackRecommend = `(() => {
1916
2351
  const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
1917
2352
  if (!frame || !frame.contentDocument) return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1918
2353
  const doc = frame.contentDocument;
1919
- const button = ${buildFirstSelectorLookupExpression(GREET_BUTTON_RECOMMEND_SELECTORS, "doc")};
2354
+ const button = ${buildSelectorCollectionExpression(GREET_BUTTON_RECOMMEND_SELECTORS, "doc")}
2355
+ .find((item) => {
2356
+ if (!item || item.offsetParent === null) return false;
2357
+ const text = String(item.textContent || '').replace(/\\s+/g, ' ').trim();
2358
+ if (/继续沟通/.test(text)) return false;
2359
+ return /打招呼|聊一聊|立即沟通/.test(text) || /沟通/.test(text);
2360
+ }) || null;
1920
2361
  if (!button || button.offsetParent === null) return { ok: false, error: 'GREET_BUTTON_NOT_FOUND' };
1921
2362
  button.click();
1922
2363
  return { ok: true };
@@ -1937,7 +2378,18 @@ const jsGetGreetStateFeatured = `(() => {
1937
2378
  const resolveGreet = (doc, offsetX, offsetY, scope) => {
1938
2379
  if (!doc) return null;
1939
2380
  const candidates = ${buildSelectorCollectionExpression(GREET_BUTTON_FEATURED_SELECTORS, "doc")};
1940
- const button = candidates.find((item) => isVisible(doc, item) && /立即沟通|沟通|打招呼|聊一聊/.test(normalize(item.textContent))) || null;
2381
+ const visibleButtons = candidates.filter((item) => isVisible(doc, item));
2382
+ const normalizeLabel = (item) => normalize(item?.textContent || '');
2383
+ const isContinue = (item) => /继续沟通/.test(normalizeLabel(item));
2384
+ const isGreetEntry = (item) => (
2385
+ /打招呼|聊一聊|立即沟通/.test(normalizeLabel(item))
2386
+ || (/沟通/.test(normalizeLabel(item)) && !isContinue(item))
2387
+ );
2388
+ const button = visibleButtons.find((item) => isGreetEntry(item)) || null;
2389
+ const continueButton = visibleButtons.find((item) => isContinue(item)) || null;
2390
+ if (!button && continueButton) {
2391
+ return { ok: false, error: 'GREET_CONTINUE_BUTTON_FOUND', scope };
2392
+ }
1941
2393
  if (!button) return null;
1942
2394
  const rect = button.getBoundingClientRect();
1943
2395
  return {
@@ -1963,7 +2415,12 @@ const jsGetGreetStateFeatured = `(() => {
1963
2415
 
1964
2416
  const jsClickGreetFallbackFeatured = `(() => {
1965
2417
  const topButton = Array.from(document.querySelectorAll('button.btn-v2.position-rights.btn-sure-v2, button.btn-v2.btn-sure-v2.position-rights, .resume-footer.item-operate button, .resume-footer-wrap button'))
1966
- .find((item) => item && item.offsetParent !== null && /立即沟通|沟通|打招呼|聊一聊/.test(String(item.textContent || '').replace(/\\s+/g, ' ')));
2418
+ .find((item) => {
2419
+ if (!item || item.offsetParent === null) return false;
2420
+ const text = String(item.textContent || '').replace(/\\s+/g, ' ').trim();
2421
+ if (/继续沟通/.test(text)) return false;
2422
+ return /打招呼|聊一聊|立即沟通/.test(text) || /沟通/.test(text);
2423
+ });
1967
2424
  if (topButton) {
1968
2425
  topButton.click();
1969
2426
  return { ok: true, scope: 'top' };
@@ -1971,7 +2428,13 @@ const jsClickGreetFallbackFeatured = `(() => {
1971
2428
  const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
1972
2429
  if (!frame || !frame.contentDocument) return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1973
2430
  const doc = frame.contentDocument;
1974
- const button = ${buildFirstSelectorLookupExpression(GREET_BUTTON_FEATURED_SELECTORS, "doc")};
2431
+ const button = ${buildSelectorCollectionExpression(GREET_BUTTON_FEATURED_SELECTORS, "doc")}
2432
+ .find((item) => {
2433
+ if (!item || item.offsetParent === null) return false;
2434
+ const text = String(item.textContent || '').replace(/\\s+/g, ' ').trim();
2435
+ if (/继续沟通/.test(text)) return false;
2436
+ return /打招呼|聊一聊|立即沟通/.test(text) || /沟通/.test(text);
2437
+ }) || null;
1975
2438
  if (!button || button.offsetParent === null) return { ok: false, error: 'GREET_BUTTON_NOT_FOUND' };
1976
2439
  button.click();
1977
2440
  return { ok: true };
@@ -2261,8 +2724,10 @@ class RecommendScreenCli {
2261
2724
  this.favoriteClickPendingSince = 0;
2262
2725
  this.favoriteNetworkTraces = [];
2263
2726
  this.webSocketByRequestId = new Map();
2727
+ this.candidateAudits = [];
2264
2728
  this.resumeSourceStats = {
2265
2729
  network: 0,
2730
+ dom_fallback: 0,
2266
2731
  image_fallback: 0
2267
2732
  };
2268
2733
  this.lastActiveTabStatus = PAGE_SCOPE_TAB_STATUS[this.args.pageScope] || null;
@@ -2273,6 +2738,7 @@ class RecommendScreenCli {
2273
2738
  this.restThreshold = 25 + Math.floor(Math.random() * 8);
2274
2739
  this.checkpointPath = this.args.checkpointPath ? path.resolve(this.args.checkpointPath) : null;
2275
2740
  this.pauseControlPath = this.args.pauseControlPath ? path.resolve(this.args.pauseControlPath) : null;
2741
+ this.inputSummary = sanitizeInputSummary(this.args.inputSummary);
2276
2742
  this.debugDir = path.join(os.tmpdir(), "boss-recommend-screen", String(Date.now()));
2277
2743
  fs.mkdirSync(this.debugDir, { recursive: true });
2278
2744
  }
@@ -2323,7 +2789,32 @@ class RecommendScreenCli {
2323
2789
  summary: item?.summary || "",
2324
2790
  imagePath: item?.imagePath || "",
2325
2791
  resumeSource: item?.resumeSource || ""
2326
- }))
2792
+ })),
2793
+ candidate_audits: this.candidateAudits.map((item) => ({
2794
+ ts: item?.ts || null,
2795
+ candidate_key: item?.candidate_key || "",
2796
+ geek_id: item?.geek_id || "",
2797
+ candidate_name: item?.candidate_name || "",
2798
+ school: item?.school || "",
2799
+ major: item?.major || "",
2800
+ company: item?.company || "",
2801
+ position: item?.position || "",
2802
+ outcome: item?.outcome || "",
2803
+ resume_source: item?.resume_source || "",
2804
+ resume_text_len: Number.isFinite(Number(item?.resume_text_len)) ? Number(item.resume_text_len) : null,
2805
+ raw_passed: item?.raw_passed === true,
2806
+ final_passed: item?.final_passed === true,
2807
+ evidence_raw_count: Number.isFinite(Number(item?.evidence_raw_count)) ? Number(item.evidence_raw_count) : null,
2808
+ evidence_matched_count: Number.isFinite(Number(item?.evidence_matched_count)) ? Number(item.evidence_matched_count) : null,
2809
+ evidence_gate_demoted: item?.evidence_gate_demoted === true,
2810
+ screening_reason: item?.screening_reason || "",
2811
+ action_taken: item?.action_taken || "",
2812
+ error_code: item?.error_code || "",
2813
+ error_message: item?.error_message || "",
2814
+ chunk_index: Number.isFinite(Number(item?.chunk_index)) ? Number(item.chunk_index) : null,
2815
+ chunk_total: Number.isFinite(Number(item?.chunk_total)) ? Number(item.chunk_total) : null
2816
+ })),
2817
+ input_summary: sanitizeInputSummary(this.inputSummary)
2327
2818
  };
2328
2819
  }
2329
2820
 
@@ -2339,6 +2830,8 @@ class RecommendScreenCli {
2339
2830
  active_tab_status: this.lastActiveTabStatus || PAGE_SCOPE_TAB_STATUS[this.args.pageScope] || null,
2340
2831
  resume_source: this.resumeSourceStats.image_fallback > 0
2341
2832
  ? "image_fallback"
2833
+ : this.resumeSourceStats.dom_fallback > 0
2834
+ ? "dom_fallback"
2342
2835
  : this.resumeSourceStats.network > 0
2343
2836
  ? "network"
2344
2837
  : defaultResumeSource,
@@ -2443,10 +2936,51 @@ class RecommendScreenCli {
2443
2936
  if (item.kind === "wait_timeout") {
2444
2937
  return `${prefix} candidate=${item.candidate_key || "-"} waited_ms=${item.waited_ms ?? "?"} reason=${item.reason || "timeout"}`;
2445
2938
  }
2939
+ if (item.kind === "dom_fallback_hit") {
2940
+ return `${prefix} candidate=${item.candidate_key || "-"} scope=${item.source || "-"} selector=${item.reason || "-"} resume_len=${item.resume_text_len ?? "?"}`;
2941
+ }
2942
+ if (item.kind === "dom_fallback_miss") {
2943
+ return `${prefix} candidate=${item.candidate_key || "-"} reason=${item.reason || "dom_not_found"}`;
2944
+ }
2945
+ if (item.kind === "dom_fallback_error") {
2946
+ return `${prefix} candidate=${item.candidate_key || "-"} error=${item.error || "unknown"}`;
2947
+ }
2446
2948
  return `${prefix} ${item.url || item.reason || "n/a"}`;
2447
2949
  });
2448
2950
  }
2449
2951
 
2952
+ recordCandidateAudit(entry = {}) {
2953
+ const normalized = {
2954
+ ts: new Date().toISOString(),
2955
+ candidate_key: normalizeText(entry?.candidate_key || entry?.geek_id || "") || "",
2956
+ geek_id: normalizeText(entry?.geek_id || entry?.candidate_key || "") || "",
2957
+ candidate_name: normalizeText(entry?.candidate_name || "") || "",
2958
+ school: normalizeText(entry?.school || "") || "",
2959
+ major: normalizeText(entry?.major || "") || "",
2960
+ company: normalizeText(entry?.company || "") || "",
2961
+ position: normalizeText(entry?.position || "") || "",
2962
+ outcome: normalizeText(entry?.outcome || "unknown") || "unknown",
2963
+ resume_source: normalizeText(entry?.resume_source || "") || "",
2964
+ resume_text_len: Number.isFinite(Number(entry?.resume_text_len)) ? Number(entry.resume_text_len) : null,
2965
+ raw_passed: entry?.raw_passed === true,
2966
+ final_passed: entry?.final_passed === true,
2967
+ evidence_raw_count: Number.isFinite(Number(entry?.evidence_raw_count)) ? Number(entry.evidence_raw_count) : null,
2968
+ evidence_matched_count: Number.isFinite(Number(entry?.evidence_matched_count)) ? Number(entry.evidence_matched_count) : null,
2969
+ evidence_gate_demoted: entry?.evidence_gate_demoted === true,
2970
+ screening_reason: normalizeText(entry?.screening_reason || "") || "",
2971
+ action_taken: normalizeText(entry?.action_taken || "") || "",
2972
+ error_code: normalizeText(entry?.error_code || "") || "",
2973
+ error_message: normalizeText(entry?.error_message || "") || "",
2974
+ chunk_index: Number.isFinite(Number(entry?.chunk_index)) ? Number(entry.chunk_index) : null,
2975
+ chunk_total: Number.isFinite(Number(entry?.chunk_total)) ? Number(entry.chunk_total) : null
2976
+ };
2977
+ this.candidateAudits.push(normalized);
2978
+ const maxItems = parsePositiveInteger(process.env.BOSS_RECOMMEND_MAX_CANDIDATE_AUDITS);
2979
+ if (maxItems && this.candidateAudits.length > maxItems) {
2980
+ this.candidateAudits = this.candidateAudits.slice(-maxItems);
2981
+ }
2982
+ }
2983
+
2450
2984
  logResumeNetworkMissDiagnostics(candidate, options = {}) {
2451
2985
  const candidateKey = normalizeText(candidate?.key || candidate?.geek_id || "");
2452
2986
  const candidateName = normalizeText(candidate?.name || "");
@@ -2507,7 +3041,9 @@ class RecommendScreenCli {
2507
3041
  };
2508
3042
  this.latestResumeNetworkPayload = wrapped;
2509
3043
  for (const id of geekIds) {
2510
- this.resumeNetworkByGeekId.set(id, wrapped);
3044
+ const normalizedId = normalizeText(id);
3045
+ if (!normalizedId) continue;
3046
+ this.resumeNetworkByGeekId.set(normalizedId, wrapped);
2511
3047
  }
2512
3048
  }
2513
3049
 
@@ -2518,7 +3054,13 @@ class RecommendScreenCli {
2518
3054
  }
2519
3055
  if (this.latestResumeNetworkPayload) {
2520
3056
  const ageMs = Date.now() - Number(this.latestResumeNetworkPayload.ts || 0);
2521
- if (ageMs <= 12000) {
3057
+ const latestGeekIds = Array.isArray(this.latestResumeNetworkPayload.geekIds)
3058
+ ? this.latestResumeNetworkPayload.geekIds.map((id) => normalizeText(id)).filter(Boolean)
3059
+ : [];
3060
+ if (!candidateKey && ageMs <= 12000) {
3061
+ return this.latestResumeNetworkPayload.candidateInfo || null;
3062
+ }
3063
+ if (candidateKey && ageMs <= 12000 && latestGeekIds.includes(candidateKey)) {
2522
3064
  return this.latestResumeNetworkPayload.candidateInfo || null;
2523
3065
  }
2524
3066
  }
@@ -2532,9 +3074,12 @@ class RecommendScreenCli {
2532
3074
  while (Date.now() < deadline) {
2533
3075
  const info = this.tryExtractNetworkResumeForCandidate(candidate);
2534
3076
  if (info && normalizeText(info.resumeText)) {
3077
+ const latestGeekIds = Array.isArray(this.latestResumeNetworkPayload?.geekIds)
3078
+ ? this.latestResumeNetworkPayload.geekIds.map((id) => normalizeText(id)).filter(Boolean)
3079
+ : [];
2535
3080
  const source = candidateKey && this.resumeNetworkByGeekId.has(candidateKey)
2536
3081
  ? "geek_id_map"
2537
- : "latest_payload";
3082
+ : (candidateKey && latestGeekIds.includes(candidateKey) ? "latest_payload_key_match" : "latest_payload");
2538
3083
  this.recordResumeNetworkDiagnostic({
2539
3084
  kind: "wait_hit",
2540
3085
  candidate_key: candidateKey,
@@ -2555,6 +3100,71 @@ class RecommendScreenCli {
2555
3100
  return null;
2556
3101
  }
2557
3102
 
3103
+ async extractResumeTextFromDom(candidate) {
3104
+ const candidateKey = normalizeText(candidate?.key || candidate?.geek_id || "");
3105
+ const candidateLabel = normalizeText(candidate?.name || candidateKey || "unknown");
3106
+ if (!this.Runtime || typeof this.Runtime.evaluate !== "function") {
3107
+ return null;
3108
+ }
3109
+ let extracted = null;
3110
+ try {
3111
+ extracted = await this.evaluate(jsExtractResumeTextFromDom);
3112
+ } catch (error) {
3113
+ this.recordResumeNetworkDiagnostic({
3114
+ kind: "dom_fallback_error",
3115
+ candidate_key: candidateKey,
3116
+ error: normalizeText(error?.message || error)
3117
+ });
3118
+ log(`[DOM简历提取失败] candidate=${candidateLabel} error=${normalizeText(error?.message || error)}`);
3119
+ return null;
3120
+ }
3121
+ if (!extracted || extracted.ok !== true) {
3122
+ this.recordResumeNetworkDiagnostic({
3123
+ kind: "dom_fallback_miss",
3124
+ candidate_key: candidateKey,
3125
+ reason: normalizeText(extracted?.reason || "resume_dom_not_found")
3126
+ });
3127
+ log(
3128
+ `[DOM简历未命中] candidate=${candidateLabel} reason=${normalizeText(extracted?.reason || "resume_dom_not_found")}`
3129
+ );
3130
+ return null;
3131
+ }
3132
+
3133
+ const resumeText = normalizeText(extracted.text || "");
3134
+ if (!resumeText) {
3135
+ this.recordResumeNetworkDiagnostic({
3136
+ kind: "dom_fallback_miss",
3137
+ candidate_key: candidateKey,
3138
+ reason: "resume_dom_text_empty"
3139
+ });
3140
+ log(`[DOM简历未命中] candidate=${candidateLabel} reason=resume_dom_text_empty`);
3141
+ return null;
3142
+ }
3143
+
3144
+ const info = {
3145
+ name: normalizeText(extracted.name || candidate?.name || ""),
3146
+ school: normalizeText(extracted.school || candidate?.school || ""),
3147
+ major: normalizeText(extracted.major || candidate?.major || ""),
3148
+ company: normalizeText(extracted.company || candidate?.last_company || ""),
3149
+ position: normalizeText(extracted.position || candidate?.last_position || ""),
3150
+ resumeText,
3151
+ alreadyInterested: false
3152
+ };
3153
+
3154
+ this.recordResumeNetworkDiagnostic({
3155
+ kind: "dom_fallback_hit",
3156
+ candidate_key: candidateKey,
3157
+ source: normalizeText(extracted.scope || "unknown"),
3158
+ reason: normalizeText(extracted.selector || "unknown"),
3159
+ resume_text_len: resumeText.length
3160
+ });
3161
+ log(
3162
+ `[DOM简历命中] candidate=${candidateLabel} scope=${normalizeText(extracted.scope || "unknown")} `
3163
+ + `selector=${normalizeText(extracted.selector || "unknown")} resume_len=${resumeText.length}`
3164
+ );
3165
+ return info;
3166
+ }
3167
+
2558
3168
  handleNetworkRequestWillBeSent(params) {
2559
3169
  const url = normalizeText(params?.request?.url || "");
2560
3170
  const postData = params?.request?.postData || "";
@@ -2740,6 +3350,8 @@ class RecommendScreenCli {
2740
3350
  this.processedKeys.delete(key);
2741
3351
  this.discoveredKeys.delete(key);
2742
3352
  }
3353
+ const rollbackSet = new Set(streakKeys);
3354
+ this.candidateAudits = this.candidateAudits.filter((item) => !rollbackSet.has(item?.candidate_key));
2743
3355
  this.resetResumeCaptureFailureStreak();
2744
3356
  return {
2745
3357
  rollback_count: rollbackCount,
@@ -2808,16 +3420,53 @@ class RecommendScreenCli {
2808
3420
  resumeSource: item?.resumeSource || ""
2809
3421
  }))
2810
3422
  : [];
3423
+ this.candidateAudits = Array.isArray(parsed.candidate_audits)
3424
+ ? parsed.candidate_audits.map((item) => ({
3425
+ ts: normalizeText(item?.ts || "") || null,
3426
+ candidate_key: normalizeText(item?.candidate_key || "") || "",
3427
+ geek_id: normalizeText(item?.geek_id || "") || "",
3428
+ candidate_name: normalizeText(item?.candidate_name || "") || "",
3429
+ school: normalizeText(item?.school || "") || "",
3430
+ major: normalizeText(item?.major || "") || "",
3431
+ company: normalizeText(item?.company || "") || "",
3432
+ position: normalizeText(item?.position || "") || "",
3433
+ outcome: normalizeText(item?.outcome || "unknown") || "unknown",
3434
+ resume_source: normalizeText(item?.resume_source || "") || "",
3435
+ resume_text_len: Number.isFinite(Number(item?.resume_text_len)) ? Number(item.resume_text_len) : null,
3436
+ raw_passed: item?.raw_passed === true,
3437
+ final_passed: item?.final_passed === true,
3438
+ evidence_raw_count: Number.isFinite(Number(item?.evidence_raw_count)) ? Number(item.evidence_raw_count) : null,
3439
+ evidence_matched_count: Number.isFinite(Number(item?.evidence_matched_count)) ? Number(item.evidence_matched_count) : null,
3440
+ evidence_gate_demoted: item?.evidence_gate_demoted === true,
3441
+ screening_reason: normalizeText(item?.screening_reason || "") || "",
3442
+ action_taken: normalizeText(item?.action_taken || "") || "",
3443
+ error_code: normalizeText(item?.error_code || "") || "",
3444
+ error_message: normalizeText(item?.error_message || "") || "",
3445
+ chunk_index: Number.isFinite(Number(item?.chunk_index)) ? Number(item.chunk_index) : null,
3446
+ chunk_total: Number.isFinite(Number(item?.chunk_total)) ? Number(item.chunk_total) : null
3447
+ }))
3448
+ : [];
3449
+ if (!this.inputSummary) {
3450
+ this.inputSummary = sanitizeInputSummary(parsed.input_summary);
3451
+ }
2811
3452
  const networkCount = this.passedCandidates.filter((item) => item?.resumeSource === "network").length;
3453
+ const domFallbackCount = this.passedCandidates.filter((item) => item?.resumeSource === "dom_fallback").length;
2812
3454
  const imageFallbackCount = this.passedCandidates.filter((item) => item?.resumeSource === "image_fallback").length;
2813
3455
  this.resumeSourceStats = {
2814
3456
  network: networkCount,
3457
+ dom_fallback: domFallbackCount,
2815
3458
  image_fallback: imageFallbackCount
2816
3459
  };
2817
- if (this.resumeSourceStats.network <= 0 && this.resumeSourceStats.image_fallback <= 0) {
3460
+ if (
3461
+ this.resumeSourceStats.network <= 0
3462
+ && this.resumeSourceStats.dom_fallback <= 0
3463
+ && this.resumeSourceStats.image_fallback <= 0
3464
+ ) {
2818
3465
  const snapshotSource = normalizeText(parsed.resume_source || "").toLowerCase();
2819
3466
  if (snapshotSource === "network") {
2820
3467
  this.resumeSourceStats.network = 1;
3468
+ } else if (snapshotSource === "dom_fallback") {
3469
+ this.resumeSourceStats.dom_fallback = 1;
2821
3470
  } else if (snapshotSource === "image_fallback") {
2822
3471
  this.resumeSourceStats.image_fallback = 1;
2823
3472
  }
@@ -3636,18 +4285,30 @@ class RecommendScreenCli {
3636
4285
  const best = passedChunks[0];
3637
4286
  return {
3638
4287
  passed: true,
4288
+ rawPassed: best?.rawPassed === true || best?.passed === true,
3639
4289
  reason: best.reason || `分段筛选命中(${best.chunkIndex}/${chunks.length})。`,
3640
4290
  summary: best.summary || best.reason || "分段筛选命中",
3641
- evidence: Array.isArray(best.evidence) ? best.evidence : []
4291
+ evidence: Array.isArray(best.evidence) ? best.evidence : [],
4292
+ evidenceRawCount: Number.isFinite(Number(best?.evidenceRawCount)) ? Number(best.evidenceRawCount) : null,
4293
+ evidenceMatchedCount: Number.isFinite(Number(best?.evidenceMatchedCount)) ? Number(best.evidenceMatchedCount) : null,
4294
+ evidenceGateDemoted: best?.evidenceGateDemoted === true,
4295
+ chunkIndex: best?.chunkIndex || null,
4296
+ chunkTotal: best?.chunkTotal || chunks.length
3642
4297
  };
3643
4298
  }
3644
4299
 
3645
4300
  const firstReason = chunkResults.map((item) => normalizeText(item?.reason)).find(Boolean);
3646
4301
  return {
3647
4302
  passed: false,
4303
+ rawPassed: chunkResults.some((item) => item?.rawPassed === true),
3648
4304
  reason: firstReason || `分段筛选未找到满足标准的证据(共 ${chunks.length} 段)。`,
3649
4305
  summary: firstReason || `分段筛选未找到满足标准的证据(共 ${chunks.length} 段)。`,
3650
- evidence: []
4306
+ evidence: [],
4307
+ evidenceRawCount: chunkResults.reduce((acc, item) => acc + (Number.isFinite(Number(item?.evidenceRawCount)) ? Number(item.evidenceRawCount) : 0), 0),
4308
+ evidenceMatchedCount: chunkResults.reduce((acc, item) => acc + (Number.isFinite(Number(item?.evidenceMatchedCount)) ? Number(item.evidenceMatchedCount) : 0), 0),
4309
+ evidenceGateDemoted: chunkResults.some((item) => item?.evidenceGateDemoted === true),
4310
+ chunkIndex: null,
4311
+ chunkTotal: chunks.length
3651
4312
  };
3652
4313
  }
3653
4314
 
@@ -3709,23 +4370,41 @@ class RecommendScreenCli {
3709
4370
  const reason = normalizeText(parsed.reason);
3710
4371
  const summary = normalizeText(parsed.summary || reason);
3711
4372
  const normalizedResume = normalizeText(safeResumeText);
4373
+ const normalizedResumeLower = toLowerSafe(normalizedResume);
3712
4374
  const parsedEvidence = toStringArray(parsed.evidence);
3713
- const evidence = parsedEvidence.filter((item) => {
3714
- const normalizedEvidence = normalizeText(item);
3715
- if (!normalizedEvidence) return false;
3716
- return safeResumeText.includes(item) || normalizedResume.includes(normalizedEvidence);
3717
- });
3718
- let passed = parsed.passed === true;
4375
+ const evidence = [];
4376
+ const unmatchedEvidence = [];
4377
+ for (const item of parsedEvidence) {
4378
+ const matched = matchEvidenceAgainstResume(item, safeResumeText, normalizedResume, normalizedResumeLower);
4379
+ if (matched.matched) {
4380
+ evidence.push(item);
4381
+ } else {
4382
+ unmatchedEvidence.push(item);
4383
+ }
4384
+ }
4385
+ const rawPassed = parsed.passed === true;
4386
+ let passed = rawPassed;
3719
4387
  let finalReason = reason || (passed ? "满足筛选标准。" : "不满足筛选标准。");
3720
- if (passed && evidence.length <= 0) {
4388
+ const evidenceGateDemoted = rawPassed && evidence.length <= 0;
4389
+ if (evidenceGateDemoted) {
3721
4390
  passed = false;
3722
4391
  finalReason = `模型未给出可在简历原文中校验的证据,按安全策略判为不通过。${reason ? ` 原始原因: ${reason}` : ""}`;
4392
+ if (unmatchedEvidence.length > 0) {
4393
+ log(
4394
+ `[EVIDENCE_GATE] passed=true 但证据未命中简历原文,已降级为不通过;` +
4395
+ `chunk=${chunkIndex}/${chunkTotal}; unmatched=${unmatchedEvidence.slice(0, 3).join(" | ")}`
4396
+ );
4397
+ }
3723
4398
  }
3724
4399
  return {
3725
4400
  passed,
4401
+ rawPassed,
3726
4402
  reason: finalReason,
3727
4403
  summary: summary || finalReason,
3728
4404
  evidence,
4405
+ evidenceRawCount: parsedEvidence.length,
4406
+ evidenceMatchedCount: evidence.length,
4407
+ evidenceGateDemoted,
3729
4408
  chunkIndex,
3730
4409
  chunkTotal
3731
4410
  };
@@ -3844,9 +4523,18 @@ class RecommendScreenCli {
3844
4523
  ? jsClickGreetFallbackFeatured
3845
4524
  : jsClickGreetFallbackRecommend;
3846
4525
  const greet = await this.evaluate(greetStateScript);
3847
- if (!greet?.ok || greet.disabled) {
4526
+ if (!greet?.ok) {
4527
+ if (greet?.error === "GREET_CONTINUE_BUTTON_FOUND") {
4528
+ throw this.buildError("GREET_CONTINUE_BUTTON_FOUND", "检测到“继续沟通”按钮,判定为已沟通过,跳过本次打招呼。");
4529
+ }
4530
+ if (greet?.error === "GREET_BUTTON_NOT_FOUND") {
4531
+ throw this.buildError("GREET_BUTTON_NOT_FOUND", "未找到可用的打招呼按钮,跳过本次打招呼。");
4532
+ }
3848
4533
  throw this.buildError("GREET_BUTTON_FAILED", greet?.error || "打招呼按钮不可用");
3849
4534
  }
4535
+ if (greet.disabled) {
4536
+ throw this.buildError("GREET_BUTTON_FAILED", "打招呼按钮不可用");
4537
+ }
3850
4538
 
3851
4539
  try {
3852
4540
  await this.simulateHumanClick(greet.x, greet.y);
@@ -3991,15 +4679,70 @@ class RecommendScreenCli {
3991
4679
  }
3992
4680
 
3993
4681
  saveCsv() {
3994
- const lines = [CSV_HEADER];
4682
+ const lines = [];
4683
+ const sanitizedInputSummary = sanitizeInputSummary(this.inputSummary);
4684
+ const inputSummaryRows = buildInputSummaryRows(sanitizedInputSummary);
4685
+ if (inputSummaryRows.length > 0) {
4686
+ lines.push(INPUT_SUMMARY_HEADER);
4687
+ for (const [key, value] of inputSummaryRows) {
4688
+ lines.push([csvEscape(key), csvEscape(value)].join(","));
4689
+ }
4690
+ lines.push("");
4691
+ }
4692
+ lines.push(CSV_HEADER);
4693
+ const passedByGeekId = new Map();
3995
4694
  for (const item of this.passedCandidates) {
4695
+ const key = normalizeText(item?.geekId || "");
4696
+ if (!key) continue;
4697
+ passedByGeekId.set(key, item);
4698
+ }
4699
+ const auditRows = Array.isArray(this.candidateAudits) && this.candidateAudits.length > 0
4700
+ ? this.candidateAudits
4701
+ : this.passedCandidates.map((item) => ({
4702
+ candidate_key: item?.geekId || "",
4703
+ geek_id: item?.geekId || "",
4704
+ candidate_name: item?.name || "",
4705
+ school: item?.school || "",
4706
+ major: item?.major || "",
4707
+ company: item?.company || "",
4708
+ position: item?.position || "",
4709
+ outcome: "passed",
4710
+ screening_reason: item?.reason || "",
4711
+ action_taken: item?.action || "none",
4712
+ resume_source: item?.resumeSource || "",
4713
+ raw_passed: true,
4714
+ final_passed: true,
4715
+ evidence_raw_count: null,
4716
+ evidence_matched_count: null,
4717
+ evidence_gate_demoted: false,
4718
+ error_code: "",
4719
+ error_message: ""
4720
+ }));
4721
+ for (const audit of auditRows) {
4722
+ const auditGeekId = normalizeText(audit?.geek_id || audit?.candidate_key || "");
4723
+ const passedItem = auditGeekId ? passedByGeekId.get(auditGeekId) : null;
4724
+ const finalPassed = audit?.final_passed === true || normalizeText(audit?.outcome || "") === "passed";
4725
+ const screeningReason = normalizeText(audit?.screening_reason || passedItem?.reason || "");
4726
+ const passReason = finalPassed ? screeningReason : "";
3996
4727
  lines.push([
3997
- csvEscape(item.name),
3998
- csvEscape(item.school),
3999
- csvEscape(item.major),
4000
- csvEscape(item.company),
4001
- csvEscape(item.position),
4002
- csvEscape(item.reason)
4728
+ csvEscape(audit?.candidate_name || passedItem?.name || ""),
4729
+ csvEscape(audit?.school || passedItem?.school || ""),
4730
+ csvEscape(audit?.major || passedItem?.major || ""),
4731
+ csvEscape(audit?.company || passedItem?.company || ""),
4732
+ csvEscape(audit?.position || passedItem?.position || ""),
4733
+ csvEscape(passReason),
4734
+ csvEscape(audit?.outcome || (finalPassed ? "passed" : "unknown")),
4735
+ csvEscape(screeningReason),
4736
+ csvEscape(audit?.action_taken || passedItem?.action || "none"),
4737
+ csvEscape(audit?.resume_source || passedItem?.resumeSource || ""),
4738
+ csvEscape(audit?.raw_passed === true ? "true" : audit?.raw_passed === false ? "false" : ""),
4739
+ csvEscape(finalPassed ? "true" : "false"),
4740
+ csvEscape(Number.isFinite(Number(audit?.evidence_raw_count)) ? Number(audit.evidence_raw_count) : ""),
4741
+ csvEscape(Number.isFinite(Number(audit?.evidence_matched_count)) ? Number(audit.evidence_matched_count) : ""),
4742
+ csvEscape(audit?.evidence_gate_demoted === true ? "true" : "false"),
4743
+ csvEscape(audit?.error_code || ""),
4744
+ csvEscape(audit?.error_message || ""),
4745
+ csvEscape(auditGeekId || passedItem?.geekId || "")
4003
4746
  ].join(","));
4004
4747
  }
4005
4748
  fs.mkdirSync(path.dirname(this.args.output), { recursive: true });
@@ -4156,6 +4899,17 @@ class RecommendScreenCli {
4156
4899
  this.processedCount += 1;
4157
4900
  log(`处理第 ${this.processedCount} 位候选人: ${nextCandidate.name || nextCandidate.geek_id}`);
4158
4901
  let shouldMarkProcessed = true;
4902
+ let resumeSource = "";
4903
+ let resumeTextLength = null;
4904
+ let screening = null;
4905
+ let candidateProfile = {
4906
+ name: nextCandidate.name || "",
4907
+ school: nextCandidate.school || "",
4908
+ major: nextCandidate.major || "",
4909
+ company: nextCandidate.last_company || "",
4910
+ position: nextCandidate.last_position || ""
4911
+ };
4912
+ let allowDetailCloseFailure = false;
4159
4913
 
4160
4914
  try {
4161
4915
  this.currentCandidateKey = nextCandidate.key || nextCandidate.geek_id || null;
@@ -4166,33 +4920,42 @@ class RecommendScreenCli {
4166
4920
  }
4167
4921
 
4168
4922
  let capture = null;
4169
- let screening = null;
4170
- let resumeSource = "image_fallback";
4171
4923
  const networkWaitMs = 4200;
4172
4924
  const networkWaitStartedAt = Date.now();
4173
4925
  const networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(nextCandidate, networkWaitMs);
4174
- if (!normalizeText(networkCandidateInfo?.resumeText) && typeof this.logResumeNetworkMissDiagnostics === "function") {
4175
- this.logResumeNetworkMissDiagnostics(nextCandidate, {
4176
- timeoutMs: networkWaitMs,
4177
- waitStartedAt: networkWaitStartedAt
4178
- });
4926
+ let domCandidateInfo = null;
4927
+ if (!normalizeText(networkCandidateInfo?.resumeText)) {
4928
+ if (typeof this.logResumeNetworkMissDiagnostics === "function") {
4929
+ this.logResumeNetworkMissDiagnostics(nextCandidate, {
4930
+ timeoutMs: networkWaitMs,
4931
+ waitStartedAt: networkWaitStartedAt
4932
+ });
4933
+ }
4934
+ domCandidateInfo = await this.extractResumeTextFromDom(nextCandidate);
4179
4935
  }
4180
- const candidateProfile = {
4181
- name: networkCandidateInfo?.name || nextCandidate.name || "",
4182
- school: networkCandidateInfo?.school || nextCandidate.school || "",
4183
- major: networkCandidateInfo?.major || nextCandidate.major || "",
4184
- company: networkCandidateInfo?.company || nextCandidate.last_company || "",
4185
- position: networkCandidateInfo?.position || nextCandidate.last_position || ""
4936
+ const resumeCandidateInfo = networkCandidateInfo?.resumeText ? networkCandidateInfo : domCandidateInfo;
4937
+ candidateProfile = {
4938
+ name: resumeCandidateInfo?.name || nextCandidate.name || "",
4939
+ school: resumeCandidateInfo?.school || nextCandidate.school || "",
4940
+ major: resumeCandidateInfo?.major || nextCandidate.major || "",
4941
+ company: resumeCandidateInfo?.company || nextCandidate.last_company || "",
4942
+ position: resumeCandidateInfo?.position || nextCandidate.last_position || ""
4186
4943
  };
4187
4944
 
4188
4945
  if (networkCandidateInfo?.resumeText) {
4189
4946
  screening = await this.callTextModel(networkCandidateInfo.resumeText);
4190
4947
  resumeSource = "network";
4948
+ resumeTextLength = normalizeText(networkCandidateInfo.resumeText).length;
4191
4949
  this.resumeSourceStats.network += 1;
4950
+ } else if (domCandidateInfo?.resumeText) {
4951
+ screening = await this.callTextModel(domCandidateInfo.resumeText);
4952
+ resumeSource = "dom_fallback";
4953
+ resumeTextLength = normalizeText(domCandidateInfo.resumeText).length;
4954
+ this.resumeSourceStats.dom_fallback += 1;
4192
4955
  } else {
4956
+ resumeSource = "image_fallback";
4193
4957
  capture = await this.captureResumeImage(nextCandidate);
4194
4958
  screening = await this.callVisionModel(capture.stitchedImage);
4195
- resumeSource = "image_fallback";
4196
4959
  this.resumeSourceStats.image_fallback += 1;
4197
4960
  }
4198
4961
  this.resetResumeCaptureFailureStreak();
@@ -4223,6 +4986,9 @@ class RecommendScreenCli {
4223
4986
  throw postActionError;
4224
4987
  }
4225
4988
  log(`[POST_ACTION_WARN] ${effectiveAction} 失败,继续写入通过候选人: ${postActionError.message || postActionError}`);
4989
+ if (effectiveAction === "greet") {
4990
+ allowDetailCloseFailure = true;
4991
+ }
4226
4992
  actionResult = {
4227
4993
  actionTaken: `${effectiveAction}_failed`,
4228
4994
  errorCode: postActionError.code || "POST_ACTION_FAILED",
@@ -4250,16 +5016,89 @@ class RecommendScreenCli {
4250
5016
  imagePath: capture?.stitchedImage || "",
4251
5017
  resumeSource
4252
5018
  });
5019
+ this.recordCandidateAudit({
5020
+ candidate_key: nextCandidate.key || nextCandidate.geek_id || "",
5021
+ geek_id: nextCandidate.geek_id || nextCandidate.key || "",
5022
+ candidate_name: candidateProfile.name || nextCandidate.name || "",
5023
+ school: candidateProfile.school || "",
5024
+ major: candidateProfile.major || "",
5025
+ company: candidateProfile.company || "",
5026
+ position: candidateProfile.position || "",
5027
+ outcome: "passed",
5028
+ resume_source: resumeSource,
5029
+ resume_text_len: resumeTextLength,
5030
+ raw_passed: screening?.rawPassed === true || screening?.passed === true,
5031
+ final_passed: true,
5032
+ evidence_raw_count: Number.isFinite(Number(screening?.evidenceRawCount))
5033
+ ? Number(screening.evidenceRawCount)
5034
+ : (Array.isArray(screening?.evidence) ? screening.evidence.length : null),
5035
+ evidence_matched_count: Number.isFinite(Number(screening?.evidenceMatchedCount))
5036
+ ? Number(screening.evidenceMatchedCount)
5037
+ : (Array.isArray(screening?.evidence) ? screening.evidence.length : null),
5038
+ evidence_gate_demoted: screening?.evidenceGateDemoted === true,
5039
+ screening_reason: screeningReason,
5040
+ action_taken: actionResult.actionTaken || "none",
5041
+ chunk_index: Number.isFinite(Number(screening?.chunkIndex)) ? Number(screening.chunkIndex) : null,
5042
+ chunk_total: Number.isFinite(Number(screening?.chunkTotal)) ? Number(screening.chunkTotal) : null
5043
+ });
4253
5044
  } else {
4254
5045
  this.skippedCount += 1;
5046
+ this.recordCandidateAudit({
5047
+ candidate_key: nextCandidate.key || nextCandidate.geek_id || "",
5048
+ geek_id: nextCandidate.geek_id || nextCandidate.key || "",
5049
+ candidate_name: candidateProfile.name || nextCandidate.name || "",
5050
+ school: candidateProfile.school || "",
5051
+ major: candidateProfile.major || "",
5052
+ company: candidateProfile.company || "",
5053
+ position: candidateProfile.position || "",
5054
+ outcome: "skipped",
5055
+ resume_source: resumeSource,
5056
+ resume_text_len: resumeTextLength,
5057
+ raw_passed: screening?.rawPassed === true,
5058
+ final_passed: false,
5059
+ evidence_raw_count: Number.isFinite(Number(screening?.evidenceRawCount))
5060
+ ? Number(screening.evidenceRawCount)
5061
+ : (Array.isArray(screening?.evidence) ? screening.evidence.length : null),
5062
+ evidence_matched_count: Number.isFinite(Number(screening?.evidenceMatchedCount))
5063
+ ? Number(screening.evidenceMatchedCount)
5064
+ : (Array.isArray(screening?.evidence) ? screening.evidence.length : null),
5065
+ evidence_gate_demoted: screening?.evidenceGateDemoted === true,
5066
+ screening_reason: normalizeText(screening?.reason || screening?.summary || "模型判定不通过"),
5067
+ chunk_index: Number.isFinite(Number(screening?.chunkIndex)) ? Number(screening.chunkIndex) : null,
5068
+ chunk_total: Number.isFinite(Number(screening?.chunkTotal)) ? Number(screening.chunkTotal) : null
5069
+ });
4255
5070
  }
4256
5071
  } catch (error) {
4257
5072
  this.skippedCount += 1;
5073
+ this.recordCandidateAudit({
5074
+ candidate_key: nextCandidate.key || nextCandidate.geek_id || "",
5075
+ geek_id: nextCandidate.geek_id || nextCandidate.key || "",
5076
+ candidate_name: nextCandidate.name || candidateProfile.name || "",
5077
+ school: candidateProfile.school || nextCandidate.school || "",
5078
+ major: candidateProfile.major || nextCandidate.major || "",
5079
+ company: candidateProfile.company || nextCandidate.last_company || "",
5080
+ position: candidateProfile.position || nextCandidate.last_position || "",
5081
+ outcome: "skipped_error",
5082
+ resume_source: resumeSource,
5083
+ resume_text_len: resumeTextLength,
5084
+ raw_passed: screening?.rawPassed === true,
5085
+ final_passed: false,
5086
+ evidence_raw_count: Number.isFinite(Number(screening?.evidenceRawCount))
5087
+ ? Number(screening.evidenceRawCount)
5088
+ : (Array.isArray(screening?.evidence) ? screening.evidence.length : null),
5089
+ evidence_matched_count: Number.isFinite(Number(screening?.evidenceMatchedCount))
5090
+ ? Number(screening.evidenceMatchedCount)
5091
+ : (Array.isArray(screening?.evidence) ? screening.evidence.length : null),
5092
+ evidence_gate_demoted: screening?.evidenceGateDemoted === true,
5093
+ screening_reason: normalizeText(screening?.reason || screening?.summary || ""),
5094
+ error_code: error?.code || "CANDIDATE_PROCESS_FAILED",
5095
+ error_message: normalizeText(error?.message || error)
5096
+ });
4258
5097
  log(`候选人处理失败: ${error.code || error.message}`);
4259
5098
  if (["RESUME_CAPTURE_FAILED", "RESUME_NETWORK_UNAVAILABLE"].includes(error.code)) {
4260
5099
  this.recordResumeCaptureFailure(nextCandidate.key);
4261
5100
  const failureLabel = error.code === "RESUME_NETWORK_UNAVAILABLE"
4262
- ? "简历 network 获取失败且截图回退未完成"
5101
+ ? "简历 network/DOM 获取失败且截图回退未完成"
4263
5102
  : "简历截图失败";
4264
5103
  log(
4265
5104
  `[候选人跳过] ${nextCandidate.name || nextCandidate.geek_id || "unknown"} ${failureLabel},` +
@@ -4268,7 +5107,7 @@ class RecommendScreenCli {
4268
5107
  if (this.consecutiveResumeCaptureFailures >= MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES) {
4269
5108
  shouldMarkProcessed = false;
4270
5109
  const rollback = this.rollbackResumeCaptureFailureStreak(nextCandidate.key);
4271
- const failureTypeText = "简历获取失败(network + 截图)";
5110
+ const failureTypeText = "简历获取失败(network + DOM + 截图)";
4272
5111
  throw this.buildError(
4273
5112
  "RESUME_CAPTURE_FAILED_CONSECUTIVE_LIMIT",
4274
5113
  `连续 ${MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES} 位候选人${failureTypeText},已停止运行以避免错误跳过。` +
@@ -4308,7 +5147,11 @@ class RecommendScreenCli {
4308
5147
  } finally {
4309
5148
  const closed = await this.closeDetailPage();
4310
5149
  if (!closed) {
4311
- throw this.buildError("DETAIL_CLOSE_FAILED", "详情页未能正确关闭");
5150
+ if (allowDetailCloseFailure) {
5151
+ log("[详情关闭兜底] 本候选人 post_action 失败后详情页关闭未确认,已记录错误并继续下一位候选人。");
5152
+ } else {
5153
+ throw this.buildError("DETAIL_CLOSE_FAILED", "详情页未能正确关闭");
5154
+ }
4312
5155
  }
4313
5156
  if (shouldMarkProcessed) {
4314
5157
  this.processedKeys.add(nextCandidate.key);
@@ -4381,7 +5224,7 @@ async function main() {
4381
5224
  console.log(JSON.stringify({
4382
5225
  status: "COMPLETED",
4383
5226
  result: {
4384
- 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> --checkpoint-path <checkpoint.json> --pause-control-path <pause-control.json> [--resume]"
5227
+ 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]"
4385
5228
  }
4386
5229
  }));
4387
5230
  return;
@@ -4427,7 +5270,10 @@ if (require.main === module) {
4427
5270
  parseFavoriteActionFromActionLog,
4428
5271
  parseFavoriteActionFromWsPayload,
4429
5272
  isRecoverablePostActionError,
4430
- classifyFinishedWrapState
5273
+ classifyFinishedWrapState,
5274
+ formatResumeApiData,
5275
+ extractEvidenceTokens,
5276
+ matchEvidenceAgainstResume
4431
5277
  }
4432
5278
  };
4433
5279
  }