@reconcrap/boss-recommend-mcp 2.0.47 → 2.0.49
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 +4 -4
- package/config/screening-config.example.json +27 -27
- package/package.json +1 -1
- package/scripts/postinstall.cjs +44 -44
- package/skills/boss-chat/README.md +39 -39
- package/skills/boss-chat/SKILL.md +93 -93
- package/skills/boss-recommend-pipeline/README.md +12 -12
- package/skills/boss-recommend-pipeline/SKILL.md +180 -180
- package/skills/boss-recruit-pipeline/README.md +17 -17
- package/skills/boss-recruit-pipeline/SKILL.md +58 -58
- package/src/chat-mcp.js +1780 -1780
- package/src/chat-runtime-config.js +749 -749
- package/src/cli.js +3054 -3054
- package/src/core/boss-cards/index.js +199 -199
- package/src/core/browser/index.js +1586 -1453
- package/src/core/capture/index.js +1201 -1201
- package/src/core/cv-acquisition/index.js +238 -238
- package/src/core/cv-capture-target/index.js +299 -299
- package/src/core/greet-quota/index.js +54 -54
- package/src/core/infinite-list/index.js +1326 -1326
- package/src/core/reporting/legacy-csv.js +341 -341
- package/src/core/run/timing.js +33 -33
- package/src/core/self-heal/index.js +973 -973
- package/src/core/self-heal/viewport.js +564 -564
- package/src/domains/chat/cards.js +137 -137
- package/src/domains/chat/constants.js +221 -221
- package/src/domains/chat/detail.js +1668 -1668
- package/src/domains/chat/index.js +7 -7
- package/src/domains/chat/jobs.js +592 -592
- package/src/domains/chat/page-guard.js +98 -98
- package/src/domains/chat/roots.js +56 -56
- package/src/domains/chat/run-service.js +1977 -1977
- package/src/domains/recommend/actions.js +457 -457
- package/src/domains/recommend/cards.js +243 -243
- package/src/domains/recommend/constants.js +165 -165
- package/src/domains/recommend/detail.js +1 -1
- package/src/domains/recommend/filters.js +610 -610
- package/src/domains/recommend/index.js +10 -10
- package/src/domains/recommend/jobs.js +378 -316
- package/src/domains/recommend/refresh.js +491 -472
- package/src/domains/recommend/roots.js +80 -80
- package/src/domains/recommend/run-service.js +50 -29
- 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 +167 -167
- package/src/domains/recruit/detail.js +461 -461
- package/src/domains/recruit/index.js +9 -9
- package/src/domains/recruit/instruction-parser.js +451 -451
- package/src/domains/recruit/refresh.js +44 -44
- package/src/domains/recruit/roots.js +68 -68
- package/src/domains/recruit/run-service.js +1207 -1207
- package/src/domains/recruit/search.js +1202 -1202
- package/src/recommend-mcp.js +22 -22
- package/src/recruit-mcp.js +1338 -1338
|
@@ -1,1326 +1,1326 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
getNodeBox,
|
|
4
|
-
getOuterHTML,
|
|
5
|
-
querySelectorAll,
|
|
6
|
-
scrollNodeIntoView,
|
|
7
|
-
sleep
|
|
8
|
-
} from "../browser/index.js";
|
|
9
|
-
|
|
10
|
-
export const DEFAULT_BOTTOM_HINT_KEYWORDS = Object.freeze([
|
|
11
|
-
"没有更多",
|
|
12
|
-
"已显示全部",
|
|
13
|
-
"已经到底",
|
|
14
|
-
"暂无更多",
|
|
15
|
-
"推荐完了",
|
|
16
|
-
"没有更多人选",
|
|
17
|
-
"没有更多了",
|
|
18
|
-
"已到底"
|
|
19
|
-
]);
|
|
20
|
-
|
|
21
|
-
export const DEFAULT_LOAD_MORE_HINT_KEYWORDS = Object.freeze([
|
|
22
|
-
"滚动加载更多",
|
|
23
|
-
"下滑加载更多",
|
|
24
|
-
"继续下滑",
|
|
25
|
-
"继续滑动",
|
|
26
|
-
"滑动加载",
|
|
27
|
-
"正在加载",
|
|
28
|
-
"加载中"
|
|
29
|
-
]);
|
|
30
|
-
|
|
31
|
-
export const DEFAULT_BOTTOM_MARKER_SELECTORS = Object.freeze([
|
|
32
|
-
".finished-wrap",
|
|
33
|
-
".loadmore",
|
|
34
|
-
".load-tips",
|
|
35
|
-
"div[role=\"tfoot\"] .load-tips",
|
|
36
|
-
".no-data-refresh",
|
|
37
|
-
".empty-tip",
|
|
38
|
-
".empty-text",
|
|
39
|
-
".no-data",
|
|
40
|
-
".tip-nodata",
|
|
41
|
-
"[class*=\"finished\"]",
|
|
42
|
-
"[class*=\"loadmore\"]",
|
|
43
|
-
"[class*=\"load-tips\"]",
|
|
44
|
-
"[class*=\"no-more\"]",
|
|
45
|
-
"[class*=\"no_more\"]"
|
|
46
|
-
]);
|
|
47
|
-
|
|
48
|
-
export const DEFAULT_BOTTOM_TEXT_SCAN_SELECTORS = Object.freeze([
|
|
49
|
-
"div",
|
|
50
|
-
"span",
|
|
51
|
-
"p",
|
|
52
|
-
"li",
|
|
53
|
-
"button",
|
|
54
|
-
"a"
|
|
55
|
-
]);
|
|
56
|
-
|
|
57
|
-
export const DEFAULT_BOTTOM_REFRESH_SELECTORS = Object.freeze([
|
|
58
|
-
".finished-wrap .btn-refresh",
|
|
59
|
-
".finished-wrap .btn",
|
|
60
|
-
".no-data-refresh .btn-refresh",
|
|
61
|
-
".no-data-refresh .btn",
|
|
62
|
-
"[class*=\"refresh\"]",
|
|
63
|
-
"[ka*=\"refresh\"]",
|
|
64
|
-
"button",
|
|
65
|
-
"a"
|
|
66
|
-
]);
|
|
67
|
-
|
|
68
|
-
function nowIso() {
|
|
69
|
-
return new Date().toISOString();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function normalizeText(value) {
|
|
73
|
-
return String(value || "").replace(/\s+/g, " ").trim();
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function uniqueValues(values = []) {
|
|
77
|
-
return Array.from(new Set(values.filter(Boolean)));
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function decodeBasicHtmlEntities(value = "") {
|
|
81
|
-
return String(value || "")
|
|
82
|
-
.replace(/ | /gi, " ")
|
|
83
|
-
.replace(/&/gi, "&")
|
|
84
|
-
.replace(/</gi, "<")
|
|
85
|
-
.replace(/>/gi, ">")
|
|
86
|
-
.replace(/"/gi, "\"")
|
|
87
|
-
.replace(/'|'/gi, "'");
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function plainTextFromHtml(html = "") {
|
|
91
|
-
return normalizeText(decodeBasicHtmlEntities(String(html || "")
|
|
92
|
-
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
|
|
93
|
-
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
|
|
94
|
-
.replace(/<[^>]+>/g, " ")));
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function isUsableBox(box) {
|
|
98
|
-
return Number(box?.rect?.width || 0) > 2 && Number(box?.rect?.height || 0) > 2;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function isUsableRect(rect) {
|
|
102
|
-
return Number(rect?.width || 0) > 2 && Number(rect?.height || 0) > 2;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function pointFromRect(rect, {
|
|
106
|
-
xRatio = 0.5,
|
|
107
|
-
yRatio = 0.75,
|
|
108
|
-
inset = 8
|
|
109
|
-
} = {}) {
|
|
110
|
-
if (!isUsableRect(rect)) return null;
|
|
111
|
-
const safeInsetX = Math.min(Math.max(0, Number(inset) || 0), Math.max(0, rect.width / 2 - 1));
|
|
112
|
-
const safeInsetY = Math.min(Math.max(0, Number(inset) || 0), Math.max(0, rect.height / 2 - 1));
|
|
113
|
-
const minX = rect.x + safeInsetX;
|
|
114
|
-
const maxX = rect.x + rect.width - safeInsetX;
|
|
115
|
-
const minY = rect.y + safeInsetY;
|
|
116
|
-
const maxY = rect.y + rect.height - safeInsetY;
|
|
117
|
-
return {
|
|
118
|
-
x: Math.min(maxX, Math.max(minX, rect.x + rect.width * (Number(xRatio) || 0.5))),
|
|
119
|
-
y: Math.min(maxY, Math.max(minY, rect.y + rect.height * (Number(yRatio) || 0.75)))
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function unionRects(rects = []) {
|
|
124
|
-
const usable = rects.filter(isUsableRect);
|
|
125
|
-
if (!usable.length) return null;
|
|
126
|
-
const left = Math.min(...usable.map((rect) => rect.x));
|
|
127
|
-
const top = Math.min(...usable.map((rect) => rect.y));
|
|
128
|
-
const right = Math.max(...usable.map((rect) => rect.x + rect.width));
|
|
129
|
-
const bottom = Math.max(...usable.map((rect) => rect.y + rect.height));
|
|
130
|
-
return {
|
|
131
|
-
x: left,
|
|
132
|
-
y: top,
|
|
133
|
-
width: right - left,
|
|
134
|
-
height: bottom - top
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function pointInsideRect(point, rect, {
|
|
139
|
-
padding = 0
|
|
140
|
-
} = {}) {
|
|
141
|
-
if (!point || !isUsableRect(rect)) return false;
|
|
142
|
-
const pad = Math.max(0, Number(padding) || 0);
|
|
143
|
-
return Number(point.x) >= rect.x + pad
|
|
144
|
-
&& Number(point.x) <= rect.x + rect.width - pad
|
|
145
|
-
&& Number(point.y) >= rect.y + pad
|
|
146
|
-
&& Number(point.y) <= rect.y + rect.height - pad;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function rectsIntersect(a, b, {
|
|
150
|
-
padding = 0
|
|
151
|
-
} = {}) {
|
|
152
|
-
if (!isUsableRect(a) || !isUsableRect(b)) return false;
|
|
153
|
-
const pad = Math.max(0, Number(padding) || 0);
|
|
154
|
-
return a.x + a.width >= b.x + pad
|
|
155
|
-
&& b.x + b.width >= a.x + pad
|
|
156
|
-
&& a.y + a.height >= b.y + pad
|
|
157
|
-
&& b.y + b.height >= a.y + pad;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function intersectRects(a, b) {
|
|
161
|
-
if (!isUsableRect(a) || !isUsableRect(b)) return null;
|
|
162
|
-
const left = Math.max(a.x, b.x);
|
|
163
|
-
const top = Math.max(a.y, b.y);
|
|
164
|
-
const right = Math.min(a.x + a.width, b.x + b.width);
|
|
165
|
-
const bottom = Math.min(a.y + a.height, b.y + b.height);
|
|
166
|
-
if (right - left <= 2 || bottom - top <= 2) return null;
|
|
167
|
-
return {
|
|
168
|
-
x: left,
|
|
169
|
-
y: top,
|
|
170
|
-
width: right - left,
|
|
171
|
-
height: bottom - top
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function normalizePoint(point) {
|
|
176
|
-
if (!point) return null;
|
|
177
|
-
const x = Number(point.x);
|
|
178
|
-
const y = Number(point.y);
|
|
179
|
-
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
180
|
-
return { x, y };
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function normalizeRandom(random) {
|
|
184
|
-
return typeof random === "function" ? random : Math.random;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function randomBetween(random, min, max) {
|
|
188
|
-
const lower = Number(min) || 0;
|
|
189
|
-
const upper = Number(max) || lower;
|
|
190
|
-
if (upper <= lower) return lower;
|
|
191
|
-
return lower + random() * (upper - lower);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function clampNumber(value, min, max) {
|
|
195
|
-
const number = Number(value);
|
|
196
|
-
if (!Number.isFinite(number)) return min;
|
|
197
|
-
return Math.min(max, Math.max(min, number));
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
export function resolveInfiniteListScrollTiming({
|
|
201
|
-
wheelDeltaY = 850,
|
|
202
|
-
settleMs = 1200,
|
|
203
|
-
listScrollJitterEnabled = false,
|
|
204
|
-
listScrollJitterMinRatio = 0.85,
|
|
205
|
-
listScrollJitterMaxRatio = 1.15,
|
|
206
|
-
listSettleJitterMinRatio = 0.75,
|
|
207
|
-
listSettleJitterMaxRatio = 1.35,
|
|
208
|
-
random = Math.random
|
|
209
|
-
} = {}) {
|
|
210
|
-
const baseDeltaY = Math.max(1, Number(wheelDeltaY) || 850);
|
|
211
|
-
const baseSettleMs = Math.max(0, Number(settleMs) || 0);
|
|
212
|
-
if (listScrollJitterEnabled !== true) {
|
|
213
|
-
return {
|
|
214
|
-
wheelDeltaY: baseDeltaY,
|
|
215
|
-
settleMs: baseSettleMs,
|
|
216
|
-
wheel_delta_jitter: {
|
|
217
|
-
enabled: false,
|
|
218
|
-
base_delta_y: baseDeltaY,
|
|
219
|
-
actual_delta_y: baseDeltaY
|
|
220
|
-
},
|
|
221
|
-
settle_jitter: {
|
|
222
|
-
enabled: false,
|
|
223
|
-
base_settle_ms: baseSettleMs,
|
|
224
|
-
actual_settle_ms: baseSettleMs
|
|
225
|
-
}
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
const nextRandom = normalizeRandom(random);
|
|
229
|
-
const minDeltaRatio = clampNumber(listScrollJitterMinRatio, 0.5, 1.5);
|
|
230
|
-
const maxDeltaRatio = clampNumber(listScrollJitterMaxRatio, minDeltaRatio, 1.5);
|
|
231
|
-
const minSettleRatio = clampNumber(listSettleJitterMinRatio, 0.4, 2);
|
|
232
|
-
const maxSettleRatio = clampNumber(listSettleJitterMaxRatio, minSettleRatio, 2);
|
|
233
|
-
const deltaRatio = randomBetween(nextRandom, minDeltaRatio, maxDeltaRatio);
|
|
234
|
-
const settleRatio = randomBetween(nextRandom, minSettleRatio, maxSettleRatio);
|
|
235
|
-
const actualDeltaY = Math.max(1, Math.round(baseDeltaY * deltaRatio));
|
|
236
|
-
const actualSettleMs = Math.max(0, Math.round(baseSettleMs * settleRatio));
|
|
237
|
-
return {
|
|
238
|
-
wheelDeltaY: actualDeltaY,
|
|
239
|
-
settleMs: actualSettleMs,
|
|
240
|
-
wheel_delta_jitter: {
|
|
241
|
-
enabled: true,
|
|
242
|
-
preserve_coverage: true,
|
|
243
|
-
base_delta_y: baseDeltaY,
|
|
244
|
-
actual_delta_y: actualDeltaY,
|
|
245
|
-
ratio: deltaRatio,
|
|
246
|
-
min_ratio: minDeltaRatio,
|
|
247
|
-
max_ratio: maxDeltaRatio
|
|
248
|
-
},
|
|
249
|
-
settle_jitter: {
|
|
250
|
-
enabled: true,
|
|
251
|
-
base_settle_ms: baseSettleMs,
|
|
252
|
-
actual_settle_ms: actualSettleMs,
|
|
253
|
-
ratio: settleRatio,
|
|
254
|
-
min_ratio: minSettleRatio,
|
|
255
|
-
max_ratio: maxSettleRatio
|
|
256
|
-
}
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function resolveViewportPoint(viewportPoint, viewport) {
|
|
261
|
-
if (!viewportPoint) return null;
|
|
262
|
-
if (viewport && ("xRatio" in viewportPoint || "yRatio" in viewportPoint)) {
|
|
263
|
-
const xRatio = Number(viewportPoint.xRatio ?? 0);
|
|
264
|
-
const yRatio = Number(viewportPoint.yRatio ?? 0);
|
|
265
|
-
if (Number.isFinite(xRatio) && Number.isFinite(yRatio)) {
|
|
266
|
-
return {
|
|
267
|
-
x: viewport.x + viewport.width * xRatio,
|
|
268
|
-
y: viewport.y + viewport.height * yRatio
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
return normalizePoint(viewportPoint);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
async function getViewportRect(client) {
|
|
276
|
-
try {
|
|
277
|
-
const metrics = await client.Page.getLayoutMetrics();
|
|
278
|
-
const viewport = metrics.visualViewport || metrics.layoutViewport || metrics.cssVisualViewport || {};
|
|
279
|
-
const width = Number(viewport.clientWidth || viewport.width || metrics.layoutViewport?.clientWidth || 0);
|
|
280
|
-
const height = Number(viewport.clientHeight || viewport.height || metrics.layoutViewport?.clientHeight || 0);
|
|
281
|
-
const x = Number(viewport.pageX || viewport.x || 0);
|
|
282
|
-
const y = Number(viewport.pageY || viewport.y || 0);
|
|
283
|
-
if (width > 0 && height > 0) {
|
|
284
|
-
return { x, y, width, height };
|
|
285
|
-
}
|
|
286
|
-
} catch {
|
|
287
|
-
// Page.getLayoutMetrics is optional for fallback only.
|
|
288
|
-
}
|
|
289
|
-
return null;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
async function collectUsableNodeBoxes(client, nodeIds = [], {
|
|
293
|
-
maxNodes = 80
|
|
294
|
-
} = {}) {
|
|
295
|
-
const boxes = [];
|
|
296
|
-
const errors = [];
|
|
297
|
-
for (const nodeId of nodeIds.slice(0, Math.max(1, Number(maxNodes) || 80))) {
|
|
298
|
-
try {
|
|
299
|
-
const box = await getNodeBox(client, nodeId);
|
|
300
|
-
if (isUsableBox(box)) {
|
|
301
|
-
boxes.push({
|
|
302
|
-
node_id: nodeId,
|
|
303
|
-
box,
|
|
304
|
-
rect: box.rect
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
} catch (error) {
|
|
308
|
-
errors.push({
|
|
309
|
-
node_id: nodeId,
|
|
310
|
-
error: error?.message || String(error)
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
return { boxes, errors };
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
async function querySelectorBoxes(client, rootNodeId, selectors = [], {
|
|
318
|
-
maxNodes = 80
|
|
319
|
-
} = {}) {
|
|
320
|
-
const attempts = [];
|
|
321
|
-
if (!rootNodeId) return { boxes: [], attempts };
|
|
322
|
-
for (const selector of selectors.filter(Boolean)) {
|
|
323
|
-
let nodeIds = [];
|
|
324
|
-
try {
|
|
325
|
-
nodeIds = await querySelectorAll(client, rootNodeId, selector);
|
|
326
|
-
} catch (error) {
|
|
327
|
-
attempts.push({
|
|
328
|
-
selector,
|
|
329
|
-
error: error?.message || String(error),
|
|
330
|
-
node_count: 0,
|
|
331
|
-
box_count: 0
|
|
332
|
-
});
|
|
333
|
-
continue;
|
|
334
|
-
}
|
|
335
|
-
const measured = await collectUsableNodeBoxes(client, nodeIds, { maxNodes });
|
|
336
|
-
attempts.push({
|
|
337
|
-
selector,
|
|
338
|
-
node_count: nodeIds.length,
|
|
339
|
-
box_count: measured.boxes.length,
|
|
340
|
-
errors: measured.errors
|
|
341
|
-
});
|
|
342
|
-
if (measured.boxes.length) {
|
|
343
|
-
return {
|
|
344
|
-
boxes: measured.boxes,
|
|
345
|
-
selector,
|
|
346
|
-
attempts
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
return { boxes: [], attempts };
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
export async function resolveInfiniteListFallbackPoint(client, {
|
|
354
|
-
rootNodeId = 0,
|
|
355
|
-
containerSelectors = [],
|
|
356
|
-
itemNodeIds = [],
|
|
357
|
-
itemSelectors = [],
|
|
358
|
-
allowedSources = ["container", "item_union", "viewport_ratio"],
|
|
359
|
-
containerXRatio = 0.5,
|
|
360
|
-
containerYRatio = 0.5,
|
|
361
|
-
itemXRatio = 0.5,
|
|
362
|
-
itemYRatio = 0.5,
|
|
363
|
-
viewportPoint = null,
|
|
364
|
-
validateViewportPoint = true,
|
|
365
|
-
maxProbeNodes = 80
|
|
366
|
-
} = {}) {
|
|
367
|
-
const attempts = [];
|
|
368
|
-
const allowed = new Set(Array.isArray(allowedSources) && allowedSources.length
|
|
369
|
-
? allowedSources.map((source) => String(source || ""))
|
|
370
|
-
: ["container", "item_union", "viewport_ratio"]);
|
|
371
|
-
|
|
372
|
-
const containerResult = await querySelectorBoxes(client, rootNodeId, containerSelectors, {
|
|
373
|
-
maxNodes: maxProbeNodes
|
|
374
|
-
});
|
|
375
|
-
attempts.push({
|
|
376
|
-
source: "container",
|
|
377
|
-
selector: containerResult.selector || null,
|
|
378
|
-
attempts: containerResult.attempts
|
|
379
|
-
});
|
|
380
|
-
const containerBox = containerResult.boxes
|
|
381
|
-
.slice()
|
|
382
|
-
.sort((a, b) => (b.rect.width * b.rect.height) - (a.rect.width * a.rect.height))[0];
|
|
383
|
-
const viewport = await getViewportRect(client);
|
|
384
|
-
const inputViewportRect = viewport
|
|
385
|
-
? { x: 0, y: 0, width: viewport.width, height: viewport.height }
|
|
386
|
-
: null;
|
|
387
|
-
const visibleContainerRect = inputViewportRect && containerBox?.rect
|
|
388
|
-
? intersectRects(containerBox.rect, inputViewportRect) || containerBox.rect
|
|
389
|
-
: containerBox?.rect;
|
|
390
|
-
const containerPoint = pointFromRect(visibleContainerRect, {
|
|
391
|
-
xRatio: containerXRatio,
|
|
392
|
-
yRatio: containerYRatio
|
|
393
|
-
});
|
|
394
|
-
if (containerPoint && allowed.has("container")) {
|
|
395
|
-
return {
|
|
396
|
-
ok: true,
|
|
397
|
-
source: "container",
|
|
398
|
-
point: containerPoint,
|
|
399
|
-
selector: containerResult.selector || null,
|
|
400
|
-
node_id: containerBox.node_id,
|
|
401
|
-
assist_node_id: itemNodeIds.slice(-1)[0] || null,
|
|
402
|
-
rect: visibleContainerRect,
|
|
403
|
-
full_rect: containerBox.rect,
|
|
404
|
-
attempts
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
let itemBoxes = [];
|
|
409
|
-
const itemProbeNodeIds = itemNodeIds.length > maxProbeNodes
|
|
410
|
-
? itemNodeIds.slice(-maxProbeNodes)
|
|
411
|
-
: itemNodeIds;
|
|
412
|
-
const measuredItems = await collectUsableNodeBoxes(client, itemProbeNodeIds, { maxNodes: maxProbeNodes });
|
|
413
|
-
itemBoxes = measuredItems.boxes;
|
|
414
|
-
attempts.push({
|
|
415
|
-
source: "visible_items",
|
|
416
|
-
node_count: itemNodeIds.length,
|
|
417
|
-
box_count: measuredItems.boxes.length,
|
|
418
|
-
errors: measuredItems.errors
|
|
419
|
-
});
|
|
420
|
-
if (!itemBoxes.length) {
|
|
421
|
-
const queriedItems = await querySelectorBoxes(client, rootNodeId, itemSelectors, {
|
|
422
|
-
maxNodes: maxProbeNodes
|
|
423
|
-
});
|
|
424
|
-
itemBoxes = queriedItems.boxes;
|
|
425
|
-
attempts.push({
|
|
426
|
-
source: "item_selector",
|
|
427
|
-
selector: queriedItems.selector || null,
|
|
428
|
-
attempts: queriedItems.attempts
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
|
-
const itemValidationRects = [
|
|
432
|
-
inputViewportRect,
|
|
433
|
-
visibleContainerRect || containerBox?.rect || null
|
|
434
|
-
].filter(isUsableRect);
|
|
435
|
-
const visibleItemBoxes = itemValidationRects.length
|
|
436
|
-
? itemBoxes.filter((item) => itemValidationRects.every((rect) => rectsIntersect(item.rect, rect, { padding: 1 })))
|
|
437
|
-
: itemBoxes;
|
|
438
|
-
attempts.push({
|
|
439
|
-
source: "visible_item_filter",
|
|
440
|
-
input_box_count: itemBoxes.length,
|
|
441
|
-
output_box_count: visibleItemBoxes.length,
|
|
442
|
-
validation_rect_count: itemValidationRects.length
|
|
443
|
-
});
|
|
444
|
-
const unionSourceBoxes = visibleItemBoxes.length ? visibleItemBoxes : itemBoxes;
|
|
445
|
-
const rawItemUnion = unionRects(unionSourceBoxes.map((item) => item.rect));
|
|
446
|
-
const itemUnion = itemValidationRects.reduce(
|
|
447
|
-
(rect, limit) => intersectRects(rect, limit) || rect,
|
|
448
|
-
rawItemUnion
|
|
449
|
-
);
|
|
450
|
-
const itemPoint = pointFromRect(itemUnion, {
|
|
451
|
-
xRatio: itemXRatio,
|
|
452
|
-
yRatio: itemYRatio
|
|
453
|
-
});
|
|
454
|
-
if (itemPoint && allowed.has("item_union")) {
|
|
455
|
-
const assistItem = unionSourceBoxes
|
|
456
|
-
.slice()
|
|
457
|
-
.sort((a, b) => ((b.rect.y + b.rect.height) - (a.rect.y + a.rect.height)))[0];
|
|
458
|
-
return {
|
|
459
|
-
ok: true,
|
|
460
|
-
source: "item_union",
|
|
461
|
-
point: itemPoint,
|
|
462
|
-
rect: itemUnion,
|
|
463
|
-
full_rect: rawItemUnion,
|
|
464
|
-
item_box_count: unionSourceBoxes.length,
|
|
465
|
-
visible_item_box_count: visibleItemBoxes.length,
|
|
466
|
-
assist_node_id: assistItem?.node_id || itemNodeIds.slice(-1)[0] || null,
|
|
467
|
-
attempts
|
|
468
|
-
};
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
const viewportRatioPoint = resolveViewportPoint(viewportPoint, viewport);
|
|
472
|
-
const normalizedViewportPoint = normalizePoint(viewportRatioPoint);
|
|
473
|
-
if (normalizedViewportPoint && allowed.has("viewport_ratio")) {
|
|
474
|
-
if (!validateViewportPoint) {
|
|
475
|
-
return {
|
|
476
|
-
ok: true,
|
|
477
|
-
source: "viewport_ratio",
|
|
478
|
-
point: normalizedViewportPoint,
|
|
479
|
-
viewport,
|
|
480
|
-
validated: false,
|
|
481
|
-
attempts
|
|
482
|
-
};
|
|
483
|
-
}
|
|
484
|
-
const validationRects = [
|
|
485
|
-
...containerResult.boxes.map((item) => item.rect),
|
|
486
|
-
...itemBoxes.map((item) => item.rect)
|
|
487
|
-
].filter(isUsableRect);
|
|
488
|
-
const validatedRect = validationRects.find((rect) => pointInsideRect(normalizedViewportPoint, rect, { padding: 4 }));
|
|
489
|
-
attempts.push({
|
|
490
|
-
source: "viewport_ratio",
|
|
491
|
-
point: normalizedViewportPoint,
|
|
492
|
-
viewport,
|
|
493
|
-
validation_rect_count: validationRects.length,
|
|
494
|
-
validated: Boolean(validatedRect)
|
|
495
|
-
});
|
|
496
|
-
if (validatedRect) {
|
|
497
|
-
return {
|
|
498
|
-
ok: true,
|
|
499
|
-
source: "viewport_ratio",
|
|
500
|
-
point: normalizedViewportPoint,
|
|
501
|
-
viewport,
|
|
502
|
-
rect: validatedRect,
|
|
503
|
-
validated: true,
|
|
504
|
-
assist_node_id: itemNodeIds.slice(-1)[0] || null,
|
|
505
|
-
attempts
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
return {
|
|
511
|
-
ok: false,
|
|
512
|
-
reason: "fallback_point_unavailable",
|
|
513
|
-
attempts
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
function shortHash(value) {
|
|
518
|
-
return crypto.createHash("sha256").update(String(value || "")).digest("hex").slice(0, 16);
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
function pickAttribute(attributes = {}, names = []) {
|
|
522
|
-
for (const name of names) {
|
|
523
|
-
const value = normalizeText(attributes[name]);
|
|
524
|
-
if (value) return value;
|
|
525
|
-
}
|
|
526
|
-
return "";
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
export function candidateKeyFromProfile(candidate = {}, {
|
|
530
|
-
nodeId = null,
|
|
531
|
-
attributes = candidate.attributes || candidate.metadata?.attributes || {}
|
|
532
|
-
} = {}) {
|
|
533
|
-
const text = normalizeText(candidate.text?.raw || candidate.text || "");
|
|
534
|
-
const textSuffix = text ? `:text:${shortHash(text.slice(0, 1000))}` : "";
|
|
535
|
-
const id = normalizeText(candidate.id);
|
|
536
|
-
const stableAttrKey = pickAttribute(attributes, [
|
|
537
|
-
"data-geek",
|
|
538
|
-
"data-geekid",
|
|
539
|
-
"data-expect",
|
|
540
|
-
"data-uid",
|
|
541
|
-
"data-securityid",
|
|
542
|
-
"encryptgeekid",
|
|
543
|
-
"geekid",
|
|
544
|
-
"expect",
|
|
545
|
-
"uid",
|
|
546
|
-
"securityid"
|
|
547
|
-
]);
|
|
548
|
-
if (id && stableAttrKey && id === stableAttrKey) return `${candidate.domain || "candidate"}:id:${id}`;
|
|
549
|
-
if (id && !stableAttrKey) return `${candidate.domain || "candidate"}:id:${id}${textSuffix}`;
|
|
550
|
-
if (id) return `${candidate.domain || "candidate"}:id:${id}${textSuffix}`;
|
|
551
|
-
|
|
552
|
-
const attrKey = pickAttribute(attributes, [
|
|
553
|
-
"data-geek",
|
|
554
|
-
"data-geekid",
|
|
555
|
-
"data-expect",
|
|
556
|
-
"data-jid",
|
|
557
|
-
"data-id",
|
|
558
|
-
"data-uid",
|
|
559
|
-
"data-securityid",
|
|
560
|
-
"encryptgeekid",
|
|
561
|
-
"href",
|
|
562
|
-
"key",
|
|
563
|
-
"id"
|
|
564
|
-
]);
|
|
565
|
-
if (attrKey) return `${candidate.domain || "candidate"}:attr:${attrKey}${textSuffix}`;
|
|
566
|
-
|
|
567
|
-
const identity = candidate.identity || {};
|
|
568
|
-
const identityKey = [
|
|
569
|
-
identity.name,
|
|
570
|
-
identity.current_company,
|
|
571
|
-
identity.current_position,
|
|
572
|
-
identity.school,
|
|
573
|
-
identity.major,
|
|
574
|
-
identity.degree,
|
|
575
|
-
identity.age,
|
|
576
|
-
identity.gender
|
|
577
|
-
].map(normalizeText).filter(Boolean).join("|");
|
|
578
|
-
if (identityKey) return `${candidate.domain || "candidate"}:identity:${shortHash(identityKey)}`;
|
|
579
|
-
|
|
580
|
-
if (text) return `${candidate.domain || "candidate"}:text:${shortHash(text.slice(0, 1000))}`;
|
|
581
|
-
|
|
582
|
-
return `${candidate.domain || "candidate"}:node:${nodeId || "unknown"}`;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
export function createInfiniteListState({
|
|
586
|
-
domain = "unknown",
|
|
587
|
-
listName = "candidate-list"
|
|
588
|
-
} = {}) {
|
|
589
|
-
return {
|
|
590
|
-
schema_version: 1,
|
|
591
|
-
domain,
|
|
592
|
-
list_name: listName,
|
|
593
|
-
created_at: nowIso(),
|
|
594
|
-
seen_keys: new Set(),
|
|
595
|
-
queued_keys: new Set(),
|
|
596
|
-
processed_keys: new Set(),
|
|
597
|
-
skipped_duplicate_count: 0,
|
|
598
|
-
read_error_count: 0,
|
|
599
|
-
scroll_count: 0,
|
|
600
|
-
stable_signature_count: 0,
|
|
601
|
-
last_visible_signature: "",
|
|
602
|
-
last_result: null,
|
|
603
|
-
ledger: []
|
|
604
|
-
};
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
export function compactInfiniteListState(state = {}) {
|
|
608
|
-
return {
|
|
609
|
-
domain: state.domain || "unknown",
|
|
610
|
-
list_name: state.list_name || "candidate-list",
|
|
611
|
-
seen_count: state.seen_keys?.size || 0,
|
|
612
|
-
queued_count: state.queued_keys?.size || 0,
|
|
613
|
-
processed_count: state.processed_keys?.size || 0,
|
|
614
|
-
skipped_duplicate_count: state.skipped_duplicate_count || 0,
|
|
615
|
-
read_error_count: state.read_error_count || 0,
|
|
616
|
-
scroll_count: state.scroll_count || 0,
|
|
617
|
-
stable_signature_count: state.stable_signature_count || 0,
|
|
618
|
-
last_visible_signature: state.last_visible_signature || "",
|
|
619
|
-
last_result: state.last_result || null
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
export function markInfiniteListCandidateProcessed(state, key, {
|
|
624
|
-
status = "processed",
|
|
625
|
-
metadata = {}
|
|
626
|
-
} = {}) {
|
|
627
|
-
if (!state || !key) return compactInfiniteListState(state);
|
|
628
|
-
state.queued_keys?.delete(key);
|
|
629
|
-
state.processed_keys?.add(key);
|
|
630
|
-
state.ledger?.push({
|
|
631
|
-
at: nowIso(),
|
|
632
|
-
event: "candidate_processed",
|
|
633
|
-
key,
|
|
634
|
-
status,
|
|
635
|
-
metadata
|
|
636
|
-
});
|
|
637
|
-
return compactInfiniteListState(state);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
export function markInfiniteListCandidateSkipped(state, key, {
|
|
641
|
-
reason = "skipped",
|
|
642
|
-
metadata = {}
|
|
643
|
-
} = {}) {
|
|
644
|
-
if (!state || !key) return compactInfiniteListState(state);
|
|
645
|
-
state.queued_keys?.delete(key);
|
|
646
|
-
state.ledger?.push({
|
|
647
|
-
at: nowIso(),
|
|
648
|
-
event: "candidate_skipped",
|
|
649
|
-
key,
|
|
650
|
-
reason,
|
|
651
|
-
metadata
|
|
652
|
-
});
|
|
653
|
-
return compactInfiniteListState(state);
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
export function resetInfiniteListForRefreshRound(state, {
|
|
657
|
-
reason = "refresh_round",
|
|
658
|
-
round = 0,
|
|
659
|
-
method = "",
|
|
660
|
-
metadata = {}
|
|
661
|
-
} = {}) {
|
|
662
|
-
if (!state) return compactInfiniteListState(state);
|
|
663
|
-
state.queued_keys?.clear();
|
|
664
|
-
state.stable_signature_count = 0;
|
|
665
|
-
state.last_visible_signature = "";
|
|
666
|
-
state.last_result = null;
|
|
667
|
-
state.ledger?.push({
|
|
668
|
-
at: nowIso(),
|
|
669
|
-
event: "refresh_round_started",
|
|
670
|
-
reason,
|
|
671
|
-
round,
|
|
672
|
-
method,
|
|
673
|
-
metadata
|
|
674
|
-
});
|
|
675
|
-
return compactInfiniteListState(state);
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
export function classifyInfiniteListBottomMarker({
|
|
679
|
-
text = "",
|
|
680
|
-
refreshButtonVisible = false,
|
|
681
|
-
bottomKeywords = DEFAULT_BOTTOM_HINT_KEYWORDS,
|
|
682
|
-
loadMoreKeywords = DEFAULT_LOAD_MORE_HINT_KEYWORDS
|
|
683
|
-
} = {}) {
|
|
684
|
-
const normalizedText = normalizeText(text);
|
|
685
|
-
const matchedBottomKeyword = bottomKeywords.find((keyword) => normalizedText.includes(keyword)) || null;
|
|
686
|
-
if (matchedBottomKeyword) {
|
|
687
|
-
return {
|
|
688
|
-
is_bottom: true,
|
|
689
|
-
reason: matchedBottomKeyword,
|
|
690
|
-
matched_bottom_keyword: matchedBottomKeyword,
|
|
691
|
-
matched_load_more_keyword: null
|
|
692
|
-
};
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
const matchedLoadMoreKeyword = loadMoreKeywords.find((keyword) => normalizedText.includes(keyword)) || null;
|
|
696
|
-
if (matchedLoadMoreKeyword) {
|
|
697
|
-
return {
|
|
698
|
-
is_bottom: false,
|
|
699
|
-
reason: null,
|
|
700
|
-
matched_bottom_keyword: null,
|
|
701
|
-
matched_load_more_keyword: matchedLoadMoreKeyword
|
|
702
|
-
};
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
if (refreshButtonVisible) {
|
|
706
|
-
return {
|
|
707
|
-
is_bottom: true,
|
|
708
|
-
reason: "refresh_button_visible",
|
|
709
|
-
matched_bottom_keyword: null,
|
|
710
|
-
matched_load_more_keyword: null
|
|
711
|
-
};
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
return {
|
|
715
|
-
is_bottom: false,
|
|
716
|
-
reason: null,
|
|
717
|
-
matched_bottom_keyword: null,
|
|
718
|
-
matched_load_more_keyword: null
|
|
719
|
-
};
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
async function safeQuerySelectorAll(client, rootNodeId, selector) {
|
|
723
|
-
try {
|
|
724
|
-
return await querySelectorAll(client, rootNodeId, selector);
|
|
725
|
-
} catch {
|
|
726
|
-
return [];
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
async function readVisibleMarkerNode(client, nodeId) {
|
|
731
|
-
let box = null;
|
|
732
|
-
try {
|
|
733
|
-
box = await getNodeBox(client, nodeId);
|
|
734
|
-
} catch {
|
|
735
|
-
return null;
|
|
736
|
-
}
|
|
737
|
-
if (!isUsableBox(box)) return null;
|
|
738
|
-
let outerHTML = "";
|
|
739
|
-
try {
|
|
740
|
-
outerHTML = await getOuterHTML(client, nodeId);
|
|
741
|
-
} catch {
|
|
742
|
-
return null;
|
|
743
|
-
}
|
|
744
|
-
return {
|
|
745
|
-
node_id: nodeId,
|
|
746
|
-
text: plainTextFromHtml(outerHTML),
|
|
747
|
-
box
|
|
748
|
-
};
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
function looksLikeRefreshLabel(text = "") {
|
|
752
|
-
const normalized = normalizeText(text).replace(/\s+/g, "");
|
|
753
|
-
return Boolean(normalized) && normalized.length <= 80 && /刷新|refresh/i.test(normalized);
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
export async function detectInfiniteListBottomMarker(client, {
|
|
757
|
-
rootNodeId,
|
|
758
|
-
markerSelectors = DEFAULT_BOTTOM_MARKER_SELECTORS,
|
|
759
|
-
textScanSelectors = DEFAULT_BOTTOM_TEXT_SCAN_SELECTORS,
|
|
760
|
-
refreshSelectors = DEFAULT_BOTTOM_REFRESH_SELECTORS,
|
|
761
|
-
bottomKeywords = DEFAULT_BOTTOM_HINT_KEYWORDS,
|
|
762
|
-
loadMoreKeywords = DEFAULT_LOAD_MORE_HINT_KEYWORDS,
|
|
763
|
-
maxMarkerNodes = 300,
|
|
764
|
-
maxTextScanNodes = 800,
|
|
765
|
-
textMaxLength = 80
|
|
766
|
-
} = {}) {
|
|
767
|
-
if (!client || !rootNodeId) {
|
|
768
|
-
return {
|
|
769
|
-
found: false,
|
|
770
|
-
reason: "missing_client_or_root"
|
|
771
|
-
};
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
const selectorCounts = {};
|
|
775
|
-
const markerNodeIds = [];
|
|
776
|
-
for (const selector of markerSelectors || []) {
|
|
777
|
-
const nodeIds = await safeQuerySelectorAll(client, rootNodeId, selector);
|
|
778
|
-
selectorCounts[selector] = nodeIds.length;
|
|
779
|
-
markerNodeIds.push(...nodeIds);
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
const visibleMarkers = [];
|
|
783
|
-
const markerIds = uniqueValues(markerNodeIds).slice(0, Math.max(0, Number(maxMarkerNodes) || 0));
|
|
784
|
-
for (const nodeId of markerIds) {
|
|
785
|
-
const marker = await readVisibleMarkerNode(client, nodeId);
|
|
786
|
-
if (!marker?.text) continue;
|
|
787
|
-
const classified = classifyInfiniteListBottomMarker({
|
|
788
|
-
text: marker.text,
|
|
789
|
-
bottomKeywords,
|
|
790
|
-
loadMoreKeywords
|
|
791
|
-
});
|
|
792
|
-
const summary = {
|
|
793
|
-
node_id: marker.node_id,
|
|
794
|
-
text: marker.text.slice(0, 160),
|
|
795
|
-
y: marker.box?.rect?.y || null,
|
|
796
|
-
matched_bottom_keyword: classified.matched_bottom_keyword,
|
|
797
|
-
matched_load_more_keyword: classified.matched_load_more_keyword
|
|
798
|
-
};
|
|
799
|
-
visibleMarkers.push(summary);
|
|
800
|
-
if (classified.is_bottom) {
|
|
801
|
-
return {
|
|
802
|
-
found: true,
|
|
803
|
-
reason: classified.reason,
|
|
804
|
-
source: "marker_selector",
|
|
805
|
-
marker: summary,
|
|
806
|
-
selector_counts: selectorCounts,
|
|
807
|
-
visible_marker_count: visibleMarkers.length,
|
|
808
|
-
refresh_button_visible: false
|
|
809
|
-
};
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
const hasLoadMoreMarker = visibleMarkers.some((marker) => marker.matched_load_more_keyword);
|
|
814
|
-
|
|
815
|
-
const refreshNodeIds = [];
|
|
816
|
-
for (const selector of refreshSelectors || []) {
|
|
817
|
-
const nodeIds = await safeQuerySelectorAll(client, rootNodeId, selector);
|
|
818
|
-
selectorCounts[selector] = (selectorCounts[selector] || 0) + nodeIds.length;
|
|
819
|
-
refreshNodeIds.push(...nodeIds);
|
|
820
|
-
}
|
|
821
|
-
const refreshButtons = [];
|
|
822
|
-
for (const nodeId of uniqueValues(refreshNodeIds).slice(0, 300)) {
|
|
823
|
-
const marker = await readVisibleMarkerNode(client, nodeId);
|
|
824
|
-
if (!marker?.text || !looksLikeRefreshLabel(marker.text)) continue;
|
|
825
|
-
refreshButtons.push({
|
|
826
|
-
node_id: marker.node_id,
|
|
827
|
-
text: marker.text.slice(0, 120),
|
|
828
|
-
y: marker.box?.rect?.y || null
|
|
829
|
-
});
|
|
830
|
-
}
|
|
831
|
-
if (refreshButtons.length && !hasLoadMoreMarker) {
|
|
832
|
-
return {
|
|
833
|
-
found: true,
|
|
834
|
-
reason: "refresh_button_visible",
|
|
835
|
-
source: "refresh_button",
|
|
836
|
-
marker: refreshButtons[0],
|
|
837
|
-
selector_counts: selectorCounts,
|
|
838
|
-
visible_marker_count: visibleMarkers.length,
|
|
839
|
-
refresh_button_visible: true,
|
|
840
|
-
refresh_button_count: refreshButtons.length
|
|
841
|
-
};
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
const scanNodeIds = [];
|
|
845
|
-
for (const selector of textScanSelectors || []) {
|
|
846
|
-
const nodeIds = await safeQuerySelectorAll(client, rootNodeId, selector);
|
|
847
|
-
selectorCounts[selector] = (selectorCounts[selector] || 0) + nodeIds.length;
|
|
848
|
-
scanNodeIds.push(...nodeIds);
|
|
849
|
-
}
|
|
850
|
-
let checkedTextNodeCount = 0;
|
|
851
|
-
for (const nodeId of uniqueValues(scanNodeIds).slice(0, Math.max(0, Number(maxTextScanNodes) || 0))) {
|
|
852
|
-
const marker = await readVisibleMarkerNode(client, nodeId);
|
|
853
|
-
if (!marker?.text || marker.text.length > textMaxLength) continue;
|
|
854
|
-
checkedTextNodeCount += 1;
|
|
855
|
-
const classified = classifyInfiniteListBottomMarker({
|
|
856
|
-
text: marker.text,
|
|
857
|
-
bottomKeywords,
|
|
858
|
-
loadMoreKeywords
|
|
859
|
-
});
|
|
860
|
-
if (classified.is_bottom) {
|
|
861
|
-
return {
|
|
862
|
-
found: true,
|
|
863
|
-
reason: classified.reason,
|
|
864
|
-
source: "text_scan",
|
|
865
|
-
marker: {
|
|
866
|
-
node_id: marker.node_id,
|
|
867
|
-
text: marker.text.slice(0, 160),
|
|
868
|
-
y: marker.box?.rect?.y || null,
|
|
869
|
-
matched_bottom_keyword: classified.matched_bottom_keyword
|
|
870
|
-
},
|
|
871
|
-
selector_counts: selectorCounts,
|
|
872
|
-
visible_marker_count: visibleMarkers.length,
|
|
873
|
-
checked_text_node_count: checkedTextNodeCount,
|
|
874
|
-
refresh_button_visible: refreshButtons.length > 0,
|
|
875
|
-
refresh_button_count: refreshButtons.length
|
|
876
|
-
};
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
return {
|
|
881
|
-
found: false,
|
|
882
|
-
reason: hasLoadMoreMarker ? "load_more_marker_visible" : "bottom_marker_not_found",
|
|
883
|
-
selector_counts: selectorCounts,
|
|
884
|
-
visible_markers: visibleMarkers.slice(0, 20),
|
|
885
|
-
visible_marker_count: visibleMarkers.length,
|
|
886
|
-
checked_text_node_count: checkedTextNodeCount,
|
|
887
|
-
refresh_button_visible: refreshButtons.length > 0,
|
|
888
|
-
refresh_button_count: refreshButtons.length
|
|
889
|
-
};
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
export async function readVisibleInfiniteListItems({
|
|
893
|
-
nodeIds = [],
|
|
894
|
-
readCandidate,
|
|
895
|
-
keyForCandidate = candidateKeyFromProfile,
|
|
896
|
-
state = null
|
|
897
|
-
} = {}) {
|
|
898
|
-
if (typeof readCandidate !== "function") {
|
|
899
|
-
throw new Error("readVisibleInfiniteListItems requires readCandidate");
|
|
900
|
-
}
|
|
901
|
-
const items = [];
|
|
902
|
-
for (let visibleIndex = 0; visibleIndex < nodeIds.length; visibleIndex += 1) {
|
|
903
|
-
const nodeId = nodeIds[visibleIndex];
|
|
904
|
-
let candidate;
|
|
905
|
-
try {
|
|
906
|
-
candidate = await readCandidate(nodeId, { visibleIndex });
|
|
907
|
-
} catch (error) {
|
|
908
|
-
if (state) {
|
|
909
|
-
state.read_error_count = (state.read_error_count || 0) + 1;
|
|
910
|
-
state.ledger?.push({
|
|
911
|
-
at: nowIso(),
|
|
912
|
-
event: "candidate_read_error",
|
|
913
|
-
node_id: nodeId,
|
|
914
|
-
visible_index: visibleIndex,
|
|
915
|
-
error: error?.message || String(error)
|
|
916
|
-
});
|
|
917
|
-
}
|
|
918
|
-
continue;
|
|
919
|
-
}
|
|
920
|
-
const key = keyForCandidate(candidate, {
|
|
921
|
-
nodeId,
|
|
922
|
-
visibleIndex,
|
|
923
|
-
attributes: candidate?.attributes || candidate?.metadata?.attributes || {}
|
|
924
|
-
});
|
|
925
|
-
items.push({
|
|
926
|
-
key,
|
|
927
|
-
node_id: nodeId,
|
|
928
|
-
visible_index: visibleIndex,
|
|
929
|
-
candidate
|
|
930
|
-
});
|
|
931
|
-
}
|
|
932
|
-
return items;
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
export function updateInfiniteListVisibleSignature(state, items = []) {
|
|
936
|
-
const signature = items.map((item) => item.key).filter(Boolean).join("|");
|
|
937
|
-
const unchanged = Boolean(signature) && signature === state.last_visible_signature;
|
|
938
|
-
state.stable_signature_count = unchanged ? (state.stable_signature_count || 0) + 1 : 0;
|
|
939
|
-
state.last_visible_signature = signature;
|
|
940
|
-
return {
|
|
941
|
-
signature,
|
|
942
|
-
unchanged,
|
|
943
|
-
stable_signature_count: state.stable_signature_count
|
|
944
|
-
};
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
export function firstUnseenInfiniteListItem(state, items = []) {
|
|
948
|
-
for (const item of items) {
|
|
949
|
-
if (!item.key) continue;
|
|
950
|
-
if (state.processed_keys.has(item.key) || state.queued_keys.has(item.key)) {
|
|
951
|
-
state.skipped_duplicate_count += 1;
|
|
952
|
-
continue;
|
|
953
|
-
}
|
|
954
|
-
state.seen_keys.add(item.key);
|
|
955
|
-
state.queued_keys.add(item.key);
|
|
956
|
-
state.ledger.push({
|
|
957
|
-
at: nowIso(),
|
|
958
|
-
event: "candidate_queued",
|
|
959
|
-
key: item.key,
|
|
960
|
-
node_id: item.node_id,
|
|
961
|
-
visible_index: item.visible_index
|
|
962
|
-
});
|
|
963
|
-
return item;
|
|
964
|
-
}
|
|
965
|
-
return null;
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
export async function scrollInfiniteListByVisibleItems(client, items = [], {
|
|
969
|
-
wheelDeltaY = 850,
|
|
970
|
-
settleMs = 1200,
|
|
971
|
-
fallbackPoint = null,
|
|
972
|
-
listScrollJitterEnabled = false,
|
|
973
|
-
listScrollJitterMinRatio = 0.85,
|
|
974
|
-
listScrollJitterMaxRatio = 1.15,
|
|
975
|
-
listSettleJitterMinRatio = 0.75,
|
|
976
|
-
listSettleJitterMaxRatio = 1.35,
|
|
977
|
-
random = Math.random
|
|
978
|
-
} = {}) {
|
|
979
|
-
const candidates = items.filter((item) => item?.node_id);
|
|
980
|
-
if (!candidates.length) {
|
|
981
|
-
return {
|
|
982
|
-
ok: false,
|
|
983
|
-
reason: "no_visible_items"
|
|
984
|
-
};
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
const errors = [];
|
|
988
|
-
const scrollTiming = resolveInfiniteListScrollTiming({
|
|
989
|
-
wheelDeltaY,
|
|
990
|
-
settleMs,
|
|
991
|
-
listScrollJitterEnabled,
|
|
992
|
-
listScrollJitterMinRatio,
|
|
993
|
-
listScrollJitterMaxRatio,
|
|
994
|
-
listSettleJitterMinRatio,
|
|
995
|
-
listSettleJitterMaxRatio,
|
|
996
|
-
random
|
|
997
|
-
});
|
|
998
|
-
const wheelDelta = scrollTiming.wheelDeltaY;
|
|
999
|
-
const actualSettleMs = scrollTiming.settleMs;
|
|
1000
|
-
async function synthesizeGesture(x, y) {
|
|
1001
|
-
if (typeof client?.Input?.synthesizeScrollGesture !== "function") return null;
|
|
1002
|
-
try {
|
|
1003
|
-
const gestureDistance = -Math.min(1200, wheelDelta);
|
|
1004
|
-
await client.Input.synthesizeScrollGesture({
|
|
1005
|
-
x,
|
|
1006
|
-
y,
|
|
1007
|
-
yDistance: gestureDistance,
|
|
1008
|
-
speed: 800,
|
|
1009
|
-
repeatCount: 1
|
|
1010
|
-
});
|
|
1011
|
-
return {
|
|
1012
|
-
ok: true,
|
|
1013
|
-
y_distance: gestureDistance
|
|
1014
|
-
};
|
|
1015
|
-
} catch (error) {
|
|
1016
|
-
return {
|
|
1017
|
-
ok: false,
|
|
1018
|
-
error: error?.message || String(error)
|
|
1019
|
-
};
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
for (const anchor of candidates.slice().reverse()) {
|
|
1023
|
-
try {
|
|
1024
|
-
await scrollNodeIntoView(client, anchor.node_id);
|
|
1025
|
-
await sleep(150);
|
|
1026
|
-
const box = await getNodeBox(client, anchor.node_id);
|
|
1027
|
-
const x = box.center.x;
|
|
1028
|
-
const y = box.center.y;
|
|
1029
|
-
await client.Input.dispatchMouseEvent({ type: "mouseMoved", x, y, button: "none" });
|
|
1030
|
-
await client.Input.dispatchMouseEvent({
|
|
1031
|
-
type: "mouseWheel",
|
|
1032
|
-
x,
|
|
1033
|
-
y,
|
|
1034
|
-
deltaX: 0,
|
|
1035
|
-
deltaY: wheelDelta
|
|
1036
|
-
});
|
|
1037
|
-
const gesture = await synthesizeGesture(x, y);
|
|
1038
|
-
if (actualSettleMs > 0) await sleep(actualSettleMs);
|
|
1039
|
-
return {
|
|
1040
|
-
ok: true,
|
|
1041
|
-
anchor_key: anchor.key,
|
|
1042
|
-
anchor_node_id: anchor.node_id,
|
|
1043
|
-
point: { x, y },
|
|
1044
|
-
wheel_delta_y: wheelDelta,
|
|
1045
|
-
base_wheel_delta_y: Math.max(1, Number(wheelDeltaY) || 850),
|
|
1046
|
-
wheel_delta_jitter: scrollTiming.wheel_delta_jitter,
|
|
1047
|
-
gesture,
|
|
1048
|
-
settle_ms: actualSettleMs,
|
|
1049
|
-
base_settle_ms: Math.max(0, Number(settleMs) || 0),
|
|
1050
|
-
settle_jitter: scrollTiming.settle_jitter,
|
|
1051
|
-
skipped_stale_anchor_count: errors.length
|
|
1052
|
-
};
|
|
1053
|
-
} catch (error) {
|
|
1054
|
-
errors.push({
|
|
1055
|
-
anchor_key: anchor.key,
|
|
1056
|
-
anchor_node_id: anchor.node_id,
|
|
1057
|
-
error: error?.message || String(error)
|
|
1058
|
-
});
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
const resolvedFallback = typeof fallbackPoint === "function"
|
|
1063
|
-
? await fallbackPoint({ client, items, errors })
|
|
1064
|
-
: (fallbackPoint ? { ok: true, source: "static", point: fallbackPoint } : null);
|
|
1065
|
-
const resolvedPoint = normalizePoint(resolvedFallback?.point || resolvedFallback);
|
|
1066
|
-
if (resolvedPoint) {
|
|
1067
|
-
const x = resolvedPoint.x;
|
|
1068
|
-
const y = resolvedPoint.y;
|
|
1069
|
-
let assist = null;
|
|
1070
|
-
if (resolvedFallback?.assist_node_id) {
|
|
1071
|
-
try {
|
|
1072
|
-
await scrollNodeIntoView(client, resolvedFallback.assist_node_id);
|
|
1073
|
-
await sleep(150);
|
|
1074
|
-
assist = {
|
|
1075
|
-
ok: true,
|
|
1076
|
-
node_id: resolvedFallback.assist_node_id
|
|
1077
|
-
};
|
|
1078
|
-
} catch (error) {
|
|
1079
|
-
assist = {
|
|
1080
|
-
ok: false,
|
|
1081
|
-
node_id: resolvedFallback.assist_node_id,
|
|
1082
|
-
error: error?.message || String(error)
|
|
1083
|
-
};
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
await client.Input.dispatchMouseEvent({ type: "mouseMoved", x, y, button: "none" });
|
|
1087
|
-
await client.Input.dispatchMouseEvent({
|
|
1088
|
-
type: "mouseWheel",
|
|
1089
|
-
x,
|
|
1090
|
-
y,
|
|
1091
|
-
deltaX: 0,
|
|
1092
|
-
deltaY: wheelDelta
|
|
1093
|
-
});
|
|
1094
|
-
const gesture = await synthesizeGesture(x, y);
|
|
1095
|
-
if (actualSettleMs > 0) await sleep(actualSettleMs);
|
|
1096
|
-
return {
|
|
1097
|
-
ok: true,
|
|
1098
|
-
mode: "fallback_point",
|
|
1099
|
-
fallback: {
|
|
1100
|
-
source: resolvedFallback?.source || "static",
|
|
1101
|
-
selector: resolvedFallback?.selector || null,
|
|
1102
|
-
node_id: resolvedFallback?.node_id || null,
|
|
1103
|
-
assist_node_id: resolvedFallback?.assist_node_id || null,
|
|
1104
|
-
rect: resolvedFallback?.rect || null,
|
|
1105
|
-
validated: resolvedFallback?.validated ?? null,
|
|
1106
|
-
reason: resolvedFallback?.reason || null
|
|
1107
|
-
},
|
|
1108
|
-
assist,
|
|
1109
|
-
point: { x, y },
|
|
1110
|
-
wheel_delta_y: wheelDelta,
|
|
1111
|
-
base_wheel_delta_y: Math.max(1, Number(wheelDeltaY) || 850),
|
|
1112
|
-
wheel_delta_jitter: scrollTiming.wheel_delta_jitter,
|
|
1113
|
-
gesture,
|
|
1114
|
-
settle_ms: actualSettleMs,
|
|
1115
|
-
base_settle_ms: Math.max(0, Number(settleMs) || 0),
|
|
1116
|
-
settle_jitter: scrollTiming.settle_jitter,
|
|
1117
|
-
skipped_stale_anchor_count: errors.length,
|
|
1118
|
-
stale_anchor_errors: errors
|
|
1119
|
-
};
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
return {
|
|
1123
|
-
ok: false,
|
|
1124
|
-
reason: "scroll_anchor_unavailable",
|
|
1125
|
-
errors,
|
|
1126
|
-
fallback: resolvedFallback || null
|
|
1127
|
-
};
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
export async function getNextInfiniteListCandidate({
|
|
1131
|
-
client,
|
|
1132
|
-
state,
|
|
1133
|
-
findNodeIds,
|
|
1134
|
-
readCandidate,
|
|
1135
|
-
detectBottomMarker = null,
|
|
1136
|
-
keyForCandidate = candidateKeyFromProfile,
|
|
1137
|
-
maxScrolls = 20,
|
|
1138
|
-
stableSignatureLimit = 2,
|
|
1139
|
-
minScrollsBeforeEnd = 3,
|
|
1140
|
-
wheelDeltaY = 850,
|
|
1141
|
-
settleMs = 1200,
|
|
1142
|
-
fallbackPoint = null,
|
|
1143
|
-
listScrollJitterEnabled = false,
|
|
1144
|
-
listScrollJitterMinRatio = 0.85,
|
|
1145
|
-
listScrollJitterMaxRatio = 1.15,
|
|
1146
|
-
listSettleJitterMinRatio = 0.75,
|
|
1147
|
-
listSettleJitterMaxRatio = 1.35,
|
|
1148
|
-
random = Math.random
|
|
1149
|
-
} = {}) {
|
|
1150
|
-
if (!client) throw new Error("getNextInfiniteListCandidate requires client");
|
|
1151
|
-
if (!state) throw new Error("getNextInfiniteListCandidate requires state");
|
|
1152
|
-
if (typeof findNodeIds !== "function") throw new Error("getNextInfiniteListCandidate requires findNodeIds");
|
|
1153
|
-
if (typeof readCandidate !== "function") throw new Error("getNextInfiniteListCandidate requires readCandidate");
|
|
1154
|
-
|
|
1155
|
-
const attempts = [];
|
|
1156
|
-
const maxAttempts = Math.max(0, Number(maxScrolls) || 0);
|
|
1157
|
-
for (let scrollAttempt = 0; scrollAttempt <= maxAttempts; scrollAttempt += 1) {
|
|
1158
|
-
const nodeIds = await findNodeIds();
|
|
1159
|
-
const items = await readVisibleInfiniteListItems({
|
|
1160
|
-
nodeIds,
|
|
1161
|
-
readCandidate,
|
|
1162
|
-
keyForCandidate,
|
|
1163
|
-
state
|
|
1164
|
-
});
|
|
1165
|
-
const signature = updateInfiniteListVisibleSignature(state, items);
|
|
1166
|
-
const next = firstUnseenInfiniteListItem(state, items);
|
|
1167
|
-
attempts.push({
|
|
1168
|
-
scroll_attempt: scrollAttempt,
|
|
1169
|
-
visible_count: items.length,
|
|
1170
|
-
signature: signature.signature,
|
|
1171
|
-
stable_signature_count: signature.stable_signature_count,
|
|
1172
|
-
found_next: Boolean(next)
|
|
1173
|
-
});
|
|
1174
|
-
|
|
1175
|
-
if (next) {
|
|
1176
|
-
state.stable_signature_count = 0;
|
|
1177
|
-
const result = {
|
|
1178
|
-
ok: true,
|
|
1179
|
-
end_reached: false,
|
|
1180
|
-
item: next,
|
|
1181
|
-
attempts,
|
|
1182
|
-
state: compactInfiniteListState(state)
|
|
1183
|
-
};
|
|
1184
|
-
state.last_result = {
|
|
1185
|
-
at: nowIso(),
|
|
1186
|
-
ok: true,
|
|
1187
|
-
key: next.key,
|
|
1188
|
-
visible_index: next.visible_index
|
|
1189
|
-
};
|
|
1190
|
-
return result;
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
if (typeof detectBottomMarker === "function") {
|
|
1194
|
-
let bottomMarker = null;
|
|
1195
|
-
try {
|
|
1196
|
-
bottomMarker = await detectBottomMarker({
|
|
1197
|
-
scrollAttempt,
|
|
1198
|
-
items,
|
|
1199
|
-
signature,
|
|
1200
|
-
state: compactInfiniteListState(state)
|
|
1201
|
-
});
|
|
1202
|
-
} catch (error) {
|
|
1203
|
-
bottomMarker = {
|
|
1204
|
-
found: false,
|
|
1205
|
-
reason: "bottom_marker_probe_failed",
|
|
1206
|
-
error: error?.message || String(error)
|
|
1207
|
-
};
|
|
1208
|
-
}
|
|
1209
|
-
attempts[attempts.length - 1].bottom_marker = bottomMarker;
|
|
1210
|
-
if (bottomMarker?.found) {
|
|
1211
|
-
state.ledger?.push({
|
|
1212
|
-
at: nowIso(),
|
|
1213
|
-
event: "bottom_marker_detected",
|
|
1214
|
-
reason: bottomMarker.reason || "bottom_marker",
|
|
1215
|
-
source: bottomMarker.source || "",
|
|
1216
|
-
marker: bottomMarker.marker || null
|
|
1217
|
-
});
|
|
1218
|
-
const result = {
|
|
1219
|
-
ok: false,
|
|
1220
|
-
end_reached: true,
|
|
1221
|
-
reason: "bottom_marker",
|
|
1222
|
-
bottom_marker: bottomMarker,
|
|
1223
|
-
attempts,
|
|
1224
|
-
state: compactInfiniteListState(state)
|
|
1225
|
-
};
|
|
1226
|
-
state.last_result = {
|
|
1227
|
-
at: nowIso(),
|
|
1228
|
-
ok: false,
|
|
1229
|
-
end_reached: true,
|
|
1230
|
-
reason: result.reason,
|
|
1231
|
-
bottom_marker: {
|
|
1232
|
-
reason: bottomMarker.reason || null,
|
|
1233
|
-
source: bottomMarker.source || null,
|
|
1234
|
-
marker: bottomMarker.marker || null
|
|
1235
|
-
}
|
|
1236
|
-
};
|
|
1237
|
-
return result;
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
if (!items.length) {
|
|
1242
|
-
const result = {
|
|
1243
|
-
ok: false,
|
|
1244
|
-
end_reached: true,
|
|
1245
|
-
reason: "empty_visible_list",
|
|
1246
|
-
attempts,
|
|
1247
|
-
state: compactInfiniteListState(state)
|
|
1248
|
-
};
|
|
1249
|
-
state.last_result = {
|
|
1250
|
-
at: nowIso(),
|
|
1251
|
-
ok: false,
|
|
1252
|
-
end_reached: true,
|
|
1253
|
-
reason: result.reason
|
|
1254
|
-
};
|
|
1255
|
-
return result;
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
const stableLimit = Math.max(1, Number(stableSignatureLimit) || 1);
|
|
1259
|
-
const minStableScrolls = Math.max(0, Number(minScrollsBeforeEnd) || 0);
|
|
1260
|
-
if (signature.stable_signature_count >= stableLimit && scrollAttempt >= minStableScrolls) {
|
|
1261
|
-
const result = {
|
|
1262
|
-
ok: false,
|
|
1263
|
-
end_reached: true,
|
|
1264
|
-
reason: "stable_visible_signature",
|
|
1265
|
-
attempts,
|
|
1266
|
-
state: compactInfiniteListState(state)
|
|
1267
|
-
};
|
|
1268
|
-
state.last_result = {
|
|
1269
|
-
at: nowIso(),
|
|
1270
|
-
ok: false,
|
|
1271
|
-
end_reached: true,
|
|
1272
|
-
reason: result.reason
|
|
1273
|
-
};
|
|
1274
|
-
return result;
|
|
1275
|
-
}
|
|
1276
|
-
if (signature.stable_signature_count >= stableLimit) {
|
|
1277
|
-
attempts[attempts.length - 1].stable_end_deferred = true;
|
|
1278
|
-
attempts[attempts.length - 1].min_scrolls_before_end = minStableScrolls;
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
const scrollResult = await scrollInfiniteListByVisibleItems(client, items, {
|
|
1282
|
-
wheelDeltaY,
|
|
1283
|
-
settleMs,
|
|
1284
|
-
fallbackPoint,
|
|
1285
|
-
listScrollJitterEnabled,
|
|
1286
|
-
listScrollJitterMinRatio,
|
|
1287
|
-
listScrollJitterMaxRatio,
|
|
1288
|
-
listSettleJitterMinRatio,
|
|
1289
|
-
listSettleJitterMaxRatio,
|
|
1290
|
-
random
|
|
1291
|
-
});
|
|
1292
|
-
state.scroll_count += scrollResult.ok ? 1 : 0;
|
|
1293
|
-
attempts[attempts.length - 1].scroll_result = scrollResult;
|
|
1294
|
-
if (!scrollResult.ok) {
|
|
1295
|
-
const result = {
|
|
1296
|
-
ok: false,
|
|
1297
|
-
end_reached: true,
|
|
1298
|
-
reason: scrollResult.reason || "scroll_failed",
|
|
1299
|
-
attempts,
|
|
1300
|
-
state: compactInfiniteListState(state)
|
|
1301
|
-
};
|
|
1302
|
-
state.last_result = {
|
|
1303
|
-
at: nowIso(),
|
|
1304
|
-
ok: false,
|
|
1305
|
-
end_reached: true,
|
|
1306
|
-
reason: result.reason
|
|
1307
|
-
};
|
|
1308
|
-
return result;
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
const result = {
|
|
1313
|
-
ok: false,
|
|
1314
|
-
end_reached: false,
|
|
1315
|
-
reason: "max_scrolls_exhausted",
|
|
1316
|
-
attempts,
|
|
1317
|
-
state: compactInfiniteListState(state)
|
|
1318
|
-
};
|
|
1319
|
-
state.last_result = {
|
|
1320
|
-
at: nowIso(),
|
|
1321
|
-
ok: false,
|
|
1322
|
-
end_reached: false,
|
|
1323
|
-
reason: result.reason
|
|
1324
|
-
};
|
|
1325
|
-
return result;
|
|
1326
|
-
}
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
getNodeBox,
|
|
4
|
+
getOuterHTML,
|
|
5
|
+
querySelectorAll,
|
|
6
|
+
scrollNodeIntoView,
|
|
7
|
+
sleep
|
|
8
|
+
} from "../browser/index.js";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_BOTTOM_HINT_KEYWORDS = Object.freeze([
|
|
11
|
+
"没有更多",
|
|
12
|
+
"已显示全部",
|
|
13
|
+
"已经到底",
|
|
14
|
+
"暂无更多",
|
|
15
|
+
"推荐完了",
|
|
16
|
+
"没有更多人选",
|
|
17
|
+
"没有更多了",
|
|
18
|
+
"已到底"
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_LOAD_MORE_HINT_KEYWORDS = Object.freeze([
|
|
22
|
+
"滚动加载更多",
|
|
23
|
+
"下滑加载更多",
|
|
24
|
+
"继续下滑",
|
|
25
|
+
"继续滑动",
|
|
26
|
+
"滑动加载",
|
|
27
|
+
"正在加载",
|
|
28
|
+
"加载中"
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_BOTTOM_MARKER_SELECTORS = Object.freeze([
|
|
32
|
+
".finished-wrap",
|
|
33
|
+
".loadmore",
|
|
34
|
+
".load-tips",
|
|
35
|
+
"div[role=\"tfoot\"] .load-tips",
|
|
36
|
+
".no-data-refresh",
|
|
37
|
+
".empty-tip",
|
|
38
|
+
".empty-text",
|
|
39
|
+
".no-data",
|
|
40
|
+
".tip-nodata",
|
|
41
|
+
"[class*=\"finished\"]",
|
|
42
|
+
"[class*=\"loadmore\"]",
|
|
43
|
+
"[class*=\"load-tips\"]",
|
|
44
|
+
"[class*=\"no-more\"]",
|
|
45
|
+
"[class*=\"no_more\"]"
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
export const DEFAULT_BOTTOM_TEXT_SCAN_SELECTORS = Object.freeze([
|
|
49
|
+
"div",
|
|
50
|
+
"span",
|
|
51
|
+
"p",
|
|
52
|
+
"li",
|
|
53
|
+
"button",
|
|
54
|
+
"a"
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
export const DEFAULT_BOTTOM_REFRESH_SELECTORS = Object.freeze([
|
|
58
|
+
".finished-wrap .btn-refresh",
|
|
59
|
+
".finished-wrap .btn",
|
|
60
|
+
".no-data-refresh .btn-refresh",
|
|
61
|
+
".no-data-refresh .btn",
|
|
62
|
+
"[class*=\"refresh\"]",
|
|
63
|
+
"[ka*=\"refresh\"]",
|
|
64
|
+
"button",
|
|
65
|
+
"a"
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
function nowIso() {
|
|
69
|
+
return new Date().toISOString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeText(value) {
|
|
73
|
+
return String(value || "").replace(/\s+/g, " ").trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function uniqueValues(values = []) {
|
|
77
|
+
return Array.from(new Set(values.filter(Boolean)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function decodeBasicHtmlEntities(value = "") {
|
|
81
|
+
return String(value || "")
|
|
82
|
+
.replace(/ | /gi, " ")
|
|
83
|
+
.replace(/&/gi, "&")
|
|
84
|
+
.replace(/</gi, "<")
|
|
85
|
+
.replace(/>/gi, ">")
|
|
86
|
+
.replace(/"/gi, "\"")
|
|
87
|
+
.replace(/'|'/gi, "'");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function plainTextFromHtml(html = "") {
|
|
91
|
+
return normalizeText(decodeBasicHtmlEntities(String(html || "")
|
|
92
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
|
|
93
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
|
|
94
|
+
.replace(/<[^>]+>/g, " ")));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isUsableBox(box) {
|
|
98
|
+
return Number(box?.rect?.width || 0) > 2 && Number(box?.rect?.height || 0) > 2;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isUsableRect(rect) {
|
|
102
|
+
return Number(rect?.width || 0) > 2 && Number(rect?.height || 0) > 2;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function pointFromRect(rect, {
|
|
106
|
+
xRatio = 0.5,
|
|
107
|
+
yRatio = 0.75,
|
|
108
|
+
inset = 8
|
|
109
|
+
} = {}) {
|
|
110
|
+
if (!isUsableRect(rect)) return null;
|
|
111
|
+
const safeInsetX = Math.min(Math.max(0, Number(inset) || 0), Math.max(0, rect.width / 2 - 1));
|
|
112
|
+
const safeInsetY = Math.min(Math.max(0, Number(inset) || 0), Math.max(0, rect.height / 2 - 1));
|
|
113
|
+
const minX = rect.x + safeInsetX;
|
|
114
|
+
const maxX = rect.x + rect.width - safeInsetX;
|
|
115
|
+
const minY = rect.y + safeInsetY;
|
|
116
|
+
const maxY = rect.y + rect.height - safeInsetY;
|
|
117
|
+
return {
|
|
118
|
+
x: Math.min(maxX, Math.max(minX, rect.x + rect.width * (Number(xRatio) || 0.5))),
|
|
119
|
+
y: Math.min(maxY, Math.max(minY, rect.y + rect.height * (Number(yRatio) || 0.75)))
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function unionRects(rects = []) {
|
|
124
|
+
const usable = rects.filter(isUsableRect);
|
|
125
|
+
if (!usable.length) return null;
|
|
126
|
+
const left = Math.min(...usable.map((rect) => rect.x));
|
|
127
|
+
const top = Math.min(...usable.map((rect) => rect.y));
|
|
128
|
+
const right = Math.max(...usable.map((rect) => rect.x + rect.width));
|
|
129
|
+
const bottom = Math.max(...usable.map((rect) => rect.y + rect.height));
|
|
130
|
+
return {
|
|
131
|
+
x: left,
|
|
132
|
+
y: top,
|
|
133
|
+
width: right - left,
|
|
134
|
+
height: bottom - top
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function pointInsideRect(point, rect, {
|
|
139
|
+
padding = 0
|
|
140
|
+
} = {}) {
|
|
141
|
+
if (!point || !isUsableRect(rect)) return false;
|
|
142
|
+
const pad = Math.max(0, Number(padding) || 0);
|
|
143
|
+
return Number(point.x) >= rect.x + pad
|
|
144
|
+
&& Number(point.x) <= rect.x + rect.width - pad
|
|
145
|
+
&& Number(point.y) >= rect.y + pad
|
|
146
|
+
&& Number(point.y) <= rect.y + rect.height - pad;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function rectsIntersect(a, b, {
|
|
150
|
+
padding = 0
|
|
151
|
+
} = {}) {
|
|
152
|
+
if (!isUsableRect(a) || !isUsableRect(b)) return false;
|
|
153
|
+
const pad = Math.max(0, Number(padding) || 0);
|
|
154
|
+
return a.x + a.width >= b.x + pad
|
|
155
|
+
&& b.x + b.width >= a.x + pad
|
|
156
|
+
&& a.y + a.height >= b.y + pad
|
|
157
|
+
&& b.y + b.height >= a.y + pad;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function intersectRects(a, b) {
|
|
161
|
+
if (!isUsableRect(a) || !isUsableRect(b)) return null;
|
|
162
|
+
const left = Math.max(a.x, b.x);
|
|
163
|
+
const top = Math.max(a.y, b.y);
|
|
164
|
+
const right = Math.min(a.x + a.width, b.x + b.width);
|
|
165
|
+
const bottom = Math.min(a.y + a.height, b.y + b.height);
|
|
166
|
+
if (right - left <= 2 || bottom - top <= 2) return null;
|
|
167
|
+
return {
|
|
168
|
+
x: left,
|
|
169
|
+
y: top,
|
|
170
|
+
width: right - left,
|
|
171
|
+
height: bottom - top
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizePoint(point) {
|
|
176
|
+
if (!point) return null;
|
|
177
|
+
const x = Number(point.x);
|
|
178
|
+
const y = Number(point.y);
|
|
179
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
180
|
+
return { x, y };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function normalizeRandom(random) {
|
|
184
|
+
return typeof random === "function" ? random : Math.random;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function randomBetween(random, min, max) {
|
|
188
|
+
const lower = Number(min) || 0;
|
|
189
|
+
const upper = Number(max) || lower;
|
|
190
|
+
if (upper <= lower) return lower;
|
|
191
|
+
return lower + random() * (upper - lower);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function clampNumber(value, min, max) {
|
|
195
|
+
const number = Number(value);
|
|
196
|
+
if (!Number.isFinite(number)) return min;
|
|
197
|
+
return Math.min(max, Math.max(min, number));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function resolveInfiniteListScrollTiming({
|
|
201
|
+
wheelDeltaY = 850,
|
|
202
|
+
settleMs = 1200,
|
|
203
|
+
listScrollJitterEnabled = false,
|
|
204
|
+
listScrollJitterMinRatio = 0.85,
|
|
205
|
+
listScrollJitterMaxRatio = 1.15,
|
|
206
|
+
listSettleJitterMinRatio = 0.75,
|
|
207
|
+
listSettleJitterMaxRatio = 1.35,
|
|
208
|
+
random = Math.random
|
|
209
|
+
} = {}) {
|
|
210
|
+
const baseDeltaY = Math.max(1, Number(wheelDeltaY) || 850);
|
|
211
|
+
const baseSettleMs = Math.max(0, Number(settleMs) || 0);
|
|
212
|
+
if (listScrollJitterEnabled !== true) {
|
|
213
|
+
return {
|
|
214
|
+
wheelDeltaY: baseDeltaY,
|
|
215
|
+
settleMs: baseSettleMs,
|
|
216
|
+
wheel_delta_jitter: {
|
|
217
|
+
enabled: false,
|
|
218
|
+
base_delta_y: baseDeltaY,
|
|
219
|
+
actual_delta_y: baseDeltaY
|
|
220
|
+
},
|
|
221
|
+
settle_jitter: {
|
|
222
|
+
enabled: false,
|
|
223
|
+
base_settle_ms: baseSettleMs,
|
|
224
|
+
actual_settle_ms: baseSettleMs
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const nextRandom = normalizeRandom(random);
|
|
229
|
+
const minDeltaRatio = clampNumber(listScrollJitterMinRatio, 0.5, 1.5);
|
|
230
|
+
const maxDeltaRatio = clampNumber(listScrollJitterMaxRatio, minDeltaRatio, 1.5);
|
|
231
|
+
const minSettleRatio = clampNumber(listSettleJitterMinRatio, 0.4, 2);
|
|
232
|
+
const maxSettleRatio = clampNumber(listSettleJitterMaxRatio, minSettleRatio, 2);
|
|
233
|
+
const deltaRatio = randomBetween(nextRandom, minDeltaRatio, maxDeltaRatio);
|
|
234
|
+
const settleRatio = randomBetween(nextRandom, minSettleRatio, maxSettleRatio);
|
|
235
|
+
const actualDeltaY = Math.max(1, Math.round(baseDeltaY * deltaRatio));
|
|
236
|
+
const actualSettleMs = Math.max(0, Math.round(baseSettleMs * settleRatio));
|
|
237
|
+
return {
|
|
238
|
+
wheelDeltaY: actualDeltaY,
|
|
239
|
+
settleMs: actualSettleMs,
|
|
240
|
+
wheel_delta_jitter: {
|
|
241
|
+
enabled: true,
|
|
242
|
+
preserve_coverage: true,
|
|
243
|
+
base_delta_y: baseDeltaY,
|
|
244
|
+
actual_delta_y: actualDeltaY,
|
|
245
|
+
ratio: deltaRatio,
|
|
246
|
+
min_ratio: minDeltaRatio,
|
|
247
|
+
max_ratio: maxDeltaRatio
|
|
248
|
+
},
|
|
249
|
+
settle_jitter: {
|
|
250
|
+
enabled: true,
|
|
251
|
+
base_settle_ms: baseSettleMs,
|
|
252
|
+
actual_settle_ms: actualSettleMs,
|
|
253
|
+
ratio: settleRatio,
|
|
254
|
+
min_ratio: minSettleRatio,
|
|
255
|
+
max_ratio: maxSettleRatio
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function resolveViewportPoint(viewportPoint, viewport) {
|
|
261
|
+
if (!viewportPoint) return null;
|
|
262
|
+
if (viewport && ("xRatio" in viewportPoint || "yRatio" in viewportPoint)) {
|
|
263
|
+
const xRatio = Number(viewportPoint.xRatio ?? 0);
|
|
264
|
+
const yRatio = Number(viewportPoint.yRatio ?? 0);
|
|
265
|
+
if (Number.isFinite(xRatio) && Number.isFinite(yRatio)) {
|
|
266
|
+
return {
|
|
267
|
+
x: viewport.x + viewport.width * xRatio,
|
|
268
|
+
y: viewport.y + viewport.height * yRatio
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return normalizePoint(viewportPoint);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function getViewportRect(client) {
|
|
276
|
+
try {
|
|
277
|
+
const metrics = await client.Page.getLayoutMetrics();
|
|
278
|
+
const viewport = metrics.visualViewport || metrics.layoutViewport || metrics.cssVisualViewport || {};
|
|
279
|
+
const width = Number(viewport.clientWidth || viewport.width || metrics.layoutViewport?.clientWidth || 0);
|
|
280
|
+
const height = Number(viewport.clientHeight || viewport.height || metrics.layoutViewport?.clientHeight || 0);
|
|
281
|
+
const x = Number(viewport.pageX || viewport.x || 0);
|
|
282
|
+
const y = Number(viewport.pageY || viewport.y || 0);
|
|
283
|
+
if (width > 0 && height > 0) {
|
|
284
|
+
return { x, y, width, height };
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
// Page.getLayoutMetrics is optional for fallback only.
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function collectUsableNodeBoxes(client, nodeIds = [], {
|
|
293
|
+
maxNodes = 80
|
|
294
|
+
} = {}) {
|
|
295
|
+
const boxes = [];
|
|
296
|
+
const errors = [];
|
|
297
|
+
for (const nodeId of nodeIds.slice(0, Math.max(1, Number(maxNodes) || 80))) {
|
|
298
|
+
try {
|
|
299
|
+
const box = await getNodeBox(client, nodeId);
|
|
300
|
+
if (isUsableBox(box)) {
|
|
301
|
+
boxes.push({
|
|
302
|
+
node_id: nodeId,
|
|
303
|
+
box,
|
|
304
|
+
rect: box.rect
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
} catch (error) {
|
|
308
|
+
errors.push({
|
|
309
|
+
node_id: nodeId,
|
|
310
|
+
error: error?.message || String(error)
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { boxes, errors };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function querySelectorBoxes(client, rootNodeId, selectors = [], {
|
|
318
|
+
maxNodes = 80
|
|
319
|
+
} = {}) {
|
|
320
|
+
const attempts = [];
|
|
321
|
+
if (!rootNodeId) return { boxes: [], attempts };
|
|
322
|
+
for (const selector of selectors.filter(Boolean)) {
|
|
323
|
+
let nodeIds = [];
|
|
324
|
+
try {
|
|
325
|
+
nodeIds = await querySelectorAll(client, rootNodeId, selector);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
attempts.push({
|
|
328
|
+
selector,
|
|
329
|
+
error: error?.message || String(error),
|
|
330
|
+
node_count: 0,
|
|
331
|
+
box_count: 0
|
|
332
|
+
});
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const measured = await collectUsableNodeBoxes(client, nodeIds, { maxNodes });
|
|
336
|
+
attempts.push({
|
|
337
|
+
selector,
|
|
338
|
+
node_count: nodeIds.length,
|
|
339
|
+
box_count: measured.boxes.length,
|
|
340
|
+
errors: measured.errors
|
|
341
|
+
});
|
|
342
|
+
if (measured.boxes.length) {
|
|
343
|
+
return {
|
|
344
|
+
boxes: measured.boxes,
|
|
345
|
+
selector,
|
|
346
|
+
attempts
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return { boxes: [], attempts };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export async function resolveInfiniteListFallbackPoint(client, {
|
|
354
|
+
rootNodeId = 0,
|
|
355
|
+
containerSelectors = [],
|
|
356
|
+
itemNodeIds = [],
|
|
357
|
+
itemSelectors = [],
|
|
358
|
+
allowedSources = ["container", "item_union", "viewport_ratio"],
|
|
359
|
+
containerXRatio = 0.5,
|
|
360
|
+
containerYRatio = 0.5,
|
|
361
|
+
itemXRatio = 0.5,
|
|
362
|
+
itemYRatio = 0.5,
|
|
363
|
+
viewportPoint = null,
|
|
364
|
+
validateViewportPoint = true,
|
|
365
|
+
maxProbeNodes = 80
|
|
366
|
+
} = {}) {
|
|
367
|
+
const attempts = [];
|
|
368
|
+
const allowed = new Set(Array.isArray(allowedSources) && allowedSources.length
|
|
369
|
+
? allowedSources.map((source) => String(source || ""))
|
|
370
|
+
: ["container", "item_union", "viewport_ratio"]);
|
|
371
|
+
|
|
372
|
+
const containerResult = await querySelectorBoxes(client, rootNodeId, containerSelectors, {
|
|
373
|
+
maxNodes: maxProbeNodes
|
|
374
|
+
});
|
|
375
|
+
attempts.push({
|
|
376
|
+
source: "container",
|
|
377
|
+
selector: containerResult.selector || null,
|
|
378
|
+
attempts: containerResult.attempts
|
|
379
|
+
});
|
|
380
|
+
const containerBox = containerResult.boxes
|
|
381
|
+
.slice()
|
|
382
|
+
.sort((a, b) => (b.rect.width * b.rect.height) - (a.rect.width * a.rect.height))[0];
|
|
383
|
+
const viewport = await getViewportRect(client);
|
|
384
|
+
const inputViewportRect = viewport
|
|
385
|
+
? { x: 0, y: 0, width: viewport.width, height: viewport.height }
|
|
386
|
+
: null;
|
|
387
|
+
const visibleContainerRect = inputViewportRect && containerBox?.rect
|
|
388
|
+
? intersectRects(containerBox.rect, inputViewportRect) || containerBox.rect
|
|
389
|
+
: containerBox?.rect;
|
|
390
|
+
const containerPoint = pointFromRect(visibleContainerRect, {
|
|
391
|
+
xRatio: containerXRatio,
|
|
392
|
+
yRatio: containerYRatio
|
|
393
|
+
});
|
|
394
|
+
if (containerPoint && allowed.has("container")) {
|
|
395
|
+
return {
|
|
396
|
+
ok: true,
|
|
397
|
+
source: "container",
|
|
398
|
+
point: containerPoint,
|
|
399
|
+
selector: containerResult.selector || null,
|
|
400
|
+
node_id: containerBox.node_id,
|
|
401
|
+
assist_node_id: itemNodeIds.slice(-1)[0] || null,
|
|
402
|
+
rect: visibleContainerRect,
|
|
403
|
+
full_rect: containerBox.rect,
|
|
404
|
+
attempts
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let itemBoxes = [];
|
|
409
|
+
const itemProbeNodeIds = itemNodeIds.length > maxProbeNodes
|
|
410
|
+
? itemNodeIds.slice(-maxProbeNodes)
|
|
411
|
+
: itemNodeIds;
|
|
412
|
+
const measuredItems = await collectUsableNodeBoxes(client, itemProbeNodeIds, { maxNodes: maxProbeNodes });
|
|
413
|
+
itemBoxes = measuredItems.boxes;
|
|
414
|
+
attempts.push({
|
|
415
|
+
source: "visible_items",
|
|
416
|
+
node_count: itemNodeIds.length,
|
|
417
|
+
box_count: measuredItems.boxes.length,
|
|
418
|
+
errors: measuredItems.errors
|
|
419
|
+
});
|
|
420
|
+
if (!itemBoxes.length) {
|
|
421
|
+
const queriedItems = await querySelectorBoxes(client, rootNodeId, itemSelectors, {
|
|
422
|
+
maxNodes: maxProbeNodes
|
|
423
|
+
});
|
|
424
|
+
itemBoxes = queriedItems.boxes;
|
|
425
|
+
attempts.push({
|
|
426
|
+
source: "item_selector",
|
|
427
|
+
selector: queriedItems.selector || null,
|
|
428
|
+
attempts: queriedItems.attempts
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
const itemValidationRects = [
|
|
432
|
+
inputViewportRect,
|
|
433
|
+
visibleContainerRect || containerBox?.rect || null
|
|
434
|
+
].filter(isUsableRect);
|
|
435
|
+
const visibleItemBoxes = itemValidationRects.length
|
|
436
|
+
? itemBoxes.filter((item) => itemValidationRects.every((rect) => rectsIntersect(item.rect, rect, { padding: 1 })))
|
|
437
|
+
: itemBoxes;
|
|
438
|
+
attempts.push({
|
|
439
|
+
source: "visible_item_filter",
|
|
440
|
+
input_box_count: itemBoxes.length,
|
|
441
|
+
output_box_count: visibleItemBoxes.length,
|
|
442
|
+
validation_rect_count: itemValidationRects.length
|
|
443
|
+
});
|
|
444
|
+
const unionSourceBoxes = visibleItemBoxes.length ? visibleItemBoxes : itemBoxes;
|
|
445
|
+
const rawItemUnion = unionRects(unionSourceBoxes.map((item) => item.rect));
|
|
446
|
+
const itemUnion = itemValidationRects.reduce(
|
|
447
|
+
(rect, limit) => intersectRects(rect, limit) || rect,
|
|
448
|
+
rawItemUnion
|
|
449
|
+
);
|
|
450
|
+
const itemPoint = pointFromRect(itemUnion, {
|
|
451
|
+
xRatio: itemXRatio,
|
|
452
|
+
yRatio: itemYRatio
|
|
453
|
+
});
|
|
454
|
+
if (itemPoint && allowed.has("item_union")) {
|
|
455
|
+
const assistItem = unionSourceBoxes
|
|
456
|
+
.slice()
|
|
457
|
+
.sort((a, b) => ((b.rect.y + b.rect.height) - (a.rect.y + a.rect.height)))[0];
|
|
458
|
+
return {
|
|
459
|
+
ok: true,
|
|
460
|
+
source: "item_union",
|
|
461
|
+
point: itemPoint,
|
|
462
|
+
rect: itemUnion,
|
|
463
|
+
full_rect: rawItemUnion,
|
|
464
|
+
item_box_count: unionSourceBoxes.length,
|
|
465
|
+
visible_item_box_count: visibleItemBoxes.length,
|
|
466
|
+
assist_node_id: assistItem?.node_id || itemNodeIds.slice(-1)[0] || null,
|
|
467
|
+
attempts
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const viewportRatioPoint = resolveViewportPoint(viewportPoint, viewport);
|
|
472
|
+
const normalizedViewportPoint = normalizePoint(viewportRatioPoint);
|
|
473
|
+
if (normalizedViewportPoint && allowed.has("viewport_ratio")) {
|
|
474
|
+
if (!validateViewportPoint) {
|
|
475
|
+
return {
|
|
476
|
+
ok: true,
|
|
477
|
+
source: "viewport_ratio",
|
|
478
|
+
point: normalizedViewportPoint,
|
|
479
|
+
viewport,
|
|
480
|
+
validated: false,
|
|
481
|
+
attempts
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
const validationRects = [
|
|
485
|
+
...containerResult.boxes.map((item) => item.rect),
|
|
486
|
+
...itemBoxes.map((item) => item.rect)
|
|
487
|
+
].filter(isUsableRect);
|
|
488
|
+
const validatedRect = validationRects.find((rect) => pointInsideRect(normalizedViewportPoint, rect, { padding: 4 }));
|
|
489
|
+
attempts.push({
|
|
490
|
+
source: "viewport_ratio",
|
|
491
|
+
point: normalizedViewportPoint,
|
|
492
|
+
viewport,
|
|
493
|
+
validation_rect_count: validationRects.length,
|
|
494
|
+
validated: Boolean(validatedRect)
|
|
495
|
+
});
|
|
496
|
+
if (validatedRect) {
|
|
497
|
+
return {
|
|
498
|
+
ok: true,
|
|
499
|
+
source: "viewport_ratio",
|
|
500
|
+
point: normalizedViewportPoint,
|
|
501
|
+
viewport,
|
|
502
|
+
rect: validatedRect,
|
|
503
|
+
validated: true,
|
|
504
|
+
assist_node_id: itemNodeIds.slice(-1)[0] || null,
|
|
505
|
+
attempts
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
ok: false,
|
|
512
|
+
reason: "fallback_point_unavailable",
|
|
513
|
+
attempts
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function shortHash(value) {
|
|
518
|
+
return crypto.createHash("sha256").update(String(value || "")).digest("hex").slice(0, 16);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function pickAttribute(attributes = {}, names = []) {
|
|
522
|
+
for (const name of names) {
|
|
523
|
+
const value = normalizeText(attributes[name]);
|
|
524
|
+
if (value) return value;
|
|
525
|
+
}
|
|
526
|
+
return "";
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function candidateKeyFromProfile(candidate = {}, {
|
|
530
|
+
nodeId = null,
|
|
531
|
+
attributes = candidate.attributes || candidate.metadata?.attributes || {}
|
|
532
|
+
} = {}) {
|
|
533
|
+
const text = normalizeText(candidate.text?.raw || candidate.text || "");
|
|
534
|
+
const textSuffix = text ? `:text:${shortHash(text.slice(0, 1000))}` : "";
|
|
535
|
+
const id = normalizeText(candidate.id);
|
|
536
|
+
const stableAttrKey = pickAttribute(attributes, [
|
|
537
|
+
"data-geek",
|
|
538
|
+
"data-geekid",
|
|
539
|
+
"data-expect",
|
|
540
|
+
"data-uid",
|
|
541
|
+
"data-securityid",
|
|
542
|
+
"encryptgeekid",
|
|
543
|
+
"geekid",
|
|
544
|
+
"expect",
|
|
545
|
+
"uid",
|
|
546
|
+
"securityid"
|
|
547
|
+
]);
|
|
548
|
+
if (id && stableAttrKey && id === stableAttrKey) return `${candidate.domain || "candidate"}:id:${id}`;
|
|
549
|
+
if (id && !stableAttrKey) return `${candidate.domain || "candidate"}:id:${id}${textSuffix}`;
|
|
550
|
+
if (id) return `${candidate.domain || "candidate"}:id:${id}${textSuffix}`;
|
|
551
|
+
|
|
552
|
+
const attrKey = pickAttribute(attributes, [
|
|
553
|
+
"data-geek",
|
|
554
|
+
"data-geekid",
|
|
555
|
+
"data-expect",
|
|
556
|
+
"data-jid",
|
|
557
|
+
"data-id",
|
|
558
|
+
"data-uid",
|
|
559
|
+
"data-securityid",
|
|
560
|
+
"encryptgeekid",
|
|
561
|
+
"href",
|
|
562
|
+
"key",
|
|
563
|
+
"id"
|
|
564
|
+
]);
|
|
565
|
+
if (attrKey) return `${candidate.domain || "candidate"}:attr:${attrKey}${textSuffix}`;
|
|
566
|
+
|
|
567
|
+
const identity = candidate.identity || {};
|
|
568
|
+
const identityKey = [
|
|
569
|
+
identity.name,
|
|
570
|
+
identity.current_company,
|
|
571
|
+
identity.current_position,
|
|
572
|
+
identity.school,
|
|
573
|
+
identity.major,
|
|
574
|
+
identity.degree,
|
|
575
|
+
identity.age,
|
|
576
|
+
identity.gender
|
|
577
|
+
].map(normalizeText).filter(Boolean).join("|");
|
|
578
|
+
if (identityKey) return `${candidate.domain || "candidate"}:identity:${shortHash(identityKey)}`;
|
|
579
|
+
|
|
580
|
+
if (text) return `${candidate.domain || "candidate"}:text:${shortHash(text.slice(0, 1000))}`;
|
|
581
|
+
|
|
582
|
+
return `${candidate.domain || "candidate"}:node:${nodeId || "unknown"}`;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function createInfiniteListState({
|
|
586
|
+
domain = "unknown",
|
|
587
|
+
listName = "candidate-list"
|
|
588
|
+
} = {}) {
|
|
589
|
+
return {
|
|
590
|
+
schema_version: 1,
|
|
591
|
+
domain,
|
|
592
|
+
list_name: listName,
|
|
593
|
+
created_at: nowIso(),
|
|
594
|
+
seen_keys: new Set(),
|
|
595
|
+
queued_keys: new Set(),
|
|
596
|
+
processed_keys: new Set(),
|
|
597
|
+
skipped_duplicate_count: 0,
|
|
598
|
+
read_error_count: 0,
|
|
599
|
+
scroll_count: 0,
|
|
600
|
+
stable_signature_count: 0,
|
|
601
|
+
last_visible_signature: "",
|
|
602
|
+
last_result: null,
|
|
603
|
+
ledger: []
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export function compactInfiniteListState(state = {}) {
|
|
608
|
+
return {
|
|
609
|
+
domain: state.domain || "unknown",
|
|
610
|
+
list_name: state.list_name || "candidate-list",
|
|
611
|
+
seen_count: state.seen_keys?.size || 0,
|
|
612
|
+
queued_count: state.queued_keys?.size || 0,
|
|
613
|
+
processed_count: state.processed_keys?.size || 0,
|
|
614
|
+
skipped_duplicate_count: state.skipped_duplicate_count || 0,
|
|
615
|
+
read_error_count: state.read_error_count || 0,
|
|
616
|
+
scroll_count: state.scroll_count || 0,
|
|
617
|
+
stable_signature_count: state.stable_signature_count || 0,
|
|
618
|
+
last_visible_signature: state.last_visible_signature || "",
|
|
619
|
+
last_result: state.last_result || null
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function markInfiniteListCandidateProcessed(state, key, {
|
|
624
|
+
status = "processed",
|
|
625
|
+
metadata = {}
|
|
626
|
+
} = {}) {
|
|
627
|
+
if (!state || !key) return compactInfiniteListState(state);
|
|
628
|
+
state.queued_keys?.delete(key);
|
|
629
|
+
state.processed_keys?.add(key);
|
|
630
|
+
state.ledger?.push({
|
|
631
|
+
at: nowIso(),
|
|
632
|
+
event: "candidate_processed",
|
|
633
|
+
key,
|
|
634
|
+
status,
|
|
635
|
+
metadata
|
|
636
|
+
});
|
|
637
|
+
return compactInfiniteListState(state);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export function markInfiniteListCandidateSkipped(state, key, {
|
|
641
|
+
reason = "skipped",
|
|
642
|
+
metadata = {}
|
|
643
|
+
} = {}) {
|
|
644
|
+
if (!state || !key) return compactInfiniteListState(state);
|
|
645
|
+
state.queued_keys?.delete(key);
|
|
646
|
+
state.ledger?.push({
|
|
647
|
+
at: nowIso(),
|
|
648
|
+
event: "candidate_skipped",
|
|
649
|
+
key,
|
|
650
|
+
reason,
|
|
651
|
+
metadata
|
|
652
|
+
});
|
|
653
|
+
return compactInfiniteListState(state);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export function resetInfiniteListForRefreshRound(state, {
|
|
657
|
+
reason = "refresh_round",
|
|
658
|
+
round = 0,
|
|
659
|
+
method = "",
|
|
660
|
+
metadata = {}
|
|
661
|
+
} = {}) {
|
|
662
|
+
if (!state) return compactInfiniteListState(state);
|
|
663
|
+
state.queued_keys?.clear();
|
|
664
|
+
state.stable_signature_count = 0;
|
|
665
|
+
state.last_visible_signature = "";
|
|
666
|
+
state.last_result = null;
|
|
667
|
+
state.ledger?.push({
|
|
668
|
+
at: nowIso(),
|
|
669
|
+
event: "refresh_round_started",
|
|
670
|
+
reason,
|
|
671
|
+
round,
|
|
672
|
+
method,
|
|
673
|
+
metadata
|
|
674
|
+
});
|
|
675
|
+
return compactInfiniteListState(state);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export function classifyInfiniteListBottomMarker({
|
|
679
|
+
text = "",
|
|
680
|
+
refreshButtonVisible = false,
|
|
681
|
+
bottomKeywords = DEFAULT_BOTTOM_HINT_KEYWORDS,
|
|
682
|
+
loadMoreKeywords = DEFAULT_LOAD_MORE_HINT_KEYWORDS
|
|
683
|
+
} = {}) {
|
|
684
|
+
const normalizedText = normalizeText(text);
|
|
685
|
+
const matchedBottomKeyword = bottomKeywords.find((keyword) => normalizedText.includes(keyword)) || null;
|
|
686
|
+
if (matchedBottomKeyword) {
|
|
687
|
+
return {
|
|
688
|
+
is_bottom: true,
|
|
689
|
+
reason: matchedBottomKeyword,
|
|
690
|
+
matched_bottom_keyword: matchedBottomKeyword,
|
|
691
|
+
matched_load_more_keyword: null
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const matchedLoadMoreKeyword = loadMoreKeywords.find((keyword) => normalizedText.includes(keyword)) || null;
|
|
696
|
+
if (matchedLoadMoreKeyword) {
|
|
697
|
+
return {
|
|
698
|
+
is_bottom: false,
|
|
699
|
+
reason: null,
|
|
700
|
+
matched_bottom_keyword: null,
|
|
701
|
+
matched_load_more_keyword: matchedLoadMoreKeyword
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (refreshButtonVisible) {
|
|
706
|
+
return {
|
|
707
|
+
is_bottom: true,
|
|
708
|
+
reason: "refresh_button_visible",
|
|
709
|
+
matched_bottom_keyword: null,
|
|
710
|
+
matched_load_more_keyword: null
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return {
|
|
715
|
+
is_bottom: false,
|
|
716
|
+
reason: null,
|
|
717
|
+
matched_bottom_keyword: null,
|
|
718
|
+
matched_load_more_keyword: null
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function safeQuerySelectorAll(client, rootNodeId, selector) {
|
|
723
|
+
try {
|
|
724
|
+
return await querySelectorAll(client, rootNodeId, selector);
|
|
725
|
+
} catch {
|
|
726
|
+
return [];
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function readVisibleMarkerNode(client, nodeId) {
|
|
731
|
+
let box = null;
|
|
732
|
+
try {
|
|
733
|
+
box = await getNodeBox(client, nodeId);
|
|
734
|
+
} catch {
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
if (!isUsableBox(box)) return null;
|
|
738
|
+
let outerHTML = "";
|
|
739
|
+
try {
|
|
740
|
+
outerHTML = await getOuterHTML(client, nodeId);
|
|
741
|
+
} catch {
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
return {
|
|
745
|
+
node_id: nodeId,
|
|
746
|
+
text: plainTextFromHtml(outerHTML),
|
|
747
|
+
box
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function looksLikeRefreshLabel(text = "") {
|
|
752
|
+
const normalized = normalizeText(text).replace(/\s+/g, "");
|
|
753
|
+
return Boolean(normalized) && normalized.length <= 80 && /刷新|refresh/i.test(normalized);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export async function detectInfiniteListBottomMarker(client, {
|
|
757
|
+
rootNodeId,
|
|
758
|
+
markerSelectors = DEFAULT_BOTTOM_MARKER_SELECTORS,
|
|
759
|
+
textScanSelectors = DEFAULT_BOTTOM_TEXT_SCAN_SELECTORS,
|
|
760
|
+
refreshSelectors = DEFAULT_BOTTOM_REFRESH_SELECTORS,
|
|
761
|
+
bottomKeywords = DEFAULT_BOTTOM_HINT_KEYWORDS,
|
|
762
|
+
loadMoreKeywords = DEFAULT_LOAD_MORE_HINT_KEYWORDS,
|
|
763
|
+
maxMarkerNodes = 300,
|
|
764
|
+
maxTextScanNodes = 800,
|
|
765
|
+
textMaxLength = 80
|
|
766
|
+
} = {}) {
|
|
767
|
+
if (!client || !rootNodeId) {
|
|
768
|
+
return {
|
|
769
|
+
found: false,
|
|
770
|
+
reason: "missing_client_or_root"
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const selectorCounts = {};
|
|
775
|
+
const markerNodeIds = [];
|
|
776
|
+
for (const selector of markerSelectors || []) {
|
|
777
|
+
const nodeIds = await safeQuerySelectorAll(client, rootNodeId, selector);
|
|
778
|
+
selectorCounts[selector] = nodeIds.length;
|
|
779
|
+
markerNodeIds.push(...nodeIds);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const visibleMarkers = [];
|
|
783
|
+
const markerIds = uniqueValues(markerNodeIds).slice(0, Math.max(0, Number(maxMarkerNodes) || 0));
|
|
784
|
+
for (const nodeId of markerIds) {
|
|
785
|
+
const marker = await readVisibleMarkerNode(client, nodeId);
|
|
786
|
+
if (!marker?.text) continue;
|
|
787
|
+
const classified = classifyInfiniteListBottomMarker({
|
|
788
|
+
text: marker.text,
|
|
789
|
+
bottomKeywords,
|
|
790
|
+
loadMoreKeywords
|
|
791
|
+
});
|
|
792
|
+
const summary = {
|
|
793
|
+
node_id: marker.node_id,
|
|
794
|
+
text: marker.text.slice(0, 160),
|
|
795
|
+
y: marker.box?.rect?.y || null,
|
|
796
|
+
matched_bottom_keyword: classified.matched_bottom_keyword,
|
|
797
|
+
matched_load_more_keyword: classified.matched_load_more_keyword
|
|
798
|
+
};
|
|
799
|
+
visibleMarkers.push(summary);
|
|
800
|
+
if (classified.is_bottom) {
|
|
801
|
+
return {
|
|
802
|
+
found: true,
|
|
803
|
+
reason: classified.reason,
|
|
804
|
+
source: "marker_selector",
|
|
805
|
+
marker: summary,
|
|
806
|
+
selector_counts: selectorCounts,
|
|
807
|
+
visible_marker_count: visibleMarkers.length,
|
|
808
|
+
refresh_button_visible: false
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const hasLoadMoreMarker = visibleMarkers.some((marker) => marker.matched_load_more_keyword);
|
|
814
|
+
|
|
815
|
+
const refreshNodeIds = [];
|
|
816
|
+
for (const selector of refreshSelectors || []) {
|
|
817
|
+
const nodeIds = await safeQuerySelectorAll(client, rootNodeId, selector);
|
|
818
|
+
selectorCounts[selector] = (selectorCounts[selector] || 0) + nodeIds.length;
|
|
819
|
+
refreshNodeIds.push(...nodeIds);
|
|
820
|
+
}
|
|
821
|
+
const refreshButtons = [];
|
|
822
|
+
for (const nodeId of uniqueValues(refreshNodeIds).slice(0, 300)) {
|
|
823
|
+
const marker = await readVisibleMarkerNode(client, nodeId);
|
|
824
|
+
if (!marker?.text || !looksLikeRefreshLabel(marker.text)) continue;
|
|
825
|
+
refreshButtons.push({
|
|
826
|
+
node_id: marker.node_id,
|
|
827
|
+
text: marker.text.slice(0, 120),
|
|
828
|
+
y: marker.box?.rect?.y || null
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
if (refreshButtons.length && !hasLoadMoreMarker) {
|
|
832
|
+
return {
|
|
833
|
+
found: true,
|
|
834
|
+
reason: "refresh_button_visible",
|
|
835
|
+
source: "refresh_button",
|
|
836
|
+
marker: refreshButtons[0],
|
|
837
|
+
selector_counts: selectorCounts,
|
|
838
|
+
visible_marker_count: visibleMarkers.length,
|
|
839
|
+
refresh_button_visible: true,
|
|
840
|
+
refresh_button_count: refreshButtons.length
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const scanNodeIds = [];
|
|
845
|
+
for (const selector of textScanSelectors || []) {
|
|
846
|
+
const nodeIds = await safeQuerySelectorAll(client, rootNodeId, selector);
|
|
847
|
+
selectorCounts[selector] = (selectorCounts[selector] || 0) + nodeIds.length;
|
|
848
|
+
scanNodeIds.push(...nodeIds);
|
|
849
|
+
}
|
|
850
|
+
let checkedTextNodeCount = 0;
|
|
851
|
+
for (const nodeId of uniqueValues(scanNodeIds).slice(0, Math.max(0, Number(maxTextScanNodes) || 0))) {
|
|
852
|
+
const marker = await readVisibleMarkerNode(client, nodeId);
|
|
853
|
+
if (!marker?.text || marker.text.length > textMaxLength) continue;
|
|
854
|
+
checkedTextNodeCount += 1;
|
|
855
|
+
const classified = classifyInfiniteListBottomMarker({
|
|
856
|
+
text: marker.text,
|
|
857
|
+
bottomKeywords,
|
|
858
|
+
loadMoreKeywords
|
|
859
|
+
});
|
|
860
|
+
if (classified.is_bottom) {
|
|
861
|
+
return {
|
|
862
|
+
found: true,
|
|
863
|
+
reason: classified.reason,
|
|
864
|
+
source: "text_scan",
|
|
865
|
+
marker: {
|
|
866
|
+
node_id: marker.node_id,
|
|
867
|
+
text: marker.text.slice(0, 160),
|
|
868
|
+
y: marker.box?.rect?.y || null,
|
|
869
|
+
matched_bottom_keyword: classified.matched_bottom_keyword
|
|
870
|
+
},
|
|
871
|
+
selector_counts: selectorCounts,
|
|
872
|
+
visible_marker_count: visibleMarkers.length,
|
|
873
|
+
checked_text_node_count: checkedTextNodeCount,
|
|
874
|
+
refresh_button_visible: refreshButtons.length > 0,
|
|
875
|
+
refresh_button_count: refreshButtons.length
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return {
|
|
881
|
+
found: false,
|
|
882
|
+
reason: hasLoadMoreMarker ? "load_more_marker_visible" : "bottom_marker_not_found",
|
|
883
|
+
selector_counts: selectorCounts,
|
|
884
|
+
visible_markers: visibleMarkers.slice(0, 20),
|
|
885
|
+
visible_marker_count: visibleMarkers.length,
|
|
886
|
+
checked_text_node_count: checkedTextNodeCount,
|
|
887
|
+
refresh_button_visible: refreshButtons.length > 0,
|
|
888
|
+
refresh_button_count: refreshButtons.length
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
export async function readVisibleInfiniteListItems({
|
|
893
|
+
nodeIds = [],
|
|
894
|
+
readCandidate,
|
|
895
|
+
keyForCandidate = candidateKeyFromProfile,
|
|
896
|
+
state = null
|
|
897
|
+
} = {}) {
|
|
898
|
+
if (typeof readCandidate !== "function") {
|
|
899
|
+
throw new Error("readVisibleInfiniteListItems requires readCandidate");
|
|
900
|
+
}
|
|
901
|
+
const items = [];
|
|
902
|
+
for (let visibleIndex = 0; visibleIndex < nodeIds.length; visibleIndex += 1) {
|
|
903
|
+
const nodeId = nodeIds[visibleIndex];
|
|
904
|
+
let candidate;
|
|
905
|
+
try {
|
|
906
|
+
candidate = await readCandidate(nodeId, { visibleIndex });
|
|
907
|
+
} catch (error) {
|
|
908
|
+
if (state) {
|
|
909
|
+
state.read_error_count = (state.read_error_count || 0) + 1;
|
|
910
|
+
state.ledger?.push({
|
|
911
|
+
at: nowIso(),
|
|
912
|
+
event: "candidate_read_error",
|
|
913
|
+
node_id: nodeId,
|
|
914
|
+
visible_index: visibleIndex,
|
|
915
|
+
error: error?.message || String(error)
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
const key = keyForCandidate(candidate, {
|
|
921
|
+
nodeId,
|
|
922
|
+
visibleIndex,
|
|
923
|
+
attributes: candidate?.attributes || candidate?.metadata?.attributes || {}
|
|
924
|
+
});
|
|
925
|
+
items.push({
|
|
926
|
+
key,
|
|
927
|
+
node_id: nodeId,
|
|
928
|
+
visible_index: visibleIndex,
|
|
929
|
+
candidate
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
return items;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
export function updateInfiniteListVisibleSignature(state, items = []) {
|
|
936
|
+
const signature = items.map((item) => item.key).filter(Boolean).join("|");
|
|
937
|
+
const unchanged = Boolean(signature) && signature === state.last_visible_signature;
|
|
938
|
+
state.stable_signature_count = unchanged ? (state.stable_signature_count || 0) + 1 : 0;
|
|
939
|
+
state.last_visible_signature = signature;
|
|
940
|
+
return {
|
|
941
|
+
signature,
|
|
942
|
+
unchanged,
|
|
943
|
+
stable_signature_count: state.stable_signature_count
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
export function firstUnseenInfiniteListItem(state, items = []) {
|
|
948
|
+
for (const item of items) {
|
|
949
|
+
if (!item.key) continue;
|
|
950
|
+
if (state.processed_keys.has(item.key) || state.queued_keys.has(item.key)) {
|
|
951
|
+
state.skipped_duplicate_count += 1;
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
state.seen_keys.add(item.key);
|
|
955
|
+
state.queued_keys.add(item.key);
|
|
956
|
+
state.ledger.push({
|
|
957
|
+
at: nowIso(),
|
|
958
|
+
event: "candidate_queued",
|
|
959
|
+
key: item.key,
|
|
960
|
+
node_id: item.node_id,
|
|
961
|
+
visible_index: item.visible_index
|
|
962
|
+
});
|
|
963
|
+
return item;
|
|
964
|
+
}
|
|
965
|
+
return null;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
export async function scrollInfiniteListByVisibleItems(client, items = [], {
|
|
969
|
+
wheelDeltaY = 850,
|
|
970
|
+
settleMs = 1200,
|
|
971
|
+
fallbackPoint = null,
|
|
972
|
+
listScrollJitterEnabled = false,
|
|
973
|
+
listScrollJitterMinRatio = 0.85,
|
|
974
|
+
listScrollJitterMaxRatio = 1.15,
|
|
975
|
+
listSettleJitterMinRatio = 0.75,
|
|
976
|
+
listSettleJitterMaxRatio = 1.35,
|
|
977
|
+
random = Math.random
|
|
978
|
+
} = {}) {
|
|
979
|
+
const candidates = items.filter((item) => item?.node_id);
|
|
980
|
+
if (!candidates.length) {
|
|
981
|
+
return {
|
|
982
|
+
ok: false,
|
|
983
|
+
reason: "no_visible_items"
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const errors = [];
|
|
988
|
+
const scrollTiming = resolveInfiniteListScrollTiming({
|
|
989
|
+
wheelDeltaY,
|
|
990
|
+
settleMs,
|
|
991
|
+
listScrollJitterEnabled,
|
|
992
|
+
listScrollJitterMinRatio,
|
|
993
|
+
listScrollJitterMaxRatio,
|
|
994
|
+
listSettleJitterMinRatio,
|
|
995
|
+
listSettleJitterMaxRatio,
|
|
996
|
+
random
|
|
997
|
+
});
|
|
998
|
+
const wheelDelta = scrollTiming.wheelDeltaY;
|
|
999
|
+
const actualSettleMs = scrollTiming.settleMs;
|
|
1000
|
+
async function synthesizeGesture(x, y) {
|
|
1001
|
+
if (typeof client?.Input?.synthesizeScrollGesture !== "function") return null;
|
|
1002
|
+
try {
|
|
1003
|
+
const gestureDistance = -Math.min(1200, wheelDelta);
|
|
1004
|
+
await client.Input.synthesizeScrollGesture({
|
|
1005
|
+
x,
|
|
1006
|
+
y,
|
|
1007
|
+
yDistance: gestureDistance,
|
|
1008
|
+
speed: 800,
|
|
1009
|
+
repeatCount: 1
|
|
1010
|
+
});
|
|
1011
|
+
return {
|
|
1012
|
+
ok: true,
|
|
1013
|
+
y_distance: gestureDistance
|
|
1014
|
+
};
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
return {
|
|
1017
|
+
ok: false,
|
|
1018
|
+
error: error?.message || String(error)
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
for (const anchor of candidates.slice().reverse()) {
|
|
1023
|
+
try {
|
|
1024
|
+
await scrollNodeIntoView(client, anchor.node_id);
|
|
1025
|
+
await sleep(150);
|
|
1026
|
+
const box = await getNodeBox(client, anchor.node_id);
|
|
1027
|
+
const x = box.center.x;
|
|
1028
|
+
const y = box.center.y;
|
|
1029
|
+
await client.Input.dispatchMouseEvent({ type: "mouseMoved", x, y, button: "none" });
|
|
1030
|
+
await client.Input.dispatchMouseEvent({
|
|
1031
|
+
type: "mouseWheel",
|
|
1032
|
+
x,
|
|
1033
|
+
y,
|
|
1034
|
+
deltaX: 0,
|
|
1035
|
+
deltaY: wheelDelta
|
|
1036
|
+
});
|
|
1037
|
+
const gesture = await synthesizeGesture(x, y);
|
|
1038
|
+
if (actualSettleMs > 0) await sleep(actualSettleMs);
|
|
1039
|
+
return {
|
|
1040
|
+
ok: true,
|
|
1041
|
+
anchor_key: anchor.key,
|
|
1042
|
+
anchor_node_id: anchor.node_id,
|
|
1043
|
+
point: { x, y },
|
|
1044
|
+
wheel_delta_y: wheelDelta,
|
|
1045
|
+
base_wheel_delta_y: Math.max(1, Number(wheelDeltaY) || 850),
|
|
1046
|
+
wheel_delta_jitter: scrollTiming.wheel_delta_jitter,
|
|
1047
|
+
gesture,
|
|
1048
|
+
settle_ms: actualSettleMs,
|
|
1049
|
+
base_settle_ms: Math.max(0, Number(settleMs) || 0),
|
|
1050
|
+
settle_jitter: scrollTiming.settle_jitter,
|
|
1051
|
+
skipped_stale_anchor_count: errors.length
|
|
1052
|
+
};
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
errors.push({
|
|
1055
|
+
anchor_key: anchor.key,
|
|
1056
|
+
anchor_node_id: anchor.node_id,
|
|
1057
|
+
error: error?.message || String(error)
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const resolvedFallback = typeof fallbackPoint === "function"
|
|
1063
|
+
? await fallbackPoint({ client, items, errors })
|
|
1064
|
+
: (fallbackPoint ? { ok: true, source: "static", point: fallbackPoint } : null);
|
|
1065
|
+
const resolvedPoint = normalizePoint(resolvedFallback?.point || resolvedFallback);
|
|
1066
|
+
if (resolvedPoint) {
|
|
1067
|
+
const x = resolvedPoint.x;
|
|
1068
|
+
const y = resolvedPoint.y;
|
|
1069
|
+
let assist = null;
|
|
1070
|
+
if (resolvedFallback?.assist_node_id) {
|
|
1071
|
+
try {
|
|
1072
|
+
await scrollNodeIntoView(client, resolvedFallback.assist_node_id);
|
|
1073
|
+
await sleep(150);
|
|
1074
|
+
assist = {
|
|
1075
|
+
ok: true,
|
|
1076
|
+
node_id: resolvedFallback.assist_node_id
|
|
1077
|
+
};
|
|
1078
|
+
} catch (error) {
|
|
1079
|
+
assist = {
|
|
1080
|
+
ok: false,
|
|
1081
|
+
node_id: resolvedFallback.assist_node_id,
|
|
1082
|
+
error: error?.message || String(error)
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
await client.Input.dispatchMouseEvent({ type: "mouseMoved", x, y, button: "none" });
|
|
1087
|
+
await client.Input.dispatchMouseEvent({
|
|
1088
|
+
type: "mouseWheel",
|
|
1089
|
+
x,
|
|
1090
|
+
y,
|
|
1091
|
+
deltaX: 0,
|
|
1092
|
+
deltaY: wheelDelta
|
|
1093
|
+
});
|
|
1094
|
+
const gesture = await synthesizeGesture(x, y);
|
|
1095
|
+
if (actualSettleMs > 0) await sleep(actualSettleMs);
|
|
1096
|
+
return {
|
|
1097
|
+
ok: true,
|
|
1098
|
+
mode: "fallback_point",
|
|
1099
|
+
fallback: {
|
|
1100
|
+
source: resolvedFallback?.source || "static",
|
|
1101
|
+
selector: resolvedFallback?.selector || null,
|
|
1102
|
+
node_id: resolvedFallback?.node_id || null,
|
|
1103
|
+
assist_node_id: resolvedFallback?.assist_node_id || null,
|
|
1104
|
+
rect: resolvedFallback?.rect || null,
|
|
1105
|
+
validated: resolvedFallback?.validated ?? null,
|
|
1106
|
+
reason: resolvedFallback?.reason || null
|
|
1107
|
+
},
|
|
1108
|
+
assist,
|
|
1109
|
+
point: { x, y },
|
|
1110
|
+
wheel_delta_y: wheelDelta,
|
|
1111
|
+
base_wheel_delta_y: Math.max(1, Number(wheelDeltaY) || 850),
|
|
1112
|
+
wheel_delta_jitter: scrollTiming.wheel_delta_jitter,
|
|
1113
|
+
gesture,
|
|
1114
|
+
settle_ms: actualSettleMs,
|
|
1115
|
+
base_settle_ms: Math.max(0, Number(settleMs) || 0),
|
|
1116
|
+
settle_jitter: scrollTiming.settle_jitter,
|
|
1117
|
+
skipped_stale_anchor_count: errors.length,
|
|
1118
|
+
stale_anchor_errors: errors
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
return {
|
|
1123
|
+
ok: false,
|
|
1124
|
+
reason: "scroll_anchor_unavailable",
|
|
1125
|
+
errors,
|
|
1126
|
+
fallback: resolvedFallback || null
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
export async function getNextInfiniteListCandidate({
|
|
1131
|
+
client,
|
|
1132
|
+
state,
|
|
1133
|
+
findNodeIds,
|
|
1134
|
+
readCandidate,
|
|
1135
|
+
detectBottomMarker = null,
|
|
1136
|
+
keyForCandidate = candidateKeyFromProfile,
|
|
1137
|
+
maxScrolls = 20,
|
|
1138
|
+
stableSignatureLimit = 2,
|
|
1139
|
+
minScrollsBeforeEnd = 3,
|
|
1140
|
+
wheelDeltaY = 850,
|
|
1141
|
+
settleMs = 1200,
|
|
1142
|
+
fallbackPoint = null,
|
|
1143
|
+
listScrollJitterEnabled = false,
|
|
1144
|
+
listScrollJitterMinRatio = 0.85,
|
|
1145
|
+
listScrollJitterMaxRatio = 1.15,
|
|
1146
|
+
listSettleJitterMinRatio = 0.75,
|
|
1147
|
+
listSettleJitterMaxRatio = 1.35,
|
|
1148
|
+
random = Math.random
|
|
1149
|
+
} = {}) {
|
|
1150
|
+
if (!client) throw new Error("getNextInfiniteListCandidate requires client");
|
|
1151
|
+
if (!state) throw new Error("getNextInfiniteListCandidate requires state");
|
|
1152
|
+
if (typeof findNodeIds !== "function") throw new Error("getNextInfiniteListCandidate requires findNodeIds");
|
|
1153
|
+
if (typeof readCandidate !== "function") throw new Error("getNextInfiniteListCandidate requires readCandidate");
|
|
1154
|
+
|
|
1155
|
+
const attempts = [];
|
|
1156
|
+
const maxAttempts = Math.max(0, Number(maxScrolls) || 0);
|
|
1157
|
+
for (let scrollAttempt = 0; scrollAttempt <= maxAttempts; scrollAttempt += 1) {
|
|
1158
|
+
const nodeIds = await findNodeIds();
|
|
1159
|
+
const items = await readVisibleInfiniteListItems({
|
|
1160
|
+
nodeIds,
|
|
1161
|
+
readCandidate,
|
|
1162
|
+
keyForCandidate,
|
|
1163
|
+
state
|
|
1164
|
+
});
|
|
1165
|
+
const signature = updateInfiniteListVisibleSignature(state, items);
|
|
1166
|
+
const next = firstUnseenInfiniteListItem(state, items);
|
|
1167
|
+
attempts.push({
|
|
1168
|
+
scroll_attempt: scrollAttempt,
|
|
1169
|
+
visible_count: items.length,
|
|
1170
|
+
signature: signature.signature,
|
|
1171
|
+
stable_signature_count: signature.stable_signature_count,
|
|
1172
|
+
found_next: Boolean(next)
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
if (next) {
|
|
1176
|
+
state.stable_signature_count = 0;
|
|
1177
|
+
const result = {
|
|
1178
|
+
ok: true,
|
|
1179
|
+
end_reached: false,
|
|
1180
|
+
item: next,
|
|
1181
|
+
attempts,
|
|
1182
|
+
state: compactInfiniteListState(state)
|
|
1183
|
+
};
|
|
1184
|
+
state.last_result = {
|
|
1185
|
+
at: nowIso(),
|
|
1186
|
+
ok: true,
|
|
1187
|
+
key: next.key,
|
|
1188
|
+
visible_index: next.visible_index
|
|
1189
|
+
};
|
|
1190
|
+
return result;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (typeof detectBottomMarker === "function") {
|
|
1194
|
+
let bottomMarker = null;
|
|
1195
|
+
try {
|
|
1196
|
+
bottomMarker = await detectBottomMarker({
|
|
1197
|
+
scrollAttempt,
|
|
1198
|
+
items,
|
|
1199
|
+
signature,
|
|
1200
|
+
state: compactInfiniteListState(state)
|
|
1201
|
+
});
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
bottomMarker = {
|
|
1204
|
+
found: false,
|
|
1205
|
+
reason: "bottom_marker_probe_failed",
|
|
1206
|
+
error: error?.message || String(error)
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
attempts[attempts.length - 1].bottom_marker = bottomMarker;
|
|
1210
|
+
if (bottomMarker?.found) {
|
|
1211
|
+
state.ledger?.push({
|
|
1212
|
+
at: nowIso(),
|
|
1213
|
+
event: "bottom_marker_detected",
|
|
1214
|
+
reason: bottomMarker.reason || "bottom_marker",
|
|
1215
|
+
source: bottomMarker.source || "",
|
|
1216
|
+
marker: bottomMarker.marker || null
|
|
1217
|
+
});
|
|
1218
|
+
const result = {
|
|
1219
|
+
ok: false,
|
|
1220
|
+
end_reached: true,
|
|
1221
|
+
reason: "bottom_marker",
|
|
1222
|
+
bottom_marker: bottomMarker,
|
|
1223
|
+
attempts,
|
|
1224
|
+
state: compactInfiniteListState(state)
|
|
1225
|
+
};
|
|
1226
|
+
state.last_result = {
|
|
1227
|
+
at: nowIso(),
|
|
1228
|
+
ok: false,
|
|
1229
|
+
end_reached: true,
|
|
1230
|
+
reason: result.reason,
|
|
1231
|
+
bottom_marker: {
|
|
1232
|
+
reason: bottomMarker.reason || null,
|
|
1233
|
+
source: bottomMarker.source || null,
|
|
1234
|
+
marker: bottomMarker.marker || null
|
|
1235
|
+
}
|
|
1236
|
+
};
|
|
1237
|
+
return result;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
if (!items.length) {
|
|
1242
|
+
const result = {
|
|
1243
|
+
ok: false,
|
|
1244
|
+
end_reached: true,
|
|
1245
|
+
reason: "empty_visible_list",
|
|
1246
|
+
attempts,
|
|
1247
|
+
state: compactInfiniteListState(state)
|
|
1248
|
+
};
|
|
1249
|
+
state.last_result = {
|
|
1250
|
+
at: nowIso(),
|
|
1251
|
+
ok: false,
|
|
1252
|
+
end_reached: true,
|
|
1253
|
+
reason: result.reason
|
|
1254
|
+
};
|
|
1255
|
+
return result;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
const stableLimit = Math.max(1, Number(stableSignatureLimit) || 1);
|
|
1259
|
+
const minStableScrolls = Math.max(0, Number(minScrollsBeforeEnd) || 0);
|
|
1260
|
+
if (signature.stable_signature_count >= stableLimit && scrollAttempt >= minStableScrolls) {
|
|
1261
|
+
const result = {
|
|
1262
|
+
ok: false,
|
|
1263
|
+
end_reached: true,
|
|
1264
|
+
reason: "stable_visible_signature",
|
|
1265
|
+
attempts,
|
|
1266
|
+
state: compactInfiniteListState(state)
|
|
1267
|
+
};
|
|
1268
|
+
state.last_result = {
|
|
1269
|
+
at: nowIso(),
|
|
1270
|
+
ok: false,
|
|
1271
|
+
end_reached: true,
|
|
1272
|
+
reason: result.reason
|
|
1273
|
+
};
|
|
1274
|
+
return result;
|
|
1275
|
+
}
|
|
1276
|
+
if (signature.stable_signature_count >= stableLimit) {
|
|
1277
|
+
attempts[attempts.length - 1].stable_end_deferred = true;
|
|
1278
|
+
attempts[attempts.length - 1].min_scrolls_before_end = minStableScrolls;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const scrollResult = await scrollInfiniteListByVisibleItems(client, items, {
|
|
1282
|
+
wheelDeltaY,
|
|
1283
|
+
settleMs,
|
|
1284
|
+
fallbackPoint,
|
|
1285
|
+
listScrollJitterEnabled,
|
|
1286
|
+
listScrollJitterMinRatio,
|
|
1287
|
+
listScrollJitterMaxRatio,
|
|
1288
|
+
listSettleJitterMinRatio,
|
|
1289
|
+
listSettleJitterMaxRatio,
|
|
1290
|
+
random
|
|
1291
|
+
});
|
|
1292
|
+
state.scroll_count += scrollResult.ok ? 1 : 0;
|
|
1293
|
+
attempts[attempts.length - 1].scroll_result = scrollResult;
|
|
1294
|
+
if (!scrollResult.ok) {
|
|
1295
|
+
const result = {
|
|
1296
|
+
ok: false,
|
|
1297
|
+
end_reached: true,
|
|
1298
|
+
reason: scrollResult.reason || "scroll_failed",
|
|
1299
|
+
attempts,
|
|
1300
|
+
state: compactInfiniteListState(state)
|
|
1301
|
+
};
|
|
1302
|
+
state.last_result = {
|
|
1303
|
+
at: nowIso(),
|
|
1304
|
+
ok: false,
|
|
1305
|
+
end_reached: true,
|
|
1306
|
+
reason: result.reason
|
|
1307
|
+
};
|
|
1308
|
+
return result;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
const result = {
|
|
1313
|
+
ok: false,
|
|
1314
|
+
end_reached: false,
|
|
1315
|
+
reason: "max_scrolls_exhausted",
|
|
1316
|
+
attempts,
|
|
1317
|
+
state: compactInfiniteListState(state)
|
|
1318
|
+
};
|
|
1319
|
+
state.last_result = {
|
|
1320
|
+
at: nowIso(),
|
|
1321
|
+
ok: false,
|
|
1322
|
+
end_reached: false,
|
|
1323
|
+
reason: result.reason
|
|
1324
|
+
};
|
|
1325
|
+
return result;
|
|
1326
|
+
}
|