@reconcrap/boss-recommend-mcp 2.0.15 → 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.15",
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,
@@ -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}`;