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