@reconcrap/boss-recommend-mcp 2.0.46 → 2.0.47

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.
Files changed (56) hide show
  1. package/bin/boss-recommend-mcp.js +4 -4
  2. package/config/screening-config.example.json +27 -27
  3. package/package.json +1 -1
  4. package/scripts/postinstall.cjs +44 -44
  5. package/skills/boss-chat/README.md +39 -39
  6. package/skills/boss-chat/SKILL.md +93 -93
  7. package/skills/boss-recommend-pipeline/README.md +12 -12
  8. package/skills/boss-recommend-pipeline/SKILL.md +180 -180
  9. package/skills/boss-recruit-pipeline/README.md +17 -17
  10. package/skills/boss-recruit-pipeline/SKILL.md +58 -58
  11. package/src/chat-mcp.js +1780 -1780
  12. package/src/chat-runtime-config.js +749 -749
  13. package/src/cli.js +3054 -3054
  14. package/src/core/boss-cards/index.js +199 -199
  15. package/src/core/browser/index.js +1453 -1453
  16. package/src/core/capture/index.js +1201 -1201
  17. package/src/core/cv-acquisition/index.js +238 -238
  18. package/src/core/cv-capture-target/index.js +299 -299
  19. package/src/core/greet-quota/index.js +54 -54
  20. package/src/core/infinite-list/index.js +1326 -1326
  21. package/src/core/reporting/legacy-csv.js +341 -341
  22. package/src/core/run/timing.js +33 -33
  23. package/src/core/screening/index.js +50 -3
  24. package/src/core/self-heal/index.js +973 -973
  25. package/src/core/self-heal/viewport.js +564 -564
  26. package/src/domains/chat/cards.js +137 -137
  27. package/src/domains/chat/constants.js +221 -221
  28. package/src/domains/chat/detail.js +1668 -1668
  29. package/src/domains/chat/index.js +7 -7
  30. package/src/domains/chat/jobs.js +592 -592
  31. package/src/domains/chat/page-guard.js +98 -98
  32. package/src/domains/chat/roots.js +56 -56
  33. package/src/domains/chat/run-service.js +1977 -1977
  34. package/src/domains/recommend/actions.js +457 -457
  35. package/src/domains/recommend/cards.js +243 -243
  36. package/src/domains/recommend/constants.js +165 -165
  37. package/src/domains/recommend/detail.js +25 -18
  38. package/src/domains/recommend/filters.js +610 -610
  39. package/src/domains/recommend/index.js +10 -10
  40. package/src/domains/recommend/jobs.js +316 -316
  41. package/src/domains/recommend/refresh.js +472 -472
  42. package/src/domains/recommend/roots.js +80 -80
  43. package/src/domains/recommend/run-service.js +27 -20
  44. package/src/domains/recommend/scopes.js +246 -246
  45. package/src/domains/recruit/actions.js +277 -277
  46. package/src/domains/recruit/cards.js +74 -74
  47. package/src/domains/recruit/constants.js +167 -167
  48. package/src/domains/recruit/detail.js +461 -461
  49. package/src/domains/recruit/index.js +9 -9
  50. package/src/domains/recruit/instruction-parser.js +451 -451
  51. package/src/domains/recruit/refresh.js +44 -44
  52. package/src/domains/recruit/roots.js +68 -68
  53. package/src/domains/recruit/run-service.js +1207 -1207
  54. package/src/domains/recruit/search.js +1202 -1202
  55. package/src/recommend-mcp.js +22 -22
  56. 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(/&lt;/gi, "<")
85
- .replace(/&gt;/gi, ">")
86
- .replace(/&quot;/gi, "\"")
87
- .replace(/&#39;|&apos;/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(/&nbsp;|&#160;/gi, " ")
83
+ .replace(/&amp;/gi, "&")
84
+ .replace(/&lt;/gi, "<")
85
+ .replace(/&gt;/gi, ">")
86
+ .replace(/&quot;/gi, "\"")
87
+ .replace(/&#39;|&apos;/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
+ }