@reconcrap/boss-recommend-mcp 2.0.54 → 2.0.55

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.54",
3
+ "version": "2.0.55",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -1,4 +1,9 @@
1
- export const CHAT_TARGET_URL = "https://www.zhipin.com/web/chat/index";
1
+ import {
2
+ BOSS_ACCOUNT_RIGHTS_PANEL_CLOSE_SELECTORS,
3
+ BOSS_ACCOUNT_RIGHTS_PANEL_TEXT_QUERIES
4
+ } from "../common/account-rights-panel.js";
5
+
6
+ export const CHAT_TARGET_URL = "https://www.zhipin.com/web/chat/index";
2
7
 
3
8
  export const CHAT_CARD_SELECTORS = Object.freeze([
4
9
  ".geek-item[data-id]",
@@ -208,29 +213,9 @@ export const CHAT_RESUME_CLOSE_SELECTORS = Object.freeze([
208
213
  '[title*="关闭"]'
209
214
  ]);
210
215
 
211
- export const CHAT_BLOCKING_PANEL_TEXT_QUERIES = Object.freeze([
212
- "我的权益",
213
- "VVIP账号-精选版专享权益",
214
- "全部账号权益使用量",
215
- "职位发布权益总量",
216
- "每日使用权益总量"
217
- ]);
218
-
219
- export const CHAT_BLOCKING_PANEL_CLOSE_SELECTORS = Object.freeze([
220
- ".boss-popup__close",
221
- ".boss-dialog__close",
222
- ".side-panel-close",
223
- ".drawer-close",
224
- ".panel-close",
225
- ".popup-close",
226
- ".modal-close",
227
- ".dialog-close",
228
- ".close-btn",
229
- ".icon-close",
230
- "[class*=\"close\"]",
231
- '[aria-label*="关闭"]',
232
- '[title*="关闭"]'
233
- ]);
216
+ export const CHAT_BLOCKING_PANEL_TEXT_QUERIES = BOSS_ACCOUNT_RIGHTS_PANEL_TEXT_QUERIES;
217
+
218
+ export const CHAT_BLOCKING_PANEL_CLOSE_SELECTORS = BOSS_ACCOUNT_RIGHTS_PANEL_CLOSE_SELECTORS;
234
219
 
235
220
  export const CHAT_PROFILE_NETWORK_PATTERNS = Object.freeze([
236
221
  /\/wapi\/zpjob\/view\/geek\/info(?:\/v2)?\b/i,
@@ -12,13 +12,17 @@ import {
12
12
  querySelectorAll,
13
13
  sleep
14
14
  } from "../../core/browser/index.js";
15
- import {
16
- buildScreeningCandidateFromDetail,
17
- htmlToText
18
- } from "../../core/screening/index.js";
19
- import {
20
- CHAT_ACTIVE_CANDIDATE_SELECTORS,
21
- CHAT_ASK_RESUME_BUTTON_SELECTORS,
15
+ import {
16
+ buildScreeningCandidateFromDetail,
17
+ htmlToText
18
+ } from "../../core/screening/index.js";
19
+ import {
20
+ closeBossAccountRightsBlockingPanel,
21
+ findBossAccountRightsBlockingPanel
22
+ } from "../common/account-rights-panel.js";
23
+ import {
24
+ CHAT_ACTIVE_CANDIDATE_SELECTORS,
25
+ CHAT_ASK_RESUME_BUTTON_SELECTORS,
22
26
  CHAT_ATTACHMENT_RESUME_BUTTON_SELECTORS,
23
27
  CHAT_BLOCKING_PANEL_CLOSE_SELECTORS,
24
28
  CHAT_BLOCKING_PANEL_TEXT_QUERIES,
@@ -735,185 +739,29 @@ export async function quickChatResumeModalOpenProbe(client, {
735
739
  };
736
740
  }
737
741
 
738
- async function performDomTextSearch(client, query, {
739
- limit = 6
740
- } = {}) {
741
- if (typeof client?.DOM?.performSearch !== "function"
742
- || typeof client?.DOM?.getSearchResults !== "function") {
743
- return [];
744
- }
745
- const searchOnce = async () => {
746
- let searchId = "";
747
- try {
748
- const search = await client.DOM.performSearch({
749
- query,
750
- includeUserAgentShadowDOM: true
751
- });
752
- searchId = search?.searchId || "";
753
- const resultCount = Math.min(Number(search?.resultCount) || 0, Math.max(0, Number(limit) || 0));
754
- if (!searchId || resultCount <= 0) return [];
755
- const results = await client.DOM.getSearchResults({
756
- searchId,
757
- fromIndex: 0,
758
- toIndex: resultCount
759
- });
760
- return results?.nodeIds || [];
761
- } catch {
762
- return [];
763
- } finally {
764
- if (searchId && typeof client?.DOM?.discardSearchResults === "function") {
765
- try {
766
- await client.DOM.discardSearchResults({ searchId });
767
- } catch {
768
- // Best-effort cleanup only.
769
- }
770
- }
771
- }
772
- };
773
-
774
- const firstPass = await searchOnce();
775
- if (!firstPass.length) return [];
776
- const firstPassNodeIds = firstPass.filter((nodeId) => Number(nodeId) > 0);
777
- if (firstPassNodeIds.length || typeof client?.DOM?.getDocument !== "function") return firstPassNodeIds;
778
- try {
779
- await client.DOM.getDocument({
780
- depth: -1,
781
- pierce: true
782
- });
783
- } catch {
784
- return firstPassNodeIds;
785
- }
786
- return (await searchOnce()).filter((nodeId) => Number(nodeId) > 0);
787
- }
788
-
789
- export async function findChatBlockingPanel(client, {
790
- textQueries = CHAT_BLOCKING_PANEL_TEXT_QUERIES
791
- } = {}) {
792
- for (const query of textQueries || []) {
793
- const nodeIds = await performDomTextSearch(client, query);
794
- for (const nodeId of nodeIds) {
795
- try {
796
- const box = await getNodeBox(client, nodeId);
797
- if (box?.rect?.width > 2 && box?.rect?.height > 2) {
798
- return {
799
- open: true,
800
- reason: "blocking_panel_text_visible",
801
- query,
802
- node_id: nodeId,
803
- rect: box.rect,
804
- center: box.center
805
- };
806
- }
807
- } catch {
808
- // Hidden or stale text hits are ignored.
809
- }
810
- }
811
- }
812
- return {
813
- open: false
814
- };
815
- }
816
-
817
- function blockingPanelOutsideClickPoint(probe = {}) {
818
- const rect = probe?.rect || {};
819
- // Click the empty lower-left sidebar area. It is outside the rights drawer
820
- // and avoids top nav, chat rows, message controls, and candidate actions.
821
- const x = 84;
822
- const y = Number.isFinite(Number(rect.y))
823
- ? Math.max(560, Math.min(680, Number(rect.y) + 600))
824
- : 660;
825
- return {
826
- x,
827
- y,
828
- mode: "empty-lower-left-sidebar"
829
- };
830
- }
831
-
832
- export async function closeChatBlockingPanels(client, {
833
- attemptsLimit = 2
834
- } = {}) {
835
- const attempts = [];
836
- let probe = await findChatBlockingPanel(client);
837
- if (!probe.open) {
838
- return {
839
- closed: true,
840
- already_closed: true,
841
- probe,
842
- attempts
843
- };
844
- }
845
-
846
- for (let index = 0; index < attemptsLimit; index += 1) {
847
- const outsidePoint = blockingPanelOutsideClickPoint(probe);
848
- try {
849
- await clickPoint(client, outsidePoint.x, outsidePoint.y, DETERMINISTIC_CLICK_OPTIONS);
850
- attempts.push({
851
- mode: "outside-click",
852
- point: outsidePoint
853
- });
854
- } catch (error) {
855
- attempts.push({
856
- mode: "outside-click-error",
857
- point: outsidePoint,
858
- error: error?.message || String(error)
859
- });
860
- }
861
- await sleep(700);
862
-
863
- probe = await findChatBlockingPanel(client);
864
- if (!probe.open) {
865
- return {
866
- closed: true,
867
- already_closed: false,
868
- probe,
869
- attempts
870
- };
871
- }
872
-
873
- const rootState = await getChatRoots(client);
874
- const closeTarget = await findVisibleTarget(client, rootState.roots, CHAT_BLOCKING_PANEL_CLOSE_SELECTORS);
875
- if (closeTarget) {
876
- try {
877
- await clickPoint(client, closeTarget.center.x, closeTarget.center.y, DETERMINISTIC_CLICK_OPTIONS);
878
- attempts.push({
879
- mode: "close-selector",
880
- selector: closeTarget.selector,
881
- root: closeTarget.root
882
- });
883
- } catch (error) {
884
- attempts.push({
885
- mode: "close-selector-error",
886
- selector: closeTarget.selector,
887
- root: closeTarget.root,
888
- error: error?.message || String(error)
889
- });
890
- }
891
- } else {
892
- await pressEscape(client);
893
- attempts.push({ mode: "Escape" });
894
- }
895
- await sleep(700);
896
-
897
- probe = await findChatBlockingPanel(client);
898
- if (!probe.open) {
899
- return {
900
- closed: true,
901
- already_closed: false,
902
- probe,
903
- attempts
904
- };
905
- }
906
- }
907
-
908
- return {
909
- closed: false,
910
- already_closed: false,
911
- probe,
912
- attempts
913
- };
914
- }
915
-
916
- export async function readChatResumeHtml(client, resumeState) {
742
+ export async function findChatBlockingPanel(client, {
743
+ textQueries = CHAT_BLOCKING_PANEL_TEXT_QUERIES
744
+ } = {}) {
745
+ const panel = await findBossAccountRightsBlockingPanel(client, { textQueries });
746
+ return panel.open
747
+ ? { ...panel, reason: "blocking_panel_text_visible" }
748
+ : panel;
749
+ }
750
+
751
+ export async function closeChatBlockingPanels(client, {
752
+ attemptsLimit = 2,
753
+ closeSelectors = CHAT_BLOCKING_PANEL_CLOSE_SELECTORS,
754
+ textQueries = CHAT_BLOCKING_PANEL_TEXT_QUERIES
755
+ } = {}) {
756
+ return closeBossAccountRightsBlockingPanel(client, {
757
+ attemptsLimit,
758
+ closeSelectors,
759
+ resolveRoots: getChatRoots,
760
+ textQueries
761
+ });
762
+ }
763
+
764
+ export async function readChatResumeHtml(client, resumeState) {
917
765
  let popupHTML = "";
918
766
  let contentHTML = "";
919
767
  let resumeIframeHTML = "";
@@ -0,0 +1,314 @@
1
+ import {
2
+ clickPoint,
3
+ DETERMINISTIC_CLICK_OPTIONS,
4
+ getDocumentRoot,
5
+ getNodeBox,
6
+ pressKey,
7
+ querySelectorAll,
8
+ sleep
9
+ } from "../../core/browser/index.js";
10
+
11
+ export const BOSS_ACCOUNT_RIGHTS_PANEL_TEXT_QUERIES = Object.freeze([
12
+ "我的权益",
13
+ "VVIP账号-精选版专享权益",
14
+ "全部账号权益使用量",
15
+ "职位发布权益总量",
16
+ "每日使用权益总量"
17
+ ]);
18
+
19
+ export const BOSS_ACCOUNT_RIGHTS_PANEL_CLOSE_SELECTORS = Object.freeze([
20
+ ".boss-popup__close",
21
+ ".boss-dialog__close",
22
+ ".side-panel-close",
23
+ ".drawer-close",
24
+ ".panel-close",
25
+ ".popup-close",
26
+ ".modal-close",
27
+ ".dialog-close",
28
+ ".close-btn",
29
+ ".icon-close",
30
+ "[class*=\"close\"]",
31
+ '[aria-label*="关闭"]',
32
+ '[title*="关闭"]'
33
+ ]);
34
+
35
+ async function performDomTextSearch(client, query, {
36
+ limit = 6
37
+ } = {}) {
38
+ if (typeof client?.DOM?.performSearch !== "function"
39
+ || typeof client?.DOM?.getSearchResults !== "function") {
40
+ return [];
41
+ }
42
+
43
+ const searchOnce = async () => {
44
+ let searchId = "";
45
+ try {
46
+ const search = await client.DOM.performSearch({
47
+ query,
48
+ includeUserAgentShadowDOM: true
49
+ });
50
+ searchId = search?.searchId || "";
51
+ const resultCount = Math.min(Number(search?.resultCount) || 0, Math.max(0, Number(limit) || 0));
52
+ if (!searchId || resultCount <= 0) return [];
53
+ const results = await client.DOM.getSearchResults({
54
+ searchId,
55
+ fromIndex: 0,
56
+ toIndex: resultCount
57
+ });
58
+ return results?.nodeIds || [];
59
+ } catch {
60
+ return [];
61
+ } finally {
62
+ if (searchId && typeof client?.DOM?.discardSearchResults === "function") {
63
+ try {
64
+ await client.DOM.discardSearchResults({ searchId });
65
+ } catch {
66
+ // Best-effort cleanup only.
67
+ }
68
+ }
69
+ }
70
+ };
71
+
72
+ const firstPass = await searchOnce();
73
+ if (!firstPass.length) return [];
74
+ const firstPassNodeIds = firstPass.filter((nodeId) => Number(nodeId) > 0);
75
+ if (firstPassNodeIds.length || typeof client?.DOM?.getDocument !== "function") return firstPassNodeIds;
76
+
77
+ try {
78
+ await client.DOM.getDocument({
79
+ depth: -1,
80
+ pierce: true
81
+ });
82
+ } catch {
83
+ return firstPassNodeIds;
84
+ }
85
+ return (await searchOnce()).filter((nodeId) => Number(nodeId) > 0);
86
+ }
87
+
88
+ export async function findBossAccountRightsBlockingPanel(client, {
89
+ textQueries = BOSS_ACCOUNT_RIGHTS_PANEL_TEXT_QUERIES
90
+ } = {}) {
91
+ for (const query of textQueries || []) {
92
+ const nodeIds = await performDomTextSearch(client, query);
93
+ for (const nodeId of nodeIds) {
94
+ try {
95
+ const box = await getNodeBox(client, nodeId);
96
+ if (box?.rect?.width > 2 && box?.rect?.height > 2) {
97
+ return {
98
+ open: true,
99
+ reason: "account_rights_panel_text_visible",
100
+ query,
101
+ node_id: nodeId,
102
+ rect: box.rect,
103
+ center: box.center
104
+ };
105
+ }
106
+ } catch {
107
+ // Hidden or stale text hits are ignored.
108
+ }
109
+ }
110
+ }
111
+ return {
112
+ open: false
113
+ };
114
+ }
115
+
116
+ export function accountRightsPanelOutsideClickPoint(probe = {}) {
117
+ const rect = probe?.rect || {};
118
+ // Click the empty lower-left sidebar area. It is outside the rights drawer
119
+ // and avoids top nav, chat rows, message controls, and candidate actions.
120
+ const x = 84;
121
+ const y = Number.isFinite(Number(rect.y))
122
+ ? Math.max(560, Math.min(680, Number(rect.y) + 600))
123
+ : 660;
124
+ return {
125
+ x,
126
+ y,
127
+ mode: "empty-lower-left-sidebar"
128
+ };
129
+ }
130
+
131
+ async function resolveBlockingPanelRoots(client, {
132
+ roots = null,
133
+ rootState = null,
134
+ resolveRoots = null
135
+ } = {}) {
136
+ if (Array.isArray(roots) && roots.some((root) => root?.nodeId)) {
137
+ return roots.filter((root) => root?.nodeId);
138
+ }
139
+ if (Array.isArray(rootState?.roots) && rootState.roots.some((root) => root?.nodeId)) {
140
+ return rootState.roots.filter((root) => root?.nodeId);
141
+ }
142
+ if (typeof resolveRoots === "function") {
143
+ try {
144
+ const resolved = await resolveRoots(client);
145
+ if (Array.isArray(resolved)) return resolved.filter((root) => root?.nodeId);
146
+ if (Array.isArray(resolved?.roots)) return resolved.roots.filter((root) => root?.nodeId);
147
+ } catch {
148
+ // Fall through to the top document. The rights panel lives there.
149
+ }
150
+ }
151
+ try {
152
+ const topRoot = await getDocumentRoot(client);
153
+ return [{ name: "top", nodeId: topRoot.nodeId }];
154
+ } catch {
155
+ return [];
156
+ }
157
+ }
158
+
159
+ async function findVisibleCloseTarget(client, roots, selectors) {
160
+ let fallback = null;
161
+ for (const root of roots || []) {
162
+ if (!root?.nodeId) continue;
163
+ for (const selector of selectors || []) {
164
+ let nodeIds = [];
165
+ try {
166
+ nodeIds = await querySelectorAll(client, root.nodeId, selector);
167
+ } catch {
168
+ nodeIds = [];
169
+ }
170
+ for (const nodeId of nodeIds) {
171
+ const target = {
172
+ root: root.name,
173
+ root_node_id: root.nodeId,
174
+ selector,
175
+ node_id: nodeId
176
+ };
177
+ if (!fallback) fallback = target;
178
+ try {
179
+ const box = await getNodeBox(client, nodeId);
180
+ if (box?.rect?.width > 2 && box?.rect?.height > 2) {
181
+ return {
182
+ ...target,
183
+ center: box.center,
184
+ rect: box.rect
185
+ };
186
+ }
187
+ } catch {
188
+ // Stale close candidates are ignored.
189
+ }
190
+ }
191
+ }
192
+ }
193
+ return fallback;
194
+ }
195
+
196
+ async function pressEscape(client) {
197
+ await pressKey(client, "Escape", {
198
+ code: "Escape",
199
+ windowsVirtualKeyCode: 27,
200
+ nativeVirtualKeyCode: 27
201
+ });
202
+ }
203
+
204
+ export async function closeBossAccountRightsBlockingPanel(client, {
205
+ attemptsLimit = 2,
206
+ closeSelectors = BOSS_ACCOUNT_RIGHTS_PANEL_CLOSE_SELECTORS,
207
+ resolveRoots = null,
208
+ roots = null,
209
+ rootState = null,
210
+ textQueries = BOSS_ACCOUNT_RIGHTS_PANEL_TEXT_QUERIES,
211
+ waitMs = 700
212
+ } = {}) {
213
+ const attempts = [];
214
+ let probe = await findBossAccountRightsBlockingPanel(client, { textQueries });
215
+ if (!probe.open) {
216
+ return {
217
+ closed: true,
218
+ already_closed: true,
219
+ probe,
220
+ attempts
221
+ };
222
+ }
223
+
224
+ for (let index = 0; index < attemptsLimit; index += 1) {
225
+ const outsidePoint = accountRightsPanelOutsideClickPoint(probe);
226
+ try {
227
+ await clickPoint(client, outsidePoint.x, outsidePoint.y, DETERMINISTIC_CLICK_OPTIONS);
228
+ attempts.push({
229
+ mode: "outside-click",
230
+ point: outsidePoint
231
+ });
232
+ } catch (error) {
233
+ attempts.push({
234
+ mode: "outside-click-error",
235
+ point: outsidePoint,
236
+ error: error?.message || String(error)
237
+ });
238
+ }
239
+ await sleep(waitMs);
240
+
241
+ probe = await findBossAccountRightsBlockingPanel(client, { textQueries });
242
+ if (!probe.open) {
243
+ return {
244
+ closed: true,
245
+ already_closed: false,
246
+ probe,
247
+ attempts
248
+ };
249
+ }
250
+
251
+ const resolvedRoots = await resolveBlockingPanelRoots(client, { roots, rootState, resolveRoots });
252
+ const closeTarget = await findVisibleCloseTarget(client, resolvedRoots, closeSelectors);
253
+ if (closeTarget) {
254
+ try {
255
+ if (closeTarget.center) {
256
+ await clickPoint(client, closeTarget.center.x, closeTarget.center.y, DETERMINISTIC_CLICK_OPTIONS);
257
+ }
258
+ attempts.push({
259
+ mode: "close-selector",
260
+ selector: closeTarget.selector,
261
+ root: closeTarget.root
262
+ });
263
+ } catch (error) {
264
+ attempts.push({
265
+ mode: "close-selector-error",
266
+ selector: closeTarget.selector,
267
+ root: closeTarget.root,
268
+ error: error?.message || String(error)
269
+ });
270
+ }
271
+ await sleep(waitMs);
272
+
273
+ probe = await findBossAccountRightsBlockingPanel(client, { textQueries });
274
+ if (!probe.open) {
275
+ return {
276
+ closed: true,
277
+ already_closed: false,
278
+ probe,
279
+ attempts
280
+ };
281
+ }
282
+ }
283
+
284
+ try {
285
+ await pressEscape(client);
286
+ attempts.push({ mode: closeTarget ? "Escape-fallback" : "Escape" });
287
+ } catch (error) {
288
+ attempts.push({
289
+ mode: "Escape-error",
290
+ error: error?.message || String(error)
291
+ });
292
+ }
293
+ await sleep(waitMs);
294
+
295
+ probe = await findBossAccountRightsBlockingPanel(client, { textQueries });
296
+ if (!probe.open) {
297
+ return {
298
+ closed: true,
299
+ already_closed: false,
300
+ probe,
301
+ attempts
302
+ };
303
+ }
304
+ }
305
+
306
+ return {
307
+ closed: false,
308
+ already_closed: false,
309
+ reason: "account_rights_panel_still_visible_after_close_attempts",
310
+ probe,
311
+ attempts
312
+ };
313
+ }
314
+
@@ -9,11 +9,15 @@ import {
9
9
  querySelectorAll,
10
10
  sleep
11
11
  } from "../../core/browser/index.js";
12
- import { candidateKeyFromProfile } from "../../core/infinite-list/index.js";
13
- import {
14
- buildScreeningCandidateFromDetail,
15
- htmlToText
16
- } from "../../core/screening/index.js";
12
+ import { candidateKeyFromProfile } from "../../core/infinite-list/index.js";
13
+ import {
14
+ buildScreeningCandidateFromDetail,
15
+ htmlToText
16
+ } from "../../core/screening/index.js";
17
+ import {
18
+ closeBossAccountRightsBlockingPanel,
19
+ findBossAccountRightsBlockingPanel
20
+ } from "../common/account-rights-panel.js";
17
21
  import {
18
22
  DETAIL_CLOSE_SELECTORS,
19
23
  DETAIL_NETWORK_PATTERNS,
@@ -41,7 +45,7 @@ export function matchesRecommendDetailNetwork(url) {
41
45
  return DETAIL_NETWORK_PATTERNS.some((pattern) => pattern.test(String(url || "")));
42
46
  }
43
47
 
44
- export function createRecommendDetailNetworkRecorder(client) {
48
+ export function createRecommendDetailNetworkRecorder(client) {
45
49
  const events = [];
46
50
  client.Network.responseReceived((event) => {
47
51
  const url = event?.response?.url || "";
@@ -76,9 +80,20 @@ export function createRecommendDetailNetworkRecorder(client) {
76
80
  events.length = 0;
77
81
  }
78
82
  };
79
- }
80
-
81
- export async function waitForRecommendDetailNetworkEvents(recorder, {
83
+ }
84
+
85
+ export async function findRecommendBlockingPanel(client, options = {}) {
86
+ return findBossAccountRightsBlockingPanel(client, options);
87
+ }
88
+
89
+ export async function closeRecommendBlockingPanels(client, options = {}) {
90
+ return closeBossAccountRightsBlockingPanel(client, {
91
+ resolveRoots: getRecommendRoots,
92
+ ...options
93
+ });
94
+ }
95
+
96
+ export async function waitForRecommendDetailNetworkEvents(recorder, {
82
97
  minCount = 1,
83
98
  requireLoaded = true,
84
99
  timeoutMs = 3500,
@@ -45,7 +45,8 @@ import {
45
45
  llmResultToScreening,
46
46
  screenCandidate
47
47
  } from "../../core/screening/index.js";
48
- import {
48
+ import {
49
+ closeRecommendBlockingPanels,
49
50
  closeRecommendDetail,
50
51
  createRecommendDetailNetworkRecorder,
51
52
  extractRecommendDetailCandidate,
@@ -465,12 +466,17 @@ function countPassedResults(results = []) {
465
466
 
466
467
  function compactCloseResult(closeResult) {
467
468
  if (!closeResult) return null;
468
- return {
469
+ const result = {
469
470
  closed: Boolean(closeResult.closed),
470
471
  reason: closeResult.reason || null,
472
+ probe: closeResult.probe || null,
471
473
  attempts: closeResult.attempts || [],
472
474
  verification: closeResult.verification || null
473
475
  };
476
+ if (closeResult.already_closed !== undefined) {
477
+ result.already_closed = Boolean(closeResult.already_closed);
478
+ }
479
+ return result;
474
480
  }
475
481
 
476
482
  function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
@@ -482,6 +488,9 @@ function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
482
488
  if (error.close_result) {
483
489
  result.close_result = compactCloseResult(error.close_result);
484
490
  }
491
+ if (error.phase) {
492
+ result.phase = error.phase;
493
+ }
485
494
  if (error.refresh_attempt) {
486
495
  result.refresh_attempt = error.refresh_attempt;
487
496
  }
@@ -507,6 +516,14 @@ function createRecommendCloseFailureError(closeResult) {
507
516
  return error;
508
517
  }
509
518
 
519
+ function createRecommendBlockingPanelCloseFailureError(closeResult, phase = "") {
520
+ const error = new Error(closeResult?.reason || "Boss account-rights panel did not close before recovery");
521
+ error.code = "ACCOUNT_RIGHTS_PANEL_CLOSE_FAILED";
522
+ error.close_result = closeResult || null;
523
+ error.phase = phase || null;
524
+ return error;
525
+ }
526
+
510
527
  function createRecommendRefreshFailureError(refreshAttempt, {
511
528
  listEndReason = "",
512
529
  targetCount = 0,
@@ -699,10 +716,11 @@ export async function runRecommendWorkflow({
699
716
  let refreshRounds = 0;
700
717
  let contextRecoveryAttempts = 0;
701
718
  let greetCount = 0;
702
- const candidateRecoveryCounts = new Map();
703
- let jobSelection = null;
719
+ const candidateRecoveryCounts = new Map();
720
+ let jobSelection = null;
704
721
  let pageScopeSelection = null;
705
722
  let filterResult = null;
723
+ let rootState = null;
706
724
  let cardNodeIds = [];
707
725
  let listEndReason = "";
708
726
  let lastHumanEvent = null;
@@ -769,9 +787,9 @@ export async function runRecommendWorkflow({
769
787
  });
770
788
  }
771
789
 
772
- function checkpointInProgressCandidate({
773
- index = results.length,
774
- candidateKey = "",
790
+ function checkpointInProgressCandidate({
791
+ index = results.length,
792
+ candidateKey = "",
775
793
  cardNodeId = null,
776
794
  detailStep = "",
777
795
  error = null
@@ -785,11 +803,22 @@ export async function runRecommendWorkflow({
785
803
  counters: countRecommendResultStatuses(results, { greetCount }),
786
804
  error: compactError(error, "RECOMMEND_IN_PROGRESS_ERROR")
787
805
  },
788
- candidate_list: compactInfiniteListState(listState)
789
- });
790
- }
791
-
792
- async function recoverAndReapplyRecommendContext(reason = "context_recovery", error = null, {
806
+ candidate_list: compactInfiniteListState(listState)
807
+ });
808
+ }
809
+
810
+ async function closeRecommendBlockingPanelsForRun(phase = "cleanup") {
811
+ const result = await closeRecommendBlockingPanels(client, {
812
+ attemptsLimit: 2,
813
+ rootState
814
+ });
815
+ if (!result?.closed) {
816
+ throw createRecommendBlockingPanelCloseFailureError(result, phase);
817
+ }
818
+ return result;
819
+ }
820
+
821
+ async function recoverAndReapplyRecommendContext(reason = "context_recovery", error = null, {
793
822
  forceRecentNotView = true
794
823
  } = {}) {
795
824
  await runControl.waitIfPaused();
@@ -797,9 +826,9 @@ export async function runRecommendWorkflow({
797
826
  const started = Date.now();
798
827
  runControl.setPhase("recommend:recover-context");
799
828
  contextRecoveryAttempts += 1;
800
- const refreshResult = await refreshRecommendListAtEnd(client, {
801
- rootState,
802
- jobLabel,
829
+ const refreshResult = await refreshRecommendListAtEnd(client, {
830
+ rootState,
831
+ jobLabel,
803
832
  pageScope: pageScopeSelection?.effective_scope || requestedPageScope,
804
833
  fallbackPageScope: normalizedFallbackPageScope,
805
834
  filter: normalizedFilter,
@@ -808,16 +837,24 @@ export async function runRecommendWorkflow({
808
837
  targetUrl: targetUrl || RECOMMEND_TARGET_URL,
809
838
  forceRecentNotView,
810
839
  cardTimeoutMs,
811
- buttonSettleMs: refreshButtonSettleMs,
812
- reloadSettleMs: refreshReloadSettleMs
813
- });
814
- const compactRefresh = {
815
- ...compactRefreshAttempt(refreshResult),
816
- context_recovery: true,
817
- recovery_reason: reason,
818
- trigger_error: compactError(error, "RECOMMEND_CONTEXT_RECOVERY_TRIGGER"),
819
- elapsed_ms: Date.now() - started
820
- };
840
+ buttonSettleMs: refreshButtonSettleMs,
841
+ reloadSettleMs: refreshReloadSettleMs
842
+ });
843
+ let blockingPanelClose = null;
844
+ if (refreshResult.ok) {
845
+ blockingPanelClose = await closeRecommendBlockingPanels(client, {
846
+ attemptsLimit: 2,
847
+ rootState: refreshResult.root_state || rootState
848
+ });
849
+ }
850
+ const compactRefresh = {
851
+ ...compactRefreshAttempt(refreshResult),
852
+ context_recovery: true,
853
+ recovery_reason: reason,
854
+ trigger_error: compactError(error, "RECOMMEND_CONTEXT_RECOVERY_TRIGGER"),
855
+ account_rights_panel_close: compactCloseResult(blockingPanelClose),
856
+ elapsed_ms: Date.now() - started
857
+ };
821
858
  refreshAttempts.push(compactRefresh);
822
859
  runControl.checkpoint({
823
860
  context_recovery: {
@@ -834,10 +871,15 @@ export async function runRecommendWorkflow({
834
871
  refresh_method: refreshResult.method || null,
835
872
  refresh_forced_recent_not_view: forceRecentNotView,
836
873
  recovery_reason: reason
837
- });
838
- throw new Error(`Recommend context recovery failed after ${reason}: ${refreshResult.reason || refreshResult.error || "refresh returned no cards"}`);
839
- }
840
- rootState = refreshResult.root_state || await getRecommendRoots(client);
874
+ });
875
+ throw new Error(`Recommend context recovery failed after ${reason}: ${refreshResult.reason || refreshResult.error || "refresh returned no cards"}`);
876
+ }
877
+ if (!blockingPanelClose?.closed) {
878
+ const panelError = createRecommendBlockingPanelCloseFailureError(blockingPanelClose, `recover:${reason}`);
879
+ panelError.refresh_attempt = compactRefresh;
880
+ throw panelError;
881
+ }
882
+ rootState = refreshResult.root_state || await getRecommendRoots(client);
841
883
  rootState = await ensureRecommendViewport(rootState, "recover_after");
842
884
  cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
843
885
  timeoutMs: cardTimeoutMs,
@@ -863,13 +905,14 @@ export async function runRecommendWorkflow({
863
905
  return refreshResult;
864
906
  }
865
907
 
866
- runControl.setPhase("recommend:cleanup");
867
- await closeRecommendDetail(client, { attemptsLimit: 2 });
868
-
869
- await runControl.waitIfPaused();
870
- runControl.throwIfCanceled();
871
- runControl.setPhase("recommend:roots");
872
- let rootState = await getRecommendRoots(client);
908
+ runControl.setPhase("recommend:cleanup");
909
+ await closeRecommendDetail(client, { attemptsLimit: 2 });
910
+ await closeRecommendBlockingPanelsForRun("cleanup");
911
+
912
+ await runControl.waitIfPaused();
913
+ runControl.throwIfCanceled();
914
+ runControl.setPhase("recommend:roots");
915
+ rootState = await getRecommendRoots(client);
873
916
  rootState = await ensureRecommendViewport(rootState, "roots");
874
917
  runControl.checkpoint({
875
918
  iframe_selector: rootState.iframe.selector,
@@ -1067,9 +1110,28 @@ export async function runRecommendWorkflow({
1067
1110
  try {
1068
1111
  await runControl.waitIfPaused();
1069
1112
  runControl.throwIfCanceled();
1070
- runControl.setPhase("recommend:detail");
1071
- detailStep = "ensure_viewport";
1072
- rootState = await ensureRecommendViewport(rootState, "detail");
1113
+ runControl.setPhase("recommend:detail");
1114
+ detailStep = "ensure_viewport";
1115
+ rootState = await ensureRecommendViewport(rootState, "detail");
1116
+ const blockingPanelClose = await closeRecommendBlockingPanels(client, {
1117
+ attemptsLimit: 2,
1118
+ rootState
1119
+ });
1120
+ if (!blockingPanelClose?.closed) {
1121
+ const panelError = createRecommendBlockingPanelCloseFailureError(
1122
+ blockingPanelClose,
1123
+ "before_detail_open"
1124
+ );
1125
+ timings.account_rights_panel_close = compactCloseResult(blockingPanelClose);
1126
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error: panelError });
1127
+ await recoverAndReapplyRecommendContext("account_rights_panel_before_detail", panelError, {
1128
+ forceRecentNotView: true
1129
+ });
1130
+ continue;
1131
+ }
1132
+ if (blockingPanelClose.already_closed === false) {
1133
+ timings.account_rights_panel_close = compactCloseResult(blockingPanelClose);
1134
+ }
1073
1135
  checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
1074
1136
  detailStep = "open_detail";
1075
1137
  networkRecorder.clear();
@@ -1182,12 +1244,13 @@ export async function runRecommendWorkflow({
1182
1244
  const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
1183
1245
  if (recoveryCount < 1) {
1184
1246
  candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
1185
- timings.image_capture_recovery_trigger = compactError(error, "IMAGE_CAPTURE_FAILED");
1186
- checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1187
- await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1188
- await recoverAndReapplyRecommendContext(`image_capture:${detailStep}`, error, {
1189
- forceRecentNotView: true
1190
- });
1247
+ timings.image_capture_recovery_trigger = compactError(error, "IMAGE_CAPTURE_FAILED");
1248
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1249
+ await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1250
+ await closeRecommendBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
1251
+ await recoverAndReapplyRecommendContext(`image_capture:${detailStep}`, error, {
1252
+ forceRecentNotView: true
1253
+ });
1191
1254
  continue;
1192
1255
  }
1193
1256
  imageEvidence = createRecoverableImageCaptureEvidence(error, {
@@ -1232,20 +1295,22 @@ export async function runRecommendWorkflow({
1232
1295
  if (!isRecoverableRecommendDetailError(error)) throw error;
1233
1296
  const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
1234
1297
  if (recoveryCount < 1) {
1235
- candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
1236
- timings.detail_recovery_trigger = compactRecoverableDetailError(error);
1237
- checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1238
- await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1239
- await recoverAndReapplyRecommendContext(`detail:${detailStep}`, error, {
1240
- forceRecentNotView: true
1241
- });
1298
+ candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
1299
+ timings.detail_recovery_trigger = compactRecoverableDetailError(error);
1300
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1301
+ await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1302
+ await closeRecommendBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
1303
+ await recoverAndReapplyRecommendContext(`detail:${detailStep}`, error, {
1304
+ forceRecentNotView: true
1305
+ });
1242
1306
  continue;
1243
1307
  }
1244
- recoverableDetailError = error;
1245
- detailResult = null;
1246
- timings.detail_recovered_error = compactRecoverableDetailError(error);
1247
- await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1248
- }
1308
+ recoverableDetailError = error;
1309
+ detailResult = null;
1310
+ timings.detail_recovered_error = compactRecoverableDetailError(error);
1311
+ await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1312
+ await closeRecommendBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
1313
+ }
1249
1314
  }
1250
1315
 
1251
1316
  await runControl.waitIfPaused();
@@ -13,6 +13,10 @@ import {
13
13
  buildScreeningCandidateFromDetail,
14
14
  htmlToText
15
15
  } from "../../core/screening/index.js";
16
+ import {
17
+ closeBossAccountRightsBlockingPanel,
18
+ findBossAccountRightsBlockingPanel
19
+ } from "../common/account-rights-panel.js";
16
20
  import {
17
21
  RECRUIT_DETAIL_CLOSE_SELECTORS,
18
22
  RECRUIT_DETAIL_NETWORK_PATTERNS,
@@ -64,6 +68,17 @@ export function createRecruitDetailNetworkRecorder(client) {
64
68
  };
65
69
  }
66
70
 
71
+ export async function findRecruitBlockingPanel(client, options = {}) {
72
+ return findBossAccountRightsBlockingPanel(client, options);
73
+ }
74
+
75
+ export async function closeRecruitBlockingPanels(client, options = {}) {
76
+ return closeBossAccountRightsBlockingPanel(client, {
77
+ resolveRoots: getRecruitRoots,
78
+ ...options
79
+ });
80
+ }
81
+
67
82
  export async function waitForRecruitDetailNetworkEvents(recorder, {
68
83
  minCount = 1,
69
84
  requireLoaded = true,
@@ -44,6 +44,7 @@ import {
44
44
  screenCandidate
45
45
  } from "../../core/screening/index.js";
46
46
  import {
47
+ closeRecruitBlockingPanels,
47
48
  closeRecruitDetail,
48
49
  createRecruitDetailNetworkRecorder,
49
50
  extractRecruitDetailCandidate,
@@ -159,6 +160,12 @@ function compactError(error, fallbackCode = "RECRUIT_RUN_ERROR") {
159
160
  code: error.code || fallbackCode,
160
161
  message: error.message || String(error)
161
162
  };
163
+ if (error.close_result) {
164
+ result.close_result = compactCloseResult(error.close_result);
165
+ }
166
+ if (error.phase) {
167
+ result.phase = error.phase;
168
+ }
162
169
  if (error.refresh_attempt) {
163
170
  result.refresh_attempt = error.refresh_attempt;
164
171
  }
@@ -174,6 +181,21 @@ function compactError(error, fallbackCode = "RECRUIT_RUN_ERROR") {
174
181
  return result;
175
182
  }
176
183
 
184
+ function compactCloseResult(closeResult) {
185
+ if (!closeResult) return null;
186
+ const result = {
187
+ closed: Boolean(closeResult.closed),
188
+ reason: closeResult.reason || null,
189
+ probe: closeResult.probe || null,
190
+ attempts: closeResult.attempts || [],
191
+ verification: closeResult.verification || null
192
+ };
193
+ if (closeResult.already_closed !== undefined) {
194
+ result.already_closed = Boolean(closeResult.already_closed);
195
+ }
196
+ return result;
197
+ }
198
+
177
199
  function createRecruitCloseFailureError(closeResult) {
178
200
  const error = new Error(closeResult?.reason || "Recruit detail did not close before recovery");
179
201
  error.code = "DETAIL_CLOSE_FAILED";
@@ -181,6 +203,14 @@ function createRecruitCloseFailureError(closeResult) {
181
203
  return error;
182
204
  }
183
205
 
206
+ function createRecruitBlockingPanelCloseFailureError(closeResult, phase = "") {
207
+ const error = new Error(closeResult?.reason || "Boss account-rights panel did not close before recovery");
208
+ error.code = "ACCOUNT_RIGHTS_PANEL_CLOSE_FAILED";
209
+ error.close_result = closeResult || null;
210
+ error.phase = phase || null;
211
+ return error;
212
+ }
213
+
184
214
  function createRecruitRefreshFailureError(refreshAttempt, {
185
215
  listEndReason = "",
186
216
  targetCount = 0,
@@ -397,6 +427,7 @@ export async function runRecruitWorkflow({
397
427
  let refreshRounds = 0;
398
428
  let contextRecoveryAttempts = 0;
399
429
  const candidateRecoveryCounts = new Map();
430
+ let rootState = null;
400
431
  let cardNodeIds = [];
401
432
  let listEndReason = "";
402
433
  let lastHumanEvent = null;
@@ -482,6 +513,17 @@ export async function runRecruitWorkflow({
482
513
  });
483
514
  }
484
515
 
516
+ async function closeRecruitBlockingPanelsForRun(phase = "cleanup") {
517
+ const result = await closeRecruitBlockingPanels(client, {
518
+ attemptsLimit: 2,
519
+ rootState
520
+ });
521
+ if (!result?.closed) {
522
+ throw createRecruitBlockingPanelCloseFailureError(result, phase);
523
+ }
524
+ return result;
525
+ }
526
+
485
527
  async function recoverAndReapplyRecruitContext(reason = "context_recovery", error = null, {
486
528
  forceRecentViewed = true
487
529
  } = {}) {
@@ -499,11 +541,18 @@ export async function runRecruitWorkflow({
499
541
  cityOptionTimeoutMs,
500
542
  forceRecentViewed
501
543
  });
544
+ let blockingPanelClose = null;
545
+ if (refreshResult.ok) {
546
+ blockingPanelClose = await closeRecruitBlockingPanels(client, {
547
+ attemptsLimit: 2
548
+ });
549
+ }
502
550
  const compactRefresh = {
503
551
  ...compactRefreshAttempt(refreshResult),
504
552
  context_recovery: true,
505
553
  recovery_reason: reason,
506
554
  trigger_error: compactError(error, "RECRUIT_CONTEXT_RECOVERY_TRIGGER"),
555
+ account_rights_panel_close: compactCloseResult(blockingPanelClose),
507
556
  elapsed_ms: Date.now() - started
508
557
  };
509
558
  refreshAttempts.push(compactRefresh);
@@ -525,6 +574,11 @@ export async function runRecruitWorkflow({
525
574
  });
526
575
  throw new Error(`Recruit context recovery failed after ${reason}: ${refreshResult.application?.reason || "refresh returned no cards"}`);
527
576
  }
577
+ if (!blockingPanelClose?.closed) {
578
+ const panelError = createRecruitBlockingPanelCloseFailureError(blockingPanelClose, `recover:${reason}`);
579
+ panelError.refresh_attempt = compactRefresh;
580
+ throw panelError;
581
+ }
528
582
  rootState = await getRecruitRoots(client);
529
583
  rootState = await ensureRecruitViewport(rootState, "recover_after");
530
584
  cardNodeIds = await waitForRecruitCardNodeIds(client, rootState.iframe.documentNodeId, {
@@ -553,11 +607,12 @@ export async function runRecruitWorkflow({
553
607
 
554
608
  runControl.setPhase("recruit:cleanup");
555
609
  await closeRecruitDetail(client, { attemptsLimit: 2 });
610
+ await closeRecruitBlockingPanelsForRun("cleanup");
556
611
 
557
612
  await runControl.waitIfPaused();
558
613
  runControl.throwIfCanceled();
559
614
  runControl.setPhase("recruit:roots");
560
- let rootState = await getRecruitRoots(client);
615
+ rootState = await getRecruitRoots(client);
561
616
  rootState = await ensureRecruitViewport(rootState, "roots");
562
617
  runControl.checkpoint({
563
618
  iframe_selector: rootState.iframe.selector,
@@ -732,6 +787,25 @@ export async function runRecruitWorkflow({
732
787
  runControl.setPhase("recruit:detail");
733
788
  detailStep = "ensure_viewport";
734
789
  rootState = await ensureRecruitViewport(rootState, "detail");
790
+ const blockingPanelClose = await closeRecruitBlockingPanels(client, {
791
+ attemptsLimit: 2,
792
+ rootState
793
+ });
794
+ if (!blockingPanelClose?.closed) {
795
+ const panelError = createRecruitBlockingPanelCloseFailureError(
796
+ blockingPanelClose,
797
+ "before_detail_open"
798
+ );
799
+ timings.account_rights_panel_close = compactCloseResult(blockingPanelClose);
800
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error: panelError });
801
+ await recoverAndReapplyRecruitContext("account_rights_panel_before_detail", panelError, {
802
+ forceRecentViewed: true
803
+ });
804
+ continue;
805
+ }
806
+ if (blockingPanelClose.already_closed === false) {
807
+ timings.account_rights_panel_close = compactCloseResult(blockingPanelClose);
808
+ }
735
809
  checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
736
810
  detailStep = "open_detail";
737
811
  networkRecorder.clear();
@@ -835,6 +909,7 @@ export async function runRecruitWorkflow({
835
909
  timings.image_capture_recovery_trigger = compactError(error, "IMAGE_CAPTURE_FAILED");
836
910
  checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
837
911
  await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
912
+ await closeRecruitBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
838
913
  await recoverAndReapplyRecruitContext(`image_capture:${detailStep}`, error, {
839
914
  forceRecentViewed: true
840
915
  });
@@ -907,6 +982,7 @@ export async function runRecruitWorkflow({
907
982
  timings.detail_recovery_trigger = compactRecoverableDetailError(error);
908
983
  checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
909
984
  await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
985
+ await closeRecruitBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
910
986
  await recoverAndReapplyRecruitContext(`detail:${detailStep}`, error, {
911
987
  forceRecentViewed: true
912
988
  });
@@ -916,6 +992,7 @@ export async function runRecruitWorkflow({
916
992
  detailResult = null;
917
993
  timings.detail_recovered_error = compactRecoverableDetailError(error);
918
994
  await closeRecruitDetail(client, { attemptsLimit: 2 }).catch(() => null);
995
+ await closeRecruitBlockingPanels(client, { attemptsLimit: 2, rootState }).catch(() => null);
919
996
  }
920
997
  }
921
998