@reconcrap/boss-recommend-mcp 2.1.24 → 2.1.25

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,16 +1,23 @@
1
- import {
2
- clickNodeCenter,
3
- clickPoint,
4
- DETERMINISTIC_CLICK_OPTIONS,
5
- getFrameDocumentNodeId,
6
- getNodeBox,
7
- getOuterHTML,
8
- pressKey,
1
+ import {
2
+ clickNodeCenter,
3
+ clickPoint,
4
+ DETERMINISTIC_CLICK_OPTIONS,
5
+ describeNode,
6
+ getAttributesMap,
7
+ getFrameDocumentNodeId,
8
+ getNodeBox,
9
+ getOuterHTML,
10
+ isClosedCdpTransportError,
11
+ pressKey,
9
12
  querySelectorAll,
10
13
  scrollNodeIntoView,
11
14
  sleep
12
15
  } from "../../core/browser/index.js";
13
- import { candidateKeyFromProfile } from "../../core/infinite-list/index.js";
16
+ import { candidateKeyFromProfile } from "../../core/infinite-list/index.js";
17
+ import {
18
+ CV_CAPTURE_TARGET_SELECTORS,
19
+ resolveCvCaptureTarget
20
+ } from "../../core/cv-capture-target/index.js";
14
21
  import {
15
22
  buildScreeningCandidateFromDetail,
16
23
  htmlToText
@@ -35,14 +42,2882 @@ import {
35
42
  readRecommendCardCandidate
36
43
  } from "./cards.js";
37
44
 
38
- const DETAIL_OUTSIDE_CLOSE_BOUNDARY_SELECTORS = Object.freeze([
45
+ const DETAIL_OUTSIDE_CLOSE_BOUNDARY_SELECTORS = Object.freeze([
39
46
  ".resume-center-side .resume-detail-wrap",
40
47
  ".resume-detail-wrap",
41
48
  ".boss-popup__wrapper .boss-popup__body",
42
49
  ".boss-popup__wrapper .dialog-body",
43
50
  ".dialog-wrap.active .resume-detail-wrap",
44
51
  ".geek-detail-modal .resume-detail-wrap"
45
- ]);
52
+ ]);
53
+
54
+ const DETAIL_CANDIDATE_ID_ATTRIBUTES = Object.freeze([
55
+ "data-geek",
56
+ "data-geekid",
57
+ "data-uid",
58
+ "data-securityid",
59
+ "geekid",
60
+ "encryptgeekid"
61
+ ]);
62
+ const DETAIL_CANDIDATE_ID_SELECTOR = DETAIL_CANDIDATE_ID_ATTRIBUTES
63
+ .map((attribute) => `[${attribute}]`)
64
+ .join(", ");
65
+ const DETAIL_IDENTITY_TEXT_SELECTOR = "span, h1, h2, h3, p, strong, b, em, li, dt, dd";
66
+ const DETAIL_IDENTITY_PRIORITY_SELECTOR = [
67
+ ".name",
68
+ ".geek-name",
69
+ ".resume-name",
70
+ ".candidate-name",
71
+ '[class*="school"]',
72
+ '[class*="major"]',
73
+ '[class*="company"]',
74
+ '[class*="position"]'
75
+ ].join(", ");
76
+ const DETAIL_SECONDARY_IDENTITY_FIELDS = Object.freeze([
77
+ "school",
78
+ "major",
79
+ "current_company",
80
+ "current_position",
81
+ "title"
82
+ ]);
83
+
84
+ function normalizeBindingText(value = "") {
85
+ return String(value || "").replace(/\s+/g, " ").trim();
86
+ }
87
+
88
+ function positiveNodeId(value) {
89
+ const parsed = Number(value);
90
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
91
+ }
92
+
93
+ function shouldRethrowRecommendProtocolError(error) {
94
+ return Boolean(
95
+ isClosedCdpTransportError(error)
96
+ || error?.cdp_outcome_unknown === true
97
+ || error?.cdp_replay_suppressed === true
98
+ || (error?.cdp_method && !isStaleRecommendNodeError(error))
99
+ );
100
+ }
101
+
102
+ function compactBindingNode(node = null) {
103
+ if (!node) return null;
104
+ return {
105
+ source: node.source || null,
106
+ field: node.field || null,
107
+ value: node.value || null,
108
+ node_id: positiveNodeId(node.node_id),
109
+ backend_node_id: positiveNodeId(node.backend_node_id),
110
+ visible: node.visible === true,
111
+ verified: node.verified === true,
112
+ reason: node.reason || null,
113
+ definitively_disappeared: node.definitively_disappeared === true,
114
+ disappearance_kind: node.disappearance_kind || null,
115
+ accessibility_verified: node.accessibility_verified === true
116
+ };
117
+ }
118
+
119
+ async function readVisibleBackendNode(client, nodeId) {
120
+ try {
121
+ const box = await getNodeBox(client, nodeId);
122
+ if (Number(box?.rect?.width || 0) <= 2 || Number(box?.rect?.height || 0) <= 2) return null;
123
+ const node = await describeNode(client, nodeId, { depth: 0, pierce: true });
124
+ const backendNodeId = positiveNodeId(node?.backendNodeId);
125
+ if (!backendNodeId) return null;
126
+ return {
127
+ node_id: nodeId,
128
+ backend_node_id: backendNodeId,
129
+ visible: true
130
+ };
131
+ } catch (error) {
132
+ // A virtual-list remount is a normal stale-node race. Transport/session
133
+ // failures are not candidate evidence and must retain their original
134
+ // error identity so the run cannot misclassify them as a local mismatch.
135
+ if (isStaleRecommendNodeError(error)) return null;
136
+ throw error;
137
+ }
138
+ }
139
+
140
+ async function readExactAccessibilityText(client, nodeId, expectedText) {
141
+ const expected = normalizeBindingText(expectedText);
142
+ if (!expected || typeof client?.Accessibility?.getPartialAXTree !== "function") return false;
143
+ const axTreeContainsExactText = (tree, expectedBackendNodeId) => {
144
+ const backendNodeId = positiveNodeId(expectedBackendNodeId);
145
+ if (!backendNodeId) return false;
146
+ const nodes = tree?.nodes || [];
147
+ const byAxId = new Map(nodes
148
+ .filter((node) => node?.nodeId != null)
149
+ .map((node) => [String(node.nodeId), node]));
150
+ const queue = nodes.filter((node) => (
151
+ positiveNodeId(node?.backendDOMNodeId) === backendNodeId
152
+ ));
153
+ const visited = new Set();
154
+ let inspected = 0;
155
+ while (queue.length && inspected < 48) {
156
+ const node = queue.shift();
157
+ const axId = node?.nodeId == null ? null : String(node.nodeId);
158
+ if (axId && visited.has(axId)) continue;
159
+ if (axId) visited.add(axId);
160
+ inspected += 1;
161
+ const exact = [node?.name?.value, node?.value?.value, node?.description?.value]
162
+ .map(normalizeBindingText)
163
+ .some((value) => value === expected);
164
+ if (exact) return true;
165
+ for (const childId of node?.childIds || []) {
166
+ const child = byAxId.get(String(childId));
167
+ if (child) queue.push(child);
168
+ }
169
+ }
170
+ return false;
171
+ };
172
+ try {
173
+ const described = await describeNode(client, nodeId, { depth: 4, pierce: true });
174
+ const tree = await client.Accessibility.getPartialAXTree({
175
+ nodeId,
176
+ fetchRelatives: false
177
+ });
178
+ if (axTreeContainsExactText(tree, described?.backendNodeId)) return true;
179
+
180
+ // BOSS commonly renders an exact identity value inside an AX-ignored
181
+ // wrapper (for example, <span class="name">...</span>). Keep the proof
182
+ // scoped to the already DOM-exact node: only inspect its bounded returned
183
+ // descendants and accept an exact #text node whose own AX entry exposes
184
+ // the same value. This avoids an unscoped full-tree/name search.
185
+ const queue = [...(described?.children || [])];
186
+ let inspected = 0;
187
+ while (queue.length && inspected < 48) {
188
+ const descendant = queue.shift();
189
+ inspected += 1;
190
+ if (
191
+ String(descendant?.nodeName || "").toLowerCase() === "#text"
192
+ && normalizeBindingText(descendant?.nodeValue) === expected
193
+ ) {
194
+ const descendantNodeId = positiveNodeId(descendant?.nodeId);
195
+ const descendantBackendNodeId = positiveNodeId(descendant?.backendNodeId);
196
+ const exactDescendantTarget = descendantNodeId
197
+ ? { nodeId: descendantNodeId }
198
+ : descendantBackendNodeId
199
+ ? { backendNodeId: descendantBackendNodeId }
200
+ : null;
201
+ if (exactDescendantTarget) {
202
+ const descendantTree = await client.Accessibility.getPartialAXTree({
203
+ ...exactDescendantTarget,
204
+ fetchRelatives: false
205
+ });
206
+ if (axTreeContainsExactText(descendantTree, descendantBackendNodeId)) return true;
207
+ }
208
+ }
209
+ for (const child of descendant?.children || []) queue.push(child);
210
+ }
211
+ return false;
212
+ } catch (error) {
213
+ if (shouldRethrowRecommendProtocolError(error)) throw error;
214
+ return false;
215
+ }
216
+ }
217
+
218
+ function candidateIdFromAttributes(attributes = {}) {
219
+ for (const attribute of DETAIL_CANDIDATE_ID_ATTRIBUTES) {
220
+ const value = normalizeBindingText(attributes?.[attribute]);
221
+ if (value) return { attribute, value };
222
+ }
223
+ return { attribute: null, value: "" };
224
+ }
225
+
226
+ function expectedSecondaryIdentity(candidate = null) {
227
+ const identity = candidate?.identity || {};
228
+ const seen = new Set();
229
+ const values = [];
230
+ const rawAge = normalizeBindingText(identity.age);
231
+ const ageMatch = rawAge.match(/^(\d{1,3})(?:\s*岁)?$/);
232
+ if (ageMatch) {
233
+ const age = Number(ageMatch[1]);
234
+ if (Number.isInteger(age) && age > 0 && age < 150) {
235
+ const value = `${age}岁`;
236
+ seen.add(value);
237
+ values.push({ field: "age", value });
238
+ }
239
+ }
240
+ for (const field of DETAIL_SECONDARY_IDENTITY_FIELDS) {
241
+ const value = normalizeBindingText(identity[field]);
242
+ if (!value || value.length < 2 || seen.has(value)) continue;
243
+ seen.add(value);
244
+ values.push({ field, value });
245
+ }
246
+ return values;
247
+ }
248
+
249
+ async function readRecommendCardBindingEvidence(client, cardNodeId, cardCandidate = null) {
250
+ const expectedCandidateId = normalizeBindingText(cardCandidate?.id);
251
+ const expectedName = normalizeBindingText(cardCandidate?.identity?.name);
252
+ const evidence = await readVisibleBackendNode(client, cardNodeId);
253
+ if (!evidence) {
254
+ return {
255
+ verified: false,
256
+ reason: "card_node_not_visible_or_stale",
257
+ node_id: positiveNodeId(cardNodeId),
258
+ backend_node_id: null,
259
+ candidate_id: null,
260
+ name: null
261
+ };
262
+ }
263
+ try {
264
+ const [attributes, currentCandidate] = await Promise.all([
265
+ getAttributesMap(client, cardNodeId),
266
+ readRecommendCardCandidate(client, cardNodeId, {
267
+ source: "recommend-detail-binding-card"
268
+ })
269
+ ]);
270
+ const idEvidence = candidateIdFromAttributes(attributes);
271
+ const candidateId = normalizeBindingText(idEvidence.value || currentCandidate?.id);
272
+ const name = normalizeBindingText(currentCandidate?.identity?.name);
273
+ const verified = Boolean(
274
+ expectedCandidateId
275
+ && expectedName
276
+ && candidateId === expectedCandidateId
277
+ && name === expectedName
278
+ );
279
+ return {
280
+ ...evidence,
281
+ verified,
282
+ reason: verified
283
+ ? null
284
+ : !expectedCandidateId || !expectedName
285
+ ? "expected_card_identity_incomplete"
286
+ : candidateId !== expectedCandidateId
287
+ ? "card_candidate_id_mismatch"
288
+ : "card_candidate_name_mismatch",
289
+ candidate_id: candidateId || null,
290
+ candidate_id_attribute: idEvidence.attribute,
291
+ name: name || null
292
+ };
293
+ } catch (error) {
294
+ if (shouldRethrowRecommendProtocolError(error)) throw error;
295
+ return {
296
+ ...evidence,
297
+ verified: false,
298
+ reason: "card_identity_read_failed",
299
+ candidate_id: null,
300
+ name: null,
301
+ error: error?.message || String(error)
302
+ };
303
+ }
304
+ }
305
+
306
+ function isDefinitiveDetachedRecommendNodeError(error) {
307
+ const message = String(error?.message || error || "");
308
+ return /Could not find node|No node with given id|Node with given id.*does not belong|detached node|not attached to the page/i.test(message);
309
+ }
310
+
311
+ function isDefinitiveHiddenRecommendNodeError(error) {
312
+ const message = String(error?.message || error || "");
313
+ return /Could not compute box model|does not have a layout object|node has no layout/i.test(message);
314
+ }
315
+
316
+ async function readRecommendCardAfterClickEvidence(client, cardNodeId, cardCandidate = null) {
317
+ let described;
318
+ try {
319
+ described = await describeNode(client, cardNodeId, { depth: 0, pierce: true });
320
+ } catch (error) {
321
+ if (shouldRethrowRecommendProtocolError(error)) throw error;
322
+ return {
323
+ verified: false,
324
+ reason: isDefinitiveDetachedRecommendNodeError(error)
325
+ ? "card_node_detached_after_click"
326
+ : "card_identity_probe_failed",
327
+ definitively_disappeared: isDefinitiveDetachedRecommendNodeError(error),
328
+ disappearance_kind: isDefinitiveDetachedRecommendNodeError(error) ? "detached" : null,
329
+ node_id: positiveNodeId(cardNodeId),
330
+ backend_node_id: null,
331
+ candidate_id: null,
332
+ name: null,
333
+ error: error?.message || String(error)
334
+ };
335
+ }
336
+ const backendNodeId = positiveNodeId(described?.backendNodeId);
337
+ if (!backendNodeId) {
338
+ return {
339
+ verified: false,
340
+ reason: "card_identity_probe_failed",
341
+ definitively_disappeared: false,
342
+ disappearance_kind: null,
343
+ node_id: positiveNodeId(cardNodeId),
344
+ backend_node_id: null,
345
+ candidate_id: null,
346
+ name: null
347
+ };
348
+ }
349
+ try {
350
+ const box = await getNodeBox(client, cardNodeId);
351
+ if (Number(box?.rect?.width || 0) <= 2 || Number(box?.rect?.height || 0) <= 2) {
352
+ return {
353
+ verified: false,
354
+ reason: "card_node_hidden_after_click",
355
+ definitively_disappeared: true,
356
+ disappearance_kind: "hidden_zero_box",
357
+ node_id: positiveNodeId(cardNodeId),
358
+ backend_node_id: backendNodeId,
359
+ candidate_id: null,
360
+ name: null
361
+ };
362
+ }
363
+ } catch (error) {
364
+ const hidden = isDefinitiveHiddenRecommendNodeError(error);
365
+ const detached = isDefinitiveDetachedRecommendNodeError(error);
366
+ return {
367
+ verified: false,
368
+ reason: detached
369
+ ? "card_node_detached_after_click"
370
+ : hidden
371
+ ? "card_node_hidden_after_click"
372
+ : "card_identity_probe_failed",
373
+ definitively_disappeared: detached || hidden,
374
+ disappearance_kind: detached ? "detached" : hidden ? "hidden_no_layout" : null,
375
+ node_id: positiveNodeId(cardNodeId),
376
+ backend_node_id: backendNodeId,
377
+ candidate_id: null,
378
+ name: null,
379
+ error: error?.message || String(error)
380
+ };
381
+ }
382
+ return readRecommendCardBindingEvidence(client, cardNodeId, cardCandidate);
383
+ }
384
+
385
+ function compactBindingAncestry(ancestry = null) {
386
+ if (!ancestry) return null;
387
+ return {
388
+ verified: ancestry.verified === true,
389
+ reason: ancestry.reason || null,
390
+ method: ancestry.method || null,
391
+ descendant_node_id: positiveNodeId(ancestry.descendant_node_id),
392
+ ancestor_node_id: positiveNodeId(ancestry.ancestor_node_id),
393
+ ancestor_backend_node_id: positiveNodeId(ancestry.ancestor_backend_node_id),
394
+ parent_id_missing: ancestry.parent_id_missing === true,
395
+ parent_id_missing_at_node_id: positiveNodeId(ancestry.parent_id_missing_at_node_id),
396
+ depth: Number.isInteger(ancestry.depth) ? ancestry.depth : null,
397
+ error: ancestry.error || null,
398
+ path: (ancestry.path || []).slice(0, 160).map((item) => ({
399
+ node_id: positiveNodeId(item.node_id),
400
+ backend_node_id: positiveNodeId(item.backend_node_id)
401
+ }))
402
+ };
403
+ }
404
+
405
+ function compactRecommendCardRootMembership(membership = null) {
406
+ if (!membership) return null;
407
+ return {
408
+ verified: membership.verified === true,
409
+ reason: membership.reason || null,
410
+ method: membership.method || null,
411
+ root_scoped: membership.root_scoped === true,
412
+ root_node_id: positiveNodeId(membership.root_node_id),
413
+ expected_root_backend_node_id: positiveNodeId(
414
+ membership.expected_root_backend_node_id
415
+ ),
416
+ expected_iframe_node_id: positiveNodeId(membership.expected_iframe_node_id),
417
+ expected_iframe_backend_node_id: positiveNodeId(
418
+ membership.expected_iframe_backend_node_id
419
+ ),
420
+ expected_linked_document_node_id: positiveNodeId(
421
+ membership.expected_linked_document_node_id
422
+ ),
423
+ expected_card_node_id: positiveNodeId(membership.expected_card_node_id),
424
+ expected_card_backend_node_id: positiveNodeId(
425
+ membership.expected_card_backend_node_id
426
+ ),
427
+ observed_card_backend_node_id: positiveNodeId(
428
+ membership.observed_card_backend_node_id
429
+ ),
430
+ query_count: Number.isInteger(membership.query_count) ? membership.query_count : null,
431
+ valid_query_count: Number.isInteger(membership.valid_query_count)
432
+ ? membership.valid_query_count
433
+ : null,
434
+ exact_frontend_match_count: Number.isInteger(membership.exact_frontend_match_count)
435
+ ? membership.exact_frontend_match_count
436
+ : null,
437
+ exact_backend_match_count: Number.isInteger(membership.exact_backend_match_count)
438
+ ? membership.exact_backend_match_count
439
+ : null,
440
+ queried_nodes: (membership.queried_nodes || []).slice(0, 160).map((item) => ({
441
+ node_id: positiveNodeId(item.node_id),
442
+ backend_node_id: positiveNodeId(item.backend_node_id)
443
+ })),
444
+ recheck: membership.recheck
445
+ ? {
446
+ verified: membership.recheck.verified === true,
447
+ root_node_id: positiveNodeId(membership.recheck.root_node_id),
448
+ expected_root_backend_node_id: positiveNodeId(
449
+ membership.recheck.expected_root_backend_node_id
450
+ ),
451
+ observed_root_backend_node_id: positiveNodeId(
452
+ membership.recheck.observed_root_backend_node_id
453
+ ),
454
+ iframe_node_id: positiveNodeId(membership.recheck.iframe_node_id),
455
+ expected_iframe_backend_node_id: positiveNodeId(
456
+ membership.recheck.expected_iframe_backend_node_id
457
+ ),
458
+ observed_iframe_backend_node_id: positiveNodeId(
459
+ membership.recheck.observed_iframe_backend_node_id
460
+ ),
461
+ expected_linked_document_node_id: positiveNodeId(
462
+ membership.recheck.expected_linked_document_node_id
463
+ ),
464
+ observed_linked_document_node_id: positiveNodeId(
465
+ membership.recheck.observed_linked_document_node_id
466
+ )
467
+ }
468
+ : null,
469
+ card_identity_recheck: membership.card_identity_recheck
470
+ ? {
471
+ verified: membership.card_identity_recheck.verified === true,
472
+ reason: membership.card_identity_recheck.reason || null,
473
+ node_id: positiveNodeId(membership.card_identity_recheck.node_id),
474
+ backend_node_id: positiveNodeId(membership.card_identity_recheck.backend_node_id),
475
+ candidate_id: membership.card_identity_recheck.candidate_id || null,
476
+ name: membership.card_identity_recheck.name || null,
477
+ visible: membership.card_identity_recheck.visible === true
478
+ }
479
+ : null,
480
+ error: membership.error || null
481
+ };
482
+ }
483
+
484
+ function compactRecommendIframePopupMembership(membership = null) {
485
+ if (!membership) return null;
486
+ return {
487
+ verified: membership.verified === true,
488
+ reason: membership.reason || null,
489
+ method: membership.method || null,
490
+ popup_scoped: membership.popup_scoped === true,
491
+ selector: membership.selector || null,
492
+ popup_node_id: positiveNodeId(membership.popup_node_id),
493
+ expected_popup_backend_node_id: positiveNodeId(
494
+ membership.expected_popup_backend_node_id
495
+ ),
496
+ expected_iframe_node_id: positiveNodeId(membership.expected_iframe_node_id),
497
+ expected_iframe_backend_node_id: positiveNodeId(
498
+ membership.expected_iframe_backend_node_id
499
+ ),
500
+ expected_document_node_id: positiveNodeId(membership.expected_document_node_id),
501
+ expected_document_backend_node_id: positiveNodeId(
502
+ membership.expected_document_backend_node_id
503
+ ),
504
+ observed_iframe_backend_node_id: positiveNodeId(
505
+ membership.observed_iframe_backend_node_id
506
+ ),
507
+ query_count: Number.isInteger(membership.query_count) ? membership.query_count : null,
508
+ valid_query_count: Number.isInteger(membership.valid_query_count)
509
+ ? membership.valid_query_count
510
+ : null,
511
+ exact_frontend_match_count: Number.isInteger(membership.exact_frontend_match_count)
512
+ ? membership.exact_frontend_match_count
513
+ : null,
514
+ exact_backend_match_count: Number.isInteger(membership.exact_backend_match_count)
515
+ ? membership.exact_backend_match_count
516
+ : null,
517
+ queried_nodes: (membership.queried_nodes || []).slice(0, 160).map((item) => ({
518
+ node_id: positiveNodeId(item.node_id),
519
+ backend_node_id: positiveNodeId(item.backend_node_id)
520
+ })),
521
+ recheck: membership.recheck
522
+ ? {
523
+ verified: membership.recheck.verified === true,
524
+ popup_node_id: positiveNodeId(membership.recheck.popup_node_id),
525
+ expected_popup_backend_node_id: positiveNodeId(
526
+ membership.recheck.expected_popup_backend_node_id
527
+ ),
528
+ observed_popup_backend_node_id: positiveNodeId(
529
+ membership.recheck.observed_popup_backend_node_id
530
+ ),
531
+ iframe_node_id: positiveNodeId(membership.recheck.iframe_node_id),
532
+ expected_iframe_backend_node_id: positiveNodeId(
533
+ membership.recheck.expected_iframe_backend_node_id
534
+ ),
535
+ observed_iframe_backend_node_id: positiveNodeId(
536
+ membership.recheck.observed_iframe_backend_node_id
537
+ ),
538
+ expected_document_node_id: positiveNodeId(
539
+ membership.recheck.expected_document_node_id
540
+ ),
541
+ observed_document_node_id: positiveNodeId(
542
+ membership.recheck.observed_document_node_id
543
+ ),
544
+ expected_document_backend_node_id: positiveNodeId(
545
+ membership.recheck.expected_document_backend_node_id
546
+ ),
547
+ observed_document_backend_node_id: positiveNodeId(
548
+ membership.recheck.observed_document_backend_node_id
549
+ )
550
+ }
551
+ : null,
552
+ error: membership.error || null
553
+ };
554
+ }
555
+
556
+ function compactRecommendCardPreClickProvenance(provenance = null) {
557
+ if (!provenance) return null;
558
+ return {
559
+ verified: provenance.verified === true,
560
+ reason: provenance.reason || null,
561
+ containment_method: provenance.containment_method || null,
562
+ card: provenance.card
563
+ ? {
564
+ verified: provenance.card.verified === true,
565
+ reason: provenance.card.reason || null,
566
+ node_id: positiveNodeId(provenance.card.node_id),
567
+ backend_node_id: positiveNodeId(provenance.card.backend_node_id),
568
+ candidate_id: provenance.card.candidate_id || null,
569
+ name: provenance.card.name || null,
570
+ visible: provenance.card.visible === true
571
+ }
572
+ : null,
573
+ card_identity_recheck: provenance.card_identity_recheck
574
+ ? {
575
+ verified: provenance.card_identity_recheck.verified === true,
576
+ reason: provenance.card_identity_recheck.reason || null,
577
+ node_id: positiveNodeId(provenance.card_identity_recheck.node_id),
578
+ backend_node_id: positiveNodeId(provenance.card_identity_recheck.backend_node_id),
579
+ candidate_id: provenance.card_identity_recheck.candidate_id || null,
580
+ name: provenance.card_identity_recheck.name || null,
581
+ visible: provenance.card_identity_recheck.visible === true
582
+ }
583
+ : null,
584
+ list_root: provenance.list_root
585
+ ? {
586
+ node_id: positiveNodeId(provenance.list_root.node_id),
587
+ backend_node_id: positiveNodeId(provenance.list_root.backend_node_id),
588
+ iframe_node_id: positiveNodeId(provenance.list_root.iframe_node_id),
589
+ iframe_backend_node_id: positiveNodeId(provenance.list_root.iframe_backend_node_id),
590
+ linked_document_node_id: positiveNodeId(provenance.list_root.linked_document_node_id)
591
+ }
592
+ : null,
593
+ ancestry: compactBindingAncestry(provenance.ancestry),
594
+ root_membership: compactRecommendCardRootMembership(provenance.root_membership)
595
+ };
596
+ }
597
+
598
+ function compactRecommendClickPoint(point = null) {
599
+ if (!point) return null;
600
+ const x = Number(point.x);
601
+ const y = Number(point.y);
602
+ return {
603
+ x: Number.isFinite(x) ? x : null,
604
+ y: Number.isFinite(y) ? y : null,
605
+ mode: point.mode || null,
606
+ attempt_index: Number.isInteger(point.attempt_index) ? point.attempt_index : null,
607
+ hit_test_candidate_index: Number.isInteger(point.hit_test_candidate_index)
608
+ ? point.hit_test_candidate_index
609
+ : null
610
+ };
611
+ }
612
+
613
+ function sameRecommendClickPoint(left = null, right = null) {
614
+ return Boolean(
615
+ left
616
+ && right
617
+ && Number.isFinite(Number(left.x))
618
+ && Number.isFinite(Number(left.y))
619
+ && Number(left.x) === Number(right.x)
620
+ && Number(left.y) === Number(right.y)
621
+ );
622
+ }
623
+
624
+ function compactRecommendCardClickEvidence(evidence = null) {
625
+ if (!evidence) return null;
626
+ const selected = compactRecommendClickPoint(
627
+ evidence?.hit_test?.selected || evidence?.click_target
628
+ );
629
+ const selectedAttempt = (evidence?.hit_test?.attempts || []).find((attempt) => (
630
+ sameRecommendClickPoint(attempt?.point, selected)
631
+ )) || evidence?.hit_test?.selected_attempt || null;
632
+ return {
633
+ verified: evidence.verified === true,
634
+ in_viewport: evidence.in_viewport === true,
635
+ reason: evidence.reason || null,
636
+ node_id: positiveNodeId(evidence.node_id),
637
+ click_target: compactRecommendClickPoint(evidence.click_target),
638
+ viewport: evidence.viewport
639
+ ? {
640
+ width: Number(evidence.viewport.width) || 0,
641
+ height: Number(evidence.viewport.height) || 0,
642
+ margin_px: Number(evidence.viewport.margin_px) || 0,
643
+ source: evidence.viewport.source || null
644
+ }
645
+ : null,
646
+ hit_test: evidence.hit_test
647
+ ? {
648
+ completed: evidence.hit_test.completed === true,
649
+ exact_card_hit_verified: evidence.hit_test.exact_card_hit_verified === true,
650
+ reason: evidence.hit_test.reason || null,
651
+ selected,
652
+ descendant_count: Number(evidence.hit_test.descendant_count) || 0,
653
+ unsafe_descendant_count: Number(evidence.hit_test.unsafe_descendant_count) || 0,
654
+ selected_attempt: selectedAttempt
655
+ ? {
656
+ point: compactRecommendClickPoint(selectedAttempt.point),
657
+ inside_viewport: selectedAttempt.inside_viewport === true,
658
+ exact_card_hit: selectedAttempt.exact_card_hit === true,
659
+ safe_card_hit: selectedAttempt.safe_card_hit === true,
660
+ safe_card_body_hit: selectedAttempt.safe_card_body_hit === true,
661
+ hit_node_id: positiveNodeId(selectedAttempt.hit_node_id),
662
+ hit_node_name: selectedAttempt.hit_node_name || null,
663
+ hit_backend_node_id: positiveNodeId(selectedAttempt.hit_backend_node_id),
664
+ reason: selectedAttempt.reason || null
665
+ }
666
+ : null
667
+ }
668
+ : null
669
+ };
670
+ }
671
+
672
+ function compactRecommendCardClickAttempts(attempts = []) {
673
+ return (Array.isArray(attempts) ? attempts : []).slice(0, 4).map((attempt) => ({
674
+ attempt: Number(attempt?.attempt) || null,
675
+ click_target: compactRecommendClickPoint(attempt?.click_target),
676
+ input_dispatched: attempt?.input_dispatched === true,
677
+ outcome: attempt?.outcome || null,
678
+ elapsed_ms: Number.isFinite(Number(attempt?.elapsed_ms))
679
+ ? Math.max(0, Number(attempt.elapsed_ms))
680
+ : null
681
+ }));
682
+ }
683
+
684
+ function appendCumulativeRecommendCardClickAttempts(target = [], attempts = []) {
685
+ for (const attempt of compactRecommendCardClickAttempts(attempts)) {
686
+ target.push({
687
+ ...attempt,
688
+ attempt: target.length + 1
689
+ });
690
+ }
691
+ return target;
692
+ }
693
+
694
+ async function readExactDescendantAncestry(client, descendantNodeId, ancestorNodeId, {
695
+ maxDepth = 160
696
+ } = {}) {
697
+ const descendant = positiveNodeId(descendantNodeId);
698
+ const ancestor = positiveNodeId(ancestorNodeId);
699
+ if (!descendant || !ancestor || descendant === ancestor) {
700
+ return {
701
+ verified: false,
702
+ reason: "detail_iframe_ancestry_identity_missing",
703
+ method: "parent_ancestry",
704
+ descendant_node_id: descendant,
705
+ ancestor_node_id: ancestor,
706
+ path: []
707
+ };
708
+ }
709
+ const path = [];
710
+ const seen = new Set();
711
+ let currentNodeId = descendant;
712
+ try {
713
+ for (let depth = 0; depth < maxDepth; depth += 1) {
714
+ if (seen.has(currentNodeId)) break;
715
+ seen.add(currentNodeId);
716
+ const described = await describeNode(client, currentNodeId, { depth: 0, pierce: true });
717
+ const backendNodeId = positiveNodeId(described?.backendNodeId);
718
+ const parentNodeId = positiveNodeId(described?.parentId);
719
+ if (!backendNodeId) {
720
+ return {
721
+ verified: false,
722
+ reason: "detail_iframe_ancestry_backend_missing",
723
+ method: "parent_ancestry",
724
+ descendant_node_id: descendant,
725
+ ancestor_node_id: ancestor,
726
+ path
727
+ };
728
+ }
729
+ path.push({
730
+ node_id: currentNodeId,
731
+ backend_node_id: backendNodeId
732
+ });
733
+ if (parentNodeId === ancestor) {
734
+ const ancestorNode = await describeNode(client, ancestor, { depth: 0, pierce: true });
735
+ const ancestorBackendNodeId = positiveNodeId(ancestorNode?.backendNodeId);
736
+ if (!ancestorBackendNodeId) {
737
+ return {
738
+ verified: false,
739
+ reason: "detail_iframe_container_backend_missing",
740
+ method: "parent_ancestry",
741
+ descendant_node_id: descendant,
742
+ ancestor_node_id: ancestor,
743
+ path
744
+ };
745
+ }
746
+ path.push({
747
+ node_id: ancestor,
748
+ backend_node_id: ancestorBackendNodeId
749
+ });
750
+ return {
751
+ verified: true,
752
+ reason: null,
753
+ method: "parent_ancestry",
754
+ descendant_node_id: descendant,
755
+ ancestor_node_id: ancestor,
756
+ ancestor_backend_node_id: ancestorBackendNodeId,
757
+ depth: path.length - 1,
758
+ path
759
+ };
760
+ }
761
+ if (!parentNodeId) {
762
+ return {
763
+ verified: false,
764
+ reason: "detail_iframe_ancestry_parent_missing",
765
+ method: "parent_ancestry",
766
+ descendant_node_id: descendant,
767
+ ancestor_node_id: ancestor,
768
+ parent_id_missing: true,
769
+ parent_id_missing_at_node_id: currentNodeId,
770
+ path
771
+ };
772
+ }
773
+ currentNodeId = parentNodeId;
774
+ }
775
+ } catch (error) {
776
+ if (shouldRethrowRecommendProtocolError(error)) throw error;
777
+ return {
778
+ verified: false,
779
+ reason: "detail_iframe_ancestry_read_failed",
780
+ method: "parent_ancestry",
781
+ descendant_node_id: descendant,
782
+ ancestor_node_id: ancestor,
783
+ path,
784
+ error: error?.message || String(error)
785
+ };
786
+ }
787
+ return {
788
+ verified: false,
789
+ reason: "detail_iframe_not_contained_by_popup",
790
+ method: "parent_ancestry",
791
+ descendant_node_id: descendant,
792
+ ancestor_node_id: ancestor,
793
+ path
794
+ };
795
+ }
796
+
797
+ async function readExactRecommendCardRootMembership(client, {
798
+ cardNodeId,
799
+ cardBackendNodeId,
800
+ cardCandidate,
801
+ listRootNodeId,
802
+ listRootBackendNodeId,
803
+ iframeNodeId,
804
+ iframeBackendNodeId,
805
+ linkedDocumentNodeId
806
+ } = {}) {
807
+ const expectedCardNodeId = positiveNodeId(cardNodeId);
808
+ const expectedCardBackendNodeId = positiveNodeId(cardBackendNodeId);
809
+ const expectedRootNodeId = positiveNodeId(listRootNodeId);
810
+ const expectedRootBackendNodeId = positiveNodeId(listRootBackendNodeId);
811
+ const expectedIframeNodeId = positiveNodeId(iframeNodeId);
812
+ const expectedIframeBackendNodeId = positiveNodeId(iframeBackendNodeId);
813
+ const expectedLinkedDocumentNodeId = positiveNodeId(linkedDocumentNodeId);
814
+ const base = {
815
+ verified: false,
816
+ reason: null,
817
+ method: "root_scoped_exact_card_identity",
818
+ root_scoped: true,
819
+ root_node_id: expectedRootNodeId,
820
+ expected_root_backend_node_id: expectedRootBackendNodeId,
821
+ expected_iframe_node_id: expectedIframeNodeId,
822
+ expected_iframe_backend_node_id: expectedIframeBackendNodeId,
823
+ expected_linked_document_node_id: expectedLinkedDocumentNodeId,
824
+ expected_card_node_id: expectedCardNodeId,
825
+ expected_card_backend_node_id: expectedCardBackendNodeId,
826
+ observed_card_backend_node_id: null,
827
+ query_count: null,
828
+ valid_query_count: null,
829
+ exact_frontend_match_count: null,
830
+ exact_backend_match_count: null,
831
+ queried_nodes: [],
832
+ recheck: null,
833
+ card_identity_recheck: null
834
+ };
835
+ if (
836
+ !expectedCardNodeId
837
+ || !expectedCardBackendNodeId
838
+ || !expectedRootNodeId
839
+ || !expectedRootBackendNodeId
840
+ || !expectedIframeNodeId
841
+ || !expectedIframeBackendNodeId
842
+ || expectedLinkedDocumentNodeId !== expectedRootNodeId
843
+ ) {
844
+ return {
845
+ ...base,
846
+ reason: "card_list_root_membership_identity_missing"
847
+ };
848
+ }
849
+
850
+ let rawNodeIds;
851
+ try {
852
+ rawNodeIds = await findRecommendCardNodeIds(client, expectedLinkedDocumentNodeId);
853
+ } catch (error) {
854
+ if (shouldRethrowRecommendProtocolError(error)) throw error;
855
+ return {
856
+ ...base,
857
+ reason: "card_list_root_query_failed",
858
+ error: error?.message || String(error)
859
+ };
860
+ }
861
+ const normalizedNodeIds = (rawNodeIds || []).map(positiveNodeId);
862
+ const queryEvidence = {
863
+ query_count: normalizedNodeIds.length,
864
+ valid_query_count: normalizedNodeIds.filter(Boolean).length,
865
+ exact_frontend_match_count: normalizedNodeIds.filter(
866
+ (nodeId) => nodeId === expectedCardNodeId
867
+ ).length
868
+ };
869
+ if (queryEvidence.valid_query_count !== queryEvidence.query_count) {
870
+ return {
871
+ ...base,
872
+ ...queryEvidence,
873
+ reason: "card_list_root_query_invalid_node_id"
874
+ };
875
+ }
876
+ if (queryEvidence.exact_frontend_match_count === 0) {
877
+ return {
878
+ ...base,
879
+ ...queryEvidence,
880
+ reason: "card_list_root_frontend_match_missing"
881
+ };
882
+ }
883
+ if (queryEvidence.exact_frontend_match_count !== 1) {
884
+ return {
885
+ ...base,
886
+ ...queryEvidence,
887
+ reason: "card_list_root_frontend_match_ambiguous"
888
+ };
889
+ }
890
+
891
+ const queriedNodes = [];
892
+ try {
893
+ for (const nodeId of normalizedNodeIds) {
894
+ const described = await describeNode(client, nodeId, { depth: 0, pierce: true });
895
+ const backendNodeId = positiveNodeId(described?.backendNodeId);
896
+ if (!backendNodeId) throw new Error(`Missing backendNodeId for recommend card node ${nodeId}`);
897
+ queriedNodes.push({
898
+ node_id: nodeId,
899
+ backend_node_id: backendNodeId
900
+ });
901
+ }
902
+ } catch (error) {
903
+ if (shouldRethrowRecommendProtocolError(error)) throw error;
904
+ return {
905
+ ...base,
906
+ ...queryEvidence,
907
+ queried_nodes: queriedNodes,
908
+ reason: "card_list_root_node_describe_failed",
909
+ error: error?.message || String(error)
910
+ };
911
+ }
912
+ const observedCardBackendNodeId = positiveNodeId(
913
+ queriedNodes.find((item) => item.node_id === expectedCardNodeId)?.backend_node_id
914
+ );
915
+ const exactBackendMatchCount = queriedNodes.filter(
916
+ (item) => item.backend_node_id === expectedCardBackendNodeId
917
+ ).length;
918
+ const identityEvidence = {
919
+ ...queryEvidence,
920
+ queried_nodes: queriedNodes,
921
+ observed_card_backend_node_id: observedCardBackendNodeId,
922
+ exact_backend_match_count: exactBackendMatchCount
923
+ };
924
+ if (observedCardBackendNodeId !== expectedCardBackendNodeId) {
925
+ return {
926
+ ...base,
927
+ ...identityEvidence,
928
+ reason: "card_list_root_card_backend_mismatch"
929
+ };
930
+ }
931
+ if (exactBackendMatchCount !== 1) {
932
+ return {
933
+ ...base,
934
+ ...identityEvidence,
935
+ reason: "card_list_root_backend_match_ambiguous"
936
+ };
937
+ }
938
+
939
+ let observedRootBackendNodeId = null;
940
+ let observedIframeBackendNodeId = null;
941
+ let observedLinkedDocumentNodeId = null;
942
+ try {
943
+ const rootNode = await describeNode(client, expectedRootNodeId, { depth: 0, pierce: true });
944
+ observedRootBackendNodeId = positiveNodeId(rootNode?.backendNodeId);
945
+ const iframeNode = await describeNode(client, expectedIframeNodeId, { depth: 0, pierce: true });
946
+ observedIframeBackendNodeId = positiveNodeId(iframeNode?.backendNodeId);
947
+ observedLinkedDocumentNodeId = positiveNodeId(
948
+ await getFrameDocumentNodeId(client, expectedIframeNodeId)
949
+ );
950
+ } catch (error) {
951
+ if (shouldRethrowRecommendProtocolError(error)) throw error;
952
+ return {
953
+ ...base,
954
+ ...identityEvidence,
955
+ reason: "card_list_root_recheck_failed",
956
+ recheck: {
957
+ verified: false,
958
+ root_node_id: expectedRootNodeId,
959
+ expected_root_backend_node_id: expectedRootBackendNodeId,
960
+ observed_root_backend_node_id: observedRootBackendNodeId,
961
+ iframe_node_id: expectedIframeNodeId,
962
+ expected_iframe_backend_node_id: expectedIframeBackendNodeId,
963
+ observed_iframe_backend_node_id: observedIframeBackendNodeId,
964
+ expected_linked_document_node_id: expectedLinkedDocumentNodeId,
965
+ observed_linked_document_node_id: observedLinkedDocumentNodeId
966
+ },
967
+ error: error?.message || String(error)
968
+ };
969
+ }
970
+ const recheck = {
971
+ verified: Boolean(
972
+ observedRootBackendNodeId === expectedRootBackendNodeId
973
+ && observedIframeBackendNodeId === expectedIframeBackendNodeId
974
+ && observedLinkedDocumentNodeId === expectedLinkedDocumentNodeId
975
+ ),
976
+ root_node_id: expectedRootNodeId,
977
+ expected_root_backend_node_id: expectedRootBackendNodeId,
978
+ observed_root_backend_node_id: observedRootBackendNodeId,
979
+ iframe_node_id: expectedIframeNodeId,
980
+ expected_iframe_backend_node_id: expectedIframeBackendNodeId,
981
+ observed_iframe_backend_node_id: observedIframeBackendNodeId,
982
+ expected_linked_document_node_id: expectedLinkedDocumentNodeId,
983
+ observed_linked_document_node_id: observedLinkedDocumentNodeId
984
+ };
985
+ const recheckReason = observedRootBackendNodeId !== expectedRootBackendNodeId
986
+ ? "card_list_root_backend_drift"
987
+ : observedIframeBackendNodeId !== expectedIframeBackendNodeId
988
+ ? "card_iframe_backend_drift"
989
+ : observedLinkedDocumentNodeId !== expectedLinkedDocumentNodeId
990
+ ? "card_iframe_document_link_drift"
991
+ : null;
992
+ if (!recheck.verified) {
993
+ return {
994
+ ...base,
995
+ ...identityEvidence,
996
+ verified: false,
997
+ reason: recheckReason,
998
+ recheck
999
+ };
1000
+ }
1001
+ const cardIdentityRecheck = await readRecommendCardBindingEvidence(
1002
+ client,
1003
+ expectedCardNodeId,
1004
+ cardCandidate
1005
+ );
1006
+ const cardIdentityRecheckVerified = Boolean(
1007
+ cardIdentityRecheck?.verified === true
1008
+ && positiveNodeId(cardIdentityRecheck?.backend_node_id) === expectedCardBackendNodeId
1009
+ );
1010
+ return {
1011
+ ...base,
1012
+ ...identityEvidence,
1013
+ verified: cardIdentityRecheckVerified,
1014
+ reason: cardIdentityRecheckVerified
1015
+ ? null
1016
+ : positiveNodeId(cardIdentityRecheck?.backend_node_id) !== expectedCardBackendNodeId
1017
+ ? "card_list_root_card_backend_recheck_mismatch"
1018
+ : "card_list_root_card_identity_recheck_failed",
1019
+ recheck,
1020
+ card_identity_recheck: cardIdentityRecheck
1021
+ };
1022
+ }
1023
+
1024
+ async function readExactRecommendIframePopupMembership(client, {
1025
+ selector,
1026
+ popupNodeId,
1027
+ popupBackendNodeId,
1028
+ iframeNodeId,
1029
+ iframeBackendNodeId,
1030
+ documentNodeId,
1031
+ documentBackendNodeId
1032
+ } = {}) {
1033
+ const exactSelector = String(selector || "").trim();
1034
+ const expectedPopupNodeId = positiveNodeId(popupNodeId);
1035
+ const expectedPopupBackendNodeId = positiveNodeId(popupBackendNodeId);
1036
+ const expectedIframeNodeId = positiveNodeId(iframeNodeId);
1037
+ const expectedIframeBackendNodeId = positiveNodeId(iframeBackendNodeId);
1038
+ const expectedDocumentNodeId = positiveNodeId(documentNodeId);
1039
+ const expectedDocumentBackendNodeId = positiveNodeId(documentBackendNodeId);
1040
+ const base = {
1041
+ verified: false,
1042
+ reason: null,
1043
+ method: "popup_scoped_exact_resume_iframe_identity",
1044
+ popup_scoped: true,
1045
+ selector: exactSelector || null,
1046
+ popup_node_id: expectedPopupNodeId,
1047
+ expected_popup_backend_node_id: expectedPopupBackendNodeId,
1048
+ expected_iframe_node_id: expectedIframeNodeId,
1049
+ expected_iframe_backend_node_id: expectedIframeBackendNodeId,
1050
+ expected_document_node_id: expectedDocumentNodeId,
1051
+ expected_document_backend_node_id: expectedDocumentBackendNodeId,
1052
+ observed_iframe_backend_node_id: null,
1053
+ query_count: null,
1054
+ valid_query_count: null,
1055
+ exact_frontend_match_count: null,
1056
+ exact_backend_match_count: null,
1057
+ queried_nodes: [],
1058
+ recheck: null
1059
+ };
1060
+ if (
1061
+ !exactSelector
1062
+ || !expectedPopupNodeId
1063
+ || !expectedPopupBackendNodeId
1064
+ || !expectedIframeNodeId
1065
+ || !expectedIframeBackendNodeId
1066
+ || !expectedDocumentNodeId
1067
+ || !expectedDocumentBackendNodeId
1068
+ ) {
1069
+ return {
1070
+ ...base,
1071
+ reason: "detail_iframe_popup_membership_identity_missing"
1072
+ };
1073
+ }
1074
+
1075
+ let rawNodeIds;
1076
+ try {
1077
+ rawNodeIds = await querySelectorAll(client, expectedPopupNodeId, exactSelector);
1078
+ } catch (error) {
1079
+ return {
1080
+ ...base,
1081
+ reason: "detail_iframe_popup_query_failed",
1082
+ error: error?.message || String(error)
1083
+ };
1084
+ }
1085
+ const normalizedNodeIds = (rawNodeIds || []).map(positiveNodeId);
1086
+ const queryEvidence = {
1087
+ query_count: normalizedNodeIds.length,
1088
+ valid_query_count: normalizedNodeIds.filter(Boolean).length,
1089
+ exact_frontend_match_count: normalizedNodeIds.filter(
1090
+ (nodeId) => nodeId === expectedIframeNodeId
1091
+ ).length
1092
+ };
1093
+ if (queryEvidence.valid_query_count !== queryEvidence.query_count) {
1094
+ return {
1095
+ ...base,
1096
+ ...queryEvidence,
1097
+ reason: "detail_iframe_popup_query_invalid_node_id"
1098
+ };
1099
+ }
1100
+ if (queryEvidence.exact_frontend_match_count === 0) {
1101
+ return {
1102
+ ...base,
1103
+ ...queryEvidence,
1104
+ reason: "detail_iframe_popup_frontend_match_missing"
1105
+ };
1106
+ }
1107
+ if (queryEvidence.exact_frontend_match_count !== 1) {
1108
+ return {
1109
+ ...base,
1110
+ ...queryEvidence,
1111
+ reason: "detail_iframe_popup_frontend_match_ambiguous"
1112
+ };
1113
+ }
1114
+
1115
+ const queriedNodes = [];
1116
+ try {
1117
+ for (const nodeId of normalizedNodeIds) {
1118
+ const described = await describeNode(client, nodeId, { depth: 0, pierce: true });
1119
+ const backendNodeId = positiveNodeId(described?.backendNodeId);
1120
+ if (!backendNodeId) throw new Error(`Missing backendNodeId for resume iframe node ${nodeId}`);
1121
+ queriedNodes.push({
1122
+ node_id: nodeId,
1123
+ backend_node_id: backendNodeId
1124
+ });
1125
+ }
1126
+ } catch (error) {
1127
+ return {
1128
+ ...base,
1129
+ ...queryEvidence,
1130
+ queried_nodes: queriedNodes,
1131
+ reason: "detail_iframe_popup_node_describe_failed",
1132
+ error: error?.message || String(error)
1133
+ };
1134
+ }
1135
+ const observedIframeBackendNodeId = positiveNodeId(
1136
+ queriedNodes.find((item) => item.node_id === expectedIframeNodeId)?.backend_node_id
1137
+ );
1138
+ const exactBackendMatchCount = queriedNodes.filter(
1139
+ (item) => item.backend_node_id === expectedIframeBackendNodeId
1140
+ ).length;
1141
+ const identityEvidence = {
1142
+ ...queryEvidence,
1143
+ queried_nodes: queriedNodes,
1144
+ observed_iframe_backend_node_id: observedIframeBackendNodeId,
1145
+ exact_backend_match_count: exactBackendMatchCount
1146
+ };
1147
+ if (observedIframeBackendNodeId !== expectedIframeBackendNodeId) {
1148
+ return {
1149
+ ...base,
1150
+ ...identityEvidence,
1151
+ reason: "detail_iframe_popup_backend_mismatch"
1152
+ };
1153
+ }
1154
+ if (exactBackendMatchCount !== 1) {
1155
+ return {
1156
+ ...base,
1157
+ ...identityEvidence,
1158
+ reason: "detail_iframe_popup_backend_match_ambiguous"
1159
+ };
1160
+ }
1161
+
1162
+ let observedPopupBackendNodeId = null;
1163
+ let observedRecheckIframeBackendNodeId = null;
1164
+ let observedDocumentNodeId = null;
1165
+ let observedDocumentBackendNodeId = null;
1166
+ try {
1167
+ const popupNode = await describeNode(client, expectedPopupNodeId, {
1168
+ depth: 0,
1169
+ pierce: true
1170
+ });
1171
+ observedPopupBackendNodeId = positiveNodeId(popupNode?.backendNodeId);
1172
+ const iframeNode = await describeNode(client, expectedIframeNodeId, {
1173
+ depth: 0,
1174
+ pierce: true
1175
+ });
1176
+ observedRecheckIframeBackendNodeId = positiveNodeId(iframeNode?.backendNodeId);
1177
+ observedDocumentNodeId = positiveNodeId(
1178
+ await getFrameDocumentNodeId(client, expectedIframeNodeId)
1179
+ );
1180
+ const documentNode = observedDocumentNodeId
1181
+ ? await describeNode(client, observedDocumentNodeId, { depth: 0, pierce: true })
1182
+ : null;
1183
+ observedDocumentBackendNodeId = positiveNodeId(documentNode?.backendNodeId);
1184
+ } catch (error) {
1185
+ return {
1186
+ ...base,
1187
+ ...identityEvidence,
1188
+ reason: "detail_iframe_popup_recheck_failed",
1189
+ recheck: {
1190
+ verified: false,
1191
+ popup_node_id: expectedPopupNodeId,
1192
+ expected_popup_backend_node_id: expectedPopupBackendNodeId,
1193
+ observed_popup_backend_node_id: observedPopupBackendNodeId,
1194
+ iframe_node_id: expectedIframeNodeId,
1195
+ expected_iframe_backend_node_id: expectedIframeBackendNodeId,
1196
+ observed_iframe_backend_node_id: observedRecheckIframeBackendNodeId,
1197
+ expected_document_node_id: expectedDocumentNodeId,
1198
+ observed_document_node_id: observedDocumentNodeId,
1199
+ expected_document_backend_node_id: expectedDocumentBackendNodeId,
1200
+ observed_document_backend_node_id: observedDocumentBackendNodeId
1201
+ },
1202
+ error: error?.message || String(error)
1203
+ };
1204
+ }
1205
+ const recheck = {
1206
+ verified: Boolean(
1207
+ observedPopupBackendNodeId === expectedPopupBackendNodeId
1208
+ && observedRecheckIframeBackendNodeId === expectedIframeBackendNodeId
1209
+ && observedDocumentNodeId === expectedDocumentNodeId
1210
+ && observedDocumentBackendNodeId === expectedDocumentBackendNodeId
1211
+ ),
1212
+ popup_node_id: expectedPopupNodeId,
1213
+ expected_popup_backend_node_id: expectedPopupBackendNodeId,
1214
+ observed_popup_backend_node_id: observedPopupBackendNodeId,
1215
+ iframe_node_id: expectedIframeNodeId,
1216
+ expected_iframe_backend_node_id: expectedIframeBackendNodeId,
1217
+ observed_iframe_backend_node_id: observedRecheckIframeBackendNodeId,
1218
+ expected_document_node_id: expectedDocumentNodeId,
1219
+ observed_document_node_id: observedDocumentNodeId,
1220
+ expected_document_backend_node_id: expectedDocumentBackendNodeId,
1221
+ observed_document_backend_node_id: observedDocumentBackendNodeId
1222
+ };
1223
+ const reason = observedPopupBackendNodeId !== expectedPopupBackendNodeId
1224
+ ? "detail_iframe_popup_backend_drift"
1225
+ : observedRecheckIframeBackendNodeId !== expectedIframeBackendNodeId
1226
+ ? "detail_iframe_backend_drift"
1227
+ : observedDocumentNodeId !== expectedDocumentNodeId
1228
+ ? "detail_iframe_document_link_drift"
1229
+ : observedDocumentBackendNodeId !== expectedDocumentBackendNodeId
1230
+ ? "detail_iframe_document_backend_drift"
1231
+ : null;
1232
+ return {
1233
+ ...base,
1234
+ ...identityEvidence,
1235
+ verified: recheck.verified,
1236
+ reason,
1237
+ recheck
1238
+ };
1239
+ }
1240
+
1241
+ export async function readRecommendCardPreClickProvenance(client, {
1242
+ cardNodeId,
1243
+ cardCandidate,
1244
+ rootState = null,
1245
+ cardEvidence = null
1246
+ } = {}) {
1247
+ const currentRootState = rootState?.iframe?.documentNodeId
1248
+ ? rootState
1249
+ : await getRecommendRoots(client);
1250
+ const card = cardEvidence || await readRecommendCardBindingEvidence(
1251
+ client,
1252
+ cardNodeId,
1253
+ cardCandidate
1254
+ );
1255
+ const listRootNodeId = positiveNodeId(currentRootState?.iframe?.documentNodeId);
1256
+ const iframeNodeId = positiveNodeId(currentRootState?.iframe?.nodeId);
1257
+ let listRootBackendNodeId = null;
1258
+ let iframeBackendNodeId = null;
1259
+ let linkedDocumentNodeId = null;
1260
+ try {
1261
+ const listRootNode = listRootNodeId
1262
+ ? await describeNode(client, listRootNodeId, { depth: 0, pierce: true })
1263
+ : null;
1264
+ listRootBackendNodeId = positiveNodeId(listRootNode?.backendNodeId);
1265
+ const iframeNode = iframeNodeId
1266
+ ? await describeNode(client, iframeNodeId, { depth: 0, pierce: true })
1267
+ : null;
1268
+ iframeBackendNodeId = positiveNodeId(iframeNode?.backendNodeId);
1269
+ linkedDocumentNodeId = iframeNodeId
1270
+ ? positiveNodeId(await getFrameDocumentNodeId(client, iframeNodeId))
1271
+ : null;
1272
+ } catch (error) {
1273
+ if (shouldRethrowRecommendProtocolError(error)) throw error;
1274
+ // The exact root requirements below fail closed with compact evidence.
1275
+ }
1276
+ const ancestry = listRootNodeId
1277
+ ? await readExactDescendantAncestry(client, cardNodeId, listRootNodeId)
1278
+ : null;
1279
+ const rootMembership = Boolean(
1280
+ card?.verified === true
1281
+ && listRootNodeId
1282
+ && listRootBackendNodeId
1283
+ && iframeNodeId
1284
+ && iframeBackendNodeId
1285
+ && linkedDocumentNodeId === listRootNodeId
1286
+ && ancestry?.verified !== true
1287
+ && ancestry?.parent_id_missing === true
1288
+ )
1289
+ ? await readExactRecommendCardRootMembership(client, {
1290
+ cardNodeId,
1291
+ cardBackendNodeId: card?.backend_node_id,
1292
+ cardCandidate,
1293
+ listRootNodeId,
1294
+ listRootBackendNodeId,
1295
+ iframeNodeId,
1296
+ iframeBackendNodeId,
1297
+ linkedDocumentNodeId
1298
+ })
1299
+ : null;
1300
+ const containmentVerified = Boolean(
1301
+ ancestry?.verified === true
1302
+ && positiveNodeId(ancestry.ancestor_backend_node_id) === listRootBackendNodeId
1303
+ ) || rootMembership?.verified === true;
1304
+ const verified = Boolean(
1305
+ card?.verified === true
1306
+ && listRootNodeId
1307
+ && listRootBackendNodeId
1308
+ && iframeNodeId
1309
+ && iframeBackendNodeId
1310
+ && linkedDocumentNodeId === listRootNodeId
1311
+ && containmentVerified
1312
+ );
1313
+ return {
1314
+ verified,
1315
+ reason: verified
1316
+ ? null
1317
+ : card?.verified !== true
1318
+ ? card?.reason || "card_identity_not_verified_before_click"
1319
+ : !listRootNodeId || !listRootBackendNodeId || !iframeNodeId || !iframeBackendNodeId
1320
+ ? "card_list_root_provenance_missing"
1321
+ : linkedDocumentNodeId !== listRootNodeId
1322
+ ? "card_iframe_document_link_mismatch"
1323
+ : rootMembership?.reason || ancestry?.reason || "card_not_bound_to_recommend_list_root",
1324
+ containment_method: ancestry?.verified === true
1325
+ ? "parent_ancestry"
1326
+ : rootMembership?.verified === true
1327
+ ? rootMembership.method
1328
+ : null,
1329
+ card,
1330
+ list_root: {
1331
+ node_id: listRootNodeId,
1332
+ backend_node_id: listRootBackendNodeId,
1333
+ iframe_node_id: iframeNodeId,
1334
+ iframe_backend_node_id: iframeBackendNodeId,
1335
+ linked_document_node_id: linkedDocumentNodeId
1336
+ },
1337
+ ancestry,
1338
+ root_membership: rootMembership
1339
+ };
1340
+ }
1341
+
1342
+ async function resolveRecommendDetailBindingScopes(client, detailState = null) {
1343
+ const scopes = [];
1344
+ const ignoredScopes = [];
1345
+ const popupNodeIds = new Set();
1346
+ if (positiveNodeId(detailState?.popup?.node_id)) popupNodeIds.add(detailState.popup.node_id);
1347
+ if (detailState?.popup?.selector && Array.isArray(detailState?.roots)) {
1348
+ for (const root of detailState.roots) {
1349
+ if (!positiveNodeId(root?.nodeId)) continue;
1350
+ try {
1351
+ for (const nodeId of await querySelectorAll(client, root.nodeId, detailState.popup.selector)) {
1352
+ if (positiveNodeId(nodeId)) popupNodeIds.add(nodeId);
1353
+ }
1354
+ } catch (error) {
1355
+ if (shouldRethrowRecommendProtocolError(error)) throw error;
1356
+ ignoredScopes.push({
1357
+ source: "popup",
1358
+ selector: detailState.popup.selector,
1359
+ root_node_id: positiveNodeId(root.nodeId),
1360
+ visible: false,
1361
+ stale: isStaleRecommendNodeError(error),
1362
+ reason: "detail_popup_selector_query_failed",
1363
+ error: error?.message || String(error)
1364
+ });
1365
+ }
1366
+ }
1367
+ }
1368
+ for (const nodeId of popupNodeIds) {
1369
+ const rootEvidence = await readVisibleBackendNode(client, nodeId);
1370
+ if (rootEvidence) {
1371
+ scopes.push({
1372
+ source: "popup",
1373
+ node_id: rootEvidence.node_id,
1374
+ backend_node_id: rootEvidence.backend_node_id,
1375
+ visible: true
1376
+ });
1377
+ } else {
1378
+ ignoredScopes.push({
1379
+ source: "popup",
1380
+ node_id: positiveNodeId(nodeId),
1381
+ backend_node_id: null,
1382
+ visible: false,
1383
+ stale: true
1384
+ });
1385
+ }
1386
+ }
1387
+
1388
+ const resumeIframeNodeIds = new Set();
1389
+ if (positiveNodeId(detailState?.resumeIframe?.node_id)) {
1390
+ resumeIframeNodeIds.add(detailState.resumeIframe.node_id);
1391
+ }
1392
+ if (detailState?.resumeIframe?.selector && Array.isArray(detailState?.roots)) {
1393
+ for (const root of detailState.roots) {
1394
+ if (!positiveNodeId(root?.nodeId)) continue;
1395
+ try {
1396
+ for (const nodeId of await querySelectorAll(
1397
+ client,
1398
+ root.nodeId,
1399
+ detailState.resumeIframe.selector
1400
+ )) {
1401
+ if (positiveNodeId(nodeId)) resumeIframeNodeIds.add(nodeId);
1402
+ }
1403
+ } catch (error) {
1404
+ if (shouldRethrowRecommendProtocolError(error)) throw error;
1405
+ ignoredScopes.push({
1406
+ source: "resume_iframe",
1407
+ selector: detailState.resumeIframe.selector,
1408
+ root_node_id: positiveNodeId(root.nodeId),
1409
+ visible: false,
1410
+ stale: isStaleRecommendNodeError(error),
1411
+ reason: "detail_resume_iframe_selector_query_failed",
1412
+ error: error?.message || String(error)
1413
+ });
1414
+ }
1415
+ }
1416
+ }
1417
+ for (const iframeNodeId of resumeIframeNodeIds) {
1418
+ try {
1419
+ const iframeEvidence = await readVisibleBackendNode(client, iframeNodeId);
1420
+ const documentNodeId = await getFrameDocumentNodeId(client, iframeNodeId);
1421
+ const documentNode = await describeNode(client, documentNodeId, { depth: 0, pierce: true });
1422
+ const documentBackendNodeId = positiveNodeId(documentNode?.backendNodeId);
1423
+ if (iframeEvidence && documentBackendNodeId) {
1424
+ const visiblePopups = scopes.filter((scope) => scope.source === "popup");
1425
+ const containerChecks = [];
1426
+ for (const popup of visiblePopups) {
1427
+ const ancestry = await readExactDescendantAncestry(
1428
+ client,
1429
+ iframeEvidence.node_id,
1430
+ popup.node_id
1431
+ );
1432
+ const membership = Boolean(
1433
+ ancestry?.verified !== true
1434
+ && ancestry?.parent_id_missing === true
1435
+ && detailState?.resumeIframe?.selector
1436
+ )
1437
+ ? await readExactRecommendIframePopupMembership(client, {
1438
+ selector: detailState.resumeIframe.selector,
1439
+ popupNodeId: popup.node_id,
1440
+ popupBackendNodeId: popup.backend_node_id,
1441
+ iframeNodeId: iframeEvidence.node_id,
1442
+ iframeBackendNodeId: iframeEvidence.backend_node_id,
1443
+ documentNodeId,
1444
+ documentBackendNodeId
1445
+ })
1446
+ : null;
1447
+ const verified = Boolean(
1448
+ ancestry?.verified === true
1449
+ && positiveNodeId(ancestry.ancestor_backend_node_id)
1450
+ === positiveNodeId(popup.backend_node_id)
1451
+ ) || membership?.verified === true;
1452
+ containerChecks.push({
1453
+ verified,
1454
+ method: ancestry?.verified === true
1455
+ ? "parent_ancestry"
1456
+ : membership?.verified === true
1457
+ ? membership.method
1458
+ : null,
1459
+ ancestry,
1460
+ membership
1461
+ });
1462
+ }
1463
+ const verifiedContainerIndexes = containerChecks
1464
+ .map((item, index) => (item.verified === true ? index : -1))
1465
+ .filter((index) => index >= 0);
1466
+ const container = verifiedContainerIndexes.length === 1
1467
+ ? visiblePopups[verifiedContainerIndexes[0]]
1468
+ : null;
1469
+ const selectedCheck = verifiedContainerIndexes.length === 1
1470
+ ? containerChecks[verifiedContainerIndexes[0]]
1471
+ : containerChecks[0] || null;
1472
+ scopes.push({
1473
+ source: "resume_iframe",
1474
+ selector: detailState?.resumeIframe?.selector || null,
1475
+ node_id: documentNodeId,
1476
+ backend_node_id: documentBackendNodeId,
1477
+ iframe_node_id: iframeEvidence.node_id,
1478
+ iframe_backend_node_id: iframeEvidence.backend_node_id,
1479
+ container_node_id: positiveNodeId(container?.node_id),
1480
+ container_backend_node_id: positiveNodeId(container?.backend_node_id),
1481
+ container_verified: visiblePopups.length === 0
1482
+ ? null
1483
+ : verifiedContainerIndexes.length === 1,
1484
+ container_match_count: verifiedContainerIndexes.length,
1485
+ containment_method: verifiedContainerIndexes.length === 1
1486
+ ? selectedCheck?.method || null
1487
+ : null,
1488
+ container_membership: selectedCheck?.membership || null,
1489
+ ancestry: selectedCheck?.ancestry?.verified === true
1490
+ ? {
1491
+ verified: true,
1492
+ method: "parent_ancestry",
1493
+ depth: selectedCheck.ancestry.depth,
1494
+ path: selectedCheck.ancestry.path
1495
+ }
1496
+ : {
1497
+ verified: false,
1498
+ reason: verifiedContainerIndexes.length > 1
1499
+ ? "detail_iframe_container_ambiguous"
1500
+ : selectedCheck?.membership?.reason
1501
+ || selectedCheck?.ancestry?.reason
1502
+ || "detail_iframe_not_contained_by_popup",
1503
+ method: selectedCheck?.ancestry?.method || "parent_ancestry",
1504
+ parent_id_missing: selectedCheck?.ancestry?.parent_id_missing === true,
1505
+ parent_id_missing_at_node_id: positiveNodeId(
1506
+ selectedCheck?.ancestry?.parent_id_missing_at_node_id
1507
+ ),
1508
+ path: selectedCheck?.ancestry?.path || []
1509
+ },
1510
+ visible: true
1511
+ });
1512
+ } else {
1513
+ ignoredScopes.push({
1514
+ source: "resume_iframe",
1515
+ node_id: positiveNodeId(documentNodeId),
1516
+ backend_node_id: documentBackendNodeId,
1517
+ iframe_node_id: positiveNodeId(iframeNodeId),
1518
+ iframe_backend_node_id: positiveNodeId(iframeEvidence?.backend_node_id),
1519
+ visible: false,
1520
+ stale: true
1521
+ });
1522
+ }
1523
+ } catch (error) {
1524
+ if (shouldRethrowRecommendProtocolError(error)) throw error;
1525
+ ignoredScopes.push({
1526
+ source: "resume_iframe",
1527
+ selector: detailState?.resumeIframe?.selector || null,
1528
+ node_id: null,
1529
+ backend_node_id: null,
1530
+ iframe_node_id: positiveNodeId(iframeNodeId),
1531
+ iframe_backend_node_id: null,
1532
+ visible: false,
1533
+ stale: isStaleRecommendNodeError(error),
1534
+ reason: "detail_resume_iframe_scope_read_failed",
1535
+ error: error?.message || String(error)
1536
+ });
1537
+ }
1538
+ }
1539
+ return { scopes, ignored_scopes: ignoredScopes };
1540
+ }
1541
+
1542
+ function compactRecommendDetailRootSnapshotScope(scope = null) {
1543
+ if (!scope) return null;
1544
+ return {
1545
+ source: scope.source || null,
1546
+ selector: scope.selector || null,
1547
+ root_node_id: positiveNodeId(scope.root_node_id),
1548
+ node_id: positiveNodeId(scope.node_id),
1549
+ backend_node_id: positiveNodeId(scope.backend_node_id),
1550
+ iframe_node_id: positiveNodeId(scope.iframe_node_id),
1551
+ iframe_backend_node_id: positiveNodeId(scope.iframe_backend_node_id),
1552
+ visible: scope.visible === true,
1553
+ stale: scope.stale === true,
1554
+ reason: scope.reason || null,
1555
+ error: scope.error || null
1556
+ };
1557
+ }
1558
+
1559
+ function compactRecommendDetailRootsBeforeSnapshot(snapshot = null) {
1560
+ if (Array.isArray(snapshot)) {
1561
+ return {
1562
+ schema_version: 1,
1563
+ captured: true,
1564
+ // Legacy arrays omitted ignored/unread-root evidence. They may support
1565
+ // the older DOM identity methods, but never authorize causal binding.
1566
+ complete: false,
1567
+ roots: snapshot.map(compactRecommendDetailRootSnapshotScope).filter(Boolean),
1568
+ ignored_scopes: [],
1569
+ legacy_array: true
1570
+ };
1571
+ }
1572
+ return {
1573
+ schema_version: 1,
1574
+ captured: snapshot?.captured === true,
1575
+ complete: snapshot?.complete === true,
1576
+ roots: (snapshot?.roots || [])
1577
+ .map(compactRecommendDetailRootSnapshotScope)
1578
+ .filter(Boolean),
1579
+ ignored_scopes: (snapshot?.ignored_scopes || [])
1580
+ .map(compactRecommendDetailRootSnapshotScope)
1581
+ .filter(Boolean),
1582
+ legacy_array: snapshot?.legacy_array === true
1583
+ };
1584
+ }
1585
+
1586
+ async function readRecommendDetailRootsBeforeClick(client, {
1587
+ rootState = null
1588
+ } = {}) {
1589
+ const state = await readRecommendDetailState(client, { rootState });
1590
+ const resolved = await resolveRecommendDetailBindingScopes(client, state);
1591
+ const roots = (resolved.scopes || [])
1592
+ .filter((scope) => scope.visible === true)
1593
+ .map(compactRecommendDetailRootSnapshotScope)
1594
+ .filter(Boolean);
1595
+ const ignoredScopes = (resolved.ignored_scopes || [])
1596
+ .map(compactRecommendDetailRootSnapshotScope)
1597
+ .filter(Boolean);
1598
+ return {
1599
+ schema_version: 1,
1600
+ captured: true,
1601
+ complete: ignoredScopes.length === 0,
1602
+ roots,
1603
+ ignored_scopes: ignoredScopes
1604
+ };
1605
+ }
1606
+
1607
+ async function readDetailCandidateIds(client, scope) {
1608
+ const probe = {
1609
+ source: scope?.source || null,
1610
+ scope_node_id: positiveNodeId(scope?.node_id),
1611
+ scope_backend_node_id: positiveNodeId(scope?.backend_node_id),
1612
+ complete: true,
1613
+ queried_node_count: 0,
1614
+ attribute_read_count: 0,
1615
+ unread_nodes: []
1616
+ };
1617
+ if (scope?.visible !== true || !positiveNodeId(scope?.node_id)) {
1618
+ return { evidence: [], probe: { ...probe, complete: false } };
1619
+ }
1620
+ // A resume-iframe scope is its document node, which cannot carry element
1621
+ // attributes. Candidate-id descendants are still queried below.
1622
+ const nodeIds = new Set(scope.source === "popup" ? [scope.node_id] : []);
1623
+ for (const nodeId of await querySelectorAll(client, scope.node_id, DETAIL_CANDIDATE_ID_SELECTOR)) {
1624
+ nodeIds.add(nodeId);
1625
+ }
1626
+ probe.queried_node_count = nodeIds.size;
1627
+ const evidence = [];
1628
+ for (const nodeId of nodeIds) {
1629
+ let attributes;
1630
+ try {
1631
+ attributes = await getAttributesMap(client, nodeId);
1632
+ probe.attribute_read_count += 1;
1633
+ } catch (error) {
1634
+ if (shouldRethrowRecommendProtocolError(error)) throw error;
1635
+ probe.complete = false;
1636
+ probe.unread_nodes.push({
1637
+ node_id: positiveNodeId(nodeId),
1638
+ reason: "candidate_id_attributes_unreadable",
1639
+ error: error?.message || String(error)
1640
+ });
1641
+ continue;
1642
+ }
1643
+ const visible = nodeId === scope.node_id
1644
+ ? {
1645
+ node_id: scope.node_id,
1646
+ backend_node_id: scope.backend_node_id,
1647
+ visible: true
1648
+ }
1649
+ : await readVisibleBackendNode(client, nodeId);
1650
+ if (!visible) {
1651
+ probe.complete = false;
1652
+ probe.unread_nodes.push({
1653
+ node_id: positiveNodeId(nodeId),
1654
+ reason: "candidate_id_node_not_visible_or_stale",
1655
+ error: null
1656
+ });
1657
+ continue;
1658
+ }
1659
+ for (const attribute of DETAIL_CANDIDATE_ID_ATTRIBUTES) {
1660
+ const value = normalizeBindingText(attributes[attribute]);
1661
+ if (!value) continue;
1662
+ evidence.push({
1663
+ source: scope.source,
1664
+ field: attribute,
1665
+ value,
1666
+ node_id: visible.node_id,
1667
+ backend_node_id: visible.backend_node_id,
1668
+ visible: true,
1669
+ accessibility_verified: false
1670
+ });
1671
+ }
1672
+ }
1673
+ return { evidence, probe };
1674
+ }
1675
+
1676
+ async function readDetailExactIdentityMatches(client, scope, expectedValues = [], {
1677
+ allowScroll = true
1678
+ } = {}) {
1679
+ const probe = {
1680
+ source: scope?.source || null,
1681
+ scope_node_id: positiveNodeId(scope?.node_id),
1682
+ scope_backend_node_id: positiveNodeId(scope?.backend_node_id),
1683
+ scoped_node_count: 0,
1684
+ html_read_count: 0,
1685
+ exact_dom_text_count: 0,
1686
+ visible_exact_dom_text_count: 0,
1687
+ ax_exact_count: 0,
1688
+ ax_rejected_count: 0,
1689
+ selector_queries: [],
1690
+ fields: {}
1691
+ };
1692
+ for (const item of expectedValues) {
1693
+ probe.fields[item.field] = {
1694
+ value: item.value,
1695
+ exact_dom_text_count: 0,
1696
+ visible_exact_dom_text_count: 0,
1697
+ ax_exact_count: 0,
1698
+ ax_rejected_count: 0
1699
+ };
1700
+ }
1701
+ if (scope?.visible !== true || !positiveNodeId(scope?.node_id)) {
1702
+ return { matches: [], probe };
1703
+ }
1704
+ const expectedByValue = new Map(expectedValues.map((item) => [item.value, item]));
1705
+ const matches = [];
1706
+ const seenNodeIds = new Set();
1707
+ for (const selector of [DETAIL_IDENTITY_PRIORITY_SELECTOR, DETAIL_IDENTITY_TEXT_SELECTOR]) {
1708
+ const nodeIds = await querySelectorAll(client, scope.node_id, selector);
1709
+ const queryProbe = {
1710
+ selector,
1711
+ raw_node_count: nodeIds.length,
1712
+ unique_node_count: 0
1713
+ };
1714
+ probe.selector_queries.push(queryProbe);
1715
+ for (const nodeId of nodeIds.slice(0, selector === DETAIL_IDENTITY_PRIORITY_SELECTOR ? 160 : 1200)) {
1716
+ if (seenNodeIds.has(nodeId)) continue;
1717
+ seenNodeIds.add(nodeId);
1718
+ queryProbe.unique_node_count += 1;
1719
+ probe.scoped_node_count += 1;
1720
+ let text = "";
1721
+ try {
1722
+ text = normalizeBindingText(htmlToText(await getOuterHTML(client, nodeId)));
1723
+ probe.html_read_count += 1;
1724
+ } catch (error) {
1725
+ if (shouldRethrowRecommendProtocolError(error)) throw error;
1726
+ continue;
1727
+ }
1728
+ const expected = expectedByValue.get(text);
1729
+ if (!expected) continue;
1730
+ const fieldProbe = probe.fields[expected.field];
1731
+ probe.exact_dom_text_count += 1;
1732
+ if (fieldProbe) fieldProbe.exact_dom_text_count += 1;
1733
+ if (allowScroll) {
1734
+ try {
1735
+ await scrollNodeIntoView(client, nodeId);
1736
+ await sleep(40);
1737
+ } catch (error) {
1738
+ if (shouldRethrowRecommendProtocolError(error)) throw error;
1739
+ continue;
1740
+ }
1741
+ }
1742
+ const visible = await readVisibleBackendNode(client, nodeId);
1743
+ if (!visible) continue;
1744
+ probe.visible_exact_dom_text_count += 1;
1745
+ if (fieldProbe) fieldProbe.visible_exact_dom_text_count += 1;
1746
+ const accessibilityVerified = await readExactAccessibilityText(client, nodeId, text);
1747
+ if (!accessibilityVerified) {
1748
+ probe.ax_rejected_count += 1;
1749
+ if (fieldProbe) fieldProbe.ax_rejected_count += 1;
1750
+ continue;
1751
+ }
1752
+ probe.ax_exact_count += 1;
1753
+ if (fieldProbe) fieldProbe.ax_exact_count += 1;
1754
+ matches.push({
1755
+ source: scope.source,
1756
+ field: expected.field,
1757
+ value: text,
1758
+ node_id: visible.node_id,
1759
+ backend_node_id: visible.backend_node_id,
1760
+ visible: true,
1761
+ accessibility_verified: true
1762
+ });
1763
+ }
1764
+ const matchedFields = new Set(matches.map((item) => item.field));
1765
+ if (expectedValues.every((item) => matchedFields.has(item.field))) break;
1766
+ }
1767
+ return { matches, probe };
1768
+ }
1769
+
1770
+ function bindingNodeSignature(nodes = []) {
1771
+ return Array.from(new Set(nodes.map((node) => (
1772
+ `${node.source || ""}:${node.field || ""}:${node.value || ""}:${positiveNodeId(node.backend_node_id) || 0}`
1773
+ )))).sort();
1774
+ }
1775
+
1776
+ function signaturesEqual(left = [], right = []) {
1777
+ return left.length === right.length && left.every((value, index) => value === right[index]);
1778
+ }
1779
+
1780
+ async function readRecommendDetailBindingSample(client, detailState, expected, {
1781
+ allowScroll = true
1782
+ } = {}) {
1783
+ const resolvedScopes = await resolveRecommendDetailBindingScopes(client, detailState);
1784
+ const scopes = resolvedScopes.scopes;
1785
+ const candidateIds = [];
1786
+ const candidateIdProbes = [];
1787
+ const identityMatches = [];
1788
+ const identityProbes = [];
1789
+ for (const scope of scopes) {
1790
+ const candidateIdResult = await readDetailCandidateIds(client, scope);
1791
+ candidateIds.push(...candidateIdResult.evidence);
1792
+ candidateIdProbes.push(candidateIdResult.probe);
1793
+ }
1794
+ const expectedValues = [
1795
+ { field: "name", value: expected.name },
1796
+ ...(candidateIds.length ? [] : expected.secondary)
1797
+ ].filter((item) => item.value);
1798
+ for (const scope of scopes) {
1799
+ const identityResult = await readDetailExactIdentityMatches(
1800
+ client,
1801
+ scope,
1802
+ expectedValues,
1803
+ { allowScroll }
1804
+ );
1805
+ identityMatches.push(...identityResult.matches);
1806
+ identityProbes.push(identityResult.probe);
1807
+ }
1808
+ const nameMatches = identityMatches.filter((item) => item.field === "name");
1809
+ const secondaryMatches = identityMatches.filter((item) => item.field !== "name");
1810
+ return {
1811
+ scopes,
1812
+ ignored_scopes: resolvedScopes.ignored_scopes,
1813
+ candidate_ids: candidateIds,
1814
+ candidate_id_probe_complete: Boolean(
1815
+ scopes.length > 0
1816
+ && (resolvedScopes.ignored_scopes || []).length === 0
1817
+ && candidateIdProbes.length === scopes.length
1818
+ && candidateIdProbes.every((probe) => probe?.complete === true)
1819
+ ),
1820
+ candidate_id_probes: candidateIdProbes,
1821
+ name_matches: nameMatches,
1822
+ secondary_matches: secondaryMatches,
1823
+ identity_probes: identityProbes,
1824
+ signatures: {
1825
+ scopes: bindingNodeSignature(scopes.map((scope) => ({
1826
+ source: scope.source,
1827
+ field: "scope",
1828
+ value: "detail",
1829
+ backend_node_id: scope.backend_node_id
1830
+ }))),
1831
+ candidate_ids: bindingNodeSignature(candidateIds),
1832
+ names: bindingNodeSignature(nameMatches),
1833
+ secondary: bindingNodeSignature(secondaryMatches)
1834
+ }
1835
+ };
1836
+ }
1837
+
1838
+ function compactDetailBindingSample(sample = null) {
1839
+ if (!sample) return null;
1840
+ return {
1841
+ scopes: (sample.scopes || []).map((scope) => ({
1842
+ source: scope.source,
1843
+ selector: scope.selector || null,
1844
+ node_id: positiveNodeId(scope.node_id),
1845
+ backend_node_id: positiveNodeId(scope.backend_node_id),
1846
+ iframe_node_id: positiveNodeId(scope.iframe_node_id),
1847
+ iframe_backend_node_id: positiveNodeId(scope.iframe_backend_node_id),
1848
+ container_node_id: positiveNodeId(scope.container_node_id),
1849
+ container_backend_node_id: positiveNodeId(scope.container_backend_node_id),
1850
+ container_verified: scope.container_verified === true,
1851
+ container_match_count: Number.isInteger(scope.container_match_count)
1852
+ ? scope.container_match_count
1853
+ : null,
1854
+ containment_method: scope.containment_method || null,
1855
+ container_membership: compactRecommendIframePopupMembership(
1856
+ scope.container_membership
1857
+ ),
1858
+ ancestry: scope.ancestry
1859
+ ? {
1860
+ verified: scope.ancestry.verified === true,
1861
+ reason: scope.ancestry.reason || null,
1862
+ method: scope.ancestry.method || null,
1863
+ parent_id_missing: scope.ancestry.parent_id_missing === true,
1864
+ parent_id_missing_at_node_id: positiveNodeId(
1865
+ scope.ancestry.parent_id_missing_at_node_id
1866
+ ),
1867
+ depth: Number.isInteger(scope.ancestry.depth) ? scope.ancestry.depth : null,
1868
+ path: (scope.ancestry.path || []).slice(0, 160).map((item) => ({
1869
+ node_id: positiveNodeId(item.node_id),
1870
+ backend_node_id: positiveNodeId(item.backend_node_id)
1871
+ }))
1872
+ }
1873
+ : null,
1874
+ visible: scope.visible === true
1875
+ })),
1876
+ ignored_scopes: (sample.ignored_scopes || []).slice(0, 20).map((scope) => ({
1877
+ source: scope.source || null,
1878
+ selector: scope.selector || null,
1879
+ root_node_id: positiveNodeId(scope.root_node_id),
1880
+ node_id: positiveNodeId(scope.node_id),
1881
+ backend_node_id: positiveNodeId(scope.backend_node_id),
1882
+ iframe_node_id: positiveNodeId(scope.iframe_node_id),
1883
+ iframe_backend_node_id: positiveNodeId(scope.iframe_backend_node_id),
1884
+ visible: false,
1885
+ stale: scope.stale === true,
1886
+ reason: scope.reason || null,
1887
+ error: scope.error || null
1888
+ })),
1889
+ candidate_ids: (sample.candidate_ids || []).slice(0, 20).map(compactBindingNode),
1890
+ candidate_id_probe_complete: sample.candidate_id_probe_complete === true,
1891
+ candidate_id_probes: (sample.candidate_id_probes || []).slice(0, 4).map((probe) => ({
1892
+ source: probe.source || null,
1893
+ scope_node_id: positiveNodeId(probe.scope_node_id),
1894
+ scope_backend_node_id: positiveNodeId(probe.scope_backend_node_id),
1895
+ complete: probe.complete === true,
1896
+ queried_node_count: Number(probe.queried_node_count) || 0,
1897
+ attribute_read_count: Number(probe.attribute_read_count) || 0,
1898
+ unread_nodes: (probe.unread_nodes || []).slice(0, 20).map((item) => ({
1899
+ node_id: positiveNodeId(item.node_id),
1900
+ reason: item.reason || null,
1901
+ error: item.error || null
1902
+ }))
1903
+ })),
1904
+ name_matches: (sample.name_matches || []).slice(0, 20).map(compactBindingNode),
1905
+ secondary_matches: (sample.secondary_matches || []).slice(0, 40).map(compactBindingNode),
1906
+ identity_probes: (sample.identity_probes || []).slice(0, 4).map((probe) => ({
1907
+ source: probe.source || null,
1908
+ scope_node_id: positiveNodeId(probe.scope_node_id),
1909
+ scope_backend_node_id: positiveNodeId(probe.scope_backend_node_id),
1910
+ scoped_node_count: Number(probe.scoped_node_count) || 0,
1911
+ html_read_count: Number(probe.html_read_count) || 0,
1912
+ exact_dom_text_count: Number(probe.exact_dom_text_count) || 0,
1913
+ visible_exact_dom_text_count: Number(probe.visible_exact_dom_text_count) || 0,
1914
+ ax_exact_count: Number(probe.ax_exact_count) || 0,
1915
+ ax_rejected_count: Number(probe.ax_rejected_count) || 0,
1916
+ selector_queries: (probe.selector_queries || []).slice(0, 4).map((query) => ({
1917
+ selector: query.selector || null,
1918
+ raw_node_count: Number(query.raw_node_count) || 0,
1919
+ unique_node_count: Number(query.unique_node_count) || 0
1920
+ })),
1921
+ fields: Object.fromEntries(Object.entries(probe.fields || {}).slice(0, 8).map(
1922
+ ([field, item]) => [field, {
1923
+ value: item.value || null,
1924
+ exact_dom_text_count: Number(item.exact_dom_text_count) || 0,
1925
+ visible_exact_dom_text_count: Number(item.visible_exact_dom_text_count) || 0,
1926
+ ax_exact_count: Number(item.ax_exact_count) || 0,
1927
+ ax_rejected_count: Number(item.ax_rejected_count) || 0
1928
+ }]
1929
+ ))
1930
+ }))
1931
+ };
1932
+ }
1933
+
1934
+ function exactScopeIdentity(left = null, right = null) {
1935
+ return Boolean(
1936
+ left?.visible === true
1937
+ && right?.visible === true
1938
+ && left?.source
1939
+ && left.source === right.source
1940
+ && (left.selector || null) === (right.selector || null)
1941
+ && positiveNodeId(left.node_id)
1942
+ && left.node_id === right.node_id
1943
+ && positiveNodeId(left.backend_node_id)
1944
+ && left.backend_node_id === right.backend_node_id
1945
+ && positiveNodeId(left.iframe_node_id) === positiveNodeId(right.iframe_node_id)
1946
+ && positiveNodeId(left.iframe_backend_node_id)
1947
+ === positiveNodeId(right.iframe_backend_node_id)
1948
+ );
1949
+ }
1950
+
1951
+ function compactContainedIframe(scope = null) {
1952
+ if (!scope) return null;
1953
+ return {
1954
+ selector: scope.selector || null,
1955
+ node_id: positiveNodeId(scope.node_id),
1956
+ backend_node_id: positiveNodeId(scope.backend_node_id),
1957
+ iframe_node_id: positiveNodeId(scope.iframe_node_id),
1958
+ iframe_backend_node_id: positiveNodeId(scope.iframe_backend_node_id),
1959
+ container_node_id: positiveNodeId(scope.container_node_id),
1960
+ container_backend_node_id: positiveNodeId(scope.container_backend_node_id),
1961
+ containment_method: scope.containment_method || null,
1962
+ container_membership: compactRecommendIframePopupMembership(
1963
+ scope.container_membership
1964
+ ),
1965
+ ancestry_depth: Number.isInteger(scope?.ancestry?.depth) ? scope.ancestry.depth : null,
1966
+ ancestry_path: (scope?.ancestry?.path || []).slice(0, 160).map((item) => ({
1967
+ node_id: positiveNodeId(item.node_id),
1968
+ backend_node_id: positiveNodeId(item.backend_node_id)
1969
+ })),
1970
+ visible: scope.visible === true,
1971
+ stable: true,
1972
+ contained: scope.container_verified === true
1973
+ };
1974
+ }
1975
+
1976
+ function exactStableRecommendDetailRoot(first = null, second = null) {
1977
+ const firstScopes = Array.isArray(first?.scopes) ? first.scopes : [];
1978
+ const secondScopes = Array.isArray(second?.scopes) ? second.scopes : [];
1979
+ const firstPopups = firstScopes.filter((scope) => scope.source === "popup" && scope.visible === true);
1980
+ const secondPopups = secondScopes.filter((scope) => scope.source === "popup" && scope.visible === true);
1981
+ const firstIframes = firstScopes.filter((scope) => scope.source === "resume_iframe" && scope.visible === true);
1982
+ const secondIframes = secondScopes.filter((scope) => scope.source === "resume_iframe" && scope.visible === true);
1983
+ if (
1984
+ firstPopups.length > 1
1985
+ || secondPopups.length > 1
1986
+ || firstIframes.length > 1
1987
+ || secondIframes.length > 1
1988
+ ) {
1989
+ return { root: null, reason: "detail_root_not_unique" };
1990
+ }
1991
+ const left = firstPopups[0] || firstIframes[0] || null;
1992
+ const right = secondPopups[0] || secondIframes[0] || null;
1993
+ if (!left || !right) return { root: null, reason: "detail_root_not_visible" };
1994
+ if (!exactScopeIdentity(left, right)) {
1995
+ return { root: null, reason: "detail_root_identity_not_stable" };
1996
+ }
1997
+
1998
+ let containedIframe = null;
1999
+ if (left.source === "popup") {
2000
+ if (firstIframes.length !== secondIframes.length) {
2001
+ return { root: null, reason: "detail_root_identity_not_stable" };
2002
+ }
2003
+ if (firstIframes.length === 1) {
2004
+ const firstIframe = firstIframes[0];
2005
+ const secondIframe = secondIframes[0];
2006
+ if (firstIframe.container_verified !== secondIframe.container_verified) {
2007
+ return { root: null, reason: "detail_iframe_ancestry_not_stable" };
2008
+ }
2009
+ if (
2010
+ firstIframe.container_verified !== true
2011
+ || secondIframe.container_verified !== true
2012
+ || positiveNodeId(firstIframe.container_node_id) !== positiveNodeId(left.node_id)
2013
+ || positiveNodeId(secondIframe.container_node_id) !== positiveNodeId(right.node_id)
2014
+ || positiveNodeId(firstIframe.container_backend_node_id)
2015
+ !== positiveNodeId(left.backend_node_id)
2016
+ || positiveNodeId(secondIframe.container_backend_node_id)
2017
+ !== positiveNodeId(right.backend_node_id)
2018
+ ) {
2019
+ return { root: null, reason: "detail_iframe_not_contained_by_popup" };
2020
+ }
2021
+ const firstAncestry = JSON.stringify(firstIframe?.ancestry?.path || []);
2022
+ const secondAncestry = JSON.stringify(secondIframe?.ancestry?.path || []);
2023
+ if (
2024
+ !exactScopeIdentity(firstIframe, secondIframe)
2025
+ || firstAncestry !== secondAncestry
2026
+ || (firstIframe?.containment_method || null)
2027
+ !== (secondIframe?.containment_method || null)
2028
+ ) {
2029
+ return { root: null, reason: "detail_iframe_ancestry_not_stable" };
2030
+ }
2031
+ containedIframe = compactContainedIframe(firstIframe);
2032
+ }
2033
+ } else if (firstPopups.length || secondPopups.length) {
2034
+ return { root: null, reason: "detail_root_identity_not_stable" };
2035
+ }
2036
+
2037
+ const root = {
2038
+ source: left.source,
2039
+ node_id: positiveNodeId(left.node_id),
2040
+ backend_node_id: positiveNodeId(left.backend_node_id),
2041
+ iframe_node_id: positiveNodeId(left.iframe_node_id),
2042
+ iframe_backend_node_id: positiveNodeId(left.iframe_backend_node_id),
2043
+ contained_iframe: containedIframe,
2044
+ canonical: true,
2045
+ action_root: true,
2046
+ visible: true,
2047
+ stable: true
2048
+ };
2049
+ return {
2050
+ root,
2051
+ reason: null
2052
+ };
2053
+ }
2054
+
2055
+ function sameCanonicalRecommendDetailContainerRoot(left = null, right = null) {
2056
+ return Boolean(
2057
+ left?.source
2058
+ && left.source === right?.source
2059
+ && positiveNodeId(left.backend_node_id)
2060
+ && positiveNodeId(left.backend_node_id) === positiveNodeId(right?.backend_node_id)
2061
+ && positiveNodeId(left.iframe_backend_node_id)
2062
+ === positiveNodeId(right?.iframe_backend_node_id)
2063
+ );
2064
+ }
2065
+
2066
+ function sameCanonicalRecommendDetailRoot(left = null, right = null) {
2067
+ // This comparison crosses DOM snapshots (pre-click roots and later
2068
+ // revalidation). Frontend node ids may be replaced by DOM.getDocument even
2069
+ // when the exact underlying detail is unchanged, so candidate authorization
2070
+ // here is based on the stable backend identity. Within a single binding
2071
+ // attempt exactStableRecommendDetailRoot still requires both frontend and
2072
+ // backend identities to remain unchanged across its two samples.
2073
+ const leftContainedIframe = left?.contained_iframe || null;
2074
+ const rightContainedIframe = right?.contained_iframe || null;
2075
+ const containedIframeMatches = Boolean(leftContainedIframe) === Boolean(rightContainedIframe)
2076
+ && (!leftContainedIframe || Boolean(
2077
+ positiveNodeId(leftContainedIframe.iframe_backend_node_id)
2078
+ && positiveNodeId(leftContainedIframe.iframe_backend_node_id)
2079
+ === positiveNodeId(rightContainedIframe?.iframe_backend_node_id)
2080
+ && positiveNodeId(leftContainedIframe.backend_node_id)
2081
+ === positiveNodeId(rightContainedIframe?.backend_node_id)
2082
+ && (leftContainedIframe.selector || null) === (rightContainedIframe?.selector || null)
2083
+ ));
2084
+ return Boolean(
2085
+ sameCanonicalRecommendDetailContainerRoot(left, right)
2086
+ && containedIframeMatches
2087
+ );
2088
+ }
2089
+
2090
+ function detailRootWasVisibleBeforeClick(detailRoot = null, detailRootsBefore = []) {
2091
+ if (!detailRoot || !Array.isArray(detailRootsBefore)) return false;
2092
+ return detailRootsBefore.some((scope) => (
2093
+ sameCanonicalRecommendDetailContainerRoot(detailRoot, scope)
2094
+ ));
2095
+ }
2096
+
2097
+ export function compactRecommendDetailCandidateBinding(binding = null) {
2098
+ if (!binding) return null;
2099
+ const sample = binding?.detail?.second || binding?.detail?.first || null;
2100
+ return {
2101
+ schema_version: binding.schema_version || 1,
2102
+ verified: binding.verified === true,
2103
+ reason: binding.reason || null,
2104
+ method: binding.method || null,
2105
+ screening_verified: binding.screening_verified === true,
2106
+ screening_reason: binding.screening_reason || null,
2107
+ screening_method: binding.screening_method || null,
2108
+ expected_candidate_id: binding.expected_candidate_id || null,
2109
+ expected_name: binding.expected_name || null,
2110
+ expected_secondary: (binding.expected_secondary || []).slice(0, 8).map((item) => ({
2111
+ field: item.field || null,
2112
+ value: item.value || null
2113
+ })),
2114
+ allow_scroll: binding.allow_scroll !== false,
2115
+ settle_ms: Number.isFinite(Number(binding.settle_ms))
2116
+ ? Math.max(0, Number(binding.settle_ms))
2117
+ : null,
2118
+ stable: binding.stable === true,
2119
+ readiness: binding.readiness
2120
+ ? {
2121
+ verified: binding.readiness.verified === true,
2122
+ strict_verified: binding.readiness.strict_verified === true,
2123
+ screening_verified: binding.readiness.screening_verified === true,
2124
+ accepted_screening_binding: binding.readiness.accepted_screening_binding === true,
2125
+ exhausted: binding.readiness.exhausted === true,
2126
+ terminal: binding.readiness.terminal === true,
2127
+ attempt_count: Number(binding.readiness.attempt_count) || 0,
2128
+ timeout_ms: Number(binding.readiness.timeout_ms) || 0,
2129
+ elapsed_ms: Number(binding.readiness.elapsed_ms) || 0,
2130
+ last_reason: binding.readiness.last_reason || null
2131
+ }
2132
+ : null,
2133
+ card: {
2134
+ stable: binding?.card?.stable === true,
2135
+ disappeared_after_click: binding?.card?.disappeared_after_click === true,
2136
+ before: compactBindingNode(binding?.card?.before),
2137
+ after: compactBindingNode(binding?.card?.after),
2138
+ candidate_id: binding?.card?.candidate_id || null,
2139
+ name: binding?.card?.name || null,
2140
+ reason: binding?.card?.reason || null,
2141
+ pre_click_provenance: compactRecommendCardPreClickProvenance(
2142
+ binding?.card?.pre_click_provenance
2143
+ ),
2144
+ click_evidence: compactRecommendCardClickEvidence(
2145
+ binding?.card?.click_evidence
2146
+ ),
2147
+ click_attempts: compactRecommendCardClickAttempts(
2148
+ binding?.card?.click_attempts
2149
+ ),
2150
+ causal_proof: binding?.card?.causal_proof
2151
+ ? {
2152
+ verified: binding.card.causal_proof.verified === true,
2153
+ reason: binding.card.causal_proof.reason || null,
2154
+ resume_iframe_selector: binding.card.causal_proof.resume_iframe_selector || null
2155
+ }
2156
+ : null
2157
+ },
2158
+ detail: {
2159
+ root: binding?.detail?.root
2160
+ ? {
2161
+ source: binding.detail.root.source || null,
2162
+ node_id: positiveNodeId(binding.detail.root.node_id),
2163
+ backend_node_id: positiveNodeId(binding.detail.root.backend_node_id),
2164
+ iframe_node_id: positiveNodeId(binding.detail.root.iframe_node_id),
2165
+ iframe_backend_node_id: positiveNodeId(binding.detail.root.iframe_backend_node_id),
2166
+ contained_iframe: binding.detail.root.contained_iframe
2167
+ ? {
2168
+ ...binding.detail.root.contained_iframe,
2169
+ ancestry_path: (binding.detail.root.contained_iframe.ancestry_path || [])
2170
+ .slice(0, 160)
2171
+ }
2172
+ : null,
2173
+ canonical: binding.detail.root.canonical === true,
2174
+ action_root: binding.detail.root.action_root === true,
2175
+ visible: binding.detail.root.visible === true,
2176
+ stable: binding.detail.root.stable === true
2177
+ }
2178
+ : null,
2179
+ newly_mounted: binding?.detail?.newly_mounted === true,
2180
+ root_matches_expected: binding?.detail?.root_matches_expected !== false,
2181
+ roots_before_click: (binding?.detail?.roots_before_click || [])
2182
+ .slice(0, 8)
2183
+ .map(compactRecommendDetailRootSnapshotScope),
2184
+ roots_before_capture: compactRecommendDetailRootsBeforeSnapshot(
2185
+ binding?.detail?.roots_before_capture || null
2186
+ ),
2187
+ candidate_id_evidence_present: binding?.detail?.candidate_id_evidence_present === true,
2188
+ candidate_id_probe_complete: binding?.detail?.candidate_id_probe_complete === true,
2189
+ exact_candidate_id: binding?.detail?.exact_candidate_id === true,
2190
+ exact_name: binding?.detail?.exact_name === true,
2191
+ exact_secondary: binding?.detail?.exact_secondary === true,
2192
+ stable_secondary_fields: (binding?.detail?.stable_secondary_fields || []).slice(0, 8),
2193
+ screening_capture_target: binding?.detail?.screening_capture_target
2194
+ ? {
2195
+ node_id: positiveNodeId(binding.detail.screening_capture_target.node_id),
2196
+ source: binding.detail.screening_capture_target.source || null,
2197
+ selector: binding.detail.screening_capture_target.selector || null,
2198
+ root_node_id: positiveNodeId(binding.detail.screening_capture_target.root_node_id),
2199
+ containment_verified:
2200
+ binding.detail.screening_capture_target.containment_verified === true,
2201
+ rect: binding.detail.screening_capture_target.rect || null,
2202
+ stability: binding.detail.screening_capture_target.stability || null
2203
+ }
2204
+ : null,
2205
+ stable_sample: sample
2206
+ ? {
2207
+ scopes: (sample.scopes || []).slice(0, 4),
2208
+ candidate_ids: (sample.candidate_ids || []).slice(0, 12),
2209
+ name_matches: (sample.name_matches || []).slice(0, 12),
2210
+ secondary_matches: (sample.secondary_matches || []).slice(0, 20),
2211
+ identity_probes: (sample.identity_probes || []).slice(0, 4)
2212
+ }
2213
+ : null
2214
+ }
2215
+ };
2216
+ }
2217
+
2218
+ export function verifyExactCardClickToNewResumeRootCausality({
2219
+ cardNodeId,
2220
+ expectedCandidateId,
2221
+ expectedName,
2222
+ beforeCard,
2223
+ afterCard,
2224
+ cardPreClickProvenance,
2225
+ cardClickEvidence,
2226
+ clickAttempts,
2227
+ detailRoot,
2228
+ rootsBeforeWereCaptured,
2229
+ rootsBeforeCaptureComplete,
2230
+ newlyMounted,
2231
+ rootMatchesExpected,
2232
+ hasCandidateIdEvidence,
2233
+ candidateIdProbeComplete
2234
+ } = {}) {
2235
+ const clickEvidence = compactRecommendCardClickEvidence(cardClickEvidence);
2236
+ const compactClickAttempts = compactRecommendCardClickAttempts(clickAttempts);
2237
+ const selectedPoint = clickEvidence?.hit_test?.selected || null;
2238
+ const selectedAttempt = clickEvidence?.hit_test?.selected_attempt || null;
2239
+ const clickAttempt = compactClickAttempts.length === 1 ? compactClickAttempts[0] : null;
2240
+ const preClickCard = cardPreClickProvenance?.card || null;
2241
+ const listRoot = cardPreClickProvenance?.list_root || null;
2242
+ const ancestry = cardPreClickProvenance?.ancestry || null;
2243
+ const rootMembership = cardPreClickProvenance?.root_membership || null;
2244
+ const containedIframe = detailRoot?.contained_iframe || null;
2245
+ const resumeIframeSelector = containedIframe?.selector
2246
+ || containedIframe?.container_membership?.selector
2247
+ || null;
2248
+ const exactExpectedCard = Boolean(
2249
+ beforeCard?.verified === true
2250
+ && beforeCard.candidate_id === expectedCandidateId
2251
+ && beforeCard.name === expectedName
2252
+ && positiveNodeId(beforeCard.node_id) === positiveNodeId(cardNodeId)
2253
+ && positiveNodeId(beforeCard.backend_node_id)
2254
+ );
2255
+ const exactListRoot = Boolean(
2256
+ positiveNodeId(listRoot?.node_id)
2257
+ && positiveNodeId(listRoot?.backend_node_id)
2258
+ && positiveNodeId(listRoot?.iframe_node_id)
2259
+ && positiveNodeId(listRoot?.iframe_backend_node_id)
2260
+ && positiveNodeId(listRoot?.linked_document_node_id)
2261
+ === positiveNodeId(listRoot?.node_id)
2262
+ );
2263
+ const exactParentAncestry = Boolean(
2264
+ cardPreClickProvenance?.containment_method === "parent_ancestry"
2265
+ && ancestry?.verified === true
2266
+ && positiveNodeId(ancestry?.descendant_node_id) === positiveNodeId(cardNodeId)
2267
+ && positiveNodeId(ancestry?.ancestor_node_id) === positiveNodeId(listRoot?.node_id)
2268
+ && positiveNodeId(ancestry?.ancestor_backend_node_id)
2269
+ === positiveNodeId(listRoot?.backend_node_id)
2270
+ );
2271
+ const membershipRecheck = rootMembership?.recheck || null;
2272
+ const membershipCardRecheck = rootMembership?.card_identity_recheck || null;
2273
+ const exactRootMembership = Boolean(
2274
+ cardPreClickProvenance?.containment_method === "root_scoped_exact_card_identity"
2275
+ && rootMembership?.verified === true
2276
+ && rootMembership?.root_scoped === true
2277
+ && rootMembership?.method === "root_scoped_exact_card_identity"
2278
+ && positiveNodeId(rootMembership?.root_node_id) === positiveNodeId(listRoot?.node_id)
2279
+ && positiveNodeId(rootMembership?.expected_root_backend_node_id)
2280
+ === positiveNodeId(listRoot?.backend_node_id)
2281
+ && positiveNodeId(rootMembership?.expected_iframe_node_id)
2282
+ === positiveNodeId(listRoot?.iframe_node_id)
2283
+ && positiveNodeId(rootMembership?.expected_iframe_backend_node_id)
2284
+ === positiveNodeId(listRoot?.iframe_backend_node_id)
2285
+ && positiveNodeId(rootMembership?.expected_linked_document_node_id)
2286
+ === positiveNodeId(listRoot?.linked_document_node_id)
2287
+ && positiveNodeId(rootMembership?.expected_card_node_id) === positiveNodeId(cardNodeId)
2288
+ && positiveNodeId(rootMembership?.expected_card_backend_node_id)
2289
+ === positiveNodeId(beforeCard?.backend_node_id)
2290
+ && positiveNodeId(rootMembership?.observed_card_backend_node_id)
2291
+ === positiveNodeId(beforeCard?.backend_node_id)
2292
+ && rootMembership?.exact_frontend_match_count === 1
2293
+ && rootMembership?.exact_backend_match_count === 1
2294
+ && membershipRecheck?.verified === true
2295
+ && positiveNodeId(membershipRecheck?.root_node_id) === positiveNodeId(listRoot?.node_id)
2296
+ && positiveNodeId(membershipRecheck?.expected_root_backend_node_id)
2297
+ === positiveNodeId(listRoot?.backend_node_id)
2298
+ && positiveNodeId(membershipRecheck?.observed_root_backend_node_id)
2299
+ === positiveNodeId(listRoot?.backend_node_id)
2300
+ && positiveNodeId(membershipRecheck?.iframe_node_id)
2301
+ === positiveNodeId(listRoot?.iframe_node_id)
2302
+ && positiveNodeId(membershipRecheck?.expected_iframe_backend_node_id)
2303
+ === positiveNodeId(listRoot?.iframe_backend_node_id)
2304
+ && positiveNodeId(membershipRecheck?.observed_iframe_backend_node_id)
2305
+ === positiveNodeId(listRoot?.iframe_backend_node_id)
2306
+ && positiveNodeId(membershipRecheck?.expected_linked_document_node_id)
2307
+ === positiveNodeId(listRoot?.linked_document_node_id)
2308
+ && positiveNodeId(membershipRecheck?.observed_linked_document_node_id)
2309
+ === positiveNodeId(listRoot?.linked_document_node_id)
2310
+ && membershipCardRecheck?.verified === true
2311
+ && membershipCardRecheck?.candidate_id === expectedCandidateId
2312
+ && membershipCardRecheck?.name === expectedName
2313
+ && positiveNodeId(membershipCardRecheck?.node_id) === positiveNodeId(cardNodeId)
2314
+ && positiveNodeId(membershipCardRecheck?.backend_node_id)
2315
+ === positiveNodeId(beforeCard?.backend_node_id)
2316
+ );
2317
+ const exactPreClickCard = Boolean(
2318
+ cardPreClickProvenance?.verified === true
2319
+ && preClickCard?.verified === true
2320
+ && preClickCard.candidate_id === expectedCandidateId
2321
+ && preClickCard.name === expectedName
2322
+ && positiveNodeId(preClickCard.node_id) === positiveNodeId(cardNodeId)
2323
+ && positiveNodeId(preClickCard.backend_node_id)
2324
+ === positiveNodeId(beforeCard?.backend_node_id)
2325
+ && exactListRoot
2326
+ && (exactParentAncestry || exactRootMembership)
2327
+ );
2328
+ const exactSafeHit = Boolean(
2329
+ clickEvidence?.verified === true
2330
+ && clickEvidence?.in_viewport === true
2331
+ && positiveNodeId(clickEvidence?.node_id) === positiveNodeId(cardNodeId)
2332
+ && clickEvidence?.hit_test?.completed === true
2333
+ && clickEvidence?.hit_test?.exact_card_hit_verified === true
2334
+ && selectedPoint
2335
+ && sameRecommendClickPoint(clickEvidence?.click_target, selectedPoint)
2336
+ && sameRecommendClickPoint(selectedAttempt?.point, selectedPoint)
2337
+ && selectedAttempt?.inside_viewport === true
2338
+ && selectedAttempt?.exact_card_hit === true
2339
+ && selectedAttempt?.safe_card_hit === true
2340
+ && selectedAttempt?.safe_card_body_hit === true
2341
+ && positiveNodeId(selectedAttempt?.hit_node_id)
2342
+ && positiveNodeId(selectedAttempt?.hit_backend_node_id)
2343
+ );
2344
+ const exactSingleClick = Boolean(
2345
+ compactClickAttempts.length === 1
2346
+ && clickAttempt?.attempt === 1
2347
+ && clickAttempt?.input_dispatched === true
2348
+ && clickAttempt?.outcome === "detail"
2349
+ && sameRecommendClickPoint(clickAttempt?.click_target, selectedPoint)
2350
+ );
2351
+ const cardDefinitivelyDetached = Boolean(
2352
+ afterCard?.verified !== true
2353
+ && afterCard?.definitively_disappeared === true
2354
+ && afterCard?.disappearance_kind === "detached"
2355
+ );
2356
+ const exactNewResumeRoot = Boolean(
2357
+ rootsBeforeWereCaptured === true
2358
+ && rootsBeforeCaptureComplete === true
2359
+ && detailRoot?.source === "popup"
2360
+ && detailRoot?.canonical === true
2361
+ && detailRoot?.action_root === true
2362
+ && detailRoot?.visible === true
2363
+ && detailRoot?.stable === true
2364
+ && positiveNodeId(detailRoot?.backend_node_id)
2365
+ && newlyMounted === true
2366
+ && rootMatchesExpected === true
2367
+ && containedIframe?.contained === true
2368
+ && containedIframe?.visible === true
2369
+ && containedIframe?.stable === true
2370
+ && positiveNodeId(containedIframe?.iframe_node_id)
2371
+ && positiveNodeId(containedIframe?.iframe_backend_node_id)
2372
+ && positiveNodeId(containedIframe?.node_id)
2373
+ && positiveNodeId(containedIframe?.backend_node_id)
2374
+ && DETAIL_RESUME_IFRAME_SELECTORS.includes(resumeIframeSelector)
2375
+ );
2376
+
2377
+ let reason = null;
2378
+ if (candidateIdProbeComplete !== true) reason = "detail_causal_candidate_id_probe_incomplete";
2379
+ else if (hasCandidateIdEvidence) reason = "detail_causal_candidate_id_evidence_present";
2380
+ else if (!exactExpectedCard) reason = "detail_causal_exact_card_identity_missing";
2381
+ else if (!exactPreClickCard) reason = "detail_causal_pre_click_provenance_unverified";
2382
+ else if (!exactSafeHit) reason = "detail_causal_safe_hit_unverified";
2383
+ else if (!exactSingleClick) reason = "detail_causal_single_click_unverified";
2384
+ else if (!cardDefinitivelyDetached) reason = "detail_causal_card_not_detached_after_click";
2385
+ else if (!exactNewResumeRoot) reason = "detail_causal_resume_iframe_not_ready";
2386
+
2387
+ return {
2388
+ verified: reason === null,
2389
+ reason,
2390
+ exact_expected_card: exactExpectedCard,
2391
+ exact_pre_click_card: exactPreClickCard,
2392
+ exact_safe_hit: exactSafeHit,
2393
+ exact_single_click: exactSingleClick,
2394
+ card_definitively_detached: cardDefinitivelyDetached,
2395
+ exact_new_resume_root: exactNewResumeRoot,
2396
+ click_evidence: clickEvidence,
2397
+ click_attempts: compactClickAttempts,
2398
+ resume_iframe_selector: resumeIframeSelector
2399
+ };
2400
+ }
2401
+
2402
+ export async function verifyRecommendDetailCandidateBinding(client, {
2403
+ cardNodeId,
2404
+ cardCandidate,
2405
+ detailState,
2406
+ cardEvidenceBefore = null,
2407
+ cardPreClickProvenance = null,
2408
+ detailRootsBefore = null,
2409
+ expectedDetailRoot = null,
2410
+ allowCardDisappearance = false,
2411
+ cardClickEvidence = null,
2412
+ clickAttempts = null,
2413
+ settleMs = 120,
2414
+ allowScroll = true
2415
+ } = {}) {
2416
+ const expected = {
2417
+ candidate_id: normalizeBindingText(cardCandidate?.id),
2418
+ name: normalizeBindingText(cardCandidate?.identity?.name),
2419
+ secondary: expectedSecondaryIdentity(cardCandidate)
2420
+ };
2421
+ const beforeCard = cardEvidenceBefore
2422
+ || cardPreClickProvenance?.card
2423
+ || await readRecommendCardBindingEvidence(
2424
+ client,
2425
+ cardNodeId,
2426
+ cardCandidate
2427
+ );
2428
+ const first = await readRecommendDetailBindingSample(client, detailState, expected, {
2429
+ allowScroll
2430
+ });
2431
+ if (settleMs > 0) await sleep(settleMs);
2432
+ const second = await readRecommendDetailBindingSample(client, detailState, expected, {
2433
+ allowScroll
2434
+ });
2435
+ const afterCard = await readRecommendCardAfterClickEvidence(client, cardNodeId, cardCandidate);
2436
+
2437
+ const detailRootSelection = exactStableRecommendDetailRoot(first, second);
2438
+ const detailRoot = detailRootSelection.root;
2439
+
2440
+ const cardPersistedStable = Boolean(
2441
+ beforeCard?.verified === true
2442
+ && afterCard?.verified === true
2443
+ && positiveNodeId(beforeCard.backend_node_id)
2444
+ && beforeCard.backend_node_id === afterCard.backend_node_id
2445
+ && beforeCard.candidate_id === afterCard.candidate_id
2446
+ && beforeCard.name === afterCard.name
2447
+ );
2448
+ const cardDisappearedAfterClick = Boolean(
2449
+ allowCardDisappearance === true
2450
+ && cardPreClickProvenance?.verified === true
2451
+ && beforeCard?.verified === true
2452
+ && afterCard?.verified !== true
2453
+ && afterCard?.definitively_disappeared === true
2454
+ );
2455
+ const cardStable = cardPersistedStable || cardDisappearedAfterClick;
2456
+ const stable = Boolean(
2457
+ signaturesEqual(first.signatures.scopes, second.signatures.scopes)
2458
+ && signaturesEqual(first.signatures.candidate_ids, second.signatures.candidate_ids)
2459
+ && signaturesEqual(first.signatures.names, second.signatures.names)
2460
+ && signaturesEqual(first.signatures.secondary, second.signatures.secondary)
2461
+ );
2462
+ const firstIds = Array.from(new Set(first.candidate_ids.map((item) => item.value)));
2463
+ const secondIds = Array.from(new Set(second.candidate_ids.map((item) => item.value)));
2464
+ const hasCandidateIdEvidence = firstIds.length > 0 || secondIds.length > 0;
2465
+ const candidateIdProbeComplete = Boolean(
2466
+ first.candidate_id_probe_complete === true
2467
+ && second.candidate_id_probe_complete === true
2468
+ );
2469
+ const exactCandidateId = Boolean(
2470
+ expected.candidate_id
2471
+ && candidateIdProbeComplete
2472
+ && firstIds.length > 0
2473
+ && firstIds.every((value) => value === expected.candidate_id)
2474
+ && secondIds.length > 0
2475
+ && secondIds.every((value) => value === expected.candidate_id)
2476
+ );
2477
+ const exactName = Boolean(
2478
+ expected.name
2479
+ && first.name_matches.length > 0
2480
+ && second.name_matches.length > 0
2481
+ );
2482
+ const stableSecondaryFields = Array.from(new Set(first.secondary_matches
2483
+ .filter((item) => second.secondary_matches.some((other) => (
2484
+ other.field === item.field
2485
+ && other.value === item.value
2486
+ && other.backend_node_id === item.backend_node_id
2487
+ )))
2488
+ .map((item) => item.field)));
2489
+ const exactSecondary = stableSecondaryFields.length > 0;
2490
+ let method = exactCandidateId && exactName
2491
+ ? "exact_candidate_id_and_name"
2492
+ : candidateIdProbeComplete && !hasCandidateIdEvidence && exactName && exactSecondary
2493
+ ? "exact_name_and_secondary_identity"
2494
+ : null;
2495
+ const rootsBeforeSnapshot = compactRecommendDetailRootsBeforeSnapshot(detailRootsBefore);
2496
+ const rootsBeforeWereCaptured = rootsBeforeSnapshot.captured === true;
2497
+ const rootsBeforeCaptureComplete = rootsBeforeSnapshot.complete === true;
2498
+ const newlyMounted = Boolean(
2499
+ detailRoot
2500
+ && (!rootsBeforeWereCaptured || !detailRootWasVisibleBeforeClick(
2501
+ detailRoot,
2502
+ rootsBeforeSnapshot.roots
2503
+ ))
2504
+ );
2505
+ const rootMatchesExpected = Boolean(
2506
+ detailRoot
2507
+ && (!expectedDetailRoot || sameCanonicalRecommendDetailRoot(detailRoot, expectedDetailRoot))
2508
+ );
2509
+ const causalProof = verifyExactCardClickToNewResumeRootCausality({
2510
+ cardNodeId,
2511
+ expectedCandidateId: expected.candidate_id,
2512
+ expectedName: expected.name,
2513
+ beforeCard,
2514
+ afterCard,
2515
+ cardPreClickProvenance,
2516
+ cardClickEvidence,
2517
+ clickAttempts,
2518
+ detailRoot,
2519
+ rootsBeforeWereCaptured,
2520
+ rootsBeforeCaptureComplete,
2521
+ newlyMounted,
2522
+ rootMatchesExpected,
2523
+ hasCandidateIdEvidence,
2524
+ candidateIdProbeComplete
2525
+ });
2526
+ const causalEvidenceProvided = Boolean(
2527
+ cardClickEvidence
2528
+ || (Array.isArray(clickAttempts) && clickAttempts.length > 0)
2529
+ );
2530
+ if (
2531
+ !method
2532
+ && candidateIdProbeComplete
2533
+ && !hasCandidateIdEvidence
2534
+ && !exactName
2535
+ && causalProof.verified
2536
+ ) {
2537
+ method = "exact_card_click_and_new_resume_root";
2538
+ }
2539
+ const verified = Boolean(
2540
+ cardStable
2541
+ && stable
2542
+ && detailRoot
2543
+ && newlyMounted
2544
+ && rootMatchesExpected
2545
+ && method
2546
+ );
2547
+ let reason = null;
2548
+ if (!expected.candidate_id || !expected.name) reason = "expected_candidate_identity_incomplete";
2549
+ else if (allowCardDisappearance === true && cardPreClickProvenance?.verified !== true) {
2550
+ reason = cardPreClickProvenance?.reason || "card_pre_click_provenance_unverified";
2551
+ }
2552
+ else if (
2553
+ afterCard?.visible === true
2554
+ && ["card_candidate_id_mismatch", "card_candidate_name_mismatch"].includes(afterCard?.reason)
2555
+ ) reason = afterCard.reason;
2556
+ else if (!cardStable) reason = "card_identity_not_stable";
2557
+ else if (!detailRoot) reason = detailRootSelection.reason || "detail_root_identity_not_stable";
2558
+ else if (!newlyMounted) reason = "detail_root_not_newly_mounted";
2559
+ else if (!rootMatchesExpected) reason = "detail_root_changed";
2560
+ else if (!stable) reason = "detail_binding_evidence_changed";
2561
+ else if (hasCandidateIdEvidence && !exactCandidateId) reason = "detail_candidate_id_mismatch";
2562
+ else if (!candidateIdProbeComplete) reason = causalEvidenceProvided
2563
+ ? causalProof.reason || "detail_candidate_id_probe_incomplete"
2564
+ : "detail_candidate_id_probe_incomplete";
2565
+ else if (!exactName && method !== "exact_card_click_and_new_resume_root") {
2566
+ reason = !hasCandidateIdEvidence && causalEvidenceProvided && causalProof.reason
2567
+ ? causalProof.reason
2568
+ : "detail_candidate_name_not_proven";
2569
+ }
2570
+ else if (
2571
+ method !== "exact_card_click_and_new_resume_root"
2572
+ && !hasCandidateIdEvidence
2573
+ && !exactSecondary
2574
+ ) reason = "detail_secondary_identity_not_proven";
2575
+
2576
+ // Screening can operate from the stable, newly-mounted popup itself. Boss
2577
+ // currently renders many resumes directly inside that popup without a
2578
+ // nested resume iframe or readable identity text. Keep this evidence
2579
+ // separate from the strict candidate binding: it may authorize only
2580
+ // screenshot/LLM reads in an explicitly zero-outbound run and must never be
2581
+ // treated as sufficient for a post action.
2582
+ let screeningCaptureTarget = null;
2583
+ if (
2584
+ !verified
2585
+ && detailRoot?.source === "popup"
2586
+ && detailRoot?.visible === true
2587
+ && detailRoot?.stable === true
2588
+ && positiveNodeId(detailRoot?.node_id)
2589
+ ) {
2590
+ screeningCaptureTarget = await resolveCvCaptureTarget(client, {
2591
+ popup: {
2592
+ ...(detailState?.popup || {}),
2593
+ node_id: positiveNodeId(detailRoot.node_id)
2594
+ },
2595
+ resumeIframe: null,
2596
+ content: null,
2597
+ roots: []
2598
+ }, {
2599
+ domain: "recommend",
2600
+ stabilitySamples: 2,
2601
+ stabilityIntervalMs: 0
2602
+ });
2603
+ }
2604
+ const exactScreeningCaptureTarget = Boolean(
2605
+ screeningCaptureTarget?.source === "popup_cv_selector"
2606
+ && CV_CAPTURE_TARGET_SELECTORS.includes(screeningCaptureTarget?.selector)
2607
+ && screeningCaptureTarget?.containment_verified === true
2608
+ && screeningCaptureTarget?.stability?.stable === true
2609
+ && Number(screeningCaptureTarget?.stability?.sample_count) >= 2
2610
+ && positiveNodeId(screeningCaptureTarget?.root_node_id)
2611
+ === positiveNodeId(detailRoot?.node_id)
2612
+ && Number(screeningCaptureTarget?.rect?.width || 0) > 2
2613
+ && Number(screeningCaptureTarget?.rect?.height || 0) > 2
2614
+ );
2615
+ const exactScreeningPopupRoot = Boolean(
2616
+ rootsBeforeWereCaptured === true
2617
+ && rootsBeforeCaptureComplete === true
2618
+ && detailRoot?.source === "popup"
2619
+ && detailRoot?.canonical === true
2620
+ && detailRoot?.action_root === true
2621
+ && detailRoot?.visible === true
2622
+ && detailRoot?.stable === true
2623
+ && positiveNodeId(detailRoot?.backend_node_id)
2624
+ && newlyMounted === true
2625
+ && rootMatchesExpected === true
2626
+ && exactScreeningCaptureTarget
2627
+ );
2628
+ const screeningFallbackVerified = Boolean(
2629
+ expected.candidate_id
2630
+ && expected.name
2631
+ && cardStable
2632
+ && stable
2633
+ && candidateIdProbeComplete
2634
+ && (!hasCandidateIdEvidence || exactCandidateId)
2635
+ && causalProof.exact_expected_card === true
2636
+ && causalProof.exact_pre_click_card === true
2637
+ && causalProof.exact_safe_hit === true
2638
+ && causalProof.exact_single_click === true
2639
+ && exactScreeningPopupRoot
2640
+ );
2641
+ const screeningVerified = verified || screeningFallbackVerified;
2642
+ const screeningMethod = verified
2643
+ ? method
2644
+ : screeningFallbackVerified
2645
+ ? "exact_card_click_and_stable_popup_cv_root"
2646
+ : null;
2647
+ let screeningReason = null;
2648
+ if (!screeningVerified) {
2649
+ if (!expected.candidate_id || !expected.name) screeningReason = "expected_candidate_identity_incomplete";
2650
+ else if (!cardStable) screeningReason = "screening_card_identity_not_stable";
2651
+ else if (!stable) screeningReason = "screening_detail_root_not_stable";
2652
+ else if (!candidateIdProbeComplete) screeningReason = "screening_candidate_id_probe_incomplete";
2653
+ else if (hasCandidateIdEvidence && !exactCandidateId) screeningReason = "detail_candidate_id_mismatch";
2654
+ else if (causalProof.exact_expected_card !== true) screeningReason = "screening_exact_card_identity_missing";
2655
+ else if (causalProof.exact_pre_click_card !== true) screeningReason = "screening_pre_click_provenance_unverified";
2656
+ else if (causalProof.exact_safe_hit !== true) screeningReason = "screening_safe_hit_unverified";
2657
+ else if (causalProof.exact_single_click !== true) screeningReason = "screening_single_click_unverified";
2658
+ else if (!exactScreeningCaptureTarget) screeningReason = "screening_popup_cv_target_unverified";
2659
+ else if (!exactScreeningPopupRoot) screeningReason = "screening_popup_cv_root_unverified";
2660
+ else screeningReason = reason || "screening_candidate_binding_unverified";
2661
+ }
2662
+
2663
+ return {
2664
+ schema_version: 1,
2665
+ verified,
2666
+ reason: verified ? null : reason || "detail_candidate_binding_unverified",
2667
+ method,
2668
+ screening_verified: screeningVerified,
2669
+ screening_reason: screeningReason,
2670
+ screening_method: screeningMethod,
2671
+ expected_candidate_id: expected.candidate_id || null,
2672
+ expected_name: expected.name || null,
2673
+ expected_secondary: expected.secondary,
2674
+ allow_scroll: allowScroll === true,
2675
+ settle_ms: Math.max(0, Number(settleMs) || 0),
2676
+ stable,
2677
+ card: {
2678
+ stable: cardStable,
2679
+ disappeared_after_click: cardDisappearedAfterClick,
2680
+ before: compactBindingNode(beforeCard),
2681
+ after: compactBindingNode(afterCard),
2682
+ candidate_id: afterCard?.candidate_id || beforeCard?.candidate_id || null,
2683
+ name: afterCard?.name || beforeCard?.name || null,
2684
+ reason: cardStable ? null : afterCard?.reason || beforeCard?.reason || null,
2685
+ pre_click_provenance: compactRecommendCardPreClickProvenance(cardPreClickProvenance),
2686
+ click_evidence: causalProof.click_evidence,
2687
+ click_attempts: causalProof.click_attempts,
2688
+ causal_proof: {
2689
+ verified: causalProof.verified,
2690
+ reason: causalProof.reason,
2691
+ resume_iframe_selector: causalProof.resume_iframe_selector
2692
+ }
2693
+ },
2694
+ detail: {
2695
+ root: detailRoot,
2696
+ newly_mounted: newlyMounted,
2697
+ root_matches_expected: rootMatchesExpected,
2698
+ roots_before_click: rootsBeforeSnapshot.roots,
2699
+ roots_before_capture: rootsBeforeSnapshot,
2700
+ candidate_id_evidence_present: hasCandidateIdEvidence,
2701
+ candidate_id_probe_complete: candidateIdProbeComplete,
2702
+ exact_candidate_id: exactCandidateId,
2703
+ exact_name: exactName,
2704
+ exact_secondary: exactSecondary,
2705
+ stable_secondary_fields: stableSecondaryFields,
2706
+ screening_capture_target: screeningCaptureTarget,
2707
+ first: compactDetailBindingSample(first),
2708
+ second: compactDetailBindingSample(second)
2709
+ }
2710
+ };
2711
+ }
2712
+
2713
+ const TERMINAL_RECOMMEND_DETAIL_BINDING_REASONS = new Set([
2714
+ "expected_candidate_identity_incomplete",
2715
+ "card_pre_click_provenance_unverified",
2716
+ "card_identity_not_verified_before_click",
2717
+ "card_list_root_provenance_missing",
2718
+ "card_iframe_document_link_mismatch",
2719
+ "card_not_bound_to_recommend_list_root",
2720
+ "card_candidate_id_mismatch",
2721
+ "card_candidate_name_mismatch",
2722
+ "detail_root_not_unique",
2723
+ "detail_root_not_newly_mounted",
2724
+ "detail_root_changed",
2725
+ "detail_candidate_id_mismatch",
2726
+ "detail_causal_exact_card_identity_missing",
2727
+ "detail_causal_pre_click_provenance_unverified",
2728
+ "detail_causal_safe_hit_unverified",
2729
+ "detail_causal_single_click_unverified",
2730
+ "detail_causal_card_not_detached_after_click"
2731
+ ]);
2732
+
2733
+ function isTerminalRecommendDetailBindingReason(reason = "") {
2734
+ return TERMINAL_RECOMMEND_DETAIL_BINDING_REASONS.has(String(reason || ""));
2735
+ }
2736
+
2737
+ export async function waitForRecommendDetailCandidateBinding(client, {
2738
+ timeoutMs = 5000,
2739
+ intervalMs = 200,
2740
+ maxAttempts = 20,
2741
+ acceptScreeningBinding = false,
2742
+ ...verificationOptions
2743
+ } = {}) {
2744
+ const started = Date.now();
2745
+ const boundedTimeoutMs = Math.max(0, Number(timeoutMs) || 0);
2746
+ const boundedIntervalMs = Math.max(0, Number(intervalMs) || 0);
2747
+ const boundedMaxAttempts = Math.max(1, Math.floor(Number(maxAttempts) || 1));
2748
+ const attempts = [];
2749
+ let lastBinding = null;
2750
+ let lastDetailState = null;
2751
+ let lastError = null;
2752
+
2753
+ for (let attemptIndex = 0; attemptIndex < boundedMaxAttempts; attemptIndex += 1) {
2754
+ try {
2755
+ // A click first mounts a generic loading dialog; the resume iframe is
2756
+ // attached later. Re-read the current detail roots on every readiness
2757
+ // attempt instead of freezing the loading-only state returned by the
2758
+ // first post-click poll.
2759
+ const currentDetailState = await readRecommendDetailState(client);
2760
+ lastDetailState = currentDetailState;
2761
+ lastBinding = await verifyRecommendDetailCandidateBinding(client, {
2762
+ ...verificationOptions,
2763
+ detailState: currentDetailState
2764
+ });
2765
+ attempts.push({
2766
+ attempt: attemptIndex + 1,
2767
+ verified: lastBinding.verified === true,
2768
+ screening_verified: lastBinding.screening_verified === true,
2769
+ reason: lastBinding.reason || null,
2770
+ method: lastBinding.method || null,
2771
+ detail_root_backend_node_id: positiveNodeId(lastBinding?.detail?.root?.backend_node_id),
2772
+ card_disappeared_after_click: lastBinding?.card?.disappeared_after_click === true
2773
+ });
2774
+ const acceptedScreeningBinding = Boolean(
2775
+ acceptScreeningBinding === true
2776
+ && lastBinding.screening_verified === true
2777
+ );
2778
+ if (lastBinding.verified === true || acceptedScreeningBinding) {
2779
+ return {
2780
+ ...lastBinding,
2781
+ observed_detail_state: currentDetailState,
2782
+ readiness: {
2783
+ verified: true,
2784
+ strict_verified: lastBinding.verified === true,
2785
+ screening_verified: lastBinding.screening_verified === true,
2786
+ accepted_screening_binding: acceptedScreeningBinding,
2787
+ exhausted: false,
2788
+ terminal: false,
2789
+ attempt_count: attempts.length,
2790
+ timeout_ms: boundedTimeoutMs,
2791
+ elapsed_ms: Date.now() - started,
2792
+ last_reason: null,
2793
+ attempts
2794
+ }
2795
+ };
2796
+ }
2797
+ const screeningMayStillBecomeReady = Boolean(
2798
+ acceptScreeningBinding === true
2799
+ && lastBinding.reason === "detail_causal_card_not_detached_after_click"
2800
+ );
2801
+ if (
2802
+ isTerminalRecommendDetailBindingReason(lastBinding.reason)
2803
+ && !screeningMayStillBecomeReady
2804
+ ) {
2805
+ return {
2806
+ ...lastBinding,
2807
+ observed_detail_state: currentDetailState,
2808
+ readiness: {
2809
+ verified: false,
2810
+ strict_verified: false,
2811
+ screening_verified: lastBinding.screening_verified === true,
2812
+ accepted_screening_binding: false,
2813
+ exhausted: false,
2814
+ terminal: true,
2815
+ attempt_count: attempts.length,
2816
+ timeout_ms: boundedTimeoutMs,
2817
+ elapsed_ms: Date.now() - started,
2818
+ last_reason: lastBinding.reason || null,
2819
+ attempts
2820
+ }
2821
+ };
2822
+ }
2823
+ } catch (error) {
2824
+ if (shouldRethrowRecommendProtocolError(error)) throw error;
2825
+ lastError = error;
2826
+ attempts.push({
2827
+ attempt: attemptIndex + 1,
2828
+ verified: false,
2829
+ reason: "detail_binding_readiness_read_failed",
2830
+ error: error?.message || String(error)
2831
+ });
2832
+ }
2833
+
2834
+ const elapsedMs = Date.now() - started;
2835
+ if (elapsedMs >= boundedTimeoutMs || attemptIndex >= boundedMaxAttempts - 1) break;
2836
+ if (boundedIntervalMs > 0) {
2837
+ await sleep(Math.min(boundedIntervalMs, Math.max(0, boundedTimeoutMs - elapsedMs)));
2838
+ }
2839
+ }
2840
+
2841
+ const lastReason = lastBinding?.reason
2842
+ || (lastError ? "detail_binding_readiness_read_failed" : "detail_candidate_binding_unverified");
2843
+ return {
2844
+ ...(lastBinding || {
2845
+ schema_version: 1,
2846
+ verified: false,
2847
+ screening_verified: false,
2848
+ screening_reason: lastReason,
2849
+ screening_method: null,
2850
+ method: null,
2851
+ expected_candidate_id: normalizeBindingText(verificationOptions?.cardCandidate?.id) || null,
2852
+ expected_name: normalizeBindingText(verificationOptions?.cardCandidate?.identity?.name) || null,
2853
+ expected_secondary: expectedSecondaryIdentity(verificationOptions?.cardCandidate),
2854
+ stable: false,
2855
+ card: {
2856
+ stable: false,
2857
+ disappeared_after_click: false,
2858
+ before: null,
2859
+ after: null,
2860
+ candidate_id: null,
2861
+ name: null,
2862
+ reason: lastReason,
2863
+ pre_click_provenance: compactRecommendCardPreClickProvenance(
2864
+ verificationOptions.cardPreClickProvenance
2865
+ )
2866
+ },
2867
+ detail: null
2868
+ }),
2869
+ verified: false,
2870
+ reason: "detail_binding_readiness_timeout",
2871
+ observed_detail_state: lastDetailState,
2872
+ readiness: {
2873
+ verified: false,
2874
+ strict_verified: false,
2875
+ screening_verified: false,
2876
+ accepted_screening_binding: false,
2877
+ exhausted: true,
2878
+ terminal: false,
2879
+ attempt_count: attempts.length,
2880
+ timeout_ms: boundedTimeoutMs,
2881
+ elapsed_ms: Date.now() - started,
2882
+ last_reason: lastReason,
2883
+ last_error: lastError?.message || null,
2884
+ attempts
2885
+ }
2886
+ };
2887
+ }
2888
+
2889
+ export function createRecommendDetailCandidateBindingError(binding = null) {
2890
+ const reason = binding?.reason || "detail_candidate_binding_unverified";
2891
+ const error = new Error(`RECOMMEND_DETAIL_CANDIDATE_MISMATCH: ${reason}`);
2892
+ error.code = "RECOMMEND_DETAIL_CANDIDATE_MISMATCH";
2893
+ error.phase = "recommend:detail-binding";
2894
+ error.detail_candidate_binding = binding;
2895
+ return error;
2896
+ }
2897
+
2898
+ export function isCleanRecommendPostClickBindingReadinessTimeout(
2899
+ binding = null,
2900
+ clickAttempts = []
2901
+ ) {
2902
+ const readiness = binding?.readiness || null;
2903
+ const attempts = Array.isArray(clickAttempts) ? clickAttempts : [];
2904
+ const dispatchedAttempts = attempts.filter((attempt) => attempt?.input_dispatched === true);
2905
+ return Boolean(
2906
+ binding?.verified !== true
2907
+ && binding?.reason === "detail_binding_readiness_timeout"
2908
+ && readiness?.exhausted === true
2909
+ && readiness?.terminal === false
2910
+ && readiness?.last_error == null
2911
+ && Number(readiness?.attempt_count || 0) > 0
2912
+ && dispatchedAttempts.length === 1
2913
+ && dispatchedAttempts[0]?.outcome === "detail"
2914
+ );
2915
+ }
2916
+
2917
+ export function isRecommendDetailCandidateBindingError(error) {
2918
+ return error?.code === "RECOMMEND_DETAIL_CANDIDATE_MISMATCH"
2919
+ || /RECOMMEND_DETAIL_CANDIDATE_MISMATCH/.test(String(error?.message || error || ""));
2920
+ }
46
2921
 
47
2922
  export function matchesRecommendDetailNetwork(url) {
48
2923
  return DETAIL_NETWORK_PATTERNS.some((pattern) => pattern.test(String(url || "")));
@@ -175,16 +3050,28 @@ export async function waitForRecommendDetail(client, {
175
3050
  return lastState;
176
3051
  }
177
3052
 
178
- async function readRecommendDetailState(client) {
179
- const rootState = await getRecommendRoots(client);
180
- const popup = await findVisibleDetailTarget(client, rootState.roots, DETAIL_POPUP_SELECTORS);
181
- const resumeIframe = await findVisibleDetailTarget(client, rootState.roots, DETAIL_RESUME_IFRAME_SELECTORS);
182
- return {
183
- iframe: rootState.iframe,
184
- roots: rootState.roots,
185
- popup,
186
- resumeIframe
187
- };
3053
+ async function readRecommendDetailState(client, {
3054
+ rootState = null
3055
+ } = {}) {
3056
+ const currentRootState = rootState?.iframe?.documentNodeId
3057
+ ? rootState
3058
+ : await getRecommendRoots(client);
3059
+ const popup = await findVisibleDetailTarget(
3060
+ client,
3061
+ currentRootState.roots,
3062
+ DETAIL_POPUP_SELECTORS
3063
+ );
3064
+ const resumeIframe = await findVisibleDetailTarget(
3065
+ client,
3066
+ currentRootState.roots,
3067
+ DETAIL_RESUME_IFRAME_SELECTORS
3068
+ );
3069
+ return {
3070
+ iframe: currentRootState.iframe,
3071
+ roots: currentRootState.roots,
3072
+ popup,
3073
+ resumeIframe
3074
+ };
188
3075
  }
189
3076
 
190
3077
  export async function waitForRecommendDetailClosed(client, {
@@ -398,18 +3285,99 @@ export function isStaleRecommendNodeError(error) {
398
3285
  if (pattern.test(message)) return true;
399
3286
  current = current?.cause || null;
400
3287
  }
401
- return false;
402
- }
403
-
3288
+ return false;
3289
+ }
3290
+
3291
+ export function isRecommendPreClickStaleNoActionError(error) {
3292
+ return Boolean(
3293
+ error?.recommend_pre_click_stale_no_action === true
3294
+ && error?.recommend_no_click_dispatched === true
3295
+ && error?.recommend_input_dispatched === false
3296
+ && error?.recommend_pre_click_stage === "pre_click_card_box"
3297
+ );
3298
+ }
3299
+
3300
+ export function summarizeRecommendPreClickRetryAttempts(attempts = []) {
3301
+ const items = Array.isArray(attempts) ? attempts : [];
3302
+ const allPreClickStaleNoAction = Boolean(
3303
+ items.length > 0
3304
+ && items.every((attempt) => (
3305
+ attempt?.stale_node === true
3306
+ && attempt?.pre_click_stale_no_action === true
3307
+ && attempt?.no_click_dispatched === true
3308
+ && attempt?.click_dispatched === false
3309
+ && attempt?.input_dispatched === false
3310
+ && attempt?.pre_click_stage === "pre_click_card_box"
3311
+ && attempt?.exact_candidate_provenance_verified === true
3312
+ && attempt?.detail_open_miss !== true
3313
+ && attempt?.candidate_binding_mismatch !== true
3314
+ ))
3315
+ );
3316
+ return {
3317
+ attempt_count: items.length,
3318
+ all_pre_click_stale_no_action: allPreClickStaleNoAction,
3319
+ no_click_dispatched: allPreClickStaleNoAction
3320
+ };
3321
+ }
3322
+
3323
+ function markRecommendPreClickStaleNoAction(error, {
3324
+ nodeId = null,
3325
+ stage = "pre_click_card_box"
3326
+ } = {}) {
3327
+ if (!error || !isStaleRecommendNodeError(error)) return error;
3328
+ error.recommend_pre_click_stale_no_action = true;
3329
+ error.recommend_no_click_dispatched = true;
3330
+ error.recommend_click_dispatched = false;
3331
+ error.recommend_input_dispatched = false;
3332
+ error.recommend_pre_click_stage = stage;
3333
+ error.recommend_pre_click_node_id = positiveNodeId(nodeId);
3334
+ if (!error.phase) error.phase = stage;
3335
+ return error;
3336
+ }
3337
+
3338
+ function createRecommendPreClickCardUnavailableError(binding = null, {
3339
+ nodeId = null,
3340
+ bindingStage = "card_binding_before_click"
3341
+ } = {}) {
3342
+ const reason = binding?.reason || "card_node_not_visible_or_stale";
3343
+ const error = new Error(
3344
+ `Could not find node with given id during ${bindingStage}: ${reason}`
3345
+ );
3346
+ error.code = "RECOMMEND_PRE_CLICK_CARD_UNAVAILABLE";
3347
+ error.detail_candidate_binding = binding;
3348
+ error.recommend_pre_click_binding_stage = bindingStage;
3349
+ return markRecommendPreClickStaleNoAction(error, {
3350
+ nodeId,
3351
+ stage: "pre_click_card_box"
3352
+ });
3353
+ }
3354
+
3355
+ function markRecommendPostInputOutcomeUnknown(error, {
3356
+ stage = "post_card_click",
3357
+ clickAttempts = null
3358
+ } = {}) {
3359
+ if (!error) return error;
3360
+ error.recommend_click_dispatched = true;
3361
+ error.recommend_input_dispatched = true;
3362
+ error.recommend_post_input_outcome_unknown = true;
3363
+ error.recommend_no_click_dispatched = false;
3364
+ error.recommend_post_input_stage = stage;
3365
+ if (!error.phase) error.phase = stage;
3366
+ if (Array.isArray(clickAttempts) && !Array.isArray(error.click_attempts)) {
3367
+ error.click_attempts = clickAttempts;
3368
+ }
3369
+ return error;
3370
+ }
3371
+
404
3372
  export function isRecommendDetailOpenMissError(error) {
405
3373
  const message = String(error?.message || error || "");
406
3374
  return isRecommendAvatarPreviewOpenError(error)
407
3375
  || /Candidate detail did not open|no known detail selectors mounted/i.test(message);
408
3376
  }
409
3377
 
410
- export function resolveRecommendCardDetailClickPoint(cardBox, {
411
- attemptIndex = 0
412
- } = {}) {
3378
+ export function resolveRecommendCardDetailClickPoint(cardBox, {
3379
+ attemptIndex = 0
3380
+ } = {}) {
413
3381
  const rect = cardBox?.rect || {};
414
3382
  const width = Number(rect.width) || 0;
415
3383
  const height = Number(rect.height) || 0;
@@ -435,13 +3403,387 @@ export function resolveRecommendCardDetailClickPoint(cardBox, {
435
3403
  attempt_index: attemptIndex,
436
3404
  offset_x: Math.round(offsetX),
437
3405
  offset_y: Math.round(offsetY)
438
- };
439
- }
440
-
441
- async function clickRecommendCardDetailPoint(client, nodeId, {
442
- scrollIntoView = true,
443
- attemptIndex = 0
444
- } = {}) {
3406
+ };
3407
+ }
3408
+
3409
+ function resolveRecommendCardDetailClickPointCandidates(cardBox, {
3410
+ attemptIndex = 0
3411
+ } = {}) {
3412
+ const primary = resolveRecommendCardDetailClickPoint(cardBox, { attemptIndex });
3413
+ const rect = cardBox?.rect || {};
3414
+ const height = Number(rect.height) || 0;
3415
+ if (height <= 2) {
3416
+ return [{
3417
+ ...primary,
3418
+ x: Math.round(Number(primary.x) || 0),
3419
+ y: Math.round(Number(primary.y) || 0),
3420
+ hit_test_candidate_index: 0
3421
+ }];
3422
+ }
3423
+ const minOffsetY = Math.min(4, height / 2);
3424
+ const maxOffsetY = Math.max(minOffsetY, height - 4);
3425
+ const offsets = [
3426
+ Number(primary.offset_y),
3427
+ height * 0.55,
3428
+ height * 0.82
3429
+ ];
3430
+ const candidates = [];
3431
+ const seen = new Set();
3432
+ for (const rawOffset of offsets) {
3433
+ const offsetY = clampPointCoordinate(rawOffset, minOffsetY, maxOffsetY);
3434
+ const point = {
3435
+ ...primary,
3436
+ x: Math.round(Number(primary.x) || 0),
3437
+ y: Math.round((Number(rect.y) || 0) + offsetY),
3438
+ offset_y: Math.round(offsetY),
3439
+ mode: candidates.length === 0
3440
+ ? primary.mode
3441
+ : "card-body-safe-point-hit-test-alternate",
3442
+ hit_test_candidate_index: candidates.length
3443
+ };
3444
+ const signature = `${point.x}:${point.y}`;
3445
+ if (seen.has(signature)) continue;
3446
+ seen.add(signature);
3447
+ candidates.push(point);
3448
+ }
3449
+ return candidates;
3450
+ }
3451
+
3452
+ const RECOMMEND_UNSAFE_CARD_CLICK_TAGS = new Set([
3453
+ "A",
3454
+ "BUTTON",
3455
+ "INPUT",
3456
+ "TEXTAREA",
3457
+ "SELECT",
3458
+ "OPTION",
3459
+ "LABEL",
3460
+ "IMG",
3461
+ "SVG"
3462
+ ]);
3463
+ const RECOMMEND_UNSAFE_CARD_CLICK_ATTRIBUTE_PATTERN = /(?:^|[\s_-])(?:avatar|portrait|head[-_]?img|action|operate|operation|btn|button|check(?:box)?|more|menu|favorite|collect|like|close|icon)(?:$|[\s_-])/i;
3464
+ const RECOMMEND_UNSAFE_CARD_CLICK_SELECTOR = [
3465
+ "a",
3466
+ "button",
3467
+ "input",
3468
+ "textarea",
3469
+ "select",
3470
+ "option",
3471
+ "label",
3472
+ "img",
3473
+ "svg",
3474
+ '[role="button"]',
3475
+ '[class*="avatar"]',
3476
+ '[class*="portrait"]',
3477
+ '[class*="head-img"]',
3478
+ '[class*="head_img"]',
3479
+ '[class*="action"]',
3480
+ '[class*="operate"]',
3481
+ '[class*="operation"]',
3482
+ '[class*="btn"]',
3483
+ '[class*="button"]',
3484
+ '[class*="checkbox"]',
3485
+ '[class*="check-box"]',
3486
+ '[class*="more"]',
3487
+ '[class*="menu"]',
3488
+ '[class*="favorite"]',
3489
+ '[class*="collect"]',
3490
+ '[class*="like"]',
3491
+ '[class*="close"]'
3492
+ ].join(", ");
3493
+
3494
+ function compactDomNodeAttributes(attributes = []) {
3495
+ const values = [];
3496
+ for (let index = 0; index < attributes.length; index += 2) {
3497
+ const name = String(attributes[index] || "").trim();
3498
+ const value = String(attributes[index + 1] || "").trim();
3499
+ if (/^(?:class|id|role|aria-label|data-testid)$/i.test(name) && value) {
3500
+ values.push(`${name}=${value}`);
3501
+ }
3502
+ }
3503
+ return values.join(" ");
3504
+ }
3505
+
3506
+ async function readRecommendCardClickTargetSafety(client, hitNodeId, cardNodeId, {
3507
+ unsafeNodeIds = new Set()
3508
+ } = {}) {
3509
+ const hit = positiveNodeId(hitNodeId);
3510
+ const card = positiveNodeId(cardNodeId);
3511
+ if (!hit || !card) {
3512
+ return {
3513
+ verified: false,
3514
+ safe: false,
3515
+ reason: "card_click_hit_identity_missing",
3516
+ path: []
3517
+ };
3518
+ }
3519
+ if (hit === card) {
3520
+ return {
3521
+ verified: true,
3522
+ safe: true,
3523
+ reason: null,
3524
+ path: []
3525
+ };
3526
+ }
3527
+ const node = await describeNode(client, hit, { depth: 0, pierce: true });
3528
+ const nodeName = String(node?.nodeName || "").toUpperCase();
3529
+ const attributeText = compactDomNodeAttributes(node?.attributes || []);
3530
+ const unsafe = unsafeNodeIds.has(hit)
3531
+ || RECOMMEND_UNSAFE_CARD_CLICK_TAGS.has(nodeName)
3532
+ || RECOMMEND_UNSAFE_CARD_CLICK_ATTRIBUTE_PATTERN.test(attributeText);
3533
+ const path = [{
3534
+ node_id: hit,
3535
+ backend_node_id: positiveNodeId(node?.backendNodeId),
3536
+ node_name: nodeName || null,
3537
+ attributes: attributeText || null,
3538
+ unsafe
3539
+ }];
3540
+ if (unsafe) {
3541
+ return {
3542
+ verified: true,
3543
+ safe: false,
3544
+ reason: "card_click_point_unsafe_interactive_target",
3545
+ path
3546
+ };
3547
+ }
3548
+ return {
3549
+ verified: true,
3550
+ safe: true,
3551
+ reason: null,
3552
+ path
3553
+ };
3554
+ }
3555
+
3556
+ async function readRecommendCardClickHitTestEvidence(client, nodeId, cardBox, {
3557
+ attemptIndex = 0,
3558
+ viewport = null
3559
+ } = {}) {
3560
+ if (typeof client?.DOM?.getNodeForLocation !== "function") {
3561
+ return {
3562
+ completed: false,
3563
+ exact_card_hit_verified: false,
3564
+ reason: "card_click_hit_test_unavailable",
3565
+ selected: null,
3566
+ attempts: []
3567
+ };
3568
+ }
3569
+ let descendantNodeIds;
3570
+ let unsafeNodeIds;
3571
+ try {
3572
+ descendantNodeIds = new Set([
3573
+ positiveNodeId(nodeId),
3574
+ ...(await querySelectorAll(client, nodeId, "*")).map(positiveNodeId)
3575
+ ].filter(Boolean));
3576
+ unsafeNodeIds = new Set();
3577
+ const unsafeRoots = (await querySelectorAll(
3578
+ client,
3579
+ nodeId,
3580
+ RECOMMEND_UNSAFE_CARD_CLICK_SELECTOR
3581
+ )).map(positiveNodeId).filter(Boolean);
3582
+ for (const unsafeRootNodeId of unsafeRoots.slice(0, 48)) {
3583
+ unsafeNodeIds.add(unsafeRootNodeId);
3584
+ for (const unsafeDescendantNodeId of (
3585
+ await querySelectorAll(client, unsafeRootNodeId, "*")
3586
+ ).map(positiveNodeId).filter(Boolean).slice(0, 96)) {
3587
+ unsafeNodeIds.add(unsafeDescendantNodeId);
3588
+ }
3589
+ }
3590
+ } catch (error) {
3591
+ if (isStaleRecommendNodeError(error)) {
3592
+ throw markRecommendPreClickStaleNoAction(error, {
3593
+ nodeId,
3594
+ stage: "pre_click_card_hit_test"
3595
+ });
3596
+ }
3597
+ throw error;
3598
+ }
3599
+ const width = Number(viewport?.width || 0);
3600
+ const height = Number(viewport?.height || 0);
3601
+ const margin = Math.max(0, Number(viewport?.margin_px) || 0);
3602
+ const attempts = [];
3603
+ for (const point of resolveRecommendCardDetailClickPointCandidates(cardBox, { attemptIndex })) {
3604
+ const insideViewport = Boolean(
3605
+ Number.isFinite(point.x)
3606
+ && Number.isFinite(point.y)
3607
+ && point.x >= margin
3608
+ && point.x <= width - margin
3609
+ && point.y >= margin
3610
+ && point.y <= height - margin
3611
+ );
3612
+ if (!insideViewport) {
3613
+ attempts.push({
3614
+ point,
3615
+ inside_viewport: false,
3616
+ exact_card_hit: false,
3617
+ hit_node_id: null,
3618
+ hit_backend_node_id: null,
3619
+ reason: "card_click_point_outside_viewport"
3620
+ });
3621
+ continue;
3622
+ }
3623
+ const hit = await client.DOM.getNodeForLocation({
3624
+ x: Math.round(point.x),
3625
+ y: Math.round(point.y),
3626
+ includeUserAgentShadowDOM: true
3627
+ });
3628
+ const hitNodeId = positiveNodeId(hit?.nodeId);
3629
+ const exactCardHit = Boolean(hitNodeId && descendantNodeIds.has(hitNodeId));
3630
+ const targetSafety = exactCardHit
3631
+ ? await readRecommendCardClickTargetSafety(client, hitNodeId, nodeId, {
3632
+ unsafeNodeIds
3633
+ })
3634
+ : null;
3635
+ const safeCardBodyHit = Boolean(
3636
+ exactCardHit
3637
+ && targetSafety?.verified === true
3638
+ && targetSafety?.safe === true
3639
+ );
3640
+ const evidence = {
3641
+ point,
3642
+ inside_viewport: true,
3643
+ exact_card_hit: exactCardHit,
3644
+ safe_card_hit: safeCardBodyHit,
3645
+ safe_card_body_hit: safeCardBodyHit,
3646
+ hit_node_id: hitNodeId,
3647
+ hit_node_name: targetSafety?.path?.[0]?.node_name || null,
3648
+ hit_backend_node_id: positiveNodeId(hit?.backendNodeId),
3649
+ hit_frame_id: hit?.frameId || null,
3650
+ target_safety: targetSafety,
3651
+ reason: safeCardBodyHit
3652
+ ? null
3653
+ : exactCardHit
3654
+ ? targetSafety?.reason || "card_click_point_not_safe_card_body"
3655
+ : "card_click_point_not_owned_by_exact_card"
3656
+ };
3657
+ attempts.push(evidence);
3658
+ if (safeCardBodyHit) {
3659
+ return {
3660
+ completed: true,
3661
+ exact_card_hit_verified: true,
3662
+ reason: null,
3663
+ selected: point,
3664
+ descendant_count: descendantNodeIds.size,
3665
+ unsafe_descendant_count: unsafeNodeIds.size,
3666
+ attempts
3667
+ };
3668
+ }
3669
+ }
3670
+ return {
3671
+ completed: true,
3672
+ exact_card_hit_verified: false,
3673
+ reason: attempts.find((attempt) => attempt.inside_viewport)?.reason
3674
+ || (attempts.some((attempt) => attempt.inside_viewport)
3675
+ ? "card_click_point_not_owned_by_exact_card"
3676
+ : "card_click_point_outside_viewport"),
3677
+ selected: null,
3678
+ descendant_count: descendantNodeIds.size,
3679
+ unsafe_descendant_count: unsafeNodeIds.size,
3680
+ attempts
3681
+ };
3682
+ }
3683
+
3684
+ export async function readRecommendCardClickViewportEvidence(client, nodeId, {
3685
+ attemptIndex = 0,
3686
+ marginPx = 4
3687
+ } = {}) {
3688
+ let box;
3689
+ try {
3690
+ box = await getNodeBox(client, nodeId);
3691
+ } catch (error) {
3692
+ throw markRecommendPreClickStaleNoAction(error, {
3693
+ nodeId,
3694
+ stage: "pre_click_card_box"
3695
+ });
3696
+ }
3697
+ const fallbackClickTarget = resolveRecommendCardDetailClickPoint(box, { attemptIndex });
3698
+ let metrics;
3699
+ try {
3700
+ metrics = typeof client?.Page?.getLayoutMetrics === "function"
3701
+ ? await client.Page.getLayoutMetrics()
3702
+ : null;
3703
+ } catch (error) {
3704
+ // Keep raw Page/session failures generic. They must never be promoted to
3705
+ // the narrow candidate-local all-pre-click-stale disposition.
3706
+ throw error;
3707
+ }
3708
+ const viewport = metrics?.cssVisualViewport
3709
+ || metrics?.visualViewport
3710
+ || metrics?.cssLayoutViewport
3711
+ || metrics?.layoutViewport
3712
+ || null;
3713
+ const width = Number(viewport?.clientWidth || viewport?.width || 0);
3714
+ const height = Number(viewport?.clientHeight || viewport?.height || 0);
3715
+ const margin = Math.max(0, Number(marginPx) || 0);
3716
+ const metricsVerified = Boolean(
3717
+ Number.isFinite(width)
3718
+ && width > margin * 2
3719
+ && Number.isFinite(height)
3720
+ && height > margin * 2
3721
+ );
3722
+ const viewportEvidence = {
3723
+ width,
3724
+ height,
3725
+ margin_px: margin,
3726
+ source: metrics?.cssVisualViewport
3727
+ ? "cssVisualViewport"
3728
+ : metrics?.visualViewport
3729
+ ? "visualViewport"
3730
+ : metrics?.cssLayoutViewport
3731
+ ? "cssLayoutViewport"
3732
+ : metrics?.layoutViewport
3733
+ ? "layoutViewport"
3734
+ : null
3735
+ };
3736
+ const hitTest = metricsVerified
3737
+ ? await readRecommendCardClickHitTestEvidence(client, nodeId, box, {
3738
+ attemptIndex,
3739
+ viewport: viewportEvidence
3740
+ })
3741
+ : {
3742
+ completed: false,
3743
+ exact_card_hit_verified: false,
3744
+ reason: "card_click_viewport_metrics_missing",
3745
+ selected: null,
3746
+ attempts: []
3747
+ };
3748
+ const pointVerified = Boolean(
3749
+ metricsVerified
3750
+ && hitTest.completed === true
3751
+ && hitTest.exact_card_hit_verified === true
3752
+ && hitTest.selected
3753
+ );
3754
+ const clickTarget = hitTest.selected || fallbackClickTarget;
3755
+ return {
3756
+ verified: metricsVerified && hitTest.completed === true,
3757
+ in_viewport: pointVerified,
3758
+ reason: !metricsVerified
3759
+ ? "card_click_viewport_metrics_missing"
3760
+ : pointVerified
3761
+ ? null
3762
+ : hitTest.reason || "card_click_point_not_owned_by_exact_card",
3763
+ node_id: positiveNodeId(nodeId),
3764
+ box,
3765
+ click_target: clickTarget,
3766
+ hit_test: hitTest,
3767
+ viewport: viewportEvidence
3768
+ };
3769
+ }
3770
+
3771
+ function createRecommendCardClickViewportProofError(evidence, reason = "card_click_viewport_unverified") {
3772
+ const error = new Error(`Recommend card click viewport proof failed: ${reason}`);
3773
+ error.code = "RECOMMEND_CARD_CLICK_VIEWPORT_UNVERIFIED";
3774
+ error.phase = "pre_click_card_viewport";
3775
+ error.recommend_card_click_viewport = evidence || null;
3776
+ error.recommend_no_click_dispatched = true;
3777
+ error.recommend_click_dispatched = false;
3778
+ error.recommend_input_dispatched = false;
3779
+ return error;
3780
+ }
3781
+
3782
+ async function clickRecommendCardDetailPoint(client, nodeId, {
3783
+ scrollIntoView = true,
3784
+ attemptIndex = 0,
3785
+ preverifiedCardBox = null
3786
+ } = {}) {
445
3787
  if (scrollIntoView) {
446
3788
  try {
447
3789
  await scrollNodeIntoView(client, nodeId);
@@ -451,15 +3793,43 @@ async function clickRecommendCardDetailPoint(client, nodeId, {
451
3793
  // helper races the virtual list, let the box lookup/retry decide.
452
3794
  }
453
3795
  }
454
- const box = await getNodeBox(client, nodeId);
455
- const clickTarget = resolveRecommendCardDetailClickPoint(box, { attemptIndex });
456
- const clickResult = await clickPoint(client, clickTarget.x, clickTarget.y, DETERMINISTIC_CLICK_OPTIONS);
457
- return {
458
- ...box,
459
- click_target: clickTarget,
460
- click_result: clickResult
461
- };
462
- }
3796
+ // Re-run geometry plus native hit-testing immediately before Input. A box
3797
+ // can be inside the numeric viewport while its nominal point is covered by
3798
+ // BOSS's fixed filter header. Only a point whose topmost hit node belongs
3799
+ // to this exact card (or one of its descendants) is authorized.
3800
+ const clickViewport = await readRecommendCardClickViewportEvidence(client, nodeId, {
3801
+ attemptIndex
3802
+ });
3803
+ if (!clickViewport.verified || !clickViewport.in_viewport) {
3804
+ throw createRecommendCardClickViewportProofError(
3805
+ clickViewport,
3806
+ clickViewport.reason || "card_click_hit_test_unverified"
3807
+ );
3808
+ }
3809
+ const box = clickViewport.box || preverifiedCardBox;
3810
+ const clickTarget = clickViewport.click_target;
3811
+ let clickResult;
3812
+ try {
3813
+ clickResult = await clickPoint(
3814
+ client,
3815
+ clickTarget.x,
3816
+ clickTarget.y,
3817
+ DETERMINISTIC_CLICK_OPTIONS
3818
+ );
3819
+ } catch (error) {
3820
+ // Once clickPoint is entered, at least one Input command may have reached
3821
+ // Chrome. Treat every failure as outcome-unknown and never replay it.
3822
+ throw markRecommendPostInputOutcomeUnknown(error, {
3823
+ stage: "card_click_input"
3824
+ });
3825
+ }
3826
+ return {
3827
+ ...box,
3828
+ click_target: clickTarget,
3829
+ click_result: clickResult,
3830
+ click_viewport: clickViewport
3831
+ };
3832
+ }
463
3833
 
464
3834
  async function waitForRecommendDetailOpenOutcome(client, {
465
3835
  timeoutMs = 10000,
@@ -495,13 +3865,15 @@ async function waitForRecommendDetailOpenOutcome(client, {
495
3865
  };
496
3866
  }
497
3867
 
498
- function makeRecommendAvatarPreviewOpenedError(outcome, clickAttempts = []) {
499
- const error = new Error("RECOMMEND_AVATAR_PREVIEW_OPENED: candidate avatar preview opened instead of resume detail");
500
- error.code = "RECOMMEND_AVATAR_PREVIEW_OPENED";
501
- error.avatar_preview = outcome?.avatar_preview || null;
502
- error.click_attempts = clickAttempts;
503
- return error;
504
- }
3868
+ function makeRecommendAvatarPreviewOpenedError(outcome, clickAttempts = []) {
3869
+ const error = new Error("RECOMMEND_AVATAR_PREVIEW_OPENED: candidate avatar preview opened instead of resume detail");
3870
+ error.code = "RECOMMEND_AVATAR_PREVIEW_OPENED";
3871
+ error.avatar_preview = outcome?.avatar_preview || null;
3872
+ error.click_attempts = clickAttempts;
3873
+ error.recommend_click_dispatched = clickAttempts.length > 0;
3874
+ error.recommend_input_dispatched = clickAttempts.length > 0;
3875
+ return error;
3876
+ }
505
3877
 
506
3878
  export async function findRecommendCardNodeForCandidateKey(client, {
507
3879
  candidateKey = "",
@@ -571,9 +3943,10 @@ export async function findRecommendCardNodeForCandidateKey(client, {
571
3943
  card_count: nodeIds.length
572
3944
  };
573
3945
  }
574
- } catch (error) {
575
- lastError = error;
576
- }
3946
+ } catch (error) {
3947
+ lastError = error;
3948
+ if (shouldRethrowRecommendProtocolError(error)) throw error;
3949
+ }
577
3950
  }
578
3951
 
579
3952
  if (intervalMs > 0) await sleep(intervalMs);
@@ -589,13 +3962,17 @@ export async function findRecommendCardNodeForCandidateKey(client, {
589
3962
  };
590
3963
  }
591
3964
 
592
- export async function openRecommendCardDetail(client, cardNodeId, {
593
- timeoutMs = 12000,
594
- scrollIntoView = true
595
- } = {}) {
596
- const started = Date.now();
597
- const clickAttempts = [];
598
- const maxClickAttempts = 3;
3965
+ export async function openRecommendCardDetail(client, cardNodeId, {
3966
+ timeoutMs = 12000,
3967
+ scrollIntoView = true,
3968
+ preverifiedCardBox = null
3969
+ } = {}) {
3970
+ const started = Date.now();
3971
+ const clickAttempts = [];
3972
+ // One fully proven card node authorizes exactly one irreversible Input
3973
+ // sequence. Any retry belongs to the outer exact-key reacquire loop, which
3974
+ // reruns candidate/root/backend provenance before another click.
3975
+ const maxClickAttempts = 1;
599
3976
  let lastOutcome = null;
600
3977
  let lastCardBox = null;
601
3978
  let candidateClickMs = 0;
@@ -603,24 +3980,40 @@ export async function openRecommendCardDetail(client, cardNodeId, {
603
3980
 
604
3981
  for (let attemptIndex = 0; attemptIndex < maxClickAttempts; attemptIndex += 1) {
605
3982
  const clickStarted = Date.now();
606
- lastCardBox = await clickRecommendCardDetailPoint(client, cardNodeId, {
607
- scrollIntoView: attemptIndex === 0 ? scrollIntoView : false,
608
- attemptIndex
609
- });
610
- candidateClickMs += Date.now() - clickStarted;
611
- const detailStarted = Date.now();
612
- lastOutcome = await waitForRecommendDetailOpenOutcome(client, {
613
- timeoutMs: attemptIndex === 0 ? timeoutMs : Math.max(2500, Math.floor(timeoutMs / 3)),
614
- intervalMs: 250
615
- });
616
- detailOpenMs += Date.now() - detailStarted;
617
- clickAttempts.push({
618
- attempt: attemptIndex + 1,
619
- click_target: lastCardBox.click_target,
620
- click_result: lastCardBox.click_result,
621
- outcome: lastOutcome.kind,
622
- elapsed_ms: lastOutcome.elapsed_ms
623
- });
3983
+ lastCardBox = await clickRecommendCardDetailPoint(client, cardNodeId, {
3984
+ scrollIntoView: attemptIndex === 0 ? scrollIntoView : false,
3985
+ attemptIndex,
3986
+ preverifiedCardBox: attemptIndex === 0 ? preverifiedCardBox : null
3987
+ });
3988
+ candidateClickMs += Date.now() - clickStarted;
3989
+ const clickAttempt = {
3990
+ attempt: attemptIndex + 1,
3991
+ click_target: lastCardBox.click_target,
3992
+ click_result: lastCardBox.click_result,
3993
+ input_dispatched: true,
3994
+ outcome: "pending",
3995
+ elapsed_ms: null
3996
+ };
3997
+ // Persist the irreversible fact before any detail-state polling. A stale
3998
+ // read after this point must never authorize another click.
3999
+ clickAttempts.push(clickAttempt);
4000
+ const detailStarted = Date.now();
4001
+ try {
4002
+ lastOutcome = await waitForRecommendDetailOpenOutcome(client, {
4003
+ timeoutMs: attemptIndex === 0 ? timeoutMs : Math.max(2500, Math.floor(timeoutMs / 3)),
4004
+ intervalMs: 250
4005
+ });
4006
+ } catch (error) {
4007
+ clickAttempt.outcome = "detail_state_poll_failed";
4008
+ clickAttempt.elapsed_ms = Date.now() - detailStarted;
4009
+ throw markRecommendPostInputOutcomeUnknown(error, {
4010
+ stage: "post_card_click_detail_poll",
4011
+ clickAttempts
4012
+ });
4013
+ }
4014
+ detailOpenMs += Date.now() - detailStarted;
4015
+ clickAttempt.outcome = lastOutcome.kind;
4016
+ clickAttempt.elapsed_ms = lastOutcome.elapsed_ms;
624
4017
 
625
4018
  if (lastOutcome.kind === "detail") {
626
4019
  return {
@@ -645,13 +4038,55 @@ export async function openRecommendCardDetail(client, cardNodeId, {
645
4038
  if (lastOutcome?.kind === "avatar_preview") {
646
4039
  throw makeRecommendAvatarPreviewOpenedError(lastOutcome, clickAttempts);
647
4040
  }
648
- const error = new Error("Candidate detail did not open or no known detail selectors mounted");
649
- error.click_attempts = clickAttempts;
650
- error.last_open_outcome = lastOutcome;
651
- throw error;
652
- }
653
-
654
- export async function openRecommendCardDetailWithFreshRetry(client, {
4041
+ const error = new Error("Candidate detail did not open or no known detail selectors mounted");
4042
+ error.click_attempts = clickAttempts;
4043
+ error.last_open_outcome = lastOutcome;
4044
+ error.recommend_click_dispatched = clickAttempts.length > 0;
4045
+ error.recommend_input_dispatched = clickAttempts.length > 0;
4046
+ // A completed polling window with kind=none is an exact negative outcome,
4047
+ // not an unknown transport result. The outer loop may reacquire/reprove the
4048
+ // candidate before one further click.
4049
+ error.recommend_click_negative_outcome_observed = Boolean(
4050
+ clickAttempts.length > 0 && lastOutcome?.kind === "none"
4051
+ );
4052
+ error.recommend_post_input_outcome_unknown = false;
4053
+ throw error;
4054
+ }
4055
+
4056
+ function attachRecommendDetailOpenRetryEvidence(error, attempts, {
4057
+ retryExhausted = false,
4058
+ reacquireFailed = false,
4059
+ expectedAttemptCount = null
4060
+ } = {}) {
4061
+ const retrySummary = summarizeRecommendPreClickRetryAttempts(attempts);
4062
+ const expectedCount = Math.max(1, Number(expectedAttemptCount) || 1);
4063
+ const candidateLocalExhaustion = Boolean(
4064
+ retryExhausted === true
4065
+ && reacquireFailed !== true
4066
+ && retrySummary.attempt_count === expectedCount
4067
+ && retrySummary.all_pre_click_stale_no_action
4068
+ );
4069
+ error.recommend_detail_open_attempts = attempts;
4070
+ error.recommend_pre_click_retry = {
4071
+ ...retrySummary,
4072
+ retry_exhausted: retryExhausted === true,
4073
+ reacquire_failed: reacquireFailed === true,
4074
+ expected_attempt_count: expectedCount,
4075
+ candidate_local_exhaustion: candidateLocalExhaustion
4076
+ };
4077
+ if (candidateLocalExhaustion) {
4078
+ error.recommend_pre_click_stale_no_action = true;
4079
+ error.recommend_no_click_dispatched = true;
4080
+ error.recommend_click_dispatched = false;
4081
+ error.recommend_input_dispatched = false;
4082
+ error.recommend_pre_click_stage = "pre_click_card_box";
4083
+ error.recommend_pre_click_retry_exhausted = true;
4084
+ error.recommend_pre_click_reacquire_failed = false;
4085
+ }
4086
+ return error;
4087
+ }
4088
+
4089
+ export async function openRecommendCardDetailWithFreshRetry(client, {
655
4090
  cardNodeId,
656
4091
  candidateKey = "",
657
4092
  cardCandidate = null,
@@ -659,42 +4094,503 @@ export async function openRecommendCardDetailWithFreshRetry(client, {
659
4094
  targetUrl = "",
660
4095
  timeoutMs = 12000,
661
4096
  scrollIntoView = true,
662
- retryTimeoutMs = 5000,
663
- retryIntervalMs = 250,
664
- maxAttempts = 2
665
- } = {}) {
4097
+ retryTimeoutMs = 5000,
4098
+ retryIntervalMs = 250,
4099
+ bindingTimeoutMs = 5000,
4100
+ bindingIntervalMs = 200,
4101
+ bindingMaxAttempts = 20,
4102
+ acceptScreeningBinding = false,
4103
+ maxAttempts = 2
4104
+ } = {}) {
666
4105
  let currentNodeId = cardNodeId;
667
- let currentCandidate = cardCandidate;
668
- let currentRootState = rootState;
669
- const attempts = [];
670
- const limit = Math.max(1, Number(maxAttempts) || 1);
671
-
672
- for (let attemptIndex = 0; attemptIndex < limit; attemptIndex += 1) {
673
- try {
674
- const opened = await openRecommendCardDetail(client, currentNodeId, {
675
- timeoutMs,
676
- scrollIntoView
677
- });
678
- return {
679
- ...opened,
680
- card_node_id: currentNodeId,
681
- card_candidate: currentCandidate,
682
- retry_attempts: attempts
683
- };
684
- } catch (error) {
685
- const stale = isStaleRecommendNodeError(error);
686
- const detailOpenMiss = isRecommendDetailOpenMissError(error);
687
- attempts.push({
688
- attempt: attemptIndex + 1,
689
- node_id: currentNodeId,
690
- stale_node: stale,
691
- detail_open_miss: detailOpenMiss,
692
- error: error?.message || String(error)
693
- });
694
- if ((!stale && !detailOpenMiss) || attemptIndex >= limit - 1 || !candidateKey) {
695
- error.recommend_detail_open_attempts = attempts;
696
- throw error;
697
- }
4106
+ let currentCandidate = cardCandidate;
4107
+ let currentRootState = rootState;
4108
+ const attempts = [];
4109
+ const cumulativeClickAttempts = [];
4110
+ const limit = Math.max(1, Number(maxAttempts) || 1);
4111
+
4112
+ for (let attemptIndex = 0; attemptIndex < limit; attemptIndex += 1) {
4113
+ let exactCandidateProvenanceVerified = false;
4114
+ let inputDispatchedWithinAttempt = false;
4115
+ let clickAttemptsRecordedWithinAttempt = false;
4116
+ let viewportPreparation = null;
4117
+ try {
4118
+ const initialViewport = await readRecommendCardClickViewportEvidence(
4119
+ client,
4120
+ currentNodeId,
4121
+ { attemptIndex: 0 }
4122
+ );
4123
+ viewportPreparation = {
4124
+ initial: initialViewport,
4125
+ scrolled: false,
4126
+ reacquire: null,
4127
+ final: null
4128
+ };
4129
+ if (!initialViewport.verified) {
4130
+ throw createRecommendCardClickViewportProofError(
4131
+ initialViewport,
4132
+ initialViewport.reason || "initial_card_click_viewport_unverified"
4133
+ );
4134
+ }
4135
+ if (!initialViewport.in_viewport) {
4136
+ if (!scrollIntoView || !candidateKey) {
4137
+ throw createRecommendCardClickViewportProofError(
4138
+ initialViewport,
4139
+ !scrollIntoView
4140
+ ? "card_click_point_outside_viewport_and_scroll_disabled"
4141
+ : "candidate_key_required_for_post_scroll_reacquire"
4142
+ );
4143
+ }
4144
+
4145
+ // Scrolling is preparation only. The old node is never clicked:
4146
+ // Boss may remount its virtual list while the scroll settles.
4147
+ await scrollNodeIntoView(client, currentNodeId);
4148
+ await sleep(150);
4149
+ viewportPreparation.scrolled = true;
4150
+ const postScrollResolved = await findRecommendCardNodeForCandidateKey(client, {
4151
+ candidateKey,
4152
+ rootState: currentRootState,
4153
+ targetUrl,
4154
+ source: "recommend-run-card-post-scroll-reacquire",
4155
+ timeoutMs: retryTimeoutMs,
4156
+ intervalMs: retryIntervalMs
4157
+ });
4158
+ viewportPreparation.reacquire = {
4159
+ ok: Boolean(postScrollResolved.ok),
4160
+ node_id: positiveNodeId(postScrollResolved.node_id),
4161
+ visible_index: postScrollResolved.visible_index ?? null,
4162
+ card_count: postScrollResolved.card_count || postScrollResolved.last_card_count || 0,
4163
+ candidate_key: postScrollResolved.key || null,
4164
+ reason: postScrollResolved.reason || null,
4165
+ error: postScrollResolved.error || null
4166
+ };
4167
+ if (
4168
+ !postScrollResolved.ok
4169
+ || !postScrollResolved.node_id
4170
+ || postScrollResolved.key !== candidateKey
4171
+ ) {
4172
+ throw createRecommendDetailCandidateBindingError({
4173
+ schema_version: 1,
4174
+ verified: false,
4175
+ reason: "card_not_exactly_reacquired_after_pre_scroll",
4176
+ method: null,
4177
+ expected_candidate_id: normalizeBindingText(currentCandidate?.id) || null,
4178
+ expected_name: normalizeBindingText(currentCandidate?.identity?.name) || null,
4179
+ card: {
4180
+ stable: false,
4181
+ before: null,
4182
+ after: null,
4183
+ candidate_id: normalizeBindingText(postScrollResolved.candidate?.id) || null,
4184
+ name: normalizeBindingText(postScrollResolved.candidate?.identity?.name) || null,
4185
+ reason: "card_not_exactly_reacquired_after_pre_scroll"
4186
+ },
4187
+ detail: null
4188
+ });
4189
+ }
4190
+ currentNodeId = postScrollResolved.node_id;
4191
+ currentCandidate = postScrollResolved.candidate || currentCandidate;
4192
+ currentRootState = postScrollResolved.root_state || null;
4193
+ }
4194
+
4195
+ // Snapshot detail roots only after any scroll/remount and exact-key
4196
+ // reacquire, then rerun full candidate/root/backend provenance.
4197
+ // Keep the pre-click detail snapshot in the same frontend-node tree as
4198
+ // the exact card/root proof below. Fetching another document root here
4199
+ // can replace frontend node ids even while backend identity is stable.
4200
+ const detailRootsBefore = await readRecommendDetailRootsBeforeClick(client, {
4201
+ rootState: currentRootState
4202
+ });
4203
+ let cardEvidenceBefore = await readRecommendCardBindingEvidence(
4204
+ client,
4205
+ currentNodeId,
4206
+ currentCandidate
4207
+ );
4208
+ if (!cardEvidenceBefore.verified) {
4209
+ if (cardEvidenceBefore.reason === "card_node_not_visible_or_stale") {
4210
+ throw createRecommendPreClickCardUnavailableError(cardEvidenceBefore, {
4211
+ nodeId: currentNodeId,
4212
+ bindingStage: "card_binding_before_click"
4213
+ });
4214
+ }
4215
+ throw createRecommendDetailCandidateBindingError({
4216
+ schema_version: 1,
4217
+ verified: false,
4218
+ reason: cardEvidenceBefore.reason || "card_identity_not_verified_before_click",
4219
+ method: null,
4220
+ expected_candidate_id: normalizeBindingText(currentCandidate?.id) || null,
4221
+ expected_name: normalizeBindingText(currentCandidate?.identity?.name) || null,
4222
+ card: {
4223
+ stable: false,
4224
+ before: compactBindingNode(cardEvidenceBefore),
4225
+ after: null,
4226
+ candidate_id: cardEvidenceBefore.candidate_id || null,
4227
+ name: cardEvidenceBefore.name || null,
4228
+ reason: cardEvidenceBefore.reason || null
4229
+ },
4230
+ detail: null
4231
+ });
4232
+ }
4233
+ let cardPreClickProvenance = await readRecommendCardPreClickProvenance(client, {
4234
+ cardNodeId: currentNodeId,
4235
+ cardCandidate: currentCandidate,
4236
+ rootState: currentRootState,
4237
+ cardEvidence: cardEvidenceBefore
4238
+ });
4239
+ if (!cardPreClickProvenance.verified) {
4240
+ throw createRecommendDetailCandidateBindingError({
4241
+ schema_version: 1,
4242
+ verified: false,
4243
+ reason: cardPreClickProvenance.reason || "card_pre_click_provenance_unverified",
4244
+ method: null,
4245
+ expected_candidate_id: normalizeBindingText(currentCandidate?.id) || null,
4246
+ expected_name: normalizeBindingText(currentCandidate?.identity?.name) || null,
4247
+ card: {
4248
+ stable: false,
4249
+ disappeared_after_click: false,
4250
+ before: compactBindingNode(cardEvidenceBefore),
4251
+ after: null,
4252
+ candidate_id: cardEvidenceBefore.candidate_id || null,
4253
+ name: cardEvidenceBefore.name || null,
4254
+ reason: cardPreClickProvenance.reason || null,
4255
+ pre_click_provenance: compactRecommendCardPreClickProvenance(
4256
+ cardPreClickProvenance
4257
+ )
4258
+ },
4259
+ detail: null
4260
+ });
4261
+ }
4262
+ const cardIdentityImmediatelyBeforeClick = await readRecommendCardBindingEvidence(
4263
+ client,
4264
+ currentNodeId,
4265
+ currentCandidate
4266
+ );
4267
+ if (!cardIdentityImmediatelyBeforeClick.verified) {
4268
+ if (cardIdentityImmediatelyBeforeClick.reason === "card_node_not_visible_or_stale") {
4269
+ throw createRecommendPreClickCardUnavailableError(
4270
+ cardIdentityImmediatelyBeforeClick,
4271
+ {
4272
+ nodeId: currentNodeId,
4273
+ bindingStage: "card_identity_immediately_before_click"
4274
+ }
4275
+ );
4276
+ }
4277
+ throw createRecommendDetailCandidateBindingError({
4278
+ schema_version: 1,
4279
+ verified: false,
4280
+ reason: cardIdentityImmediatelyBeforeClick.reason
4281
+ || "card_identity_not_verified_immediately_before_click",
4282
+ method: null,
4283
+ expected_candidate_id: normalizeBindingText(currentCandidate?.id) || null,
4284
+ expected_name: normalizeBindingText(currentCandidate?.identity?.name) || null,
4285
+ card: {
4286
+ stable: false,
4287
+ before: compactBindingNode(cardEvidenceBefore),
4288
+ after: compactBindingNode(cardIdentityImmediatelyBeforeClick),
4289
+ candidate_id: cardIdentityImmediatelyBeforeClick.candidate_id || null,
4290
+ name: cardIdentityImmediatelyBeforeClick.name || null,
4291
+ reason: cardIdentityImmediatelyBeforeClick.reason || null,
4292
+ pre_click_provenance: compactRecommendCardPreClickProvenance(
4293
+ cardPreClickProvenance
4294
+ )
4295
+ },
4296
+ detail: null
4297
+ });
4298
+ }
4299
+ if (
4300
+ positiveNodeId(cardIdentityImmediatelyBeforeClick.backend_node_id)
4301
+ !== positiveNodeId(cardEvidenceBefore.backend_node_id)
4302
+ ) {
4303
+ throw createRecommendDetailCandidateBindingError({
4304
+ schema_version: 1,
4305
+ verified: false,
4306
+ reason: "card_backend_changed_immediately_before_click",
4307
+ method: null,
4308
+ expected_candidate_id: normalizeBindingText(currentCandidate?.id) || null,
4309
+ expected_name: normalizeBindingText(currentCandidate?.identity?.name) || null,
4310
+ card: {
4311
+ stable: false,
4312
+ before: compactBindingNode(cardEvidenceBefore),
4313
+ after: compactBindingNode(cardIdentityImmediatelyBeforeClick),
4314
+ candidate_id: cardIdentityImmediatelyBeforeClick.candidate_id || null,
4315
+ name: cardIdentityImmediatelyBeforeClick.name || null,
4316
+ reason: "card_backend_changed_immediately_before_click",
4317
+ pre_click_provenance: compactRecommendCardPreClickProvenance(
4318
+ cardPreClickProvenance
4319
+ )
4320
+ },
4321
+ detail: null
4322
+ });
4323
+ }
4324
+ cardEvidenceBefore = cardIdentityImmediatelyBeforeClick;
4325
+ cardPreClickProvenance.card = cardIdentityImmediatelyBeforeClick;
4326
+ cardPreClickProvenance.card_identity_recheck = cardIdentityImmediatelyBeforeClick;
4327
+ exactCandidateProvenanceVerified = true;
4328
+ const finalViewport = await readRecommendCardClickViewportEvidence(
4329
+ client,
4330
+ currentNodeId,
4331
+ { attemptIndex: 0 }
4332
+ );
4333
+ viewportPreparation.final = finalViewport;
4334
+ if (!finalViewport.verified) {
4335
+ throw createRecommendCardClickViewportProofError(
4336
+ finalViewport,
4337
+ finalViewport.reason || "final_card_click_viewport_unverified"
4338
+ );
4339
+ }
4340
+ if (!finalViewport.in_viewport) {
4341
+ const viewportBindingError = createRecommendDetailCandidateBindingError({
4342
+ schema_version: 1,
4343
+ verified: false,
4344
+ reason: finalViewport.reason
4345
+ ? `exact_card_click_target_unverified_after_preparation:${finalViewport.reason}`
4346
+ : "exact_card_click_point_outside_viewport_after_preparation",
4347
+ method: null,
4348
+ expected_candidate_id: normalizeBindingText(currentCandidate?.id) || null,
4349
+ expected_name: normalizeBindingText(currentCandidate?.identity?.name) || null,
4350
+ card: {
4351
+ stable: false,
4352
+ before: compactBindingNode(cardEvidenceBefore),
4353
+ after: compactBindingNode(cardIdentityImmediatelyBeforeClick),
4354
+ candidate_id: cardIdentityImmediatelyBeforeClick.candidate_id || null,
4355
+ name: cardIdentityImmediatelyBeforeClick.name || null,
4356
+ reason: finalViewport.reason
4357
+ ? `exact_card_click_target_unverified_after_preparation:${finalViewport.reason}`
4358
+ : "exact_card_click_point_outside_viewport_after_preparation",
4359
+ pre_click_provenance: compactRecommendCardPreClickProvenance(
4360
+ cardPreClickProvenance
4361
+ ),
4362
+ click_viewport: finalViewport
4363
+ },
4364
+ detail: null
4365
+ });
4366
+ viewportBindingError.recommend_no_click_dispatched = true;
4367
+ viewportBindingError.recommend_click_dispatched = false;
4368
+ viewportBindingError.recommend_input_dispatched = false;
4369
+ viewportBindingError.recommend_pre_click_stage = "final_card_click_target_proof";
4370
+ viewportBindingError.recommend_card_click_viewport = finalViewport;
4371
+ throw viewportBindingError;
4372
+ }
4373
+ // The viewport read itself is asynchronous and may trigger a virtual
4374
+ // list update. Reprove exact ID/name/backend once more after geometry,
4375
+ // then use the already-proven box with no intervening DOM operation.
4376
+ const cardIdentityAfterFinalViewport = await readRecommendCardBindingEvidence(
4377
+ client,
4378
+ currentNodeId,
4379
+ currentCandidate
4380
+ );
4381
+ if (
4382
+ !cardIdentityAfterFinalViewport.verified
4383
+ && cardIdentityAfterFinalViewport.reason === "card_node_not_visible_or_stale"
4384
+ ) {
4385
+ throw createRecommendPreClickCardUnavailableError(cardIdentityAfterFinalViewport, {
4386
+ nodeId: currentNodeId,
4387
+ bindingStage: "card_identity_after_final_viewport"
4388
+ });
4389
+ }
4390
+ if (
4391
+ !cardIdentityAfterFinalViewport.verified
4392
+ || positiveNodeId(cardIdentityAfterFinalViewport.backend_node_id)
4393
+ !== positiveNodeId(cardEvidenceBefore.backend_node_id)
4394
+ ) {
4395
+ throw createRecommendDetailCandidateBindingError({
4396
+ schema_version: 1,
4397
+ verified: false,
4398
+ reason: !cardIdentityAfterFinalViewport.verified
4399
+ ? cardIdentityAfterFinalViewport.reason
4400
+ || "card_identity_changed_after_final_viewport_proof"
4401
+ : "card_backend_changed_after_final_viewport_proof",
4402
+ method: null,
4403
+ expected_candidate_id: normalizeBindingText(currentCandidate?.id) || null,
4404
+ expected_name: normalizeBindingText(currentCandidate?.identity?.name) || null,
4405
+ card: {
4406
+ stable: false,
4407
+ before: compactBindingNode(cardEvidenceBefore),
4408
+ after: compactBindingNode(cardIdentityAfterFinalViewport),
4409
+ candidate_id: cardIdentityAfterFinalViewport.candidate_id || null,
4410
+ name: cardIdentityAfterFinalViewport.name || null,
4411
+ reason: !cardIdentityAfterFinalViewport.verified
4412
+ ? cardIdentityAfterFinalViewport.reason || null
4413
+ : "card_backend_changed_after_final_viewport_proof",
4414
+ pre_click_provenance: compactRecommendCardPreClickProvenance(
4415
+ cardPreClickProvenance
4416
+ ),
4417
+ click_viewport: finalViewport
4418
+ },
4419
+ detail: null
4420
+ });
4421
+ }
4422
+ cardEvidenceBefore = cardIdentityAfterFinalViewport;
4423
+ const cardProvenanceAfterFinalViewport = await readRecommendCardPreClickProvenance(
4424
+ client,
4425
+ {
4426
+ cardNodeId: currentNodeId,
4427
+ cardCandidate: currentCandidate,
4428
+ rootState: currentRootState,
4429
+ cardEvidence: cardIdentityAfterFinalViewport
4430
+ }
4431
+ );
4432
+ if (!cardProvenanceAfterFinalViewport.verified) {
4433
+ throw createRecommendDetailCandidateBindingError({
4434
+ schema_version: 1,
4435
+ verified: false,
4436
+ reason: cardProvenanceAfterFinalViewport.reason
4437
+ || "card_root_provenance_changed_after_final_viewport_proof",
4438
+ method: null,
4439
+ expected_candidate_id: normalizeBindingText(currentCandidate?.id) || null,
4440
+ expected_name: normalizeBindingText(currentCandidate?.identity?.name) || null,
4441
+ card: {
4442
+ stable: false,
4443
+ before: compactBindingNode(cardEvidenceBefore),
4444
+ after: compactBindingNode(cardIdentityAfterFinalViewport),
4445
+ candidate_id: cardIdentityAfterFinalViewport.candidate_id || null,
4446
+ name: cardIdentityAfterFinalViewport.name || null,
4447
+ reason: cardProvenanceAfterFinalViewport.reason || null,
4448
+ pre_click_provenance: compactRecommendCardPreClickProvenance(
4449
+ cardProvenanceAfterFinalViewport
4450
+ ),
4451
+ click_viewport: finalViewport
4452
+ },
4453
+ detail: null
4454
+ });
4455
+ }
4456
+ cardPreClickProvenance = cardProvenanceAfterFinalViewport;
4457
+ cardPreClickProvenance.card = cardIdentityAfterFinalViewport;
4458
+ cardPreClickProvenance.card_identity_recheck = cardIdentityAfterFinalViewport;
4459
+ const opened = await openRecommendCardDetail(client, currentNodeId, {
4460
+ timeoutMs,
4461
+ // No scroll or second box lookup is allowed after the final exact
4462
+ // provenance + viewport proof. This box authorizes one click only.
4463
+ scrollIntoView: false,
4464
+ preverifiedCardBox: finalViewport.box
4465
+ });
4466
+ appendCumulativeRecommendCardClickAttempts(
4467
+ cumulativeClickAttempts,
4468
+ opened?.click_attempts || []
4469
+ );
4470
+ clickAttemptsRecordedWithinAttempt = true;
4471
+ inputDispatchedWithinAttempt = cumulativeClickAttempts.some(
4472
+ (attempt) => attempt?.input_dispatched === true
4473
+ );
4474
+ const candidateBinding = await waitForRecommendDetailCandidateBinding(client, {
4475
+ cardNodeId: currentNodeId,
4476
+ cardCandidate: currentCandidate,
4477
+ detailState: opened.detail_state,
4478
+ cardEvidenceBefore,
4479
+ cardPreClickProvenance,
4480
+ detailRootsBefore,
4481
+ allowCardDisappearance: true,
4482
+ cardClickEvidence: opened?.card_box?.click_viewport || null,
4483
+ clickAttempts: cumulativeClickAttempts,
4484
+ timeoutMs: bindingTimeoutMs,
4485
+ intervalMs: bindingIntervalMs,
4486
+ maxAttempts: bindingMaxAttempts,
4487
+ acceptScreeningBinding
4488
+ });
4489
+ const acceptedCandidateBinding = Boolean(
4490
+ candidateBinding.verified === true
4491
+ || (
4492
+ acceptScreeningBinding === true
4493
+ && candidateBinding.screening_verified === true
4494
+ )
4495
+ );
4496
+ if (!acceptedCandidateBinding) {
4497
+ const bindingError = createRecommendDetailCandidateBindingError(candidateBinding);
4498
+ bindingError.click_attempts = cumulativeClickAttempts;
4499
+ bindingError.recommend_click_dispatched = true;
4500
+ bindingError.recommend_input_dispatched = true;
4501
+ bindingError.recommend_no_click_dispatched = false;
4502
+ bindingError.recommend_post_input_outcome_unknown = false;
4503
+ bindingError.recommend_post_input_stage = "post_card_click_binding";
4504
+ bindingError.recommend_clean_pre_action_detail_binding_timeout =
4505
+ isCleanRecommendPostClickBindingReadinessTimeout(
4506
+ candidateBinding,
4507
+ cumulativeClickAttempts
4508
+ );
4509
+ bindingError.recommend_card_click_viewport = opened?.card_box?.click_viewport || null;
4510
+ throw bindingError;
4511
+ }
4512
+ return {
4513
+ ...opened,
4514
+ detail_state: {
4515
+ ...(candidateBinding.observed_detail_state || opened.detail_state),
4516
+ candidate_binding: candidateBinding,
4517
+ candidate_binding_context: {
4518
+ card_pre_click_provenance: candidateBinding?.card?.pre_click_provenance || null,
4519
+ detail_roots_before: candidateBinding?.detail?.roots_before_capture || null,
4520
+ expected_detail_root: candidateBinding?.detail?.root || null,
4521
+ allow_card_disappearance: true,
4522
+ card_click_evidence: candidateBinding?.card?.click_evidence || null,
4523
+ click_attempts: candidateBinding?.card?.click_attempts || []
4524
+ }
4525
+ },
4526
+ card_node_id: currentNodeId,
4527
+ card_candidate: currentCandidate,
4528
+ candidate_binding: candidateBinding,
4529
+ retry_attempts: attempts,
4530
+ viewport_preparation: viewportPreparation
4531
+ };
4532
+ } catch (error) {
4533
+ if (!clickAttemptsRecordedWithinAttempt) {
4534
+ appendCumulativeRecommendCardClickAttempts(
4535
+ cumulativeClickAttempts,
4536
+ error?.click_attempts || []
4537
+ );
4538
+ }
4539
+ const candidateBindingMismatch = isRecommendDetailCandidateBindingError(error);
4540
+ if (
4541
+ inputDispatchedWithinAttempt
4542
+ && !candidateBindingMismatch
4543
+ && error?.recommend_input_dispatched !== true
4544
+ ) {
4545
+ markRecommendPostInputOutcomeUnknown(error, {
4546
+ stage: "post_card_click_binding"
4547
+ });
4548
+ }
4549
+ const stale = isStaleRecommendNodeError(error);
4550
+ const detailOpenMiss = isRecommendDetailOpenMissError(error);
4551
+ const preClickStaleNoAction = isRecommendPreClickStaleNoActionError(error);
4552
+ const clickDispatched = error?.recommend_click_dispatched === true
4553
+ ? true
4554
+ : error?.recommend_click_dispatched === false
4555
+ || error?.recommend_no_click_dispatched === true
4556
+ ? false
4557
+ : null;
4558
+ const inputDispatched = error?.recommend_input_dispatched === true
4559
+ ? true
4560
+ : error?.recommend_input_dispatched === false
4561
+ ? false
4562
+ : null;
4563
+ attempts.push({
4564
+ attempt: attemptIndex + 1,
4565
+ node_id: currentNodeId,
4566
+ stale_node: stale,
4567
+ detail_open_miss: detailOpenMiss,
4568
+ candidate_binding_mismatch: candidateBindingMismatch,
4569
+ pre_click_stale_no_action: preClickStaleNoAction,
4570
+ no_click_dispatched: error?.recommend_no_click_dispatched === true,
4571
+ click_dispatched: clickDispatched,
4572
+ input_dispatched: inputDispatched,
4573
+ pre_click_stage: error?.recommend_pre_click_stage || null,
4574
+ pre_click_node_id: positiveNodeId(error?.recommend_pre_click_node_id),
4575
+ exact_candidate_provenance_verified: exactCandidateProvenanceVerified,
4576
+ viewport_preparation: viewportPreparation,
4577
+ candidate_binding: error?.detail_candidate_binding || null,
4578
+ click_attempts: compactRecommendCardClickAttempts(error?.click_attempts || []),
4579
+ cumulative_click_attempt_count: cumulativeClickAttempts.length,
4580
+ error: error?.message || String(error)
4581
+ });
4582
+ if (
4583
+ (inputDispatched === true && error?.recommend_click_negative_outcome_observed !== true)
4584
+ || candidateBindingMismatch
4585
+ || (!stale && !detailOpenMiss)
4586
+ || attemptIndex >= limit - 1
4587
+ || !candidateKey
4588
+ ) {
4589
+ throw attachRecommendDetailOpenRetryEvidence(error, attempts, {
4590
+ retryExhausted: attemptIndex >= limit - 1,
4591
+ expectedAttemptCount: limit
4592
+ });
4593
+ }
698
4594
 
699
4595
  const resolved = await findRecommendCardNodeForCandidateKey(client, {
700
4596
  candidateKey,
@@ -711,10 +4607,14 @@ export async function openRecommendCardDetailWithFreshRetry(client, {
711
4607
  reason: resolved.reason || null,
712
4608
  error: resolved.error || null
713
4609
  };
714
- if (!resolved.ok || !resolved.node_id) {
715
- error.recommend_detail_open_attempts = attempts;
716
- throw error;
717
- }
4610
+ if (!resolved.ok || !resolved.node_id || resolved.key !== candidateKey) {
4611
+ attempts[attempts.length - 1].refresh_lookup.exact_candidate_key_match = false;
4612
+ throw attachRecommendDetailOpenRetryEvidence(error, attempts, {
4613
+ reacquireFailed: true,
4614
+ expectedAttemptCount: limit
4615
+ });
4616
+ }
4617
+ attempts[attempts.length - 1].refresh_lookup.exact_candidate_key_match = true;
718
4618
  currentNodeId = resolved.node_id;
719
4619
  currentCandidate = resolved.candidate || currentCandidate;
720
4620
  currentRootState = resolved.root_state || null;
@@ -1114,10 +5014,11 @@ export async function extractRecommendDetailCandidate(client, {
1114
5014
  closeResult = await closeRecommendDetail(client);
1115
5015
  }
1116
5016
 
1117
- return {
1118
- candidate: detailCandidateResult.candidate,
1119
- parsed_network_profiles: detailCandidateResult.parsed_network_profiles,
1120
- network_bodies: networkBodies,
5017
+ return {
5018
+ candidate: detailCandidateResult.candidate,
5019
+ parsed_network_profiles: detailCandidateResult.parsed_network_profiles,
5020
+ network_profile_binding: detailCandidateResult.network_profile_binding || null,
5021
+ network_bodies: networkBodies,
1121
5022
  network_parse_retry_elapsed_ms: Date.now() - parseStarted,
1122
5023
  network_event_count: networkEvents.length,
1123
5024
  detail: {