@reconcrap/boss-recommend-mcp 1.3.14 → 1.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.3.14",
3
+ "version": "1.3.16",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
package/src/parser.js CHANGED
@@ -86,26 +86,33 @@ const RECENT_NOT_VIEW_NEGATIVE_PATTERNS = [
86
86
  /保留[^。;;\n]{0,12}14天/i
87
87
  ];
88
88
  const TARGET_COUNT_PATTERNS = [
89
+ /目标筛选数(?:量)?(?:为|是|:|:)?\s*(\d+)/i,
90
+ /目标通过数(?:量)?(?:为|是|:|:)?\s*(\d+)/i,
89
91
  /目标(?:处理|筛选|通过)?(?:人数|数量)?(?:为|是|:|:)?\s*(\d+)/i,
90
92
  /至少(?:处理|筛选|通过)\s*(\d+)\s*(?:位|人)/i,
91
93
  /(?:处理|筛选|通过)\s*(\d+)\s*(?:位|人)/i
92
94
  ];
93
95
  const MAX_GREET_COUNT_PATTERNS = [
96
+ /最大招呼数(?:量)?(?:为|是|:|:)?\s*(\d+)/i,
97
+ /最大(?:打招呼|招呼|沟通|联系)(?:人数|数量|数)?(?:为|是|:|:)?\s*(\d+)/i,
94
98
  /最多(?:打招呼|沟通|联系)\s*(\d+)\s*(?:位|人|个)?/i,
95
99
  /(?:打招呼|沟通|联系)(?:上限|最多|不超过|至多)(?:为|是|:|:)?\s*(\d+)/i
96
100
  ];
97
- const FILTER_CLAUSE_PATTERNS = [
98
- /学校标签|院校标签|985|211|双一流|留学|国内外名校|公办本科/i,
99
- /学历|学位|教育|初中及以下|中专|中技|高中|大专|专科|本科|硕士|研究生|博士/i,
100
- /性别|男生|女生|男性|女性|男\b|女\b/i,
101
- /近?14天(?:内)?没有|近?14天(?:内)?没看过|近?14天(?:内)?未查看|过滤[^。;;\n]{0,12}14天|排除[^。;;\n]{0,12}14天/i,
102
- /目标(?:处理|筛选|通过)?(?:人数|数量)?|至少(?:处理|筛选|通过)|(?:处理|筛选|通过)\s*\d+\s*(?:位|人)/i,
103
- /最多(?:打招呼|沟通|联系)|(?:打招呼|沟通|联系)(?:上限|最多|不超过|至多)/i,
104
- /收藏|打招呼|直接沟通|什么也不做|不做任何操作|不操作|仅筛选|只筛选/i
101
+ const CRITERIA_EXPLICIT_MARKER_PATTERN = /筛选条件\s*[::]/i;
102
+ const CRITERIA_EXPLICIT_STOP_PATTERN = /(?:^|[\n;;])\s*(?:页面选择|学校标签|院校标签|学历|学位|性别|是否过滤近14天看过|目标筛选数|目标通过人数|通过筛选后动作|最大招呼数|最大打招呼数|岗位)\s*[::]/i;
103
+ const CRITERIA_META_FIELD_PREFIX_PATTERNS = [
104
+ /^(?:页面选择|学校标签|院校标签|学历|学位|性别|是否过滤近14天看过|目标筛选数|目标通过人数|通过筛选后动作|最大招呼数|最大打招呼数|岗位)\s*(?:[::]|$)/i,
105
+ /^(?:近?14天(?:内)?(?:没有|没看过|未查看)|(?:不过滤|保留|过滤|排除)[^。;;\n]{0,12}14天)\s*(?:[::]|$)?/i,
106
+ /^(?:目标(?:处理|筛选|通过)?(?:人数|数量)?|至少(?:处理|筛选|通过)|(?:处理|筛选|通过)\s*\d+\s*(?:位|人))(?:[::\s]|$)/i,
107
+ /^(?:最多(?:打招呼|沟通|联系)|(?:打招呼|沟通|联系)(?:上限|最多|不超过|至多))(?:[::\s]|$)/i,
108
+ /^(?:(?:通过筛选后)?动作|post[_\s-]?action|max[_\s-]?greet[_\s-]?count|target[_\s-]?count)\s*(?:[::]|$)/i
105
109
  ];
106
110
  const META_CLAUSE_PATTERNS = [
107
- /推荐页|推荐页面|boss推荐/i,
108
- /帮我|请|运行|skill/i
111
+ /^推荐页|^推荐页面|^boss推荐/i,
112
+ /^帮我|^请|^运行|^使用.*skill/i,
113
+ /^启动boss推荐任务/i,
114
+ /^条件如下(?:[::]|$)/i,
115
+ /^(?:符合标准(?:的人选)?(?:都)?(?:的)?(?:动作)?[::]?\s*)?(?:收藏|打招呼|直接沟通|什么也不做|不做任何操作|不操作|仅筛选|只筛选)(?:[::]|$)/i
109
116
  ];
110
117
  const FEATURED_SCOPE_PATTERN = /(?:精选牛人|精选页|精选页面|精选tab|精选标签|tab[^。;;\n]{0,6}精选|精选)/i;
111
118
  const LATEST_SCOPE_PATTERN = /(?:最新页|最新页面|最新tab|最新标签|tab[^。;;\n]{0,6}最新|最新)/i;
@@ -394,6 +401,13 @@ function extractMaxGreetCount(text) {
394
401
  return null;
395
402
  }
396
403
 
404
+ function extractJobSelectionHint(text) {
405
+ const normalized = String(text || "").replace(/\r\n/g, "\n");
406
+ const match = normalized.match(/(?:^|[\n;;])\s*(?:岗位|职位|job)\s*[::]\s*([^\n;;]+)/i);
407
+ if (!match?.[1]) return null;
408
+ return normalizeText(String(match[1] || "").replace(/[。;;]+$/, "").trim());
409
+ }
410
+
397
411
  function sanitizeClause(clause) {
398
412
  let current = normalizeText(clause);
399
413
  for (const pattern of LEADING_NOISE_PATTERNS) {
@@ -408,25 +422,128 @@ function sanitizeClause(clause) {
408
422
  return current;
409
423
  }
410
424
 
411
- function buildCriteria(text, overrideCriteria) {
412
- const normalizedOverride = normalizeText(overrideCriteria);
413
- if (normalizedOverride) {
414
- return normalizedOverride;
425
+ function isMetaClause(clause) {
426
+ const normalized = sanitizeClause(clause);
427
+ if (!normalized) return true;
428
+ const withoutNumbering = normalized.replace(/^\d+\s*[))]\s*/, "").trim();
429
+ if (!withoutNumbering) return true;
430
+ if (CRITERIA_META_FIELD_PREFIX_PATTERNS.some((pattern) => pattern.test(withoutNumbering))) return true;
431
+ if (META_CLAUSE_PATTERNS.some((pattern) => pattern.test(withoutNumbering))) return true;
432
+ return false;
433
+ }
434
+
435
+ function splitRawCriteriaClauses(text) {
436
+ const normalized = String(text || "").replace(/\r\n/g, "\n").trim();
437
+ if (!normalized) return [];
438
+ const firstNumberedIndex = normalized.search(/\d+\s*[))]/);
439
+ if (firstNumberedIndex === -1) {
440
+ return normalized
441
+ .split(/[;;\n]+/)
442
+ .map((item) => String(item || "").trim())
443
+ .filter(Boolean);
415
444
  }
416
445
 
446
+ const prefix = normalized
447
+ .slice(0, firstNumberedIndex)
448
+ .replace(/[;;,,。]+$/, "")
449
+ .trim();
450
+ const numberedClauses = normalized
451
+ .slice(firstNumberedIndex)
452
+ .split(/(?=\d+\s*[))])/)
453
+ .map((item) => String(item || "").trim())
454
+ .filter(Boolean);
455
+
456
+ return prefix ? [prefix, ...numberedClauses] : numberedClauses;
457
+ }
458
+
459
+ function normalizeRawCriteriaClauses(clauses = []) {
460
+ const filtered = clauses
461
+ .map((item) => String(item || "").replace(/^[;;,,。]+/, "").replace(/[;;,,。]+$/, "").trim())
462
+ .filter(Boolean)
463
+ .filter((item) => !isMetaClause(item));
464
+ const unique = uniqueList(filtered);
465
+ if (!unique.length) return null;
466
+ return unique.reduce((acc, clause) => {
467
+ if (!acc) return clause;
468
+ if (/[::]$/.test(acc) && /^\d+\s*[))]/.test(clause)) {
469
+ return `${acc}${clause}`;
470
+ }
471
+ return `${acc};${clause}`;
472
+ }, "");
473
+ }
474
+
475
+ function normalizeCriteriaClauses(clauses = []) {
476
+ const filtered = clauses
477
+ .map((item) => sanitizeClause(item))
478
+ .map((item) => item.replace(/^[;;,,。]+/, "").replace(/[;;,,。]+$/, "").trim())
479
+ .filter(Boolean)
480
+ .filter((item) => !isMetaClause(item));
481
+ const unique = uniqueList(filtered.map((item) => normalizeText(item)));
482
+ if (!unique.length) return null;
483
+ return unique.reduce((acc, clause) => {
484
+ if (!acc) return clause;
485
+ if (/[::]$/.test(acc) && /^\d+\s*[))]/.test(clause)) {
486
+ return `${acc}${clause}`;
487
+ }
488
+ return `${acc};${clause}`;
489
+ }, "");
490
+ }
491
+
492
+ function extractExplicitCriteriaBlock(text) {
493
+ const normalizedText = String(text || "").replace(/\r\n/g, "\n");
494
+ const markerMatch = normalizedText.match(CRITERIA_EXPLICIT_MARKER_PATTERN);
495
+ if (!markerMatch) return {
496
+ raw: null,
497
+ normalized: null
498
+ };
499
+
500
+ let block = normalizedText.slice(markerMatch.index + markerMatch[0].length);
501
+ const stopMatch = block.match(CRITERIA_EXPLICIT_STOP_PATTERN);
502
+ if (stopMatch && stopMatch.index > 0) {
503
+ block = block.slice(0, stopMatch.index);
504
+ }
505
+ const rawClauses = splitRawCriteriaClauses(block);
506
+ return {
507
+ raw: normalizeRawCriteriaClauses(rawClauses),
508
+ normalized: normalizeCriteriaClauses(rawClauses)
509
+ };
510
+ }
511
+
512
+ function buildFallbackCriteria(text) {
417
513
  const clauses = sanitizeInstruction(text)
418
514
  .split(/[,,。;;\n]/)
419
- .map((item) => sanitizeClause(item))
515
+ .map((item) => String(item || "").trim())
420
516
  .filter(Boolean);
517
+ return {
518
+ raw: normalizeRawCriteriaClauses(clauses),
519
+ normalized: normalizeCriteriaClauses(clauses)
520
+ };
521
+ }
421
522
 
422
- const filtered = clauses.filter((clause) => {
423
- if (FILTER_CLAUSE_PATTERNS.some((pattern) => pattern.test(clause))) return false;
424
- if (META_CLAUSE_PATTERNS.some((pattern) => pattern.test(clause))) return false;
425
- return true;
426
- });
523
+ function buildCriteria({ instruction, rawInstruction, overrideCriteria }) {
524
+ const rawOverride = String(overrideCriteria || "").trim();
525
+ const normalizedOverride = normalizeText(rawOverride);
526
+ if (normalizedOverride) {
527
+ return {
528
+ raw: rawOverride || normalizedOverride,
529
+ normalized: normalizedOverride,
530
+ source: "override"
531
+ };
532
+ }
427
533
 
428
- const result = uniqueList(filtered.map(normalizeText)).join(";");
429
- return result || null;
534
+ const explicitCriteria = extractExplicitCriteriaBlock(rawInstruction || instruction);
535
+ if (explicitCriteria.raw) {
536
+ return {
537
+ ...explicitCriteria,
538
+ source: "explicit"
539
+ };
540
+ }
541
+
542
+ const fallbackCriteria = buildFallbackCriteria(rawInstruction || instruction);
543
+ return {
544
+ ...fallbackCriteria,
545
+ source: fallbackCriteria.raw ? "fallback" : null
546
+ };
430
547
  }
431
548
 
432
549
  function resolvePostAction({ instruction, confirmation, overrides }) {
@@ -550,7 +667,8 @@ function collectSuspiciousFields({ invalidOverrideSchoolTags, maxGreetCountResol
550
667
  }
551
668
 
552
669
  export function parseRecommendInstruction({ instruction, confirmation, overrides }) {
553
- const text = normalizeText(instruction);
670
+ const rawInstruction = String(instruction || "");
671
+ const text = normalizeText(rawInstruction);
554
672
  const detectedSchoolTags = extractSchoolTags(text);
555
673
  const detectedDegrees = extractDegrees(text);
556
674
  const schoolTagAudit = auditSchoolTagSelections(overrides?.school_tag);
@@ -563,7 +681,17 @@ export function parseRecommendInstruction({ instruction, confirmation, overrides
563
681
  const overrideRecentNotView = normalizeRecentNotView(overrides?.recent_not_view);
564
682
  const confirmationRecentNotView = normalizeRecentNotView(confirmation?.recent_not_view_value);
565
683
  const overrideCriteria = overrides?.criteria;
566
- const jobSelectionHint = normalizeText(overrides?.job || confirmation?.job_value || "");
684
+ const criteriaResolution = buildCriteria({
685
+ instruction: text,
686
+ rawInstruction,
687
+ overrideCriteria
688
+ });
689
+ const jobSelectionHint = normalizeText(
690
+ overrides?.job
691
+ || confirmation?.job_value
692
+ || extractJobSelectionHint(rawInstruction)
693
+ || ""
694
+ );
567
695
  const pageScopeResolution = resolvePageScope({ instruction: text, confirmation, overrides });
568
696
 
569
697
  const inferredSchoolTag = detectedSchoolTags.length > 0
@@ -584,7 +712,7 @@ export function parseRecommendInstruction({ instruction, confirmation, overrides
584
712
  recent_not_view: overrideRecentNotView || confirmationRecentNotView || extractRecentNotView(text) || "不限"
585
713
  };
586
714
  const screenParams = {
587
- criteria: buildCriteria(text, overrideCriteria),
715
+ criteria: criteriaResolution.raw || criteriaResolution.normalized || null,
588
716
  target_count: null,
589
717
  post_action: null,
590
718
  max_greet_count: null
@@ -765,6 +893,7 @@ export function parseRecommendInstruction({ instruction, confirmation, overrides
765
893
  needs_post_action_confirmation,
766
894
  needs_max_greet_count_confirmation,
767
895
  needs_page_confirmation,
896
+ criteria_normalized: criteriaResolution.normalized,
768
897
  proposed_target_count: targetCountResolution.proposed_target_count,
769
898
  proposed_post_action: postActionResolution.proposed_post_action,
770
899
  proposed_max_greet_count: maxGreetCountResolution.proposed_max_greet_count,
@@ -777,13 +906,17 @@ export function parseRecommendInstruction({ instruction, confirmation, overrides
777
906
  extracted_search_params: searchParams,
778
907
  extracted_screen_params: {
779
908
  criteria: screenParams.criteria,
909
+ criteria_normalized: criteriaResolution.normalized,
780
910
  target_count: targetCountResolution.proposed_target_count,
781
911
  post_action: postActionResolution.proposed_post_action,
782
912
  max_greet_count: maxGreetCountResolution.proposed_max_greet_count
783
913
  },
784
914
  current_page_scope: pageScopeResolution.page_scope,
785
915
  current_search_params: searchParams,
786
- current_screen_params: screenParams,
916
+ current_screen_params: {
917
+ ...screenParams,
918
+ criteria_normalized: criteriaResolution.normalized
919
+ },
787
920
  missing_fields,
788
921
  suspicious_fields,
789
922
  pending_questions
@@ -1,6 +1,19 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { parseRecommendInstruction } from "./parser.js";
3
3
 
4
+ const REPRODUCTION_INSTRUCTION = `启动boss推荐任务。条件如下:
5
+
6
+ 页面选择:推荐;
7
+ 学校标签:985/211/国内外名校;
8
+ 学历:本科及以上;
9
+ 性别:不限;
10
+ 是否过滤近14天看过:近14天没有;
11
+ 目标筛选数:152;
12
+ 通过筛选后动作:打招呼;
13
+ 最大招呼数:152;
14
+ 岗位:研发工程师(AI应用方向)-2026届校招 _ 杭州;
15
+ 筛选条件:需同时满足全部条件:1)如果有本科学历,本科学历必须为 211 及以上或 QS 前 500 海外院校;2)至少一段学历为 985、QS 前 100 海外院校或中科院;3)具备大模型 / AI / 图形学 / 计算机视觉 / 3D相关的算法或工程经验(实习、项目、科研均可)。学校是否是985、211、qs排名等判断如果简历内没有明确标明,需要通过学校名称来判断;4)必须是25年应届生或者26年应届生或者27年应届生,除了标签以外需要通过人选最高学历的求学年份判断(比如:本科简历里写了2021 - 2025,应该理解为25年毕业,属于25年应届生);5)年龄必须35岁以内。`;
16
+
4
17
  function testNeedConfirmationIncludesPostAction() {
5
18
  const result = parseRecommendInstruction({
6
19
  instruction: "推荐页上筛选985男生,近14天没有,有大模型平台经验,符合标准的收藏",
@@ -606,6 +619,85 @@ function testPageScopeOverrideShouldNotBypassConfirmation() {
606
619
  assert.equal(result.pending_questions.some((item) => item.field === "page_scope"), true);
607
620
  }
608
621
 
622
+ function testExplicitCriteriaBlockShouldKeepAllCoreRulesAndExcludeMetaFields() {
623
+ const result = parseRecommendInstruction({
624
+ instruction: REPRODUCTION_INSTRUCTION,
625
+ confirmation: null,
626
+ overrides: null
627
+ });
628
+ const criteria = result.screenParams.criteria || "";
629
+
630
+ assert.equal(criteria.includes("需同时满足全部条件"), true);
631
+ assert.equal(criteria.includes("1)如果有本科学历,本科学历必须为 211 及以上或 QS 前 500 海外院校"), true);
632
+ assert.equal(criteria.includes("2)至少一段学历为 985、QS 前 100 海外院校或中科院"), true);
633
+ assert.equal(criteria.includes("3)具备大模型 / AI / 图形学 / 计算机视觉 / 3D相关的算法或工程经验"), true);
634
+ assert.equal(criteria.includes("4)必须是25年应届生或者26年应届生或者27年应届生"), true);
635
+ assert.equal(criteria.includes("5)年龄必须35岁以内"), true);
636
+
637
+ assert.equal(criteria.includes("页面选择"), false);
638
+ assert.equal(criteria.includes("目标筛选数"), false);
639
+ assert.equal(criteria.includes("通过筛选后动作"), false);
640
+ assert.equal(criteria.includes("最大招呼数"), false);
641
+ assert.equal(criteria.includes("岗位"), false);
642
+ }
643
+
644
+ function testFallbackCriteriaShouldStillWorkWithoutExplicitMarker() {
645
+ const result = parseRecommendInstruction({
646
+ instruction: "页面选择:推荐;学校标签:985/211;岗位:算法工程师;候选人需满足至少两段 AI 项目经验;最大招呼数:20;",
647
+ confirmation: null,
648
+ overrides: null
649
+ });
650
+ const criteria = result.screenParams.criteria || "";
651
+
652
+ assert.equal(criteria.includes("至少两段 AI 项目经验"), true);
653
+ assert.equal(criteria.includes("页面选择"), false);
654
+ assert.equal(criteria.includes("岗位"), false);
655
+ assert.equal(criteria.includes("最大招呼数"), false);
656
+ }
657
+
658
+ function testOverrideCriteriaShouldHaveHighestPriorityOverExplicitCriteriaBlock() {
659
+ const result = parseRecommendInstruction({
660
+ instruction: REPRODUCTION_INSTRUCTION,
661
+ confirmation: null,
662
+ overrides: {
663
+ criteria: "只看有开源 Agent 项目经验的人选"
664
+ }
665
+ });
666
+
667
+ assert.equal(result.screenParams.criteria, "只看有开源 Agent 项目经验的人选");
668
+ }
669
+
670
+ function testFallbackCriteriaShouldNotDropReal985211QsRules() {
671
+ const result = parseRecommendInstruction({
672
+ instruction: "页面选择:推荐;目标筛选数:10;至少一段学历为985或QS前100海外院校;如果有本科学历,本科必须211或QS前500;岗位:算法工程师;",
673
+ confirmation: null,
674
+ overrides: null
675
+ });
676
+ const criteria = result.screenParams.criteria || "";
677
+
678
+ assert.equal(criteria.includes("985"), true);
679
+ assert.equal(criteria.includes("QS前100"), true);
680
+ assert.equal(criteria.includes("211"), true);
681
+ assert.equal(criteria.includes("QS前500"), true);
682
+ }
683
+
684
+ function testMetaHintsShouldBeProposedFromInstruction() {
685
+ const result = parseRecommendInstruction({
686
+ instruction: `页面选择:推荐;目标筛选数:5;通过筛选后动作:打招呼;最大招呼数:3;岗位:研发工程师(AI应用方向)-2026届校招 _ 杭州;筛选条件:需同时满足全部条件:1)具备AI经验;`,
687
+ confirmation: null,
688
+ overrides: null
689
+ });
690
+
691
+ assert.equal(result.proposed_page_scope, "recommend");
692
+ assert.equal(result.proposed_target_count, 5);
693
+ assert.equal(result.proposed_post_action, "greet");
694
+ assert.equal(result.proposed_max_greet_count, 3);
695
+ assert.equal(result.job_selection_hint, "研发工程师(AI应用方向)-2026届校招 _ 杭州");
696
+ assert.equal(result.screenParams.target_count, null);
697
+ assert.equal(result.screenParams.post_action, null);
698
+ assert.equal(result.screenParams.max_greet_count, null);
699
+ }
700
+
609
701
  function main() {
610
702
  testNeedConfirmationIncludesPostAction();
611
703
  testConfirmedPostActionAndOverrides();
@@ -639,6 +731,11 @@ function main() {
639
731
  testLatestKeywordShouldProposeLatestPageScope();
640
732
  testConfirmedPageScopeShouldBeResolved();
641
733
  testPageScopeOverrideShouldNotBypassConfirmation();
734
+ testExplicitCriteriaBlockShouldKeepAllCoreRulesAndExcludeMetaFields();
735
+ testFallbackCriteriaShouldStillWorkWithoutExplicitMarker();
736
+ testOverrideCriteriaShouldHaveHighestPriorityOverExplicitCriteriaBlock();
737
+ testFallbackCriteriaShouldNotDropReal985211QsRules();
738
+ testMetaHintsShouldBeProposedFromInstruction();
642
739
  console.log("parser tests passed");
643
740
  }
644
741
 
@@ -3305,6 +3305,60 @@ class RecommendScreenCli {
3305
3305
  return info;
3306
3306
  }
3307
3307
 
3308
+ async resolveDomResumeFallback(candidate, cardProfile) {
3309
+ let domCandidateInfo = await this.extractResumeTextFromDom(candidate);
3310
+ let networkCandidateInfo = null;
3311
+ if (domCandidateInfo && !isDomProfileConsistentWithCard(cardProfile, domCandidateInfo)) {
3312
+ this.recordResumeNetworkDiagnostic({
3313
+ kind: "dom_profile_mismatch",
3314
+ candidate_key: normalizeText(candidate?.key || candidate?.geek_id || ""),
3315
+ card_name: normalizeText(cardProfile?.name || ""),
3316
+ dom_name: normalizeText(domCandidateInfo?.name || ""),
3317
+ card_school: normalizeText(cardProfile?.school || ""),
3318
+ dom_school: normalizeText(domCandidateInfo?.school || "")
3319
+ });
3320
+ log(
3321
+ `[DOM简历疑似错位] candidate=${candidate?.key || candidate?.geek_id || "unknown"} ` +
3322
+ `card=${normalizeText(cardProfile?.name || "-")} dom=${normalizeText(domCandidateInfo?.name || "-")},尝试重试一次点击+监听。`
3323
+ );
3324
+ try {
3325
+ const retryCaptureStartedAt = Date.now();
3326
+ await this.clickCandidate(candidate);
3327
+ const retryDetailOpen = await this.ensureDetailOpen();
3328
+ if (retryDetailOpen) {
3329
+ networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(
3330
+ candidate,
3331
+ NETWORK_RESUME_RETRY_WAIT_MS,
3332
+ { minTs: retryCaptureStartedAt }
3333
+ );
3334
+ if (!normalizeText(networkCandidateInfo?.resumeText)) {
3335
+ const retryDomCandidateInfo = await this.extractResumeTextFromDom(candidate);
3336
+ if (retryDomCandidateInfo && isDomProfileConsistentWithCard(cardProfile, retryDomCandidateInfo)) {
3337
+ domCandidateInfo = retryDomCandidateInfo;
3338
+ } else {
3339
+ domCandidateInfo = null;
3340
+ }
3341
+ } else {
3342
+ domCandidateInfo = null;
3343
+ }
3344
+ } else {
3345
+ domCandidateInfo = null;
3346
+ }
3347
+ } catch (retryError) {
3348
+ domCandidateInfo = null;
3349
+ this.recordResumeNetworkDiagnostic({
3350
+ kind: "dom_profile_mismatch_retry_failed",
3351
+ candidate_key: normalizeText(candidate?.key || candidate?.geek_id || ""),
3352
+ error: normalizeText(retryError?.message || retryError)
3353
+ });
3354
+ }
3355
+ }
3356
+ return {
3357
+ domCandidateInfo,
3358
+ networkCandidateInfo
3359
+ };
3360
+ }
3361
+
3308
3362
  handleNetworkRequestWillBeSent(params) {
3309
3363
  const url = normalizeText(params?.request?.url || "");
3310
3364
  const postData = params?.request?.postData || "";
@@ -5222,82 +5276,69 @@ class RecommendScreenCli {
5222
5276
  }
5223
5277
  );
5224
5278
  }
5225
- if (!normalizeText(networkCandidateInfo?.resumeText)) {
5226
- domCandidateInfo = await this.extractResumeTextFromDom(nextCandidate);
5227
- if (domCandidateInfo && !isDomProfileConsistentWithCard(cardProfile, domCandidateInfo)) {
5228
- this.recordResumeNetworkDiagnostic({
5229
- kind: "dom_profile_mismatch",
5230
- candidate_key: normalizeText(nextCandidate?.key || nextCandidate?.geek_id || ""),
5231
- card_name: normalizeText(cardProfile?.name || ""),
5232
- dom_name: normalizeText(domCandidateInfo?.name || ""),
5233
- card_school: normalizeText(cardProfile?.school || ""),
5234
- dom_school: normalizeText(domCandidateInfo?.school || "")
5235
- });
5236
- log(
5237
- `[DOM简历疑似错位] candidate=${nextCandidate?.key || nextCandidate?.geek_id || "unknown"} ` +
5238
- `card=${normalizeText(cardProfile?.name || "-")} dom=${normalizeText(domCandidateInfo?.name || "-")},尝试重试一次点击+监听。`
5239
- );
5240
- try {
5241
- const retryCaptureStartedAt = Date.now();
5242
- await this.clickCandidate(nextCandidate);
5243
- const retryDetailOpen = await this.ensureDetailOpen();
5244
- if (retryDetailOpen) {
5245
- networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(
5246
- nextCandidate,
5247
- NETWORK_RESUME_RETRY_WAIT_MS,
5248
- { minTs: retryCaptureStartedAt }
5249
- );
5250
- if (!normalizeText(networkCandidateInfo?.resumeText)) {
5251
- const retryDomCandidateInfo = await this.extractResumeTextFromDom(nextCandidate);
5252
- if (retryDomCandidateInfo && isDomProfileConsistentWithCard(cardProfile, retryDomCandidateInfo)) {
5253
- domCandidateInfo = retryDomCandidateInfo;
5254
- } else {
5255
- domCandidateInfo = null;
5256
- }
5257
- } else {
5258
- domCandidateInfo = null;
5259
- }
5260
- } else {
5261
- domCandidateInfo = null;
5262
- }
5263
- } catch (retryError) {
5264
- domCandidateInfo = null;
5265
- this.recordResumeNetworkDiagnostic({
5266
- kind: "dom_profile_mismatch_retry_failed",
5267
- candidate_key: normalizeText(nextCandidate?.key || nextCandidate?.geek_id || ""),
5268
- error: normalizeText(retryError?.message || retryError)
5269
- });
5270
- }
5271
- }
5272
- }
5273
- const resumeCandidateInfo = networkCandidateInfo?.resumeText ? networkCandidateInfo : domCandidateInfo;
5274
- candidateProfile = mergeCandidateProfiles(
5275
- resumeCandidateInfo || null,
5276
- cardProfile || null,
5277
- {
5278
- name: nextCandidate.name || "",
5279
- school: nextCandidate.school || "",
5280
- major: nextCandidate.major || "",
5281
- company: nextCandidate.last_company || "",
5282
- position: nextCandidate.last_position || ""
5283
- }
5284
- );
5285
5279
 
5286
5280
  if (networkCandidateInfo?.resumeText) {
5287
5281
  screening = await this.callTextModel(networkCandidateInfo.resumeText);
5288
5282
  resumeSource = "network";
5289
5283
  resumeTextLength = normalizeText(networkCandidateInfo.resumeText).length;
5290
5284
  this.resumeSourceStats.network += 1;
5291
- } else if (domCandidateInfo?.resumeText) {
5292
- screening = await this.callTextModel(domCandidateInfo.resumeText);
5293
- resumeSource = "dom_fallback";
5294
- resumeTextLength = normalizeText(domCandidateInfo.resumeText).length;
5295
- this.resumeSourceStats.dom_fallback += 1;
5285
+ candidateProfile = mergeCandidateProfiles(
5286
+ networkCandidateInfo || null,
5287
+ cardProfile || null,
5288
+ {
5289
+ name: nextCandidate.name || "",
5290
+ school: nextCandidate.school || "",
5291
+ major: nextCandidate.major || "",
5292
+ company: nextCandidate.last_company || "",
5293
+ position: nextCandidate.last_position || ""
5294
+ }
5295
+ );
5296
5296
  } else {
5297
- resumeSource = "image_fallback";
5298
- capture = await this.captureResumeImage(nextCandidate);
5299
- screening = await this.callVisionModel(capture.stitchedImage);
5300
- this.resumeSourceStats.image_fallback += 1;
5297
+ try {
5298
+ resumeSource = "image_fallback";
5299
+ capture = await this.captureResumeImage(nextCandidate);
5300
+ screening = await this.callVisionModel(capture.stitchedImage);
5301
+ this.resumeSourceStats.image_fallback += 1;
5302
+ } 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);
5307
+ resumeSource = "network";
5308
+ resumeTextLength = normalizeText(networkCandidateInfo.resumeText).length;
5309
+ this.resumeSourceStats.network += 1;
5310
+ candidateProfile = mergeCandidateProfiles(
5311
+ networkCandidateInfo || null,
5312
+ cardProfile || null,
5313
+ {
5314
+ name: nextCandidate.name || "",
5315
+ school: nextCandidate.school || "",
5316
+ major: nextCandidate.major || "",
5317
+ company: nextCandidate.last_company || "",
5318
+ position: nextCandidate.last_position || ""
5319
+ }
5320
+ );
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
+ } else {
5339
+ throw imageFallbackError;
5340
+ }
5341
+ }
5301
5342
  }
5302
5343
  this.resetResumeCaptureFailureStreak();
5303
5344
  log(`筛选结果: ${screening.passed ? "通过" : "不通过"}`);
@@ -467,7 +467,7 @@ async function testRecommendShouldPreferNetworkResumeWhenAvailable() {
467
467
  assert.equal(result.result.resume_source, "network");
468
468
  }
469
469
 
470
- async function testNetworkMissShouldFallbackToDomBeforeImageCapture() {
470
+ async function testNetworkMissShouldFallbackToImageThenDom() {
471
471
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-network-dom-fallback-"));
472
472
  const candidate = { key: "dom-1", geek_id: "dom-1", name: "dom candidate" };
473
473
  const cli = new FakeRecommendScreenCli(createArgs(tempDir), {
@@ -485,13 +485,15 @@ async function testNetworkMissShouldFallbackToDomBeforeImageCapture() {
485
485
  });
486
486
 
487
487
  cli.waitForNetworkResumeCandidateInfo = async () => null;
488
+ let captureAttempted = false;
488
489
  cli.callTextModel = async (resumeText) => ({
489
490
  passed: true,
490
491
  reason: resumeText.includes("华中科技大学") ? "dom fallback used" : "unexpected",
491
492
  summary: "dom fallback used"
492
493
  });
493
494
  cli.captureResumeImage = async () => {
494
- throw new Error("capture should not be called when dom fallback resume exists");
495
+ captureAttempted = true;
496
+ throw new Error("capture failed, should fallback to dom");
495
497
  };
496
498
 
497
499
  const result = await cli.run();
@@ -501,6 +503,7 @@ async function testNetworkMissShouldFallbackToDomBeforeImageCapture() {
501
503
  assert.equal(cli.passedCandidates.length, 1);
502
504
  assert.equal(cli.passedCandidates[0].school, "华中科技大学");
503
505
  assert.equal(cli.passedCandidates[0].resumeSource, "dom_fallback");
506
+ assert.equal(captureAttempted, true);
504
507
  }
505
508
 
506
509
  async function testNetworkMissShouldFallbackToImageCapture() {
@@ -600,7 +603,7 @@ function testLatestPayloadShouldRemainAvailableWhenCandidateKeyMissing() {
600
603
  key: "",
601
604
  geek_id: ""
602
605
  });
603
- assert.equal(extracted?.resumeText, "recent resume payload");
606
+ assert.equal(extracted?.candidateInfo?.resumeText, "recent resume payload");
604
607
  }
605
608
 
606
609
  async function testVisionModelFailureShouldSkipCandidateAndContinue() {
@@ -638,25 +641,44 @@ async function testVisionModelFailureShouldSkipCandidateAndContinue() {
638
641
  assert.equal(result.result.skipped_count, 1);
639
642
  }
640
643
 
641
- async function testFeaturedNetworkMissShouldSkipWithoutImageCapture() {
644
+ async function testFeaturedNetworkMissShouldFallbackToDomAfterImageFailure() {
642
645
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-screen-featured-network-only-"));
643
646
  const args = createArgs(tempDir);
644
647
  args.pageScope = "featured";
645
648
  const candidate = { key: "featured-no-network", geek_id: "featured-no-network", name: "featured no network" };
646
649
  const cli = new FakeRecommendScreenCli(args, {
647
- candidates: [candidate]
650
+ candidates: [candidate],
651
+ domResumeByKey: new Map([
652
+ ["featured-no-network", {
653
+ name: "featured no network",
654
+ school: "华中科技大学",
655
+ major: "软件工程",
656
+ company: "测试公司",
657
+ position: "后端工程师",
658
+ resumeText: "featured network miss 后应在截图失败后走 DOM 兜底。"
659
+ }]
660
+ ])
648
661
  });
649
662
  cli.waitForNetworkResumeCandidateInfo = async () => null;
663
+ let captureAttempted = false;
664
+ cli.callTextModel = async () => ({
665
+ passed: true,
666
+ reason: "dom fallback used",
667
+ summary: "dom fallback used"
668
+ });
650
669
  cli.captureResumeImage = async () => {
651
- throw new Error("capture should not be called for featured scope");
670
+ captureAttempted = true;
671
+ throw new Error("capture failed for featured scope");
652
672
  };
653
673
 
654
674
  const result = await cli.run();
655
675
  assert.equal(result.status, "COMPLETED");
656
676
  assert.equal(result.result.processed_count, 1);
657
- assert.equal(result.result.passed_count, 0);
658
- assert.equal(result.result.skipped_count, 1);
659
- assert.equal(result.result.resume_source, "network");
677
+ assert.equal(result.result.passed_count, 1);
678
+ assert.equal(result.result.skipped_count, 0);
679
+ assert.equal(result.result.resume_source, "dom_fallback");
680
+ assert.equal(captureAttempted, true);
681
+ assert.equal(cli.passedCandidates[0].resumeSource, "dom_fallback");
660
682
  }
661
683
 
662
684
  async function testFeaturedFavoriteShouldNotUseDomFallback() {
@@ -1461,14 +1483,14 @@ async function main() {
1461
1483
  await testTargetCountShouldNotTreatProcessedCountAsReached();
1462
1484
  await testFeaturedShouldUseNetworkResumeOnly();
1463
1485
  await testRecommendShouldPreferNetworkResumeWhenAvailable();
1464
- await testNetworkMissShouldFallbackToDomBeforeImageCapture();
1486
+ await testNetworkMissShouldFallbackToImageThenDom();
1465
1487
  await testNetworkMissShouldFallbackToImageCapture();
1466
1488
  await testLatestShouldPreferNetworkResumeWhenAvailable();
1467
1489
  await testLatestNetworkMissShouldFallbackToImageCapture();
1468
1490
  testLatestPayloadShouldNotLeakAcrossCandidates();
1469
1491
  testLatestPayloadShouldRemainAvailableWhenCandidateKeyMissing();
1470
1492
  await testVisionModelFailureShouldSkipCandidateAndContinue();
1471
- await testFeaturedNetworkMissShouldSkipWithoutImageCapture();
1493
+ await testFeaturedNetworkMissShouldFallbackToDomAfterImageFailure();
1472
1494
  await testFeaturedFavoriteShouldNotUseDomFallback();
1473
1495
  await testFeaturedFavoriteShouldSkipClickWhenAlreadyInterested();
1474
1496
  await testFeaturedFavoriteShouldRecognizeAlreadyFavoritedByDelThenAdd();