@reconcrap/boss-recommend-mcp 2.1.23 → 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.
- package/README.md +5 -0
- package/bin/boss-recommend-mcp.js +4 -4
- package/package.json +6 -1
- package/scripts/install-macos.sh +280 -280
- package/scripts/postinstall.cjs +44 -44
- package/skills/boss-chat/README.md +43 -42
- package/skills/boss-chat/SKILL.md +107 -106
- package/skills/boss-recommend-pipeline/README.md +13 -13
- package/skills/boss-recommend-pipeline/SKILL.md +47 -47
- package/skills/boss-recruit-pipeline/README.md +19 -19
- package/skills/boss-recruit-pipeline/SKILL.md +89 -89
- package/src/chat-mcp.js +301 -127
- package/src/chat-runtime-config.js +7 -5
- package/src/core/boss-cards/index.js +199 -199
- package/src/core/browser/index.js +302 -145
- package/src/core/capture/index.js +2930 -1201
- package/src/core/cv-acquisition/index.js +512 -238
- package/src/core/cv-capture-target/index.js +513 -299
- package/src/core/greet-quota/index.js +71 -71
- package/src/core/infinite-list/index.js +31 -31
- package/src/core/reporting/legacy-csv.js +12 -12
- package/src/core/run/detached-launcher.js +305 -0
- package/src/core/run/index.js +109 -55
- package/src/core/run/timing.js +33 -33
- package/src/core/run/windows-detached-worker.ps1 +106 -0
- package/src/core/screening/index.js +2400 -2135
- package/src/core/self-heal/index.js +989 -973
- package/src/core/self-heal/viewport.js +1505 -564
- package/src/detached-worker.js +99 -99
- package/src/domains/chat/action-journal.js +536 -0
- package/src/domains/chat/cards.js +137 -137
- package/src/domains/chat/constants.js +9 -9
- package/src/domains/chat/detail.js +1684 -401
- package/src/domains/chat/index.js +8 -7
- package/src/domains/chat/jobs.js +620 -620
- package/src/domains/chat/page-guard.js +157 -122
- package/src/domains/chat/roots.js +56 -56
- package/src/domains/chat/run-service.js +1801 -760
- package/src/domains/common/account-rights-panel.js +314 -314
- package/src/domains/common/recovery-settle.js +159 -159
- package/src/domains/recommend/actions.js +1219 -472
- package/src/domains/recommend/cards.js +243 -243
- package/src/domains/recommend/colleague-contact.js +1079 -333
- package/src/domains/recommend/constants.js +92 -92
- package/src/domains/recommend/detail.js +4037 -136
- package/src/domains/recommend/filters.js +608 -590
- package/src/domains/recommend/index.js +9 -9
- package/src/domains/recommend/jobs.js +571 -542
- package/src/domains/recommend/location.js +754 -707
- package/src/domains/recommend/refresh.js +677 -392
- package/src/domains/recommend/roots.js +80 -80
- package/src/domains/recommend/run-service.js +4048 -1447
- package/src/domains/recommend/scopes.js +246 -246
- package/src/domains/recruit/actions.js +277 -277
- package/src/domains/recruit/cards.js +74 -74
- package/src/domains/recruit/constants.js +236 -236
- package/src/domains/recruit/detail.js +588 -588
- package/src/domains/recruit/index.js +9 -9
- package/src/domains/recruit/instruction-parser.js +866 -866
- package/src/domains/recruit/refresh.js +45 -45
- package/src/domains/recruit/roots.js +68 -68
- package/src/domains/recruit/run-service.js +1817 -1620
- package/src/domains/recruit/search.js +3229 -3229
- package/src/index.js +16 -1
- package/src/parser.js +1296 -1296
- package/src/recommend-mcp.js +1061 -450
- package/src/recommend-scheduler.js +75 -75
|
@@ -1,16 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
clickNodeCenter,
|
|
3
|
-
clickPoint,
|
|
4
|
-
DETERMINISTIC_CLICK_OPTIONS,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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, {
|
|
@@ -388,28 +3275,109 @@ export async function readRecommendDetailHtml(client, detailState) {
|
|
|
388
3275
|
}
|
|
389
3276
|
|
|
390
3277
|
export function isStaleRecommendNodeError(error) {
|
|
391
|
-
const pattern = /Could not find node with given id|No node with given id|Node is detached|Cannot find node|Could not compute box model/i;
|
|
392
|
-
const seen = new Set();
|
|
393
|
-
let current = error;
|
|
394
|
-
for (let depth = 0; current && depth < 5; depth += 1) {
|
|
395
|
-
if ((typeof current === "object" || typeof current === "function") && seen.has(current)) break;
|
|
396
|
-
if (typeof current === "object" || typeof current === "function") seen.add(current);
|
|
397
|
-
const message = String(current?.message || current || "");
|
|
398
|
-
if (pattern.test(message)) return true;
|
|
399
|
-
current = current?.cause || null;
|
|
400
|
-
}
|
|
3278
|
+
const pattern = /Could not find node with given id|No node with given id|Node with given id does not exist|No node found for given backend id|Invalid (?:backend )?Node\s*Id|Node is detached|Cannot find node|Could not compute box model/i;
|
|
3279
|
+
const seen = new Set();
|
|
3280
|
+
let current = error;
|
|
3281
|
+
for (let depth = 0; current && depth < 5; depth += 1) {
|
|
3282
|
+
if ((typeof current === "object" || typeof current === "function") && seen.has(current)) break;
|
|
3283
|
+
if (typeof current === "object" || typeof current === "function") seen.add(current);
|
|
3284
|
+
const message = String(current?.message || current || "");
|
|
3285
|
+
if (pattern.test(message)) return true;
|
|
3286
|
+
current = current?.cause || null;
|
|
3287
|
+
}
|
|
401
3288
|
return false;
|
|
402
3289
|
}
|
|
403
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
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
|
-
|
|
597
|
-
const
|
|
598
|
-
const
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
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
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|