@reconcrap/boss-recommend-mcp 2.0.41 → 2.0.43

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.41",
3
+ "version": "2.0.43",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -60,6 +60,7 @@
60
60
  "live:infinite-list": "node scripts/live-infinite-list-smoke.js",
61
61
  "live:scroll-end": "node scripts/live-scroll-end-screenshot.js",
62
62
  "live:refresh-round": "node scripts/live-refresh-round-smoke.js",
63
+ "live:recommend-recovery": "node scripts/live-recommend-recovery-smoke.js",
63
64
  "live:self-heal": "node scripts/live-self-heal-smoke.js",
64
65
  "live:recommend-actions": "node scripts/live-recommend-actions-smoke.js",
65
66
  "live:recommend-phase10-full": "node scripts/live-recommend-phase10-full.js",
@@ -82,6 +83,7 @@
82
83
  "config/screening-config.example.json",
83
84
  "skills",
84
85
  "scripts/postinstall.cjs",
86
+ "scripts/live-recommend-recovery-smoke.js",
85
87
  "src/core",
86
88
  "src/domains",
87
89
  "src/chat-mcp.js",
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import {
6
+ assertNoForbiddenCdpCalls,
7
+ assertRuntimeEvaluateBlocked,
8
+ bringPageToFront,
9
+ connectToChromeTarget,
10
+ enableDomains,
11
+ getAttributesMap,
12
+ getOuterHTML,
13
+ querySelector,
14
+ sleep
15
+ } from "../src/core/browser/index.js";
16
+ import {
17
+ htmlToText,
18
+ normalizeText
19
+ } from "../src/core/screening/index.js";
20
+ import {
21
+ findRecommendJobTrigger,
22
+ getRecommendRoots,
23
+ refreshRecommendListAtEnd,
24
+ RECOMMEND_TARGET_URL,
25
+ waitForRecommendCardNodeIds
26
+ } from "../src/domains/recommend/index.js";
27
+
28
+ function parseArgs(argv) {
29
+ const options = {
30
+ host: "127.0.0.1",
31
+ port: 9222,
32
+ targetUrl: RECOMMEND_TARGET_URL,
33
+ targetUrlIncludes: RECOMMEND_TARGET_URL,
34
+ jobLabel: "",
35
+ pageScope: "recommend",
36
+ fallbackPageScope: "recommend",
37
+ forceNavigate: true,
38
+ forceRecentNotView: true,
39
+ reloadSettleMs: 12000,
40
+ cardTimeoutMs: 60000,
41
+ saveReport: ".live-artifacts/recommend-recovery-smoke.json",
42
+ filterGroups: [
43
+ {
44
+ group: "degree",
45
+ labels: ["本科", "硕士", "博士"],
46
+ selectAllLabels: true
47
+ },
48
+ {
49
+ group: "school",
50
+ labels: ["985", "211", "双一流院校", "国内外名校"],
51
+ selectAllLabels: true
52
+ }
53
+ ]
54
+ };
55
+
56
+ for (let index = 0; index < argv.length; index += 1) {
57
+ const arg = argv[index];
58
+ if (arg === "--host") options.host = argv[++index];
59
+ if (arg === "--port") options.port = Number(argv[++index]);
60
+ if (arg === "--target-url") options.targetUrl = argv[++index];
61
+ if (arg === "--target-url-includes") options.targetUrlIncludes = argv[++index];
62
+ if (arg === "--job") options.jobLabel = argv[++index];
63
+ if (arg === "--page-scope") options.pageScope = argv[++index];
64
+ if (arg === "--fallback-page-scope") options.fallbackPageScope = argv[++index];
65
+ if (arg === "--no-force-navigate") options.forceNavigate = false;
66
+ if (arg === "--no-recent-not-view") options.forceRecentNotView = false;
67
+ if (arg === "--reload-settle-ms") options.reloadSettleMs = Number(argv[++index]);
68
+ if (arg === "--card-timeout-ms") options.cardTimeoutMs = Number(argv[++index]);
69
+ if (arg === "--save-report") options.saveReport = argv[++index];
70
+ if (arg === "--no-save-report") options.saveReport = "";
71
+ if (arg === "--no-default-filter") options.filterGroups = [];
72
+ if (arg === "--filter") {
73
+ const raw = String(argv[++index] || "");
74
+ const [group, labelsRaw = ""] = raw.split(/[:=]/);
75
+ options.filterGroups.push({
76
+ group: group.trim(),
77
+ labels: labelsRaw.split(/[,,、|/]/).map((item) => item.trim()).filter(Boolean),
78
+ selectAllLabels: true
79
+ });
80
+ }
81
+ }
82
+
83
+ return options;
84
+ }
85
+
86
+ function methodSummary(methodLog) {
87
+ const summary = {};
88
+ for (const entry of methodLog) {
89
+ summary[entry.method] = (summary[entry.method] || 0) + 1;
90
+ }
91
+ return summary;
92
+ }
93
+
94
+ function writeJsonFile(filePath, payload) {
95
+ const resolved = path.resolve(filePath);
96
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
97
+ fs.writeFileSync(resolved, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
98
+ return resolved;
99
+ }
100
+
101
+ async function connectToRecommendSession(options) {
102
+ try {
103
+ return await connectToChromeTarget({
104
+ host: options.host,
105
+ port: options.port,
106
+ targetUrlIncludes: options.targetUrlIncludes
107
+ });
108
+ } catch (error) {
109
+ return connectToChromeTarget({
110
+ host: options.host,
111
+ port: options.port,
112
+ targetPredicate: (target) => (
113
+ target?.type === "page"
114
+ && String(target?.url || "").includes("zhipin.com/web/chat")
115
+ )
116
+ });
117
+ }
118
+ }
119
+
120
+ async function readCurrentJobLabel(client, frameDocumentNodeId) {
121
+ const labelNodeId = await querySelector(
122
+ client,
123
+ frameDocumentNodeId,
124
+ ".job-selecter-wrap .ui-dropmenu-label, .ui-dropmenu-label"
125
+ );
126
+ if (!labelNodeId) return "";
127
+ const html = await getOuterHTML(client, labelNodeId);
128
+ return normalizeText(htmlToText(html));
129
+ }
130
+
131
+ async function summarizeRecommendState(client) {
132
+ const roots = await getRecommendRoots(client);
133
+ const iframeAttributes = await getAttributesMap(client, roots.iframe.nodeId).catch(() => ({}));
134
+ const cardNodeIds = await waitForRecommendCardNodeIds(client, roots.iframe.documentNodeId, {
135
+ timeoutMs: 1200,
136
+ intervalMs: 200
137
+ }).catch(() => []);
138
+ const trigger = await findRecommendJobTrigger(client, roots.iframe.documentNodeId).catch(() => null);
139
+ const currentJobLabel = trigger
140
+ ? await readCurrentJobLabel(client, roots.iframe.documentNodeId).catch(() => "")
141
+ : "";
142
+
143
+ return {
144
+ roots,
145
+ summary: {
146
+ iframe_selector: roots.iframe.selector || "",
147
+ iframe_node_id: roots.iframe.nodeId,
148
+ iframe_document_node_id: roots.iframe.documentNodeId,
149
+ iframe_src: iframeAttributes.src || "",
150
+ current_job_label: currentJobLabel,
151
+ job_trigger_found: Boolean(trigger),
152
+ job_trigger_rect: trigger?.rect || null,
153
+ card_count: cardNodeIds.length
154
+ }
155
+ };
156
+ }
157
+
158
+ function compactRefreshResult(refreshResult = {}) {
159
+ return {
160
+ ok: Boolean(refreshResult.ok),
161
+ method: refreshResult.method || "",
162
+ reason: refreshResult.reason || null,
163
+ error: refreshResult.error || null,
164
+ forced_recent_not_view: Boolean(refreshResult.forced_recent_not_view),
165
+ target_url: refreshResult.target_url || null,
166
+ card_count: refreshResult.card_count || 0,
167
+ elapsed_ms: refreshResult.elapsed_ms || 0,
168
+ attempts: (refreshResult.attempts || []).map((attempt) => ({
169
+ ok: Boolean(attempt.ok),
170
+ method: attempt.method || "",
171
+ reason: attempt.reason || null,
172
+ error: attempt.error || null,
173
+ card_count: attempt.card_count || 0,
174
+ elapsed_ms: attempt.elapsed_ms || 0,
175
+ job_selection_attempts: attempt.job_selection_attempts || []
176
+ })),
177
+ job_selection: refreshResult.job_selection
178
+ ? {
179
+ requested: refreshResult.job_selection.requested,
180
+ selected: Boolean(refreshResult.job_selection.selected),
181
+ already_current: Boolean(refreshResult.job_selection.already_current),
182
+ reason: refreshResult.job_selection.reason || null,
183
+ selected_option: refreshResult.job_selection.selected_option || null,
184
+ refresh_attempts: refreshResult.job_selection.refresh_attempts || []
185
+ }
186
+ : null,
187
+ job_selection_attempts: refreshResult.job_selection_attempts || [],
188
+ page_scope: refreshResult.page_scope
189
+ ? {
190
+ requested_scope: refreshResult.page_scope.requested_scope,
191
+ effective_scope: refreshResult.page_scope.effective_scope,
192
+ selected: Boolean(refreshResult.page_scope.selected),
193
+ fallback_applied: Boolean(refreshResult.page_scope.fallback_applied),
194
+ reason: refreshResult.page_scope.reason || null,
195
+ card_count: refreshResult.page_scope.card_count || refreshResult.page_scope.after?.card_count || 0
196
+ }
197
+ : null,
198
+ filter: refreshResult.filter
199
+ ? {
200
+ confirmed: Boolean(refreshResult.filter.confirmed),
201
+ selected_option: refreshResult.filter.selected_option || null,
202
+ selected_options: refreshResult.filter.selected_options || []
203
+ }
204
+ : null,
205
+ filter_reapply_attempts: refreshResult.filter_reapply_attempts || []
206
+ };
207
+ }
208
+
209
+ async function run() {
210
+ const options = parseArgs(process.argv.slice(2));
211
+ let session;
212
+ const result = {
213
+ status: "UNKNOWN",
214
+ generated_at: new Date().toISOString(),
215
+ chrome: {
216
+ host: options.host,
217
+ port: options.port,
218
+ target_url_includes: options.targetUrlIncludes
219
+ },
220
+ input: {
221
+ target_url: options.targetUrl,
222
+ job_label: options.jobLabel,
223
+ page_scope: options.pageScope,
224
+ fallback_page_scope: options.fallbackPageScope,
225
+ force_navigate: options.forceNavigate,
226
+ force_recent_not_view: options.forceRecentNotView,
227
+ reload_settle_ms: options.reloadSettleMs,
228
+ card_timeout_ms: options.cardTimeoutMs,
229
+ filter_groups: options.filterGroups
230
+ },
231
+ before: null,
232
+ refresh: null,
233
+ after: null
234
+ };
235
+
236
+ try {
237
+ session = await connectToRecommendSession(options);
238
+ const { client, methodLog, target } = session;
239
+ result.chrome.target = {
240
+ id: target.id,
241
+ type: target.type,
242
+ url: target.url,
243
+ title: target.title
244
+ };
245
+ result.runtime_guard_probe = await assertRuntimeEvaluateBlocked(client);
246
+ await enableDomains(client, ["Page", "DOM", "Input", "Accessibility"]);
247
+ await bringPageToFront(client);
248
+
249
+ const beforeState = await summarizeRecommendState(client);
250
+ result.before = beforeState.summary;
251
+ const jobLabel = options.jobLabel || beforeState.summary.current_job_label;
252
+ if (!jobLabel) {
253
+ throw new Error("No recommend job label was provided or detectable; pass --job");
254
+ }
255
+ result.input.job_label = jobLabel;
256
+
257
+ const refreshResult = await refreshRecommendListAtEnd(client, {
258
+ rootState: beforeState.roots,
259
+ jobLabel,
260
+ pageScope: options.pageScope,
261
+ fallbackPageScope: options.fallbackPageScope,
262
+ filter: { filterGroups: options.filterGroups },
263
+ preferEndRefreshButton: false,
264
+ forceNavigate: options.forceNavigate,
265
+ targetUrl: options.targetUrl,
266
+ forceRecentNotView: options.forceRecentNotView,
267
+ cardTimeoutMs: options.cardTimeoutMs,
268
+ reloadSettleMs: options.reloadSettleMs
269
+ });
270
+ result.refresh = compactRefreshResult(refreshResult);
271
+
272
+ await sleep(1000);
273
+ const afterState = await summarizeRecommendState(client);
274
+ result.after = afterState.summary;
275
+ result.method_summary = methodSummary(methodLog);
276
+ assertNoForbiddenCdpCalls(methodLog);
277
+
278
+ if (!refreshResult.ok) {
279
+ throw new Error(`Recommend recovery refresh failed: ${refreshResult.reason || refreshResult.error || "unknown"}`);
280
+ }
281
+ if (!refreshResult.job_selection?.selected) {
282
+ throw new Error("Recommend recovery smoke did not select the requested job");
283
+ }
284
+ if (!refreshResult.card_count) {
285
+ throw new Error("Recommend recovery smoke found no cards after refresh");
286
+ }
287
+
288
+ result.status = "PASS";
289
+ } catch (error) {
290
+ result.status = "FAIL";
291
+ result.error = {
292
+ message: error?.message || String(error),
293
+ stack: error?.stack || ""
294
+ };
295
+ process.exitCode = 1;
296
+ } finally {
297
+ if (session) await session.close().catch(() => null);
298
+ if (options.saveReport) {
299
+ result.report_path = writeJsonFile(options.saveReport, result);
300
+ }
301
+ console.log(JSON.stringify(result, null, 2));
302
+ }
303
+ }
304
+
305
+ await run();
@@ -22,12 +22,21 @@ import {
22
22
  import {
23
23
  getRecommendRoots
24
24
  } from "./roots.js";
25
- import {
26
- findRecommendCardNodeIds,
27
- readRecommendCardCandidate
28
- } from "./cards.js";
29
-
30
- export function matchesRecommendDetailNetwork(url) {
25
+ import {
26
+ findRecommendCardNodeIds,
27
+ readRecommendCardCandidate
28
+ } from "./cards.js";
29
+
30
+ const DETAIL_OUTSIDE_CLOSE_BOUNDARY_SELECTORS = Object.freeze([
31
+ ".resume-center-side .resume-detail-wrap",
32
+ ".resume-detail-wrap",
33
+ ".boss-popup__wrapper .boss-popup__body",
34
+ ".boss-popup__wrapper .dialog-body",
35
+ ".dialog-wrap.active .resume-detail-wrap",
36
+ ".geek-detail-modal .resume-detail-wrap"
37
+ ]);
38
+
39
+ export function matchesRecommendDetailNetwork(url) {
31
40
  return DETAIL_NETWORK_PATTERNS.some((pattern) => pattern.test(String(url || "")));
32
41
  }
33
42
 
@@ -150,10 +159,10 @@ async function readRecommendDetailState(client) {
150
159
  };
151
160
  }
152
161
 
153
- export async function waitForRecommendDetailClosed(client, {
154
- timeoutMs = 4000,
155
- intervalMs = 250
156
- } = {}) {
162
+ export async function waitForRecommendDetailClosed(client, {
163
+ timeoutMs = 4000,
164
+ intervalMs = 250
165
+ } = {}) {
157
166
  const started = Date.now();
158
167
  let lastState = null;
159
168
  while (Date.now() - started <= timeoutMs) {
@@ -171,12 +180,67 @@ export async function waitForRecommendDetailClosed(client, {
171
180
  closed: false,
172
181
  elapsed_ms: Date.now() - started,
173
182
  state: lastState
174
- };
175
- }
176
-
177
- async function findVisibleDetailTarget(client, roots, selectors) {
178
- for (const root of roots) {
179
- if (!root?.nodeId) continue;
183
+ };
184
+ }
185
+
186
+ function compactRect(rect) {
187
+ if (!rect) return null;
188
+ return {
189
+ x: Math.round(Number(rect.x) || 0),
190
+ y: Math.round(Number(rect.y) || 0),
191
+ width: Math.round(Number(rect.width) || 0),
192
+ height: Math.round(Number(rect.height) || 0)
193
+ };
194
+ }
195
+
196
+ function compactDetailTarget(target) {
197
+ if (!target) return null;
198
+ return {
199
+ root: target.root || "",
200
+ root_node_id: target.root_node_id || null,
201
+ selector: target.selector || "",
202
+ node_id: target.node_id || null,
203
+ rect: compactRect(target.rect)
204
+ };
205
+ }
206
+
207
+ function compactDetailOpenState(state) {
208
+ if (!state) {
209
+ return {
210
+ open: false,
211
+ popup: null,
212
+ resume_iframe: null,
213
+ iframe_document_node_id: null
214
+ };
215
+ }
216
+ return {
217
+ open: Boolean(state.popup || state.resumeIframe),
218
+ popup: compactDetailTarget(state.popup),
219
+ resume_iframe: compactDetailTarget(state.resumeIframe),
220
+ iframe_document_node_id: state.iframe?.documentNodeId || null
221
+ };
222
+ }
223
+
224
+ async function verifyRecommendDetailStillOpen(client, {
225
+ settleMs = 350
226
+ } = {}) {
227
+ const firstState = await readRecommendDetailState(client);
228
+ if (settleMs > 0) await sleep(settleMs);
229
+ const secondState = await readRecommendDetailState(client);
230
+ const first = compactDetailOpenState(firstState);
231
+ const second = compactDetailOpenState(secondState);
232
+ const stableOpen = Boolean(first.open && second.open);
233
+ return {
234
+ open: Boolean(second.open),
235
+ stable_open: stableOpen,
236
+ first,
237
+ second
238
+ };
239
+ }
240
+
241
+ async function findVisibleDetailTarget(client, roots, selectors) {
242
+ for (const root of roots) {
243
+ if (!root?.nodeId) continue;
180
244
  for (const selector of selectors) {
181
245
  const nodeIds = await querySelectorAll(client, root.nodeId, selector);
182
246
  for (const nodeId of nodeIds) {
@@ -477,15 +541,35 @@ export async function closeRecommendDetail(client, {
477
541
  closed: closedAfterClick.closed,
478
542
  elapsed_ms: closedAfterClick.elapsed_ms
479
543
  });
480
- if (closedAfterClick.closed) {
481
- return {
482
- closed: true,
483
- attempts
484
- };
485
- }
486
-
487
- await pressEscape(client);
488
- attempts.push({ mode: "Escape-fallback" });
544
+ if (closedAfterClick.closed) {
545
+ return {
546
+ closed: true,
547
+ attempts
548
+ };
549
+ }
550
+
551
+ const outsideClick = await clickOutsideRecommendDetail(client, closedAfterClick.state || existingState);
552
+ attempts.push(outsideClick);
553
+ if (outsideClick.clicked) {
554
+ const closedAfterOutsideClick = await waitForRecommendDetailClosed(client, {
555
+ timeoutMs: closeWaitMs,
556
+ intervalMs: 250
557
+ });
558
+ attempts.push({
559
+ mode: "wait-closed-after-outside-click",
560
+ closed: closedAfterOutsideClick.closed,
561
+ elapsed_ms: closedAfterOutsideClick.elapsed_ms
562
+ });
563
+ if (closedAfterOutsideClick.closed) {
564
+ return {
565
+ closed: true,
566
+ attempts
567
+ };
568
+ }
569
+ }
570
+
571
+ await pressEscape(client);
572
+ attempts.push({ mode: "Escape-fallback" });
489
573
 
490
574
  const closedAfterEscape = await waitForRecommendDetailClosed(client, {
491
575
  timeoutMs: escapeWaitMs,
@@ -502,13 +586,33 @@ export async function closeRecommendDetail(client, {
502
586
  attempts
503
587
  };
504
588
  }
505
- }
506
-
507
- return {
508
- closed: false,
509
- attempts
510
- };
511
- }
589
+ }
590
+
591
+ const verification = await verifyRecommendDetailStillOpen(client);
592
+ attempts.push({
593
+ mode: "final-close-verification",
594
+ open: verification.open,
595
+ stable_open: verification.stable_open,
596
+ popup: verification.second.popup,
597
+ resume_iframe: verification.second.resume_iframe
598
+ });
599
+ if (!verification.open) {
600
+ return {
601
+ closed: true,
602
+ attempts,
603
+ verification
604
+ };
605
+ }
606
+
607
+ return {
608
+ closed: false,
609
+ reason: verification.stable_open
610
+ ? "detail_still_visible_after_close_attempts"
611
+ : "detail_visibility_ambiguous_after_close_attempts",
612
+ attempts,
613
+ verification
614
+ };
615
+ }
512
616
 
513
617
  async function findVisibleCloseTarget(client, roots, selectors) {
514
618
  let fallback = null;
@@ -540,15 +644,107 @@ async function findVisibleCloseTarget(client, roots, selectors) {
540
644
  return fallback;
541
645
  }
542
646
 
543
- async function pressEscape(client) {
544
- await pressKey(client, "Escape", {
545
- code: "Escape",
546
- windowsVirtualKeyCode: 27,
547
- nativeVirtualKeyCode: 27
548
- });
549
- }
550
-
551
- export async function extractRecommendDetailCandidate(client, {
647
+ async function pressEscape(client) {
648
+ await pressKey(client, "Escape", {
649
+ code: "Escape",
650
+ windowsVirtualKeyCode: 27,
651
+ nativeVirtualKeyCode: 27
652
+ });
653
+ }
654
+
655
+ function clampPointCoordinate(value, min, max) {
656
+ return Math.max(min, Math.min(max, value));
657
+ }
658
+
659
+ async function getClickViewport(client) {
660
+ try {
661
+ const metrics = typeof client?.Page?.getLayoutMetrics === "function"
662
+ ? await client.Page.getLayoutMetrics()
663
+ : null;
664
+ const viewport = metrics?.cssLayoutViewport || metrics?.layoutViewport || metrics?.visualViewport || {};
665
+ return {
666
+ width: Number(viewport.clientWidth || viewport.width || 1440),
667
+ height: Number(viewport.clientHeight || viewport.height || 900)
668
+ };
669
+ } catch {
670
+ return {
671
+ width: 1440,
672
+ height: 900
673
+ };
674
+ }
675
+ }
676
+
677
+ function getOutsideClickPoint(rect, viewport) {
678
+ if (!rect || rect.width <= 2 || rect.height <= 2) return null;
679
+ const margin = 24;
680
+ const minX = 8;
681
+ const minY = 8;
682
+ const maxX = Math.max(minX, (Number(viewport?.width) || 1440) - 8);
683
+ const maxY = Math.max(minY, (Number(viewport?.height) || 900) - 8);
684
+ const midX = rect.x + rect.width / 2;
685
+ const midY = rect.y + Math.min(Math.max(rect.height * 0.2, 48), Math.max(48, rect.height - 24));
686
+ const candidates = [
687
+ { side: "left", x: rect.x - margin, y: midY },
688
+ { side: "right", x: rect.x + rect.width + margin, y: midY },
689
+ { side: "above", x: midX, y: rect.y - margin },
690
+ { side: "below", x: midX, y: rect.y + rect.height + margin },
691
+ { side: "viewport-corner", x: 16, y: 16 }
692
+ ];
693
+
694
+ for (const candidate of candidates) {
695
+ const x = clampPointCoordinate(candidate.x, minX, maxX);
696
+ const y = clampPointCoordinate(candidate.y, minY, maxY);
697
+ const insideRect = (
698
+ x >= rect.x
699
+ && x <= rect.x + rect.width
700
+ && y >= rect.y
701
+ && y <= rect.y + rect.height
702
+ );
703
+ if (!insideRect) {
704
+ return {
705
+ ...candidate,
706
+ x,
707
+ y
708
+ };
709
+ }
710
+ }
711
+ return null;
712
+ }
713
+
714
+ async function clickOutsideRecommendDetail(client, detailState) {
715
+ const rootState = detailState?.roots?.length
716
+ ? detailState
717
+ : await readRecommendDetailState(client);
718
+ const boundaryTarget = await findVisibleDetailTarget(
719
+ client,
720
+ rootState.roots || [],
721
+ DETAIL_OUTSIDE_CLOSE_BOUNDARY_SELECTORS
722
+ );
723
+ const target = boundaryTarget || rootState.resumeIframe || rootState.popup || null;
724
+ const viewport = await getClickViewport(client);
725
+ const point = getOutsideClickPoint(target?.rect, viewport);
726
+ if (!point) {
727
+ return {
728
+ clicked: false,
729
+ mode: "outside-modal-click",
730
+ reason: "no_outside_click_point",
731
+ selector: target?.selector || null,
732
+ root: target?.root || null
733
+ };
734
+ }
735
+ await clickPoint(client, point.x, point.y);
736
+ return {
737
+ clicked: true,
738
+ mode: "outside-modal-click",
739
+ selector: target?.selector || null,
740
+ root: target?.root || null,
741
+ side: point.side,
742
+ x: Math.round(point.x),
743
+ y: Math.round(point.y)
744
+ };
745
+ }
746
+
747
+ export async function extractRecommendDetailCandidate(client, {
552
748
  cardCandidate,
553
749
  cardNodeId,
554
750
  detailState,
@@ -420,17 +420,31 @@ export function countRecommendResultStatuses(results = [], {
420
420
  };
421
421
  }
422
422
 
423
- function countPassedResults(results = []) {
424
- return countRecommendResultStatuses(results).passed;
425
- }
426
-
427
- function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
428
- if (!error) return null;
429
- return {
430
- code: error.code || fallbackCode,
431
- message: error.message || String(error)
432
- };
433
- }
423
+ function countPassedResults(results = []) {
424
+ return countRecommendResultStatuses(results).passed;
425
+ }
426
+
427
+ function compactCloseResult(closeResult) {
428
+ if (!closeResult) return null;
429
+ return {
430
+ closed: Boolean(closeResult.closed),
431
+ reason: closeResult.reason || null,
432
+ attempts: closeResult.attempts || [],
433
+ verification: closeResult.verification || null
434
+ };
435
+ }
436
+
437
+ function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
438
+ if (!error) return null;
439
+ const result = {
440
+ code: error.code || fallbackCode,
441
+ message: error.message || String(error)
442
+ };
443
+ if (error.close_result) {
444
+ result.close_result = compactCloseResult(error.close_result);
445
+ }
446
+ return result;
447
+ }
434
448
 
435
449
  function createRecommendCloseFailureError(closeResult) {
436
450
  const error = new Error(closeResult?.reason || "Recommend detail did not close before recovery");