@reconcrap/boss-recommend-mcp 2.0.15 → 2.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,10 @@
4
4
  "model": "gpt-4.1-mini",
5
5
  "llmThinkingLevel": "low",
6
6
  "llmTimeoutMs": 60000,
7
+ "llmMaxTokens": 512,
7
8
  "llmMaxRetries": 3,
9
+ "llmImageLimit": 8,
10
+ "llmImageDetail": "low",
8
11
  "debugPort": 9222,
9
12
  "outputDir": "",
10
13
  "humanRestEnabled": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.0.15",
3
+ "version": "2.0.17",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -10,6 +10,7 @@ const SCREEN_CONFIG_TEMPLATE_DEFAULTS = Object.freeze({
10
10
  apiKey: "replace-with-your-api-key",
11
11
  model: "gpt-4.1-mini"
12
12
  });
13
+ const LLM_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "auto", "current"]);
13
14
 
14
15
  export const TARGET_COUNT_CANONICAL_ALL = "all";
15
16
  export const TARGET_COUNT_ACCEPTED_EXAMPLES = [TARGET_COUNT_CANONICAL_ALL, -1, 20, "全部候选人"];
@@ -223,6 +224,12 @@ function parsePositiveInteger(raw, fallback = null) {
223
224
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
224
225
  }
225
226
 
227
+ function parseConfigNumber(raw, fallback = null) {
228
+ if (raw === undefined || raw === null || raw === "") return fallback;
229
+ const parsed = Number(raw);
230
+ return Number.isFinite(parsed) ? parsed : fallback;
231
+ }
232
+
226
233
  function parseConfigBoolean(raw, fallback = false) {
227
234
  if (typeof raw === "boolean") return raw;
228
235
  const normalized = normalizeText(raw).toLowerCase();
@@ -231,6 +238,11 @@ function parseConfigBoolean(raw, fallback = false) {
231
238
  return fallback;
232
239
  }
233
240
 
241
+ function normalizeLlmThinkingLevel(raw, fallback = "low") {
242
+ const normalized = normalizeText(raw).toLowerCase();
243
+ return LLM_THINKING_LEVELS.has(normalized) ? normalized : fallback;
244
+ }
245
+
234
246
  function resolveConfigPathValue(raw, configDir) {
235
247
  const normalized = normalizeText(raw);
236
248
  if (!normalized) return "";
@@ -387,8 +399,18 @@ export function resolveBossScreeningConfig(workspaceRoot) {
387
399
  baseUrl: normalizeText(parsed.baseUrl).replace(/\/+$/, ""),
388
400
  apiKey: normalizeText(parsed.apiKey),
389
401
  model: normalizeText(parsed.model),
402
+ openaiOrganization: normalizeText(parsed.openaiOrganization || parsed.organization),
403
+ openaiProject: normalizeText(parsed.openaiProject || parsed.project),
390
404
  debugPort: parsePositiveInteger(parsed.debugPort, 9222),
391
- llmThinkingLevel: normalizeText(parsed.llmThinkingLevel || parsed.thinkingLevel || parsed.reasoningEffort),
405
+ llmThinkingLevel: normalizeLlmThinkingLevel(parsed.llmThinkingLevel || parsed.thinkingLevel || parsed.reasoningEffort, "low"),
406
+ llmTimeoutMs: parsePositiveInteger(parsed.llmTimeoutMs || parsed.timeoutMs, null),
407
+ llmMaxRetries: parsePositiveInteger(parsed.llmMaxRetries || parsed.maxRetries, null),
408
+ llmMaxTokens: parsePositiveInteger(parsed.llmMaxTokens || parsed.maxTokens, null),
409
+ llmMaxCompletionTokens: parsePositiveInteger(parsed.llmMaxCompletionTokens || parsed.maxCompletionTokens, null),
410
+ llmImageLimit: parsePositiveInteger(parsed.llmImageLimit || parsed.imageLimit, null),
411
+ llmImageDetail: normalizeText(parsed.llmImageDetail || parsed.imageDetail),
412
+ temperature: parseConfigNumber(parsed.temperature, null),
413
+ topP: parseConfigNumber(parsed.topP || parsed.top_p, null),
392
414
  outputDir: resolveConfigPathValue(parsed.outputDir, configDir),
393
415
  humanRestEnabled: parseConfigBoolean(parsed.humanRestEnabled, false)
394
416
  },
package/src/cli.js CHANGED
@@ -55,6 +55,10 @@ const externalMcpTargetsEnv = "BOSS_RECOMMEND_MCP_CONFIG_TARGETS";
55
55
  const externalSkillDirsEnv = "BOSS_RECOMMEND_EXTERNAL_SKILL_DIRS";
56
56
  const installConfigDefaults = Object.freeze({
57
57
  llmThinkingLevel: "low",
58
+ llmMaxTokens: 512,
59
+ llmMaxRetries: 3,
60
+ llmImageLimit: 8,
61
+ llmImageDetail: "low",
58
62
  humanRestEnabled: false
59
63
  });
60
64
  const bossChatRuntimeChildDirs = ["logs", "runs", "profiles", "reports", "artifacts", "state"];
@@ -77,6 +77,31 @@ function applyChatCompletionThinking(payload, { baseUrl = "", model = "", thinki
77
77
  return payload;
78
78
  }
79
79
 
80
+ function parsePositiveNumber(value, fallback = null) {
81
+ if (value === undefined || value === null || value === "") return fallback;
82
+ const parsed = Number(value);
83
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
84
+ }
85
+
86
+ function parseFiniteNumber(value, fallback = null) {
87
+ if (value === undefined || value === null || value === "") return fallback;
88
+ const parsed = Number(value);
89
+ return Number.isFinite(parsed) ? parsed : fallback;
90
+ }
91
+
92
+ function resolveLlmOutputTokenBudget(config = {}, thinkingLevel = "") {
93
+ const explicit = parsePositiveNumber(
94
+ config.llmMaxCompletionTokens
95
+ ?? config.maxCompletionTokens
96
+ ?? config.llmMaxTokens
97
+ ?? config.maxTokens,
98
+ null
99
+ );
100
+ if (explicit) return Math.max(1, Math.floor(explicit));
101
+ const normalizedThinking = normalizeLlmThinkingLevel(thinkingLevel || "low") || "low";
102
+ return normalizedThinking === "off" || normalizedThinking === "minimal" ? 64 : 512;
103
+ }
104
+
80
105
  export function normalizeText(input) {
81
106
  return String(input || "").replace(/\s+/g, " ").trim();
82
107
  }
@@ -1436,20 +1461,31 @@ export async function callScreeningLlm({
1436
1461
  throw new Error("Candidate text and image evidence are empty");
1437
1462
  }
1438
1463
 
1464
+ const thinkingLevel = config.llmThinkingLevel || config.thinkingLevel || config.reasoningEffort || "low";
1465
+ const outputTokenBudget = resolveLlmOutputTokenBudget(config, thinkingLevel);
1439
1466
  const payload = {
1440
1467
  model,
1441
- temperature: 0.1,
1442
- max_tokens: Math.max(1, Number(config.maxTokens || config.llmMaxTokens) || 64),
1468
+ temperature: parseFiniteNumber(config.temperature, 0.1),
1469
+ max_tokens: outputTokenBudget,
1443
1470
  messages: buildScreeningLlmMessages({
1444
1471
  candidate,
1445
1472
  criteria,
1446
1473
  imageInputs
1447
1474
  })
1448
1475
  };
1476
+ const topP = parseFiniteNumber(config.topP ?? config.top_p, null);
1477
+ if (topP !== null) payload.top_p = topP;
1478
+ const maxCompletionTokens = parsePositiveNumber(
1479
+ config.llmMaxCompletionTokens ?? config.maxCompletionTokens,
1480
+ null
1481
+ );
1482
+ if (maxCompletionTokens !== null) {
1483
+ payload.max_completion_tokens = Math.max(1, Math.floor(maxCompletionTokens));
1484
+ }
1449
1485
  applyChatCompletionThinking(payload, {
1450
1486
  baseUrl,
1451
1487
  model,
1452
- thinkingLevel: config.llmThinkingLevel || config.thinkingLevel || config.reasoningEffort || "low"
1488
+ thinkingLevel
1453
1489
  });
1454
1490
 
1455
1491
  const maxRetries = normalizeLlmMaxRetries(config.llmMaxRetries ?? config.maxRetries);
@@ -1504,7 +1540,12 @@ export async function callScreeningLlm({
1504
1540
  ok: true,
1505
1541
  provider: {
1506
1542
  baseUrl: baseUrl.replace(/\/\/[^/]+/, "//[redacted-host]"),
1507
- model
1543
+ model,
1544
+ thinking_level: normalizeLlmThinkingLevel(thinkingLevel) || "low",
1545
+ thinking: payload.thinking || null,
1546
+ reasoning_effort: payload.reasoning_effort || null,
1547
+ max_tokens: payload.max_tokens,
1548
+ max_completion_tokens: payload.max_completion_tokens || null
1508
1549
  },
1509
1550
  passed,
1510
1551
  reason: "",
@@ -39,13 +39,65 @@ import {
39
39
  import {
40
40
  assertChatShellNotResumeTopLevel,
41
41
  getChatTopLevelState,
42
+ isForbiddenChatResumeTopLevelUrl,
42
43
  makeForbiddenChatResumeNavigationError
43
44
  } from "./page-guard.js";
44
45
 
46
+ export const CHAT_UNSAFE_ONLINE_RESUME_LINK_CODE = "CHAT_UNSAFE_ONLINE_RESUME_LINK";
47
+
45
48
  export function matchesChatProfileNetwork(url) {
46
49
  return CHAT_PROFILE_NETWORK_PATTERNS.some((pattern) => pattern.test(String(url || "")));
47
50
  }
48
51
 
52
+ function looksLikeForbiddenChatResumePath(value = "") {
53
+ const normalized = String(value || "");
54
+ return isForbiddenChatResumeTopLevelUrl(normalized)
55
+ || /(?:^|["'\s=])(?:https?:\/\/[^"'\s>]*zhipin\.com)?\/web\/frame\/c-resume(?:[/?#"' >]|$)/i
56
+ .test(normalized);
57
+ }
58
+
59
+ function extractFirstHtmlAttribute(html = "", names = []) {
60
+ const source = String(html || "");
61
+ for (const name of names) {
62
+ const escaped = String(name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
63
+ const regex = new RegExp(`${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i");
64
+ const match = source.match(regex);
65
+ if (match) return match[1] ?? match[2] ?? match[3] ?? "";
66
+ }
67
+ return "";
68
+ }
69
+
70
+ export function isUnsafeChatOnlineResumeTarget(target = {}, buttonHTML = "") {
71
+ const attributes = target?.attributes || {};
72
+ const href = attributes.href
73
+ || attributes["data-href"]
74
+ || attributes["data-url"]
75
+ || attributes.url
76
+ || extractFirstHtmlAttribute(buttonHTML, ["href", "data-href", "data-url", "url"]);
77
+ return looksLikeForbiddenChatResumePath(href)
78
+ || looksLikeForbiddenChatResumePath(buttonHTML);
79
+ }
80
+
81
+ export function makeUnsafeChatOnlineResumeLinkError(target = {}, buttonHTML = "") {
82
+ const href = target?.attributes?.href
83
+ || target?.attributes?.["data-href"]
84
+ || target?.attributes?.["data-url"]
85
+ || extractFirstHtmlAttribute(buttonHTML, ["href", "data-href", "data-url", "url"])
86
+ || null;
87
+ const error = new Error("CHAT_UNSAFE_ONLINE_RESUME_LINK: refusing to click an online resume link that can navigate the chat tab to /web/frame/c-resume/");
88
+ error.code = CHAT_UNSAFE_ONLINE_RESUME_LINK_CODE;
89
+ error.href = href;
90
+ error.button_selector = target?.selector || null;
91
+ error.button_text = htmlToText(buttonHTML).slice(0, 120);
92
+ error.button_html_length = String(buttonHTML || "").length;
93
+ return error;
94
+ }
95
+
96
+ export function isUnsafeChatOnlineResumeLinkError(error) {
97
+ return error?.code === CHAT_UNSAFE_ONLINE_RESUME_LINK_CODE
98
+ || /CHAT_UNSAFE_ONLINE_RESUME_LINK/i.test(String(error?.message || error || ""));
99
+ }
100
+
49
101
  export function createChatProfileNetworkRecorder(client) {
50
102
  const events = [];
51
103
  client.Network.responseReceived((event) => {
@@ -673,6 +725,22 @@ export async function openChatOnlineResume(client, {
673
725
  buttonHTML = await getOuterHTML(client, buttonState.target.node_id);
674
726
  } catch {}
675
727
 
728
+ if (isUnsafeChatOnlineResumeTarget(buttonState.target, buttonHTML)) {
729
+ const error = makeUnsafeChatOnlineResumeLinkError(buttonState.target, buttonHTML);
730
+ attempts.push({
731
+ attempt: index + 1,
732
+ ok: false,
733
+ error: error.code,
734
+ blocked_pre_click: true,
735
+ button_selector: buttonState.target.selector,
736
+ button_text: error.button_text,
737
+ button_href: error.href,
738
+ button_html_length: buttonHTML.length
739
+ });
740
+ error.attempts = attempts;
741
+ throw error;
742
+ }
743
+
676
744
  try {
677
745
  if (buttonState.target.center) {
678
746
  await clickPoint(client, buttonState.target.center.x, buttonState.target.center.y);
@@ -49,6 +49,7 @@ import {
49
49
  closeChatResumeModal,
50
50
  createChatProfileNetworkRecorder,
51
51
  extractChatProfileCandidate,
52
+ isUnsafeChatOnlineResumeLinkError,
52
53
  openChatOnlineResume,
53
54
  readChatConversationReadyState,
54
55
  requestChatResumeForPassedCandidate,
@@ -205,6 +206,8 @@ function createSkippedDetailResult(cardCandidate, reason, error = null) {
205
206
 
206
207
  const CHAT_FULL_CV_DOM_MIN_TEXT_LENGTH = 500;
207
208
  const CHAT_FULL_CV_DOM_MIN_SECTION_TEXT_LENGTH = 180;
209
+ const CHAT_FULL_CV_NETWORK_MIN_TEXT_LENGTH = 650;
210
+ const CHAT_FULL_CV_NETWORK_MIN_RICH_ITEM_COUNT = 3;
208
211
  const CHAT_FULL_CV_SECTION_PATTERNS = Object.freeze([
209
212
  /教育(?:经历|背景|经验)?/i,
210
213
  /工作(?:经历|经验)?/i,
@@ -250,17 +253,32 @@ function isFullCvNetworkProfile(profileResult = {}) {
250
253
  const sourceKeys = profileResult.profile?.source_keys || {};
251
254
  const textLength = networkProfileTextLength(profileResult);
252
255
  const sectionCount = resumeSectionMatchCount(profileResult.profile?.text || "");
256
+ const richItemCount = [
257
+ "education_count",
258
+ "work_count",
259
+ "project_count",
260
+ "expectation_count",
261
+ "certification_count"
262
+ ].reduce((sum, key) => sum + (Number(sourceKeys[key]) || 0), 0);
263
+ const hasResumeSections = sectionCount >= 3 || (sectionCount >= 2 && richItemCount >= 2);
264
+ const hasEnoughNetworkText = textLength >= CHAT_FULL_CV_NETWORK_MIN_TEXT_LENGTH;
253
265
 
254
- if (sourceKeys.geek_detail_info || sourceKeys.geek_detail) return true;
266
+ if (sourceKeys.geek_detail_info || sourceKeys.geek_detail) {
267
+ return hasEnoughNetworkText && (
268
+ hasResumeSections
269
+ || richItemCount >= CHAT_FULL_CV_NETWORK_MIN_RICH_ITEM_COUNT
270
+ );
271
+ }
255
272
  if (sourceKeys.network_html_text) {
256
- return textLength >= CHAT_FULL_CV_DOM_MIN_TEXT_LENGTH
257
- || sectionCount >= 2;
273
+ return textLength >= CHAT_FULL_CV_NETWORK_MIN_TEXT_LENGTH
274
+ && sectionCount >= 2;
258
275
  }
259
276
  if (sourceKeys.chat_history_resume) {
260
277
  const educationCount = Number(sourceKeys.education_count) || 0;
261
278
  const workCount = Number(sourceKeys.work_count) || 0;
262
- return (educationCount + workCount) > 0
263
- && (textLength >= CHAT_FULL_CV_DOM_MIN_SECTION_TEXT_LENGTH || sectionCount >= 2);
279
+ return (educationCount + workCount) >= 2
280
+ && textLength >= CHAT_FULL_CV_NETWORK_MIN_TEXT_LENGTH
281
+ && sectionCount >= 2;
264
282
  }
265
283
  return false;
266
284
  }
@@ -826,28 +844,8 @@ export async function runChatWorkflow({
826
844
  }
827
845
 
828
846
  if (!detailResult) {
829
- detailStep = "open_online_resume";
830
- networkRecorder.clear();
831
- const openedResume = await measureTiming(timings, "detail_open_ms", () => openChatOnlineResume(client, {
832
- timeoutMs: readyTimeoutMs
833
- }));
834
847
  const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
835
- detailStep = "wait_network";
836
- const networkWait = ["network", "cascade"].includes(normalizedDetailSource)
837
- ? await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
838
- waitForChatProfileNetworkEvents,
839
- networkRecorder,
840
- {
841
- waitPlan,
842
- minCount: 1,
843
- requireLoaded: true,
844
- intervalMs: 200
845
- }
846
- ))
847
- : null;
848
- if (networkWait?.elapsed_ms != null) {
849
- timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
850
- }
848
+ let networkWait = null;
851
849
  let contentWait = {
852
850
  ok: false,
853
851
  skipped: false,
@@ -855,39 +853,44 @@ export async function runChatWorkflow({
855
853
  elapsed_ms: 0,
856
854
  text_length: 0
857
855
  };
858
- let resumeState = openedResume.resume_state;
856
+ let resumeState = null;
859
857
  let resumeHtml = null;
860
- let resumeNetworkEvents = networkRecorder.events.slice();
858
+ let resumeNetworkEvents = [];
861
859
  let parsedNetworkProfileCount = 0;
862
860
 
863
861
  if (
864
862
  ["network", "cascade"].includes(normalizedDetailSource)
865
- && networkWait?.count > 0
863
+ && selectionNetworkEvents.length > 0
866
864
  ) {
867
- detailStep = "extract_network_profile";
865
+ detailStep = "extract_selection_network_profile";
868
866
  detailResult = await extractChatProfileCandidate(client, {
869
867
  cardCandidate,
870
868
  cardNodeId: effectiveCardNodeId,
871
- resumeState,
872
- resumeHtml,
873
- networkEvents: selectedDetailNetworkEvents(
874
- normalizedDetailSource,
875
- selectionNetworkEvents,
876
- resumeNetworkEvents
877
- ),
869
+ resumeState: null,
870
+ resumeHtml: null,
871
+ networkEvents: selectionNetworkEvents,
878
872
  targetUrl,
879
873
  closeResume: false,
880
- networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
881
- networkParseIntervalMs: 250
874
+ networkParseRetryMs: waitPlan.mode_before === "image" ? 250 : 900,
875
+ networkParseIntervalMs: 150
882
876
  });
883
877
  addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
884
878
  parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
885
- const networkEvidence = summarizeChatFullCvEvidence({ detailResult, contentWait });
886
- if (networkEvidence.network_full_cv_count > 0) {
879
+ const selectionNetworkEvidence = summarizeChatFullCvEvidence({ detailResult, contentWait });
880
+ if (selectionNetworkEvidence.network_full_cv_count > 0) {
881
+ networkWait = {
882
+ ok: true,
883
+ skipped: true,
884
+ reason: "selection_network_full_cv_before_online_resume_click",
885
+ elapsed_ms: detailResult.network_parse_retry_elapsed_ms,
886
+ count: selectionNetworkEvents.length,
887
+ total_event_count: selectionNetworkEvents.length,
888
+ wait_plan: waitPlan
889
+ };
887
890
  contentWait = {
888
891
  ok: true,
889
892
  skipped: true,
890
- reason: "network_full_cv_parsed_before_dom_wait",
893
+ reason: "selection_network_full_cv_before_online_resume_click",
891
894
  elapsed_ms: 0,
892
895
  text_length: 0
893
896
  };
@@ -897,32 +900,94 @@ export async function runChatWorkflow({
897
900
  }
898
901
 
899
902
  if (!detailResult) {
900
- detailStep = "wait_resume_content";
901
- contentWait = await measureTiming(timings, "dom_fallback_ms", () => waitForChatResumeContent(client, {
902
- timeoutMs: resumeDomTimeoutMs,
903
- intervalMs: 300
903
+ detailStep = "open_online_resume";
904
+ networkRecorder.clear();
905
+ const openedResume = await measureTiming(timings, "detail_open_ms", () => openChatOnlineResume(client, {
906
+ timeoutMs: readyTimeoutMs
904
907
  }));
905
- resumeState = contentWait.resume_state || openedResume.resume_state;
906
- resumeHtml = contentWait.resume_html || null;
908
+ resumeState = openedResume.resume_state;
909
+ detailStep = "wait_network";
910
+ networkWait = ["network", "cascade"].includes(normalizedDetailSource)
911
+ ? await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
912
+ waitForChatProfileNetworkEvents,
913
+ networkRecorder,
914
+ {
915
+ waitPlan,
916
+ minCount: 1,
917
+ requireLoaded: true,
918
+ intervalMs: 200
919
+ }
920
+ ))
921
+ : null;
922
+ if (networkWait?.elapsed_ms != null) {
923
+ timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
924
+ }
907
925
  resumeNetworkEvents = networkRecorder.events.slice();
908
- detailStep = "extract_resume_content";
909
- detailResult = await extractChatProfileCandidate(client, {
910
- cardCandidate,
911
- cardNodeId: effectiveCardNodeId,
912
- resumeState,
913
- resumeHtml,
914
- networkEvents: selectedDetailNetworkEvents(
915
- normalizedDetailSource,
916
- selectionNetworkEvents,
917
- resumeNetworkEvents
918
- ),
919
- targetUrl,
920
- closeResume: false,
921
- networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
922
- networkParseIntervalMs: 250
923
- });
924
- addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
925
- parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
926
+
927
+ if (
928
+ ["network", "cascade"].includes(normalizedDetailSource)
929
+ && networkWait?.count > 0
930
+ ) {
931
+ detailStep = "extract_network_profile";
932
+ detailResult = await extractChatProfileCandidate(client, {
933
+ cardCandidate,
934
+ cardNodeId: effectiveCardNodeId,
935
+ resumeState,
936
+ resumeHtml,
937
+ networkEvents: selectedDetailNetworkEvents(
938
+ normalizedDetailSource,
939
+ selectionNetworkEvents,
940
+ resumeNetworkEvents
941
+ ),
942
+ targetUrl,
943
+ closeResume: false,
944
+ networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
945
+ networkParseIntervalMs: 250
946
+ });
947
+ addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
948
+ parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
949
+ const networkEvidence = summarizeChatFullCvEvidence({ detailResult, contentWait });
950
+ if (networkEvidence.network_full_cv_count > 0) {
951
+ contentWait = {
952
+ ok: true,
953
+ skipped: true,
954
+ reason: "network_full_cv_parsed_before_dom_wait",
955
+ elapsed_ms: 0,
956
+ text_length: 0
957
+ };
958
+ } else {
959
+ detailResult = null;
960
+ }
961
+ }
962
+
963
+ if (!detailResult) {
964
+ detailStep = "wait_resume_content";
965
+ contentWait = await measureTiming(timings, "dom_fallback_ms", () => waitForChatResumeContent(client, {
966
+ timeoutMs: resumeDomTimeoutMs,
967
+ intervalMs: 300
968
+ }));
969
+ resumeState = contentWait.resume_state || openedResume.resume_state;
970
+ resumeHtml = contentWait.resume_html || null;
971
+ resumeNetworkEvents = networkRecorder.events.slice();
972
+ detailStep = "extract_resume_content";
973
+ detailResult = await extractChatProfileCandidate(client, {
974
+ cardCandidate,
975
+ cardNodeId: effectiveCardNodeId,
976
+ resumeState,
977
+ resumeHtml,
978
+ networkEvents: selectedDetailNetworkEvents(
979
+ normalizedDetailSource,
980
+ selectionNetworkEvents,
981
+ resumeNetworkEvents
982
+ ),
983
+ targetUrl,
984
+ closeResume: false,
985
+ networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
986
+ networkParseIntervalMs: 250
987
+ });
988
+ addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
989
+ parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
990
+ }
926
991
  }
927
992
 
928
993
  let source = normalizedDetailSource === "dom" ? "dom" : "network";
@@ -1113,6 +1178,13 @@ export async function runChatWorkflow({
1113
1178
  const recovery = await recoverAndReapplyChatContext(detailUnavailableReason, error);
1114
1179
  detailResult = createSkippedDetailResult(cardCandidate, detailUnavailableReason, error);
1115
1180
  detailResult.cv_acquisition.recovery = recovery;
1181
+ } else if (isUnsafeChatOnlineResumeLinkError(error)) {
1182
+ detailUnavailableReason = "unsafe_online_resume_navigation_link";
1183
+ detailResult = createSkippedDetailResult(cardCandidate, detailUnavailableReason, error);
1184
+ detailResult.cv_acquisition.blocked_pre_click = true;
1185
+ detailResult.cv_acquisition.button_href = error.href || null;
1186
+ detailResult.cv_acquisition.button_selector = error.button_selector || null;
1187
+ detailResult.cv_acquisition.attempts = error.attempts || null;
1116
1188
  } else {
1117
1189
  if (!isRecoverableCdpNodeError(error)) throw error;
1118
1190
  detailUnavailableReason = `recoverable_cdp_node_stale:${detailStep}`;