@reconcrap/boss-recommend-mcp 2.0.37 → 2.0.39

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.
@@ -1,569 +1,612 @@
1
- import {
2
- clickNodeCenter,
3
- clickPoint,
4
- getFrameDocumentNodeId,
5
- getNodeBox,
6
- getOuterHTML,
7
- pressKey,
8
- querySelectorAll,
9
- sleep
10
- } from "../../core/browser/index.js";
11
- import { candidateKeyFromProfile } from "../../core/infinite-list/index.js";
12
- import {
13
- buildScreeningCandidateFromDetail,
14
- htmlToText
15
- } from "../../core/screening/index.js";
16
- import {
17
- DETAIL_CLOSE_SELECTORS,
18
- DETAIL_NETWORK_PATTERNS,
19
- DETAIL_POPUP_SELECTORS,
20
- DETAIL_RESUME_IFRAME_SELECTORS
21
- } from "./constants.js";
22
- import {
23
- getRecommendRoots
24
- } from "./roots.js";
25
- import {
26
- findRecommendCardNodeIds,
27
- readRecommendCardCandidate
28
- } from "./cards.js";
29
-
30
- export function matchesRecommendDetailNetwork(url) {
31
- return DETAIL_NETWORK_PATTERNS.some((pattern) => pattern.test(String(url || "")));
32
- }
33
-
34
- export function createRecommendDetailNetworkRecorder(client) {
35
- const events = [];
36
- client.Network.responseReceived((event) => {
37
- const url = event?.response?.url || "";
38
- if (!matchesRecommendDetailNetwork(url)) return;
39
- events.push({
40
- requestId: event.requestId,
41
- url,
42
- status: event.response?.status,
43
- mimeType: event.response?.mimeType,
44
- type: event.type
45
- });
46
- });
47
- if (typeof client.Network.loadingFinished === "function") {
48
- client.Network.loadingFinished((event) => {
49
- const found = events.find((item) => item.requestId === event.requestId);
50
- if (!found) return;
51
- found.loading_finished = true;
52
- found.encodedDataLength = event.encodedDataLength;
53
- });
54
- }
55
- if (typeof client.Network.loadingFailed === "function") {
56
- client.Network.loadingFailed((event) => {
57
- const found = events.find((item) => item.requestId === event.requestId);
58
- if (!found) return;
59
- found.loading_failed = true;
60
- found.loading_error = event.errorText || event.blockedReason || "Network loading failed";
61
- });
62
- }
63
- return {
64
- events,
65
- clear() {
66
- events.length = 0;
67
- }
68
- };
69
- }
70
-
71
- export async function waitForRecommendDetailNetworkEvents(recorder, {
72
- minCount = 1,
73
- requireLoaded = true,
74
- timeoutMs = 3500,
75
- intervalMs = 100
76
- } = {}) {
77
- const started = Date.now();
78
- const events = Array.isArray(recorder) ? recorder : recorder?.events || [];
79
- let matching = [];
80
- while (Date.now() - started <= timeoutMs) {
81
- matching = events.filter((event) => (
82
- !requireLoaded
83
- || event.loading_finished === true
84
- || event.loading_failed === true
85
- ));
86
- if (matching.length >= minCount) {
87
- return {
88
- ok: true,
89
- elapsed_ms: Date.now() - started,
90
- count: matching.length,
91
- events: matching
92
- };
93
- }
94
- await sleep(intervalMs);
95
- }
96
- return {
97
- ok: false,
98
- elapsed_ms: Date.now() - started,
99
- count: matching.length,
100
- events: matching,
101
- total_event_count: events.length
102
- };
103
- }
104
-
105
- export async function readRecommendDetailNetworkBodies(client, events = [], {
106
- limit = 10
107
- } = {}) {
108
- const bodies = [];
109
- for (const event of events.slice(0, limit)) {
110
- try {
111
- const body = await client.Network.getResponseBody({ requestId: event.requestId });
112
- bodies.push({
113
- ...event,
114
- body,
115
- body_length: String(body?.body || "").length
116
- });
117
- } catch (error) {
118
- bodies.push({
119
- ...event,
120
- body_error: error?.message || String(error)
121
- });
122
- }
123
- }
124
- return bodies;
125
- }
126
-
127
- export async function waitForRecommendDetail(client, {
128
- timeoutMs = 10000,
129
- intervalMs = 250
130
- } = {}) {
131
- const started = Date.now();
132
- let lastState = null;
133
- while (Date.now() - started <= timeoutMs) {
134
- const rootState = await getRecommendRoots(client);
135
- const popup = await findVisibleDetailTarget(client, rootState.roots, DETAIL_POPUP_SELECTORS);
136
- const resumeIframe = await findVisibleDetailTarget(client, rootState.roots, DETAIL_RESUME_IFRAME_SELECTORS);
137
- lastState = {
138
- iframe: rootState.iframe,
139
- roots: rootState.roots,
140
- popup,
141
- resumeIframe
142
- };
143
- if (popup || resumeIframe) return lastState;
144
- await sleep(intervalMs);
145
- }
146
- return lastState;
147
- }
148
-
149
- async function findVisibleDetailTarget(client, roots, selectors) {
150
- for (const root of roots) {
151
- if (!root?.nodeId) continue;
152
- for (const selector of selectors) {
153
- const nodeIds = await querySelectorAll(client, root.nodeId, selector);
154
- for (const nodeId of nodeIds) {
155
- try {
156
- const box = await getNodeBox(client, nodeId);
157
- if (box.rect.width > 2 && box.rect.height > 2) {
158
- return {
159
- root: root.name,
160
- root_node_id: root.nodeId,
161
- selector,
162
- node_id: nodeId,
163
- center: box.center,
164
- rect: box.rect
165
- };
166
- }
167
- } catch {}
168
- }
169
- }
170
- }
171
- return null;
172
- }
173
-
174
- export async function readRecommendDetailHtml(client, detailState) {
175
- let popupHTML = "";
176
- let resumeHTML = "";
177
- let resumeIframeDocumentNodeId = null;
178
- const errors = [];
179
-
180
- if (detailState?.popup?.node_id) {
181
- try {
182
- popupHTML = await getOuterHTML(client, detailState.popup.node_id);
183
- } catch (error) {
184
- errors.push({
185
- source: "popup",
186
- node_id: detailState.popup.node_id,
187
- stale_node: isStaleRecommendNodeError(error),
188
- error: error?.message || String(error)
189
- });
190
- }
191
- }
192
-
193
- if (detailState?.resumeIframe?.node_id) {
194
- try {
195
- resumeIframeDocumentNodeId = await getFrameDocumentNodeId(client, detailState.resumeIframe.node_id);
196
- resumeHTML = await getOuterHTML(client, resumeIframeDocumentNodeId);
197
- } catch (error) {
198
- errors.push({
199
- source: "resume_iframe",
200
- node_id: detailState.resumeIframe.node_id,
201
- document_node_id: resumeIframeDocumentNodeId,
202
- stale_node: isStaleRecommendNodeError(error),
203
- error: error?.message || String(error)
204
- });
205
- resumeIframeDocumentNodeId = null;
206
- resumeHTML = "";
207
- }
208
- }
209
-
210
- return {
211
- popupHTML,
212
- resumeHTML,
213
- resumeIframeDocumentNodeId,
214
- popupText: htmlToText(popupHTML),
215
- resumeText: htmlToText(resumeHTML),
216
- errors
217
- };
218
- }
219
-
220
- export function isStaleRecommendNodeError(error) {
221
- const message = String(error?.message || error || "");
222
- return /Could not find node with given id|No node with given id|Node is detached|Cannot find node/i.test(message);
223
- }
224
-
225
- export async function findRecommendCardNodeForCandidateKey(client, {
226
- candidateKey = "",
227
- rootState = null,
228
- targetUrl = "",
229
- source = "recommend-run-card-retry",
230
- timeoutMs = 5000,
231
- intervalMs = 250
232
- } = {}) {
233
- if (!candidateKey) {
234
- return {
235
- ok: false,
236
- reason: "candidate_key_required"
237
- };
238
- }
239
-
240
- const started = Date.now();
241
- let lastError = null;
242
- let lastCardCount = 0;
243
- while (Date.now() - started <= timeoutMs) {
244
- const currentRootState = rootState?.iframe?.documentNodeId
245
- ? rootState
246
- : await getRecommendRoots(client);
247
- const frameNodeId = currentRootState?.iframe?.documentNodeId;
248
- if (!frameNodeId) {
249
- return {
250
- ok: false,
251
- reason: "recommend_frame_not_found"
252
- };
253
- }
254
-
255
- const nodeIds = await findRecommendCardNodeIds(client, frameNodeId);
256
- lastCardCount = nodeIds.length;
257
- for (let visibleIndex = 0; visibleIndex < nodeIds.length; visibleIndex += 1) {
258
- const nodeId = nodeIds[visibleIndex];
259
- try {
260
- const candidate = await readRecommendCardCandidate(client, nodeId, {
261
- targetUrl,
262
- source,
263
- metadata: {
264
- visible_index: visibleIndex,
265
- retry_reason: "stale_detail_node"
266
- }
267
- });
268
- const key = candidateKeyFromProfile(candidate, {
269
- nodeId,
270
- visibleIndex,
271
- attributes: candidate?.attributes || candidate?.metadata?.attributes || {}
272
- });
273
- if (key === candidateKey) {
274
- return {
275
- ok: true,
276
- node_id: nodeId,
277
- visible_index: visibleIndex,
278
- candidate,
279
- key,
280
- root_state: currentRootState,
281
- card_count: nodeIds.length
282
- };
283
- }
284
- } catch (error) {
285
- lastError = error;
286
- }
287
- }
288
-
289
- if (intervalMs > 0) await sleep(intervalMs);
290
- rootState = null;
291
- }
292
-
293
- return {
294
- ok: false,
295
- reason: "candidate_key_not_mounted",
296
- candidate_key: candidateKey,
297
- last_card_count: lastCardCount,
298
- error: lastError?.message || null
299
- };
300
- }
301
-
302
- export async function openRecommendCardDetail(client, cardNodeId, {
303
- timeoutMs = 12000,
304
- scrollIntoView = true
305
- } = {}) {
306
- const started = Date.now();
307
- const clickStarted = Date.now();
308
- const cardBox = await clickNodeCenter(client, cardNodeId, { scrollIntoView });
309
- const candidateClickMs = Date.now() - clickStarted;
310
- const detailStarted = Date.now();
311
- const detailState = await waitForRecommendDetail(client, { timeoutMs });
312
- const detailOpenMs = Date.now() - detailStarted;
313
- if (!detailState?.popup && !detailState?.resumeIframe) {
314
- throw new Error("Candidate detail did not open or no known detail selectors mounted");
315
- }
316
-
317
- return {
318
- card_box: cardBox,
319
- detail_state: detailState,
320
- timings: {
321
- candidate_click_ms: candidateClickMs,
322
- detail_open_ms: detailOpenMs,
323
- open_total_ms: Date.now() - started
324
- }
325
- };
326
- }
327
-
328
- export async function openRecommendCardDetailWithFreshRetry(client, {
329
- cardNodeId,
330
- candidateKey = "",
331
- cardCandidate = null,
332
- rootState = null,
333
- targetUrl = "",
334
- timeoutMs = 12000,
335
- scrollIntoView = true,
336
- retryTimeoutMs = 5000,
337
- retryIntervalMs = 250,
338
- maxAttempts = 2
339
- } = {}) {
340
- let currentNodeId = cardNodeId;
341
- let currentCandidate = cardCandidate;
342
- let currentRootState = rootState;
343
- const attempts = [];
344
- const limit = Math.max(1, Number(maxAttempts) || 1);
345
-
346
- for (let attemptIndex = 0; attemptIndex < limit; attemptIndex += 1) {
347
- try {
348
- const opened = await openRecommendCardDetail(client, currentNodeId, {
349
- timeoutMs,
350
- scrollIntoView
351
- });
352
- return {
353
- ...opened,
354
- card_node_id: currentNodeId,
355
- card_candidate: currentCandidate,
356
- retry_attempts: attempts
357
- };
358
- } catch (error) {
359
- const stale = isStaleRecommendNodeError(error);
360
- attempts.push({
361
- attempt: attemptIndex + 1,
362
- node_id: currentNodeId,
363
- stale_node: stale,
364
- error: error?.message || String(error)
365
- });
366
- if (!stale || attemptIndex >= limit - 1 || !candidateKey) {
367
- error.recommend_detail_open_attempts = attempts;
368
- throw error;
369
- }
370
-
371
- const resolved = await findRecommendCardNodeForCandidateKey(client, {
372
- candidateKey,
373
- rootState: currentRootState,
374
- targetUrl,
375
- timeoutMs: retryTimeoutMs,
376
- intervalMs: retryIntervalMs
377
- });
378
- attempts[attempts.length - 1].refresh_lookup = {
379
- ok: Boolean(resolved.ok),
380
- node_id: resolved.node_id || null,
381
- visible_index: resolved.visible_index ?? null,
382
- card_count: resolved.card_count || resolved.last_card_count || 0,
383
- reason: resolved.reason || null,
384
- error: resolved.error || null
385
- };
386
- if (!resolved.ok || !resolved.node_id) {
387
- error.recommend_detail_open_attempts = attempts;
388
- throw error;
389
- }
390
- currentNodeId = resolved.node_id;
391
- currentCandidate = resolved.candidate || currentCandidate;
392
- currentRootState = resolved.root_state || null;
393
- }
394
- }
395
-
396
- throw new Error("Recommend detail retry exhausted");
397
- }
398
-
399
- export async function closeRecommendDetail(client, {
400
- attemptsLimit = 3
401
- } = {}) {
402
- const attempts = [];
403
- for (let index = 0; index < attemptsLimit; index += 1) {
404
- const existingState = await waitForRecommendDetail(client, { timeoutMs: 500 });
405
- if (!existingState?.popup && !existingState?.resumeIframe) {
406
- return {
407
- closed: true,
408
- attempts
409
- };
410
- }
411
-
412
- const rootState = await getRecommendRoots(client);
413
- const closeTarget = await findVisibleCloseTarget(client, rootState.roots, DETAIL_CLOSE_SELECTORS);
414
- if (closeTarget) {
415
- try {
416
- if (closeTarget.center) {
417
- await clickPoint(client, closeTarget.center.x, closeTarget.center.y);
418
- } else {
419
- await clickNodeCenter(client, closeTarget.node_id);
420
- }
421
- attempts.push({
422
- mode: "close-selector",
423
- selector: closeTarget.selector,
424
- root: closeTarget.root
425
- });
426
- } catch (error) {
427
- attempts.push({
428
- mode: "close-selector-error",
429
- selector: closeTarget.selector,
430
- root: closeTarget.root,
431
- error: error?.message || String(error)
432
- });
433
- await pressEscape(client);
434
- attempts.push({ mode: "Escape-after-close-selector-error" });
435
- }
436
- await sleep(700);
437
- } else {
438
- await pressEscape(client);
439
- attempts.push({ mode: "Escape" });
440
- await sleep(700);
441
- }
442
-
443
- let state = await waitForRecommendDetail(client, { timeoutMs: 1000 });
444
- if (!state?.popup && !state?.resumeIframe) {
445
- return {
446
- closed: true,
447
- attempts
448
- };
449
- }
450
-
451
- await pressEscape(client);
452
- attempts.push({ mode: "Escape-fallback" });
453
- await sleep(700);
454
-
455
- state = await waitForRecommendDetail(client, { timeoutMs: 1000 });
456
- if (!state?.popup && !state?.resumeIframe) {
457
- return {
458
- closed: true,
459
- attempts
460
- };
461
- }
462
- }
463
-
464
- return {
465
- closed: false,
466
- attempts
467
- };
468
- }
469
-
470
- async function findVisibleCloseTarget(client, roots, selectors) {
471
- let fallback = null;
472
- for (const root of roots) {
473
- if (!root?.nodeId) continue;
474
- for (const selector of selectors) {
475
- const nodeIds = await querySelectorAll(client, root.nodeId, selector);
476
- for (const nodeId of nodeIds) {
477
- const target = {
478
- root: root.name,
479
- root_node_id: root.nodeId,
480
- selector,
481
- node_id: nodeId
482
- };
483
- if (!fallback) fallback = target;
484
- try {
485
- const box = await getNodeBox(client, nodeId);
486
- if (box.rect.width > 2 && box.rect.height > 2) {
487
- return {
488
- ...target,
489
- center: box.center,
490
- rect: box.rect
491
- };
492
- }
493
- } catch {}
494
- }
495
- }
496
- }
497
- return fallback;
498
- }
499
-
500
- async function pressEscape(client) {
501
- await pressKey(client, "Escape", {
502
- code: "Escape",
503
- windowsVirtualKeyCode: 27,
504
- nativeVirtualKeyCode: 27
505
- });
506
- }
507
-
508
- export async function extractRecommendDetailCandidate(client, {
509
- cardCandidate,
510
- cardNodeId,
511
- detailState,
512
- networkEvents = [],
513
- targetUrl = "",
514
- closeDetail = true,
515
- networkParseRetryMs = 1800,
516
- networkParseIntervalMs = 250
517
- } = {}) {
518
- const detailHtml = await readRecommendDetailHtml(client, detailState);
519
- const detailText = [
520
- detailHtml.popupText,
521
- detailHtml.resumeText
522
- ].filter(Boolean).join("\n\n");
523
-
524
- const parseStarted = Date.now();
525
- let networkBodies = [];
526
- let detailCandidateResult = null;
527
- do {
528
- networkBodies = await readRecommendDetailNetworkBodies(client, networkEvents);
529
- detailCandidateResult = buildScreeningCandidateFromDetail({
530
- cardCandidate,
531
- detailText,
532
- networkBodies,
533
- metadata: {
534
- target_url: targetUrl,
535
- card_node_id: cardNodeId,
536
- detail_popup_selector: detailState?.popup?.selector || null,
537
- detail_popup_root: detailState?.popup?.root || null,
538
- resume_iframe_selector: detailState?.resumeIframe?.selector || null,
539
- resume_iframe_root: detailState?.resumeIframe?.root || null,
540
- resume_iframe_document_node_id: detailHtml.resumeIframeDocumentNodeId,
541
- detail_html_errors: detailHtml.errors || []
542
- }
543
- });
544
- if (detailCandidateResult.parsed_network_profiles.some((item) => item.ok)) break;
545
- if (Date.now() - parseStarted >= Math.max(0, Number(networkParseRetryMs) || 0)) break;
546
- await sleep(Math.max(50, Number(networkParseIntervalMs) || 250));
547
- } while (true);
548
-
549
- let closeResult = null;
550
- if (closeDetail) {
551
- closeResult = await closeRecommendDetail(client);
552
- }
553
-
554
- return {
555
- candidate: detailCandidateResult.candidate,
556
- parsed_network_profiles: detailCandidateResult.parsed_network_profiles,
557
- network_bodies: networkBodies,
558
- network_parse_retry_elapsed_ms: Date.now() - parseStarted,
559
- network_event_count: networkEvents.length,
560
- detail: {
561
- popup_text: detailHtml.popupText,
562
- resume_text: detailHtml.resumeText,
563
- popup_html_length: detailHtml.popupHTML.length,
564
- resume_html_length: detailHtml.resumeHTML.length,
565
- html_errors: detailHtml.errors || []
566
- },
567
- close_result: closeResult
568
- };
569
- }
1
+ import {
2
+ clickNodeCenter,
3
+ clickPoint,
4
+ getFrameDocumentNodeId,
5
+ getNodeBox,
6
+ getOuterHTML,
7
+ pressKey,
8
+ querySelectorAll,
9
+ sleep
10
+ } from "../../core/browser/index.js";
11
+ import { candidateKeyFromProfile } from "../../core/infinite-list/index.js";
12
+ import {
13
+ buildScreeningCandidateFromDetail,
14
+ htmlToText
15
+ } from "../../core/screening/index.js";
16
+ import {
17
+ DETAIL_CLOSE_SELECTORS,
18
+ DETAIL_NETWORK_PATTERNS,
19
+ DETAIL_POPUP_SELECTORS,
20
+ DETAIL_RESUME_IFRAME_SELECTORS
21
+ } from "./constants.js";
22
+ import {
23
+ getRecommendRoots
24
+ } from "./roots.js";
25
+ import {
26
+ findRecommendCardNodeIds,
27
+ readRecommendCardCandidate
28
+ } from "./cards.js";
29
+
30
+ export function matchesRecommendDetailNetwork(url) {
31
+ return DETAIL_NETWORK_PATTERNS.some((pattern) => pattern.test(String(url || "")));
32
+ }
33
+
34
+ export function createRecommendDetailNetworkRecorder(client) {
35
+ const events = [];
36
+ client.Network.responseReceived((event) => {
37
+ const url = event?.response?.url || "";
38
+ if (!matchesRecommendDetailNetwork(url)) return;
39
+ events.push({
40
+ requestId: event.requestId,
41
+ url,
42
+ status: event.response?.status,
43
+ mimeType: event.response?.mimeType,
44
+ type: event.type
45
+ });
46
+ });
47
+ if (typeof client.Network.loadingFinished === "function") {
48
+ client.Network.loadingFinished((event) => {
49
+ const found = events.find((item) => item.requestId === event.requestId);
50
+ if (!found) return;
51
+ found.loading_finished = true;
52
+ found.encodedDataLength = event.encodedDataLength;
53
+ });
54
+ }
55
+ if (typeof client.Network.loadingFailed === "function") {
56
+ client.Network.loadingFailed((event) => {
57
+ const found = events.find((item) => item.requestId === event.requestId);
58
+ if (!found) return;
59
+ found.loading_failed = true;
60
+ found.loading_error = event.errorText || event.blockedReason || "Network loading failed";
61
+ });
62
+ }
63
+ return {
64
+ events,
65
+ clear() {
66
+ events.length = 0;
67
+ }
68
+ };
69
+ }
70
+
71
+ export async function waitForRecommendDetailNetworkEvents(recorder, {
72
+ minCount = 1,
73
+ requireLoaded = true,
74
+ timeoutMs = 3500,
75
+ intervalMs = 100
76
+ } = {}) {
77
+ const started = Date.now();
78
+ const events = Array.isArray(recorder) ? recorder : recorder?.events || [];
79
+ let matching = [];
80
+ while (Date.now() - started <= timeoutMs) {
81
+ matching = events.filter((event) => (
82
+ !requireLoaded
83
+ || event.loading_finished === true
84
+ || event.loading_failed === true
85
+ ));
86
+ if (matching.length >= minCount) {
87
+ return {
88
+ ok: true,
89
+ elapsed_ms: Date.now() - started,
90
+ count: matching.length,
91
+ events: matching
92
+ };
93
+ }
94
+ await sleep(intervalMs);
95
+ }
96
+ return {
97
+ ok: false,
98
+ elapsed_ms: Date.now() - started,
99
+ count: matching.length,
100
+ events: matching,
101
+ total_event_count: events.length
102
+ };
103
+ }
104
+
105
+ export async function readRecommendDetailNetworkBodies(client, events = [], {
106
+ limit = 10
107
+ } = {}) {
108
+ const bodies = [];
109
+ for (const event of events.slice(0, limit)) {
110
+ try {
111
+ const body = await client.Network.getResponseBody({ requestId: event.requestId });
112
+ bodies.push({
113
+ ...event,
114
+ body,
115
+ body_length: String(body?.body || "").length
116
+ });
117
+ } catch (error) {
118
+ bodies.push({
119
+ ...event,
120
+ body_error: error?.message || String(error)
121
+ });
122
+ }
123
+ }
124
+ return bodies;
125
+ }
126
+
127
+ export async function waitForRecommendDetail(client, {
128
+ timeoutMs = 10000,
129
+ intervalMs = 250
130
+ } = {}) {
131
+ const started = Date.now();
132
+ let lastState = null;
133
+ while (Date.now() - started <= timeoutMs) {
134
+ lastState = await readRecommendDetailState(client);
135
+ if (lastState?.popup || lastState?.resumeIframe) return lastState;
136
+ await sleep(intervalMs);
137
+ }
138
+ return lastState;
139
+ }
140
+
141
+ async function readRecommendDetailState(client) {
142
+ const rootState = await getRecommendRoots(client);
143
+ const popup = await findVisibleDetailTarget(client, rootState.roots, DETAIL_POPUP_SELECTORS);
144
+ const resumeIframe = await findVisibleDetailTarget(client, rootState.roots, DETAIL_RESUME_IFRAME_SELECTORS);
145
+ return {
146
+ iframe: rootState.iframe,
147
+ roots: rootState.roots,
148
+ popup,
149
+ resumeIframe
150
+ };
151
+ }
152
+
153
+ export async function waitForRecommendDetailClosed(client, {
154
+ timeoutMs = 4000,
155
+ intervalMs = 250
156
+ } = {}) {
157
+ const started = Date.now();
158
+ let lastState = null;
159
+ while (Date.now() - started <= timeoutMs) {
160
+ lastState = await readRecommendDetailState(client);
161
+ if (!lastState?.popup && !lastState?.resumeIframe) {
162
+ return {
163
+ closed: true,
164
+ elapsed_ms: Date.now() - started,
165
+ state: lastState
166
+ };
167
+ }
168
+ await sleep(intervalMs);
169
+ }
170
+ return {
171
+ closed: false,
172
+ elapsed_ms: Date.now() - started,
173
+ state: lastState
174
+ };
175
+ }
176
+
177
+ async function findVisibleDetailTarget(client, roots, selectors) {
178
+ for (const root of roots) {
179
+ if (!root?.nodeId) continue;
180
+ for (const selector of selectors) {
181
+ const nodeIds = await querySelectorAll(client, root.nodeId, selector);
182
+ for (const nodeId of nodeIds) {
183
+ try {
184
+ const box = await getNodeBox(client, nodeId);
185
+ if (box.rect.width > 2 && box.rect.height > 2) {
186
+ return {
187
+ root: root.name,
188
+ root_node_id: root.nodeId,
189
+ selector,
190
+ node_id: nodeId,
191
+ center: box.center,
192
+ rect: box.rect
193
+ };
194
+ }
195
+ } catch {}
196
+ }
197
+ }
198
+ }
199
+ return null;
200
+ }
201
+
202
+ export async function readRecommendDetailHtml(client, detailState) {
203
+ let popupHTML = "";
204
+ let resumeHTML = "";
205
+ let resumeIframeDocumentNodeId = null;
206
+ const errors = [];
207
+
208
+ if (detailState?.popup?.node_id) {
209
+ try {
210
+ popupHTML = await getOuterHTML(client, detailState.popup.node_id);
211
+ } catch (error) {
212
+ errors.push({
213
+ source: "popup",
214
+ node_id: detailState.popup.node_id,
215
+ stale_node: isStaleRecommendNodeError(error),
216
+ error: error?.message || String(error)
217
+ });
218
+ }
219
+ }
220
+
221
+ if (detailState?.resumeIframe?.node_id) {
222
+ try {
223
+ resumeIframeDocumentNodeId = await getFrameDocumentNodeId(client, detailState.resumeIframe.node_id);
224
+ resumeHTML = await getOuterHTML(client, resumeIframeDocumentNodeId);
225
+ } catch (error) {
226
+ errors.push({
227
+ source: "resume_iframe",
228
+ node_id: detailState.resumeIframe.node_id,
229
+ document_node_id: resumeIframeDocumentNodeId,
230
+ stale_node: isStaleRecommendNodeError(error),
231
+ error: error?.message || String(error)
232
+ });
233
+ resumeIframeDocumentNodeId = null;
234
+ resumeHTML = "";
235
+ }
236
+ }
237
+
238
+ return {
239
+ popupHTML,
240
+ resumeHTML,
241
+ resumeIframeDocumentNodeId,
242
+ popupText: htmlToText(popupHTML),
243
+ resumeText: htmlToText(resumeHTML),
244
+ errors
245
+ };
246
+ }
247
+
248
+ export function isStaleRecommendNodeError(error) {
249
+ const message = String(error?.message || error || "");
250
+ return /Could not find node with given id|No node with given id|Node is detached|Cannot find node/i.test(message);
251
+ }
252
+
253
+ export async function findRecommendCardNodeForCandidateKey(client, {
254
+ candidateKey = "",
255
+ rootState = null,
256
+ targetUrl = "",
257
+ source = "recommend-run-card-retry",
258
+ timeoutMs = 5000,
259
+ intervalMs = 250
260
+ } = {}) {
261
+ if (!candidateKey) {
262
+ return {
263
+ ok: false,
264
+ reason: "candidate_key_required"
265
+ };
266
+ }
267
+
268
+ const started = Date.now();
269
+ let lastError = null;
270
+ let lastCardCount = 0;
271
+ while (Date.now() - started <= timeoutMs) {
272
+ const currentRootState = rootState?.iframe?.documentNodeId
273
+ ? rootState
274
+ : await getRecommendRoots(client);
275
+ const frameNodeId = currentRootState?.iframe?.documentNodeId;
276
+ if (!frameNodeId) {
277
+ return {
278
+ ok: false,
279
+ reason: "recommend_frame_not_found"
280
+ };
281
+ }
282
+
283
+ const nodeIds = await findRecommendCardNodeIds(client, frameNodeId);
284
+ lastCardCount = nodeIds.length;
285
+ for (let visibleIndex = 0; visibleIndex < nodeIds.length; visibleIndex += 1) {
286
+ const nodeId = nodeIds[visibleIndex];
287
+ try {
288
+ const candidate = await readRecommendCardCandidate(client, nodeId, {
289
+ targetUrl,
290
+ source,
291
+ metadata: {
292
+ visible_index: visibleIndex,
293
+ retry_reason: "stale_detail_node"
294
+ }
295
+ });
296
+ const key = candidateKeyFromProfile(candidate, {
297
+ nodeId,
298
+ visibleIndex,
299
+ attributes: candidate?.attributes || candidate?.metadata?.attributes || {}
300
+ });
301
+ if (key === candidateKey) {
302
+ return {
303
+ ok: true,
304
+ node_id: nodeId,
305
+ visible_index: visibleIndex,
306
+ candidate,
307
+ key,
308
+ root_state: currentRootState,
309
+ card_count: nodeIds.length
310
+ };
311
+ }
312
+ } catch (error) {
313
+ lastError = error;
314
+ }
315
+ }
316
+
317
+ if (intervalMs > 0) await sleep(intervalMs);
318
+ rootState = null;
319
+ }
320
+
321
+ return {
322
+ ok: false,
323
+ reason: "candidate_key_not_mounted",
324
+ candidate_key: candidateKey,
325
+ last_card_count: lastCardCount,
326
+ error: lastError?.message || null
327
+ };
328
+ }
329
+
330
+ export async function openRecommendCardDetail(client, cardNodeId, {
331
+ timeoutMs = 12000,
332
+ scrollIntoView = true
333
+ } = {}) {
334
+ const started = Date.now();
335
+ const clickStarted = Date.now();
336
+ const cardBox = await clickNodeCenter(client, cardNodeId, { scrollIntoView });
337
+ const candidateClickMs = Date.now() - clickStarted;
338
+ const detailStarted = Date.now();
339
+ const detailState = await waitForRecommendDetail(client, { timeoutMs });
340
+ const detailOpenMs = Date.now() - detailStarted;
341
+ if (!detailState?.popup && !detailState?.resumeIframe) {
342
+ throw new Error("Candidate detail did not open or no known detail selectors mounted");
343
+ }
344
+
345
+ return {
346
+ card_box: cardBox,
347
+ detail_state: detailState,
348
+ timings: {
349
+ candidate_click_ms: candidateClickMs,
350
+ detail_open_ms: detailOpenMs,
351
+ open_total_ms: Date.now() - started
352
+ }
353
+ };
354
+ }
355
+
356
+ export async function openRecommendCardDetailWithFreshRetry(client, {
357
+ cardNodeId,
358
+ candidateKey = "",
359
+ cardCandidate = null,
360
+ rootState = null,
361
+ targetUrl = "",
362
+ timeoutMs = 12000,
363
+ scrollIntoView = true,
364
+ retryTimeoutMs = 5000,
365
+ retryIntervalMs = 250,
366
+ maxAttempts = 2
367
+ } = {}) {
368
+ let currentNodeId = cardNodeId;
369
+ let currentCandidate = cardCandidate;
370
+ let currentRootState = rootState;
371
+ const attempts = [];
372
+ const limit = Math.max(1, Number(maxAttempts) || 1);
373
+
374
+ for (let attemptIndex = 0; attemptIndex < limit; attemptIndex += 1) {
375
+ try {
376
+ const opened = await openRecommendCardDetail(client, currentNodeId, {
377
+ timeoutMs,
378
+ scrollIntoView
379
+ });
380
+ return {
381
+ ...opened,
382
+ card_node_id: currentNodeId,
383
+ card_candidate: currentCandidate,
384
+ retry_attempts: attempts
385
+ };
386
+ } catch (error) {
387
+ const stale = isStaleRecommendNodeError(error);
388
+ attempts.push({
389
+ attempt: attemptIndex + 1,
390
+ node_id: currentNodeId,
391
+ stale_node: stale,
392
+ error: error?.message || String(error)
393
+ });
394
+ if (!stale || attemptIndex >= limit - 1 || !candidateKey) {
395
+ error.recommend_detail_open_attempts = attempts;
396
+ throw error;
397
+ }
398
+
399
+ const resolved = await findRecommendCardNodeForCandidateKey(client, {
400
+ candidateKey,
401
+ rootState: currentRootState,
402
+ targetUrl,
403
+ timeoutMs: retryTimeoutMs,
404
+ intervalMs: retryIntervalMs
405
+ });
406
+ attempts[attempts.length - 1].refresh_lookup = {
407
+ ok: Boolean(resolved.ok),
408
+ node_id: resolved.node_id || null,
409
+ visible_index: resolved.visible_index ?? null,
410
+ card_count: resolved.card_count || resolved.last_card_count || 0,
411
+ reason: resolved.reason || null,
412
+ error: resolved.error || null
413
+ };
414
+ if (!resolved.ok || !resolved.node_id) {
415
+ error.recommend_detail_open_attempts = attempts;
416
+ throw error;
417
+ }
418
+ currentNodeId = resolved.node_id;
419
+ currentCandidate = resolved.candidate || currentCandidate;
420
+ currentRootState = resolved.root_state || null;
421
+ }
422
+ }
423
+
424
+ throw new Error("Recommend detail retry exhausted");
425
+ }
426
+
427
+ export async function closeRecommendDetail(client, {
428
+ attemptsLimit = 4,
429
+ closeWaitMs = 5000,
430
+ escapeWaitMs = 3500
431
+ } = {}) {
432
+ const attempts = [];
433
+ for (let index = 0; index < attemptsLimit; index += 1) {
434
+ const existingState = await waitForRecommendDetail(client, { timeoutMs: 500 });
435
+ if (!existingState?.popup && !existingState?.resumeIframe) {
436
+ return {
437
+ closed: true,
438
+ attempts
439
+ };
440
+ }
441
+
442
+ const rootState = await getRecommendRoots(client);
443
+ const closeTarget = await findVisibleCloseTarget(client, rootState.roots, DETAIL_CLOSE_SELECTORS);
444
+ if (closeTarget) {
445
+ try {
446
+ if (closeTarget.center) {
447
+ await clickPoint(client, closeTarget.center.x, closeTarget.center.y);
448
+ } else {
449
+ await clickNodeCenter(client, closeTarget.node_id);
450
+ }
451
+ attempts.push({
452
+ mode: "close-selector",
453
+ selector: closeTarget.selector,
454
+ root: closeTarget.root
455
+ });
456
+ } catch (error) {
457
+ attempts.push({
458
+ mode: "close-selector-error",
459
+ selector: closeTarget.selector,
460
+ root: closeTarget.root,
461
+ error: error?.message || String(error)
462
+ });
463
+ await pressEscape(client);
464
+ attempts.push({ mode: "Escape-after-close-selector-error" });
465
+ }
466
+ } else {
467
+ await pressEscape(client);
468
+ attempts.push({ mode: "Escape" });
469
+ }
470
+
471
+ const closedAfterClick = await waitForRecommendDetailClosed(client, {
472
+ timeoutMs: closeWaitMs,
473
+ intervalMs: 250
474
+ });
475
+ attempts.push({
476
+ mode: "wait-closed-after-primary",
477
+ closed: closedAfterClick.closed,
478
+ elapsed_ms: closedAfterClick.elapsed_ms
479
+ });
480
+ if (closedAfterClick.closed) {
481
+ return {
482
+ closed: true,
483
+ attempts
484
+ };
485
+ }
486
+
487
+ await pressEscape(client);
488
+ attempts.push({ mode: "Escape-fallback" });
489
+
490
+ const closedAfterEscape = await waitForRecommendDetailClosed(client, {
491
+ timeoutMs: escapeWaitMs,
492
+ intervalMs: 250
493
+ });
494
+ attempts.push({
495
+ mode: "wait-closed-after-escape",
496
+ closed: closedAfterEscape.closed,
497
+ elapsed_ms: closedAfterEscape.elapsed_ms
498
+ });
499
+ if (closedAfterEscape.closed) {
500
+ return {
501
+ closed: true,
502
+ attempts
503
+ };
504
+ }
505
+ }
506
+
507
+ return {
508
+ closed: false,
509
+ attempts
510
+ };
511
+ }
512
+
513
+ async function findVisibleCloseTarget(client, roots, selectors) {
514
+ let fallback = null;
515
+ for (const root of roots) {
516
+ if (!root?.nodeId) continue;
517
+ for (const selector of selectors) {
518
+ const nodeIds = await querySelectorAll(client, root.nodeId, selector);
519
+ for (const nodeId of nodeIds) {
520
+ const target = {
521
+ root: root.name,
522
+ root_node_id: root.nodeId,
523
+ selector,
524
+ node_id: nodeId
525
+ };
526
+ if (!fallback) fallback = target;
527
+ try {
528
+ const box = await getNodeBox(client, nodeId);
529
+ if (box.rect.width > 2 && box.rect.height > 2) {
530
+ return {
531
+ ...target,
532
+ center: box.center,
533
+ rect: box.rect
534
+ };
535
+ }
536
+ } catch {}
537
+ }
538
+ }
539
+ }
540
+ return fallback;
541
+ }
542
+
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, {
552
+ cardCandidate,
553
+ cardNodeId,
554
+ detailState,
555
+ networkEvents = [],
556
+ targetUrl = "",
557
+ closeDetail = true,
558
+ networkParseRetryMs = 1800,
559
+ networkParseIntervalMs = 250
560
+ } = {}) {
561
+ const detailHtml = await readRecommendDetailHtml(client, detailState);
562
+ const detailText = [
563
+ detailHtml.popupText,
564
+ detailHtml.resumeText
565
+ ].filter(Boolean).join("\n\n");
566
+
567
+ const parseStarted = Date.now();
568
+ let networkBodies = [];
569
+ let detailCandidateResult = null;
570
+ do {
571
+ networkBodies = await readRecommendDetailNetworkBodies(client, networkEvents);
572
+ detailCandidateResult = buildScreeningCandidateFromDetail({
573
+ cardCandidate,
574
+ detailText,
575
+ networkBodies,
576
+ metadata: {
577
+ target_url: targetUrl,
578
+ card_node_id: cardNodeId,
579
+ detail_popup_selector: detailState?.popup?.selector || null,
580
+ detail_popup_root: detailState?.popup?.root || null,
581
+ resume_iframe_selector: detailState?.resumeIframe?.selector || null,
582
+ resume_iframe_root: detailState?.resumeIframe?.root || null,
583
+ resume_iframe_document_node_id: detailHtml.resumeIframeDocumentNodeId,
584
+ detail_html_errors: detailHtml.errors || []
585
+ }
586
+ });
587
+ if (detailCandidateResult.parsed_network_profiles.some((item) => item.ok)) break;
588
+ if (Date.now() - parseStarted >= Math.max(0, Number(networkParseRetryMs) || 0)) break;
589
+ await sleep(Math.max(50, Number(networkParseIntervalMs) || 250));
590
+ } while (true);
591
+
592
+ let closeResult = null;
593
+ if (closeDetail) {
594
+ closeResult = await closeRecommendDetail(client);
595
+ }
596
+
597
+ return {
598
+ candidate: detailCandidateResult.candidate,
599
+ parsed_network_profiles: detailCandidateResult.parsed_network_profiles,
600
+ network_bodies: networkBodies,
601
+ network_parse_retry_elapsed_ms: Date.now() - parseStarted,
602
+ network_event_count: networkEvents.length,
603
+ detail: {
604
+ popup_text: detailHtml.popupText,
605
+ resume_text: detailHtml.resumeText,
606
+ popup_html_length: detailHtml.popupHTML.length,
607
+ resume_html_length: detailHtml.resumeHTML.length,
608
+ html_errors: detailHtml.errors || []
609
+ },
610
+ close_result: closeResult
611
+ };
612
+ }