@reconcrap/boss-recommend-mcp 2.0.14 → 2.0.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": "2.0.14",
3
+ "version": "2.0.16",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -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,
@@ -203,6 +204,132 @@ function createSkippedDetailResult(cardCandidate, reason, error = null) {
203
204
  };
204
205
  }
205
206
 
207
+ const CHAT_FULL_CV_DOM_MIN_TEXT_LENGTH = 500;
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;
211
+ const CHAT_FULL_CV_SECTION_PATTERNS = Object.freeze([
212
+ /教育(?:经历|背景|经验)?/i,
213
+ /工作(?:经历|经验)?/i,
214
+ /项目(?:经历|经验)?/i,
215
+ /实习(?:经历|经验)?/i,
216
+ /科研(?:经历|经验)?/i,
217
+ /论文|会议|专利/i,
218
+ /个人(?:优势|总结|介绍|评价)/i,
219
+ /专业技能|技能(?:特长|标签)?/i,
220
+ /求职(?:期望|意向)/i,
221
+ /校园经历|在校经历|竞赛|证书/i
222
+ ]);
223
+
224
+ function detailTextForFullCvCheck(detailResult = {}) {
225
+ return [
226
+ detailResult?.detail?.popup_text,
227
+ detailResult?.detail?.content_text,
228
+ detailResult?.detail?.resume_iframe_text
229
+ ].filter(Boolean).join("\n\n");
230
+ }
231
+
232
+ function resumeSectionMatchCount(text = "") {
233
+ const normalized = normalizeText(text);
234
+ if (!normalized) return 0;
235
+ return CHAT_FULL_CV_SECTION_PATTERNS
236
+ .filter((pattern) => pattern.test(normalized))
237
+ .length;
238
+ }
239
+
240
+ function hasResumeLikeDomText(text = "") {
241
+ const normalized = normalizeText(text);
242
+ if (normalized.length >= CHAT_FULL_CV_DOM_MIN_TEXT_LENGTH) return true;
243
+ return normalized.length >= CHAT_FULL_CV_DOM_MIN_SECTION_TEXT_LENGTH
244
+ && resumeSectionMatchCount(normalized) >= 2;
245
+ }
246
+
247
+ function networkProfileTextLength(profileResult = {}) {
248
+ return normalizeText(profileResult?.profile?.text || "").length;
249
+ }
250
+
251
+ function isFullCvNetworkProfile(profileResult = {}) {
252
+ if (!profileResult?.ok) return false;
253
+ const sourceKeys = profileResult.profile?.source_keys || {};
254
+ const textLength = networkProfileTextLength(profileResult);
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;
265
+
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
+ }
272
+ if (sourceKeys.network_html_text) {
273
+ return textLength >= CHAT_FULL_CV_NETWORK_MIN_TEXT_LENGTH
274
+ && sectionCount >= 2;
275
+ }
276
+ if (sourceKeys.chat_history_resume) {
277
+ const educationCount = Number(sourceKeys.education_count) || 0;
278
+ const workCount = Number(sourceKeys.work_count) || 0;
279
+ return (educationCount + workCount) >= 2
280
+ && textLength >= CHAT_FULL_CV_NETWORK_MIN_TEXT_LENGTH
281
+ && sectionCount >= 2;
282
+ }
283
+ return false;
284
+ }
285
+
286
+ function hasUsableImageEvidence(imageEvidence = null) {
287
+ if (!imageEvidence || imageEvidence.ok === false) return false;
288
+ return Boolean(
289
+ (Array.isArray(imageEvidence.llm_file_paths) && imageEvidence.llm_file_paths.length)
290
+ || (Array.isArray(imageEvidence.file_paths) && imageEvidence.file_paths.length)
291
+ || Number(imageEvidence.llm_screenshot_count) > 0
292
+ || Number(imageEvidence.unique_screenshot_count) > 0
293
+ || Number(imageEvidence.screenshot_count) > 0
294
+ || Number(imageEvidence.capture_count) > 0
295
+ );
296
+ }
297
+
298
+ export function summarizeChatFullCvEvidence({
299
+ detailResult = null,
300
+ contentWait = null,
301
+ imageEvidence = null
302
+ } = {}) {
303
+ const parsedProfiles = (detailResult?.parsed_network_profiles || []).filter((item) => item?.ok);
304
+ const fullNetworkProfiles = parsedProfiles.filter(isFullCvNetworkProfile);
305
+ const profileOnlyCount = Math.max(0, parsedProfiles.length - fullNetworkProfiles.length);
306
+ const detailText = detailTextForFullCvCheck(detailResult);
307
+ const domTextLength = detailText.length;
308
+ const domSectionCount = resumeSectionMatchCount(detailText);
309
+ const domFullCv = Boolean(contentWait?.ok) && hasResumeLikeDomText(detailText);
310
+ const imageFullCv = hasUsableImageEvidence(imageEvidence);
311
+ const source = fullNetworkProfiles.length
312
+ ? "network"
313
+ : domFullCv
314
+ ? "dom"
315
+ : imageFullCv
316
+ ? "image"
317
+ : null;
318
+ return {
319
+ full_cv_acquired: Boolean(source),
320
+ source,
321
+ network_full_cv_count: fullNetworkProfiles.length,
322
+ network_profile_only_count: profileOnlyCount,
323
+ parsed_network_profile_count: parsedProfiles.length,
324
+ dom_full_cv: domFullCv,
325
+ dom_text_length: domTextLength,
326
+ dom_section_count: domSectionCount,
327
+ content_wait_ok: Boolean(contentWait?.ok),
328
+ image_full_cv: imageFullCv,
329
+ image_summary: summarizeImageEvidence(imageEvidence)
330
+ };
331
+ }
332
+
206
333
  async function resolveFreshChatCardNodeId(client, {
207
334
  fallbackNodeId,
208
335
  candidate,
@@ -717,28 +844,8 @@ export async function runChatWorkflow({
717
844
  }
718
845
 
719
846
  if (!detailResult) {
720
- detailStep = "open_online_resume";
721
- networkRecorder.clear();
722
- const openedResume = await measureTiming(timings, "detail_open_ms", () => openChatOnlineResume(client, {
723
- timeoutMs: readyTimeoutMs
724
- }));
725
847
  const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
726
- detailStep = "wait_network";
727
- const networkWait = ["network", "cascade"].includes(normalizedDetailSource)
728
- ? await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
729
- waitForChatProfileNetworkEvents,
730
- networkRecorder,
731
- {
732
- waitPlan,
733
- minCount: 1,
734
- requireLoaded: true,
735
- intervalMs: 200
736
- }
737
- ))
738
- : null;
739
- if (networkWait?.elapsed_ms != null) {
740
- timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
741
- }
848
+ let networkWait = null;
742
849
  let contentWait = {
743
850
  ok: false,
744
851
  skipped: false,
@@ -746,38 +853,44 @@ export async function runChatWorkflow({
746
853
  elapsed_ms: 0,
747
854
  text_length: 0
748
855
  };
749
- let resumeState = openedResume.resume_state;
856
+ let resumeState = null;
750
857
  let resumeHtml = null;
751
- let resumeNetworkEvents = networkRecorder.events.slice();
858
+ let resumeNetworkEvents = [];
752
859
  let parsedNetworkProfileCount = 0;
753
860
 
754
861
  if (
755
862
  ["network", "cascade"].includes(normalizedDetailSource)
756
- && networkWait?.count > 0
863
+ && selectionNetworkEvents.length > 0
757
864
  ) {
758
- detailStep = "extract_network_profile";
865
+ detailStep = "extract_selection_network_profile";
759
866
  detailResult = await extractChatProfileCandidate(client, {
760
867
  cardCandidate,
761
868
  cardNodeId: effectiveCardNodeId,
762
- resumeState,
763
- resumeHtml,
764
- networkEvents: selectedDetailNetworkEvents(
765
- normalizedDetailSource,
766
- selectionNetworkEvents,
767
- resumeNetworkEvents
768
- ),
869
+ resumeState: null,
870
+ resumeHtml: null,
871
+ networkEvents: selectionNetworkEvents,
769
872
  targetUrl,
770
873
  closeResume: false,
771
- networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
772
- networkParseIntervalMs: 250
874
+ networkParseRetryMs: waitPlan.mode_before === "image" ? 250 : 900,
875
+ networkParseIntervalMs: 150
773
876
  });
774
877
  addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
775
878
  parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
776
- if (parsedNetworkProfileCount > 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
+ };
777
890
  contentWait = {
778
891
  ok: true,
779
892
  skipped: true,
780
- reason: "network_profile_parsed_before_dom_wait",
893
+ reason: "selection_network_full_cv_before_online_resume_click",
781
894
  elapsed_ms: 0,
782
895
  text_length: 0
783
896
  };
@@ -787,40 +900,103 @@ export async function runChatWorkflow({
787
900
  }
788
901
 
789
902
  if (!detailResult) {
790
- detailStep = "wait_resume_content";
791
- contentWait = await measureTiming(timings, "dom_fallback_ms", () => waitForChatResumeContent(client, {
792
- timeoutMs: resumeDomTimeoutMs,
793
- intervalMs: 300
903
+ detailStep = "open_online_resume";
904
+ networkRecorder.clear();
905
+ const openedResume = await measureTiming(timings, "detail_open_ms", () => openChatOnlineResume(client, {
906
+ timeoutMs: readyTimeoutMs
794
907
  }));
795
- resumeState = contentWait.resume_state || openedResume.resume_state;
796
- 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
+ }
797
925
  resumeNetworkEvents = networkRecorder.events.slice();
798
- detailStep = "extract_resume_content";
799
- detailResult = await extractChatProfileCandidate(client, {
800
- cardCandidate,
801
- cardNodeId: effectiveCardNodeId,
802
- resumeState,
803
- resumeHtml,
804
- networkEvents: selectedDetailNetworkEvents(
805
- normalizedDetailSource,
806
- selectionNetworkEvents,
807
- resumeNetworkEvents
808
- ),
809
- targetUrl,
810
- closeResume: false,
811
- networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
812
- networkParseIntervalMs: 250
813
- });
814
- addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
815
- 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
+ }
816
991
  }
817
992
 
818
993
  let source = normalizedDetailSource === "dom" ? "dom" : "network";
819
994
  let imageEvidence = null;
820
995
  let llmResult = null;
821
996
  const captureNodeId = captureNodeIdFromResumeState(resumeState);
997
+ let fullCvEvidence = summarizeChatFullCvEvidence({ detailResult, contentWait });
822
998
  const shouldCaptureImage = normalizedDetailSource === "image"
823
- || (normalizedDetailSource === "cascade" && parsedNetworkProfileCount < 1);
999
+ || (normalizedDetailSource === "cascade" && !fullCvEvidence.full_cv_acquired);
824
1000
  if (shouldCaptureImage) {
825
1001
  if (captureNodeId) {
826
1002
  detailStep = "capture_image_fallback";
@@ -861,12 +1037,20 @@ export async function runChatWorkflow({
861
1037
  }
862
1038
  }));
863
1039
  source = "image";
1040
+ fullCvEvidence = summarizeChatFullCvEvidence({
1041
+ detailResult,
1042
+ contentWait,
1043
+ imageEvidence
1044
+ });
864
1045
  recordCvImageFallback(cvAcquisitionState, {
1046
+ reason: fullCvEvidence.network_profile_only_count > 0
1047
+ ? "profile_only_network_image_fallback"
1048
+ : "network_miss_image_fallback",
865
1049
  parsedNetworkProfileCount,
866
1050
  waitResult: networkWait,
867
1051
  imageEvidence
868
1052
  });
869
- if (callLlmOnImage) {
1053
+ if (callLlmOnImage && fullCvEvidence.full_cv_acquired) {
870
1054
  detailStep = "llm_image_screening";
871
1055
  if (!llmConfig) {
872
1056
  llmResult = createMissingLlmConfigResult();
@@ -888,14 +1072,39 @@ export async function runChatWorkflow({
888
1072
  }
889
1073
  } else {
890
1074
  source = "missing_capture_node";
1075
+ fullCvEvidence = summarizeChatFullCvEvidence({
1076
+ detailResult,
1077
+ contentWait,
1078
+ imageEvidence
1079
+ });
891
1080
  recordCvNetworkMiss(cvAcquisitionState, {
892
1081
  reason: "network_miss_no_capture_node",
893
1082
  parsedNetworkProfileCount,
894
1083
  waitResult: networkWait
895
1084
  });
896
1085
  }
897
- } else if (parsedNetworkProfileCount > 0) {
1086
+ } else if (fullCvEvidence.network_full_cv_count > 0) {
1087
+ source = "network";
898
1088
  recordCvNetworkHit(cvAcquisitionState, {
1089
+ reason: "full_cv_network_profile",
1090
+ parsedNetworkProfileCount,
1091
+ waitResult: networkWait
1092
+ });
1093
+ } else if (fullCvEvidence.dom_full_cv) {
1094
+ source = "dom";
1095
+ if (normalizedDetailSource !== "dom") {
1096
+ recordCvNetworkMiss(cvAcquisitionState, {
1097
+ reason: parsedNetworkProfileCount > 0
1098
+ ? "profile_only_network_dom_fallback"
1099
+ : "network_miss_dom_fallback",
1100
+ parsedNetworkProfileCount,
1101
+ waitResult: networkWait
1102
+ });
1103
+ }
1104
+ } else if (parsedNetworkProfileCount > 0) {
1105
+ source = "profile_only_network";
1106
+ recordCvNetworkMiss(cvAcquisitionState, {
1107
+ reason: "profile_only_network_not_full_cv",
899
1108
  parsedNetworkProfileCount,
900
1109
  waitResult: networkWait
901
1110
  });
@@ -909,25 +1118,29 @@ export async function runChatWorkflow({
909
1118
  }
910
1119
 
911
1120
  if (useLlmScreening && !llmResult) {
912
- detailStep = "llm_screening";
913
- if (!llmConfig) {
914
- llmResult = createMissingLlmConfigResult();
1121
+ if (!fullCvEvidence.full_cv_acquired) {
1122
+ detailUnavailableReason = "full_cv_not_acquired";
915
1123
  } else {
916
- try {
917
- const llmTimingKey = imageEvidence?.file_paths?.length
918
- ? "vision_model_ms"
919
- : "text_model_ms";
920
- llmResult = await measureTiming(timings, llmTimingKey, () => callScreeningLlm({
921
- candidate: detailResult.candidate,
922
- criteria,
923
- config: llmConfig,
924
- timeoutMs: llmTimeoutMs,
925
- imageEvidence,
926
- maxImages: llmImageLimit,
927
- imageDetail: llmImageDetail
928
- }));
929
- } catch (error) {
930
- llmResult = createFailedLlmResult(error);
1124
+ detailStep = "llm_screening";
1125
+ if (!llmConfig) {
1126
+ llmResult = createMissingLlmConfigResult();
1127
+ } else {
1128
+ try {
1129
+ const llmTimingKey = imageEvidence?.file_paths?.length
1130
+ ? "vision_model_ms"
1131
+ : "text_model_ms";
1132
+ llmResult = await measureTiming(timings, llmTimingKey, () => callScreeningLlm({
1133
+ candidate: detailResult.candidate,
1134
+ criteria,
1135
+ config: llmConfig,
1136
+ timeoutMs: llmTimeoutMs,
1137
+ imageEvidence,
1138
+ maxImages: llmImageLimit,
1139
+ imageDetail: llmImageDetail
1140
+ }));
1141
+ } catch (error) {
1142
+ llmResult = createFailedLlmResult(error);
1143
+ }
931
1144
  }
932
1145
  }
933
1146
  }
@@ -955,7 +1168,8 @@ export async function runChatWorkflow({
955
1168
  text_length: contentWait.text_length
956
1169
  },
957
1170
  parsed_network_profile_count: parsedNetworkProfileCount,
958
- image_evidence: summarizeImageEvidence(imageEvidence)
1171
+ image_evidence: summarizeImageEvidence(imageEvidence),
1172
+ full_cv_evidence: fullCvEvidence
959
1173
  };
960
1174
  }
961
1175
  } catch (error) {
@@ -964,6 +1178,13 @@ export async function runChatWorkflow({
964
1178
  const recovery = await recoverAndReapplyChatContext(detailUnavailableReason, error);
965
1179
  detailResult = createSkippedDetailResult(cardCandidate, detailUnavailableReason, error);
966
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;
967
1188
  } else {
968
1189
  if (!isRecoverableCdpNodeError(error)) throw error;
969
1190
  detailUnavailableReason = `recoverable_cdp_node_stale:${detailStep}`;
@@ -979,22 +1200,9 @@ export async function runChatWorkflow({
979
1200
  runControl.setPhase("chat:screening");
980
1201
  let cardOnlyLlmResult = null;
981
1202
  if (useLlmScreening && !detailUnavailableReason && !detailResult?.llm_result) {
982
- if (!llmConfig) {
983
- cardOnlyLlmResult = createMissingLlmConfigResult();
984
- } else {
985
- try {
986
- cardOnlyLlmResult = await measureTiming(timings, "text_model_ms", () => callScreeningLlm({
987
- candidate: screeningCandidate,
988
- criteria,
989
- config: llmConfig,
990
- timeoutMs: llmTimeoutMs,
991
- maxImages: llmImageLimit,
992
- imageDetail: llmImageDetail
993
- }));
994
- } catch (error) {
995
- cardOnlyLlmResult = createFailedLlmResult(error);
996
- }
997
- }
1203
+ detailUnavailableReason = detailResult
1204
+ ? "full_cv_not_acquired"
1205
+ : "detail_not_opened_full_cv_required";
998
1206
  }
999
1207
  const effectiveLlmResult = detailResult?.llm_result || cardOnlyLlmResult;
1000
1208
  const screening = detailUnavailableReason