@reconcrap/boss-recommend-mcp 1.3.12 → 1.3.13

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.12",
3
+ "version": "1.3.13",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -32,6 +32,8 @@ const INPUT_SUMMARY_HEADER = ["运行输入字段", "运行输入值"].join(",")
32
32
  const RESUME_CAPTURE_WAIT_MS = 60000;
33
33
  const RESUME_CAPTURE_MAX_ATTEMPTS = 4;
34
34
  const RESUME_CAPTURE_RETRY_DELAY_MS = 1200;
35
+ const NETWORK_RESUME_WAIT_MS = 4200;
36
+ const NETWORK_RESUME_RETRY_WAIT_MS = 2000;
35
37
  const MAX_CONSECUTIVE_RESUME_CAPTURE_FAILURES = 10;
36
38
  const DEFAULT_VISION_MAX_IMAGE_PIXELS = 36000000;
37
39
  const DEFAULT_VISION_RETRY_MAX_IMAGE_PIXELS = 30000000;
@@ -519,6 +521,104 @@ function matchEvidenceAgainstResume(evidenceText, rawResumeText, normalizedResum
519
521
  };
520
522
  }
521
523
 
524
+ function truncateText(value, maxLength = 96) {
525
+ const text = normalizeText(value);
526
+ if (text.length <= maxLength) return text;
527
+ return `${text.slice(0, Math.max(12, maxLength - 1))}…`;
528
+ }
529
+
530
+ function isMaskedName(value) {
531
+ return /[**]/.test(normalizeText(value));
532
+ }
533
+
534
+ function normalizeNameForCompare(value) {
535
+ return normalizeText(value).replace(/[**]/g, "");
536
+ }
537
+
538
+ function isLikelyNameMatch(expected, actual) {
539
+ const left = normalizeNameForCompare(expected);
540
+ const right = normalizeNameForCompare(actual);
541
+ if (!left || !right) return true;
542
+ if (left === right) return true;
543
+ if (left.includes(right) || right.includes(left)) return true;
544
+ return left[0] === right[0];
545
+ }
546
+
547
+ function isLikelyTextMatch(expected, actual) {
548
+ const left = toLowerSafe(normalizeText(expected));
549
+ const right = toLowerSafe(normalizeText(actual));
550
+ if (!left || !right) return true;
551
+ return left === right || left.includes(right) || right.includes(left);
552
+ }
553
+
554
+ function preferReadableName(...values) {
555
+ const normalized = values.map((item) => normalizeText(item)).filter(Boolean);
556
+ if (normalized.length <= 0) return "";
557
+ const nonMasked = normalized.find((item) => !isMaskedName(item));
558
+ return nonMasked || normalized[0];
559
+ }
560
+
561
+ function mergeCandidateProfiles(...profiles) {
562
+ const list = profiles.filter((item) => item && typeof item === "object");
563
+ const takeFirst = (field) => {
564
+ for (const item of list) {
565
+ const text = normalizeText(item?.[field] || "");
566
+ if (text) return text;
567
+ }
568
+ return "";
569
+ };
570
+ return {
571
+ name: preferReadableName(...list.map((item) => item?.name || "")),
572
+ school: takeFirst("school"),
573
+ major: takeFirst("major"),
574
+ company: takeFirst("company"),
575
+ position: takeFirst("position")
576
+ };
577
+ }
578
+
579
+ function isDomProfileConsistentWithCard(cardProfile, domProfile) {
580
+ if (!cardProfile || !domProfile) return true;
581
+ let compared = 0;
582
+ let mismatched = 0;
583
+ const compareField = (field, matcher) => {
584
+ const expected = normalizeText(cardProfile?.[field] || "");
585
+ const actual = normalizeText(domProfile?.[field] || "");
586
+ if (!expected || !actual) return;
587
+ compared += 1;
588
+ if (!matcher(expected, actual)) {
589
+ mismatched += 1;
590
+ }
591
+ };
592
+ compareField("name", isLikelyNameMatch);
593
+ compareField("school", isLikelyTextMatch);
594
+ compareField("major", isLikelyTextMatch);
595
+ if (compared <= 0) return true;
596
+ return mismatched <= 1;
597
+ }
598
+
599
+ function isGenericReason(reason) {
600
+ const text = normalizeText(reason);
601
+ if (!text) return true;
602
+ if (text.length < 24) return true;
603
+ return /^(候选人同时满足全部筛选条件|满足筛选标准|不满足筛选标准|模型判定不通过|通过|不通过)[。!!?]?$/u.test(text);
604
+ }
605
+
606
+ function enrichReasonWithEvidence(reason, summary, evidence = [], passed = false) {
607
+ const normalizedReason = normalizeText(reason);
608
+ if (!isGenericReason(normalizedReason)) return normalizedReason;
609
+ const normalizedSummary = normalizeText(summary);
610
+ const evidenceItems = toStringArray(evidence, 4).map((item, index) => `${index + 1}) ${truncateText(item, 72)}`);
611
+ const evidenceText = evidenceItems.length > 0 ? evidenceItems.join(";") : "";
612
+ const base = normalizedReason || (passed ? "候选人满足筛选标准。" : "候选人未满足筛选标准。");
613
+ if (evidenceText) {
614
+ return `${base} 关键依据:${evidenceText}`;
615
+ }
616
+ if (normalizedSummary && normalizedSummary !== normalizedReason) {
617
+ return `${base} 摘要:${normalizedSummary}`;
618
+ }
619
+ return base;
620
+ }
621
+
522
622
  function formatEducationDegree(edu) {
523
623
  const degreeName = normalizeText(edu?.degreeName || edu?.degreeCategory || "");
524
624
  if (degreeName) return degreeName;
@@ -1493,6 +1593,20 @@ function buildListCandidatesExpr(processedKeys) {
1493
1593
  const featuredCards = ${buildSelectorCollectionExpression(FEATURED_CARD_SELECTORS, "doc")};
1494
1594
  const latestCards = ${buildSelectorCollectionExpression(LATEST_CARD_SELECTORS, "doc")};
1495
1595
  const textOf = (el) => String(el ? el.textContent : '').replace(/\s+/g, ' ').trim();
1596
+ const pickText = (root, selectors) => {
1597
+ if (!root) return '';
1598
+ for (const selector of selectors || []) {
1599
+ let node = null;
1600
+ try {
1601
+ node = root.querySelector(selector);
1602
+ } catch {
1603
+ node = null;
1604
+ }
1605
+ const text = textOf(node);
1606
+ if (text) return text;
1607
+ }
1608
+ return '';
1609
+ };
1496
1610
  const tabs = ${buildSelectorCollectionExpression(RECOMMEND_TAB_SELECTORS, "doc")};
1497
1611
  const activeTab = tabs.find((node) => /(?:^|\\s)(?:curr|current|active|selected)(?:\\s|$)/i.test(String(node.className || ''))) || null;
1498
1612
  const activeStatus = activeTab ? String(activeTab.getAttribute('data-status') || '') : '';
@@ -1502,7 +1616,7 @@ function buildListCandidatesExpr(processedKeys) {
1502
1616
  const geekId = inner.getAttribute('data-geekid');
1503
1617
  if (!geekId) return null;
1504
1618
  const rect = card.getBoundingClientRect();
1505
- const nameEl = card.querySelector('.name');
1619
+ const name = pickText(card, ['.geek-name-wrap .name', '.name-wrap .name', 'span.name', '.name']);
1506
1620
  const eduSpans = Array.from(card.querySelectorAll('.edu-wrap .edu-exp span, .edu-wrap .content span, .edu-wrap span'))
1507
1621
  .map((item) => textOf(item))
1508
1622
  .filter(Boolean);
@@ -1515,7 +1629,7 @@ function buildListCandidatesExpr(processedKeys) {
1515
1629
  index,
1516
1630
  key: geekId,
1517
1631
  geek_id: geekId,
1518
- name: textOf(nameEl),
1632
+ name,
1519
1633
  school: eduSpans[0] || '',
1520
1634
  major: eduSpans[1] || '',
1521
1635
  degree: eduSpans[2] || '',
@@ -1534,7 +1648,7 @@ function buildListCandidatesExpr(processedKeys) {
1534
1648
  const geekId = anchor.getAttribute('data-geekid');
1535
1649
  if (!geekId) return null;
1536
1650
  const rect = card.getBoundingClientRect();
1537
- const nameEl = card.querySelector('.name, .geek-name, .name-wrap .name');
1651
+ const name = pickText(card, ['.geek-name-wrap .name', '.name-wrap .name', '.name', '.geek-name']);
1538
1652
  const tags = Array.from(card.querySelectorAll('.base-info span, .desc span, .tag-wrap span, .edu-wrap span'))
1539
1653
  .map((item) => textOf(item))
1540
1654
  .filter(Boolean);
@@ -1543,7 +1657,7 @@ function buildListCandidatesExpr(processedKeys) {
1543
1657
  index,
1544
1658
  key: geekId,
1545
1659
  geek_id: geekId,
1546
- name: textOf(nameEl),
1660
+ name,
1547
1661
  school: tags[0] || '',
1548
1662
  major: tags[1] || '',
1549
1663
  degree: tags[2] || '',
@@ -1562,7 +1676,7 @@ function buildListCandidatesExpr(processedKeys) {
1562
1676
  const geekId = inner.getAttribute('data-geek');
1563
1677
  if (!geekId) return null;
1564
1678
  const rect = card.getBoundingClientRect();
1565
- const nameEl = card.querySelector('.name, .name-wrap .name, .name-wrap');
1679
+ const name = pickText(card, ['.geek-name-wrap .name', '.name-wrap .name', '.name-wrap', '.name']);
1566
1680
  const tags = Array.from(card.querySelectorAll('.base-info span, .edu-wrap span, .desc span, .tag-wrap span, .tag-item'))
1567
1681
  .map((item) => textOf(item))
1568
1682
  .filter(Boolean);
@@ -1575,7 +1689,7 @@ function buildListCandidatesExpr(processedKeys) {
1575
1689
  index,
1576
1690
  key: geekId,
1577
1691
  geek_id: geekId,
1578
- name: textOf(nameEl),
1692
+ name,
1579
1693
  school: tags[0] || '',
1580
1694
  major: tags[1] || '',
1581
1695
  degree: tags[2] || '',
@@ -2945,6 +3059,12 @@ class RecommendScreenCli {
2945
3059
  if (item.kind === "dom_fallback_error") {
2946
3060
  return `${prefix} candidate=${item.candidate_key || "-"} error=${item.error || "unknown"}`;
2947
3061
  }
3062
+ if (item.kind === "dom_profile_mismatch") {
3063
+ return `${prefix} candidate=${item.candidate_key || "-"} card=${item.card_name || "-"} dom=${item.dom_name || "-"}`;
3064
+ }
3065
+ if (item.kind === "dom_profile_mismatch_retry_failed") {
3066
+ return `${prefix} candidate=${item.candidate_key || "-"} error=${item.error || "unknown"}`;
3067
+ }
2948
3068
  return `${prefix} ${item.url || item.reason || "n/a"}`;
2949
3069
  });
2950
3070
  }
@@ -3047,44 +3167,63 @@ class RecommendScreenCli {
3047
3167
  }
3048
3168
  }
3049
3169
 
3050
- tryExtractNetworkResumeForCandidate(candidate) {
3170
+ tryExtractNetworkResumeForCandidate(candidate, options = {}) {
3051
3171
  const candidateKey = normalizeText(candidate?.key || candidate?.geek_id || "");
3172
+ const minTs = Number.isFinite(Number(options?.minTs)) ? Number(options.minTs) : 0;
3052
3173
  if (candidateKey && this.resumeNetworkByGeekId.has(candidateKey)) {
3053
- return this.resumeNetworkByGeekId.get(candidateKey)?.candidateInfo || null;
3174
+ const wrapped = this.resumeNetworkByGeekId.get(candidateKey);
3175
+ const payloadTs = Number(wrapped?.ts || 0);
3176
+ if (payloadTs >= minTs) {
3177
+ return {
3178
+ candidateInfo: wrapped?.candidateInfo || null,
3179
+ source: "geek_id_map",
3180
+ ts: payloadTs
3181
+ };
3182
+ }
3054
3183
  }
3055
3184
  if (this.latestResumeNetworkPayload) {
3056
- const ageMs = Date.now() - Number(this.latestResumeNetworkPayload.ts || 0);
3057
- const latestGeekIds = Array.isArray(this.latestResumeNetworkPayload.geekIds)
3058
- ? this.latestResumeNetworkPayload.geekIds.map((id) => normalizeText(id)).filter(Boolean)
3185
+ const wrapped = this.latestResumeNetworkPayload;
3186
+ const payloadTs = Number(wrapped?.ts || 0);
3187
+ const ageMs = Date.now() - payloadTs;
3188
+ const latestGeekIds = Array.isArray(wrapped?.geekIds)
3189
+ ? wrapped.geekIds.map((id) => normalizeText(id)).filter(Boolean)
3059
3190
  : [];
3060
- if (!candidateKey && ageMs <= 12000) {
3061
- return this.latestResumeNetworkPayload.candidateInfo || null;
3191
+ const withinAge = ageMs <= 12000;
3192
+ const withinTs = payloadTs >= minTs;
3193
+ if (!candidateKey && withinAge && withinTs) {
3194
+ return {
3195
+ candidateInfo: wrapped?.candidateInfo || null,
3196
+ source: "latest_payload",
3197
+ ts: payloadTs
3198
+ };
3062
3199
  }
3063
- if (candidateKey && ageMs <= 12000 && latestGeekIds.includes(candidateKey)) {
3064
- return this.latestResumeNetworkPayload.candidateInfo || null;
3200
+ if (candidateKey && withinAge && withinTs && latestGeekIds.includes(candidateKey)) {
3201
+ return {
3202
+ candidateInfo: wrapped?.candidateInfo || null,
3203
+ source: "latest_payload_key_match",
3204
+ ts: payloadTs
3205
+ };
3065
3206
  }
3066
3207
  }
3067
3208
  return null;
3068
3209
  }
3069
3210
 
3070
- async waitForNetworkResumeCandidateInfo(candidate, timeoutMs = 2200) {
3211
+ async waitForNetworkResumeCandidateInfo(candidate, timeoutMs = 2200, options = {}) {
3071
3212
  const waitStartedAt = Date.now();
3072
3213
  const candidateKey = normalizeText(candidate?.key || candidate?.geek_id || "");
3214
+ const minTs = Number.isFinite(Number(options?.minTs)) ? Number(options.minTs) : 0;
3073
3215
  const deadline = Date.now() + timeoutMs;
3074
3216
  while (Date.now() < deadline) {
3075
- const info = this.tryExtractNetworkResumeForCandidate(candidate);
3217
+ const match = this.tryExtractNetworkResumeForCandidate(candidate, { minTs });
3218
+ const info = match?.candidateInfo || null;
3076
3219
  if (info && normalizeText(info.resumeText)) {
3077
- const latestGeekIds = Array.isArray(this.latestResumeNetworkPayload?.geekIds)
3078
- ? this.latestResumeNetworkPayload.geekIds.map((id) => normalizeText(id)).filter(Boolean)
3079
- : [];
3080
- const source = candidateKey && this.resumeNetworkByGeekId.has(candidateKey)
3081
- ? "geek_id_map"
3082
- : (candidateKey && latestGeekIds.includes(candidateKey) ? "latest_payload_key_match" : "latest_payload");
3083
3220
  this.recordResumeNetworkDiagnostic({
3084
3221
  kind: "wait_hit",
3085
3222
  candidate_key: candidateKey,
3086
- source,
3223
+ source: match?.source || "unknown",
3087
3224
  waited_ms: Date.now() - waitStartedAt,
3225
+ min_ts: minTs || null,
3226
+ payload_ts: Number(match?.ts || 0) || null,
3088
3227
  resume_text_len: normalizeText(info.resumeText).length
3089
3228
  });
3090
3229
  return info;
@@ -3095,6 +3234,7 @@ class RecommendScreenCli {
3095
3234
  kind: "wait_timeout",
3096
3235
  candidate_key: candidateKey,
3097
3236
  waited_ms: Date.now() - waitStartedAt,
3237
+ min_ts: minTs || null,
3098
3238
  reason: "resume_text_not_ready"
3099
3239
  });
3100
3240
  return null;
@@ -3142,7 +3282,7 @@ class RecommendScreenCli {
3142
3282
  }
3143
3283
 
3144
3284
  const info = {
3145
- name: normalizeText(extracted.name || candidate?.name || ""),
3285
+ name: preferReadableName(extracted.name || "", candidate?.name || ""),
3146
3286
  school: normalizeText(extracted.school || candidate?.school || ""),
3147
3287
  major: normalizeText(extracted.major || candidate?.major || ""),
3148
3288
  company: normalizeText(extracted.company || candidate?.last_company || ""),
@@ -3888,6 +4028,83 @@ class RecommendScreenCli {
3888
4028
  })(${JSON.stringify(candidateKey)})`);
3889
4029
  }
3890
4030
 
4031
+ async extractCandidateProfileFromCard(candidate) {
4032
+ const candidateKey = candidate?.key || candidate?.geek_id || null;
4033
+ if (!candidateKey) {
4034
+ return null;
4035
+ }
4036
+ let profile = null;
4037
+ try {
4038
+ profile = await this.evaluate(`((candidateKey) => {
4039
+ const frame = ${buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS)};
4040
+ if (!frame || !frame.contentDocument) {
4041
+ return { ok: false, error: "NO_RECOMMEND_IFRAME" };
4042
+ }
4043
+ const doc = frame.contentDocument;
4044
+ const textOf = (el) => String(el ? el.textContent : "").replace(/\\s+/g, " ").trim();
4045
+ const pick = (root, selectors) => {
4046
+ if (!root) return "";
4047
+ for (const selector of selectors || []) {
4048
+ let node = null;
4049
+ try {
4050
+ node = root.querySelector(selector);
4051
+ } catch {
4052
+ node = null;
4053
+ }
4054
+ const text = textOf(node);
4055
+ if (text) return text;
4056
+ }
4057
+ return "";
4058
+ };
4059
+ const recommendInner = Array.from(doc.querySelectorAll(".card-inner[data-geekid]"))
4060
+ .find((item) => (item.getAttribute("data-geekid") || "") === String(candidateKey)) || null;
4061
+ const latestInner = recommendInner
4062
+ ? null
4063
+ : ${buildSelectorCollectionExpression([".candidate-card-wrap .card-inner[data-geek]", ".candidate-card-wrap [data-geek]"], "doc")}
4064
+ .find((item) => (item.getAttribute("data-geek") || "") === String(candidateKey)) || null;
4065
+ const featuredAnchor = (recommendInner || latestInner)
4066
+ ? null
4067
+ : ${buildSelectorCollectionExpression(["li.geek-info-card a[data-geekid]", "a[data-geekid]"], "doc")}
4068
+ .find((item) => (item.getAttribute("data-geekid") || "") === String(candidateKey)) || null;
4069
+ const card = recommendInner
4070
+ ? (recommendInner.closest("li.card-item") || recommendInner.closest(".card-item"))
4071
+ : latestInner
4072
+ ? (latestInner.closest(".candidate-card-wrap") || latestInner.closest("li.card-item") || latestInner.closest(".card-item"))
4073
+ : (featuredAnchor ? (featuredAnchor.closest("li.geek-info-card") || featuredAnchor.closest(".geek-info-card")) : null);
4074
+ if (!card) {
4075
+ return { ok: false, error: "CANDIDATE_CARD_NOT_FOUND" };
4076
+ }
4077
+ const eduSpans = Array.from(card.querySelectorAll(".edu-wrap .edu-exp span, .edu-wrap .content span, .edu-wrap span"))
4078
+ .map((item) => textOf(item))
4079
+ .filter(Boolean);
4080
+ const latestWork = card.querySelector(".timeline-wrap.work-exps .timeline-item");
4081
+ const workSpans = latestWork
4082
+ ? Array.from(latestWork.querySelectorAll(".join-text-wrap.content span")).map((item) => textOf(item)).filter(Boolean)
4083
+ : [];
4084
+ return {
4085
+ ok: true,
4086
+ name: pick(card, [".geek-name-wrap .name", ".name-wrap .name", "span.name", ".name"]),
4087
+ school: eduSpans[0] || pick(card, [".edu-wrap .school-name", ".base-info .school-name", ".school-name"]),
4088
+ major: eduSpans[1] || pick(card, [".edu-wrap .major", ".major"]),
4089
+ company: workSpans[0] || pick(card, [".company-name-wrap .name", ".company-name"]),
4090
+ position: workSpans[1] || pick(card, [".position span", ".position"])
4091
+ };
4092
+ })(${JSON.stringify(candidateKey)})`);
4093
+ } catch {
4094
+ profile = null;
4095
+ }
4096
+ if (!profile?.ok) {
4097
+ return null;
4098
+ }
4099
+ return {
4100
+ name: normalizeText(profile?.name || ""),
4101
+ school: normalizeText(profile?.school || ""),
4102
+ major: normalizeText(profile?.major || ""),
4103
+ company: normalizeText(profile?.company || ""),
4104
+ position: normalizeText(profile?.position || "")
4105
+ };
4106
+ }
4107
+
3891
4108
  async clickCandidate(candidate) {
3892
4109
  const centered = await this.getCenteredCandidateClickPoint(candidate);
3893
4110
  if (centered?.ok) {
@@ -4204,6 +4421,10 @@ class RecommendScreenCli {
4204
4421
  `筛选标准:\n${this.args.criteria}\n\n` +
4205
4422
  "你将收到候选人完整简历的一个或多个顺序分段图片。必须完整阅读全部分段后再判断," +
4206
4423
  "严禁编造任何不存在的经历、项目、学校、公司或时间线;证据不足时必须判定为不通过。\n\n" +
4424
+ "要求:\n" +
4425
+ "1) reason 必须写出可审计的判定依据,至少包含 2 条与筛选标准直接相关的事实。\n" +
4426
+ "2) 禁止只写“候选人同时满足全部筛选条件/不满足筛选条件”等泛化句。\n" +
4427
+ "3) evidence 至少给出 2 条可在简历中定位的原文短句。\n\n" +
4207
4428
  "请返回严格 JSON: " +
4208
4429
  "{\"passed\": true/false, \"reason\": \"...\", \"summary\": \"...\", \"evidence\": [\"证据原文1\", \"证据原文2\"]}"
4209
4430
  }
@@ -4272,11 +4493,13 @@ class RecommendScreenCli {
4272
4493
  const finalReason = evidenceGateDemoted
4273
4494
  ? `模型未给出可在简历截图中引用的证据,按安全策略判为不通过。${reason ? ` 原始原因: ${reason}` : ""}`
4274
4495
  : (reason || (rawPassed ? "满足筛选标准。" : "未满足筛选标准。"));
4496
+ const passed = evidenceGateDemoted ? false : rawPassed;
4497
+ const enrichedReason = enrichReasonWithEvidence(finalReason, summary || finalReason, evidence, passed);
4275
4498
  return {
4276
- passed: evidenceGateDemoted ? false : rawPassed,
4499
+ passed,
4277
4500
  rawPassed,
4278
- reason: finalReason,
4279
- summary: summary || finalReason,
4501
+ reason: enrichedReason,
4502
+ summary: summary || enrichedReason,
4280
4503
  evidence,
4281
4504
  evidenceRawCount: evidence.length,
4282
4505
  evidenceMatchedCount: evidence.length,
@@ -4380,6 +4603,9 @@ class RecommendScreenCli {
4380
4603
  "1) 必须完整阅读上面的全部简历文本。\n" +
4381
4604
  "2) 只能依据简历中真实出现的信息判断,严禁编造不存在的经历/项目/学历/公司。\n" +
4382
4605
  "3) 若证据不足,必须返回 passed=false。\n\n" +
4606
+ "4) reason 必须写出可审计依据,至少包含 2 条与筛选标准直接相关的事实。\n" +
4607
+ "5) 禁止只写“候选人同时满足全部筛选条件/不满足筛选条件”等泛化句。\n" +
4608
+ "6) evidence 至少给出 2 条可在简历原文定位的证据短句。\n\n" +
4383
4609
  "请返回严格 JSON: " +
4384
4610
  "{\"passed\": true/false, \"reason\": \"...\", \"summary\": \"...\", \"evidence\": [\"证据原文1\", \"证据原文2\"]}"
4385
4611
  }
@@ -4435,11 +4661,12 @@ class RecommendScreenCli {
4435
4661
  );
4436
4662
  }
4437
4663
  }
4664
+ const enrichedReason = enrichReasonWithEvidence(finalReason, summary || finalReason, evidence, passed);
4438
4665
  return {
4439
4666
  passed,
4440
4667
  rawPassed,
4441
- reason: finalReason,
4442
- summary: summary || finalReason,
4668
+ reason: enrichedReason,
4669
+ summary: summary || enrichedReason,
4443
4670
  evidence,
4444
4671
  evidenceRawCount: parsedEvidence.length,
4445
4672
  evidenceMatchedCount: evidence.length,
@@ -4941,17 +5168,31 @@ class RecommendScreenCli {
4941
5168
  let resumeSource = "";
4942
5169
  let resumeTextLength = null;
4943
5170
  let screening = null;
4944
- let candidateProfile = {
4945
- name: nextCandidate.name || "",
4946
- school: nextCandidate.school || "",
4947
- major: nextCandidate.major || "",
4948
- company: nextCandidate.last_company || "",
4949
- position: nextCandidate.last_position || ""
4950
- };
5171
+ let candidateProfile = mergeCandidateProfiles(
5172
+ {
5173
+ name: nextCandidate.name || "",
5174
+ school: nextCandidate.school || "",
5175
+ major: nextCandidate.major || "",
5176
+ company: nextCandidate.last_company || "",
5177
+ position: nextCandidate.last_position || ""
5178
+ }
5179
+ );
4951
5180
  let allowDetailCloseFailure = false;
4952
5181
 
4953
5182
  try {
4954
5183
  this.currentCandidateKey = nextCandidate.key || nextCandidate.geek_id || null;
5184
+ const cardProfile = await this.extractCandidateProfileFromCard(nextCandidate);
5185
+ candidateProfile = mergeCandidateProfiles(
5186
+ cardProfile || null,
5187
+ {
5188
+ name: nextCandidate.name || "",
5189
+ school: nextCandidate.school || "",
5190
+ major: nextCandidate.major || "",
5191
+ company: nextCandidate.last_company || "",
5192
+ position: nextCandidate.last_position || ""
5193
+ }
5194
+ );
5195
+ const candidateCaptureStartedAt = Date.now();
4955
5196
  await this.clickCandidate(nextCandidate);
4956
5197
  const detailOpen = await this.ensureDetailOpen();
4957
5198
  if (!detailOpen) {
@@ -4959,9 +5200,11 @@ class RecommendScreenCli {
4959
5200
  }
4960
5201
 
4961
5202
  let capture = null;
4962
- const networkWaitMs = 4200;
5203
+ const networkWaitMs = NETWORK_RESUME_WAIT_MS;
4963
5204
  const networkWaitStartedAt = Date.now();
4964
- const networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(nextCandidate, networkWaitMs);
5205
+ let networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(nextCandidate, networkWaitMs, {
5206
+ minTs: candidateCaptureStartedAt
5207
+ });
4965
5208
  let domCandidateInfo = null;
4966
5209
  if (!normalizeText(networkCandidateInfo?.resumeText)) {
4967
5210
  if (typeof this.logResumeNetworkMissDiagnostics === "function") {
@@ -4970,16 +5213,75 @@ class RecommendScreenCli {
4970
5213
  waitStartedAt: networkWaitStartedAt
4971
5214
  });
4972
5215
  }
5216
+ await sleep(NETWORK_RESUME_RETRY_WAIT_MS);
5217
+ networkCandidateInfo = await this.waitForNetworkResumeCandidateInfo(
5218
+ nextCandidate,
5219
+ NETWORK_RESUME_RETRY_WAIT_MS,
5220
+ {
5221
+ minTs: candidateCaptureStartedAt
5222
+ }
5223
+ );
5224
+ }
5225
+ if (!normalizeText(networkCandidateInfo?.resumeText)) {
4973
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
+ }
4974
5272
  }
4975
5273
  const resumeCandidateInfo = networkCandidateInfo?.resumeText ? networkCandidateInfo : domCandidateInfo;
4976
- candidateProfile = {
4977
- name: resumeCandidateInfo?.name || nextCandidate.name || "",
4978
- school: resumeCandidateInfo?.school || nextCandidate.school || "",
4979
- major: resumeCandidateInfo?.major || nextCandidate.major || "",
4980
- company: resumeCandidateInfo?.company || nextCandidate.last_company || "",
4981
- position: resumeCandidateInfo?.position || nextCandidate.last_position || ""
4982
- };
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
+ );
4983
5285
 
4984
5286
  if (networkCandidateInfo?.resumeText) {
4985
5287
  screening = await this.callTextModel(networkCandidateInfo.resumeText);