@reconcrap/boss-recommend-mcp 2.1.22 → 2.1.24

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 (66) hide show
  1. package/README.md +5 -0
  2. package/bin/boss-recommend-mcp.js +4 -4
  3. package/package.json +14 -8
  4. package/scripts/install-macos.sh +280 -280
  5. package/scripts/postinstall.cjs +44 -44
  6. package/skills/boss-chat/README.md +43 -42
  7. package/skills/boss-chat/SKILL.md +107 -106
  8. package/skills/boss-recommend-pipeline/README.md +13 -13
  9. package/skills/boss-recommend-pipeline/SKILL.md +47 -47
  10. package/skills/boss-recruit-pipeline/README.md +19 -19
  11. package/skills/boss-recruit-pipeline/SKILL.md +89 -89
  12. package/src/chat-mcp.js +301 -127
  13. package/src/core/boss-cards/index.js +199 -199
  14. package/src/core/browser/index.js +291 -114
  15. package/src/core/capture/index.js +2930 -1201
  16. package/src/core/cv-acquisition/index.js +512 -238
  17. package/src/core/cv-capture-target/index.js +513 -299
  18. package/src/core/greet-quota/index.js +71 -71
  19. package/src/core/infinite-list/index.js +11 -2
  20. package/src/core/reporting/legacy-csv.js +12 -12
  21. package/src/core/run/detached-launcher.js +305 -0
  22. package/src/core/run/index.js +112 -42
  23. package/src/core/run/timing.js +33 -33
  24. package/src/core/run/windows-detached-worker.ps1 +106 -0
  25. package/src/core/screening/index.js +2135 -2135
  26. package/src/core/self-heal/index.js +989 -973
  27. package/src/core/self-heal/viewport.js +1505 -564
  28. package/src/detached-worker.js +99 -99
  29. package/src/domains/chat/action-journal.js +443 -0
  30. package/src/domains/chat/cards.js +137 -137
  31. package/src/domains/chat/constants.js +9 -9
  32. package/src/domains/chat/detail.js +1684 -401
  33. package/src/domains/chat/index.js +8 -7
  34. package/src/domains/chat/jobs.js +620 -620
  35. package/src/domains/chat/page-guard.js +157 -122
  36. package/src/domains/chat/roots.js +56 -56
  37. package/src/domains/chat/run-service.js +1801 -760
  38. package/src/domains/common/account-rights-panel.js +314 -314
  39. package/src/domains/common/recovery-settle.js +159 -159
  40. package/src/domains/recommend/actions.js +515 -472
  41. package/src/domains/recommend/cards.js +243 -243
  42. package/src/domains/recommend/colleague-contact.js +333 -333
  43. package/src/domains/recommend/constants.js +92 -92
  44. package/src/domains/recommend/detail.js +12 -3
  45. package/src/domains/recommend/filters.js +611 -611
  46. package/src/domains/recommend/index.js +9 -9
  47. package/src/domains/recommend/jobs.js +542 -542
  48. package/src/domains/recommend/location.js +736 -736
  49. package/src/domains/recommend/refresh.js +410 -329
  50. package/src/domains/recommend/roots.js +80 -80
  51. package/src/domains/recommend/run-service.js +1783 -592
  52. package/src/domains/recommend/scopes.js +246 -246
  53. package/src/domains/recruit/actions.js +277 -277
  54. package/src/domains/recruit/cards.js +74 -74
  55. package/src/domains/recruit/constants.js +236 -236
  56. package/src/domains/recruit/detail.js +588 -588
  57. package/src/domains/recruit/index.js +9 -9
  58. package/src/domains/recruit/instruction-parser.js +866 -866
  59. package/src/domains/recruit/refresh.js +45 -45
  60. package/src/domains/recruit/roots.js +68 -68
  61. package/src/domains/recruit/run-service.js +1817 -1620
  62. package/src/domains/recruit/search.js +3229 -3229
  63. package/src/index.js +124 -5
  64. package/src/parser.js +1296 -1296
  65. package/src/recommend-mcp.js +515 -80
  66. package/src/recommend-scheduler.js +66 -0
@@ -1,1201 +1,2930 @@
1
- import fs from "node:fs";
2
- import crypto from "node:crypto";
3
- import path from "node:path";
4
- import sharp from "sharp";
5
- import {
6
- getAttributesMap,
7
- getNodeBox,
8
- getOuterHTML,
9
- querySelectorAll,
10
- sleep
11
- } from "../browser/index.js";
12
- import {
13
- htmlToText,
14
- normalizeText
15
- } from "../screening/index.js";
16
-
17
- function nowIso() {
18
- return new Date().toISOString();
19
- }
20
-
21
- function resolveOutputPath(filePath) {
22
- if (!filePath) return null;
23
- const resolved = path.resolve(filePath);
24
- fs.mkdirSync(path.dirname(resolved), { recursive: true });
25
- return resolved;
26
- }
27
-
28
- function withPadding(rect, padding = 0) {
29
- const safePadding = Math.max(0, Number(padding) || 0);
30
- const x = Math.max(0, rect.x - safePadding);
31
- const y = Math.max(0, rect.y - safePadding);
32
- return {
33
- x,
34
- y,
35
- width: Math.max(1, rect.width + safePadding * 2 - (rect.x - x)),
36
- height: Math.max(1, rect.height + safePadding * 2 - (rect.y - y)),
37
- scale: 1
38
- };
39
- }
40
-
41
- function normalizeRandom(random) {
42
- return typeof random === "function" ? random : Math.random;
43
- }
44
-
45
- function randomBetween(random, min, max) {
46
- const lower = Number(min) || 0;
47
- const upper = Number(max) || lower;
48
- if (upper <= lower) return lower;
49
- return lower + normalizeRandom(random)() * (upper - lower);
50
- }
51
-
52
- function normalizeRatio(raw, fallback, { min = 0, max = 1 } = {}) {
53
- const parsed = Number(raw);
54
- const value = Number.isFinite(parsed) ? parsed : fallback;
55
- return Math.min(max, Math.max(min, value));
56
- }
57
-
58
- function normalizeScrollDeltaJitter({
59
- enabled = false,
60
- minRatio = 0.65,
61
- maxRatio = 0.9,
62
- minOverlapRatio = 0.2,
63
- preserveCoverage = true,
64
- random = Math.random
65
- } = {}) {
66
- const safeMinRatio = normalizeRatio(minRatio, 0.65, { min: 0.1, max: 1 });
67
- const safeMaxRatio = Math.max(safeMinRatio, normalizeRatio(maxRatio, 0.9, { min: safeMinRatio, max: 1 }));
68
- return {
69
- enabled: enabled === true,
70
- min_ratio: safeMinRatio,
71
- max_ratio: safeMaxRatio,
72
- min_overlap_ratio: normalizeRatio(minOverlapRatio, 0.2, { min: 0, max: 0.8 }),
73
- preserve_coverage: preserveCoverage !== false,
74
- random: normalizeRandom(random)
75
- };
76
- }
77
-
78
- function resolveCoverageSafeScrollDelta({
79
- baseDelta,
80
- clipHeight,
81
- jitter
82
- } = {}) {
83
- const safeBase = Math.max(1, Number(baseDelta) || 650);
84
- if (!jitter?.enabled) {
85
- return {
86
- deltaY: safeBase,
87
- jittered: false,
88
- base_delta_y: safeBase
89
- };
90
- }
91
- const safeClipHeight = Math.max(1, Number(clipHeight) || 1);
92
- const maxDeltaForOverlap = Math.max(1, Math.floor(safeClipHeight * (1 - jitter.min_overlap_ratio)));
93
- const upper = Math.max(1, Math.min(Math.round(safeBase * jitter.max_ratio), maxDeltaForOverlap));
94
- const lower = Math.min(upper, Math.max(1, Math.round(safeBase * jitter.min_ratio)));
95
- const deltaY = Math.max(1, Math.round(randomBetween(jitter.random, lower, upper)));
96
- return {
97
- deltaY,
98
- jittered: true,
99
- base_delta_y: safeBase,
100
- min_delta_y: lower,
101
- max_delta_y: upper,
102
- min_ratio: jitter.min_ratio,
103
- max_ratio: jitter.max_ratio,
104
- min_overlap_ratio: jitter.min_overlap_ratio,
105
- clip_height: safeClipHeight,
106
- max_delta_for_overlap: maxDeltaForOverlap,
107
- preserve_coverage: jitter.preserve_coverage
108
- };
109
- }
110
-
111
- export async function captureNodeHtml(client, nodeId, {
112
- domain = "unknown",
113
- source = "dom",
114
- metadata = {}
115
- } = {}) {
116
- const [attributes, outerHTML] = await Promise.all([
117
- getAttributesMap(client, nodeId),
118
- getOuterHTML(client, nodeId)
119
- ]);
120
- const text = htmlToText(outerHTML);
121
- return {
122
- schema_version: 1,
123
- domain: normalizeText(domain) || "unknown",
124
- source,
125
- captured_at: nowIso(),
126
- node_id: nodeId,
127
- attributes,
128
- outer_html_length: outerHTML.length,
129
- text_length: text.length,
130
- text,
131
- outer_html: outerHTML,
132
- metadata
133
- };
134
- }
135
-
136
- export async function captureNodeScreenshot(client, nodeId, {
137
- filePath,
138
- format = "png",
139
- quality,
140
- padding = 0,
141
- captureBeyondViewport = true,
142
- fromSurface = true,
143
- metadata = {}
144
- } = {}) {
145
- const box = await getNodeBox(client, nodeId);
146
- const clip = withPadding(box.rect, padding);
147
- const captureOptions = {
148
- format,
149
- fromSurface,
150
- captureBeyondViewport,
151
- clip
152
- };
153
- if (quality != null) {
154
- captureOptions.quality = quality;
155
- }
156
- const screenshot = await client.Page.captureScreenshot(captureOptions);
157
- const buffer = Buffer.from(screenshot.data || "", "base64");
158
- const resolvedPath = resolveOutputPath(filePath);
159
- if (resolvedPath) {
160
- fs.writeFileSync(resolvedPath, buffer);
161
- }
162
- return {
163
- schema_version: 1,
164
- source: "image",
165
- captured_at: nowIso(),
166
- node_id: nodeId,
167
- format,
168
- mime_type: `image/${format === "jpeg" ? "jpeg" : "png"}`,
169
- byte_length: buffer.length,
170
- file_path: resolvedPath,
171
- clip,
172
- node_rect: box.rect,
173
- metadata
174
- };
175
- }
176
-
177
- export async function captureViewportScreenshot(client, {
178
- filePath,
179
- format = "png",
180
- quality,
181
- captureBeyondViewport = false,
182
- fromSurface = true,
183
- metadata = {}
184
- } = {}) {
185
- const captureOptions = {
186
- format,
187
- fromSurface,
188
- captureBeyondViewport
189
- };
190
- if (quality != null) {
191
- captureOptions.quality = quality;
192
- }
193
- const screenshot = await client.Page.captureScreenshot(captureOptions);
194
- const buffer = Buffer.from(screenshot.data || "", "base64");
195
- const resolvedPath = resolveOutputPath(filePath);
196
- if (resolvedPath) {
197
- fs.writeFileSync(resolvedPath, buffer);
198
- }
199
- return {
200
- schema_version: 1,
201
- source: "viewport-image",
202
- captured_at: nowIso(),
203
- format,
204
- mime_type: `image/${format === "jpeg" ? "jpeg" : "png"}`,
205
- byte_length: buffer.length,
206
- file_path: resolvedPath,
207
- capture_beyond_viewport: Boolean(captureBeyondViewport),
208
- metadata
209
- };
210
- }
211
-
212
- function filePathForSequence(basePath, index, extension) {
213
- const resolved = resolveOutputPath(basePath);
214
- if (!resolved) return null;
215
- const parsed = path.parse(resolved);
216
- const page = String(index + 1).padStart(2, "0");
217
- return path.join(parsed.dir, `${parsed.name}-page-${page}${parsed.ext || `.${extension}`}`);
218
- }
219
-
220
- function filePathForLlmSequence(basePath, index) {
221
- const resolved = resolveOutputPath(basePath);
222
- if (!resolved) return null;
223
- const parsed = path.parse(resolved);
224
- const page = String(index + 1).padStart(2, "0");
225
- return path.join(parsed.dir, `${parsed.name}-llm-${page}.jpg`);
226
- }
227
-
228
- function screenshotHash(buffer) {
229
- return crypto.createHash("sha256").update(buffer).digest("hex");
230
- }
231
-
232
- function createCaptureTimeoutError(label, timeoutMs) {
233
- const error = new Error(`Image fallback capture timed out during ${label} after ${timeoutMs}ms`);
234
- error.code = "IMAGE_CAPTURE_TIMEOUT";
235
- error.capture_step = label;
236
- error.timeout_ms = timeoutMs;
237
- return error;
238
- }
239
-
240
- async function withCaptureTimeout(promise, {
241
- label = "capture_step",
242
- timeoutMs = 0
243
- } = {}) {
244
- const safeTimeout = Math.max(0, Number(timeoutMs) || 0);
245
- if (!safeTimeout) return promise;
246
- let timer = null;
247
- try {
248
- return await Promise.race([
249
- promise,
250
- new Promise((_, reject) => {
251
- timer = setTimeout(() => reject(createCaptureTimeoutError(label, safeTimeout)), safeTimeout);
252
- })
253
- ]);
254
- } finally {
255
- if (timer) clearTimeout(timer);
256
- }
257
- }
258
-
259
- function assertCaptureTotalBudget(started, totalTimeoutMs, label) {
260
- const safeTimeout = Math.max(0, Number(totalTimeoutMs) || 0);
261
- if (!safeTimeout) return;
262
- const elapsed = Date.now() - started;
263
- if (elapsed <= safeTimeout) return;
264
- const error = createCaptureTimeoutError(label, safeTimeout);
265
- error.elapsed_ms = elapsed;
266
- error.code = "IMAGE_CAPTURE_TOTAL_TIMEOUT";
267
- throw error;
268
- }
269
-
270
- const DEFAULT_SCROLL_ANCHOR_SELECTOR = [
271
- "h1",
272
- "h2",
273
- "h3",
274
- "h4",
275
- "h5",
276
- "p",
277
- "li",
278
- "section",
279
- "article",
280
- "table",
281
- "tr",
282
- "dl",
283
- "dt",
284
- "dd",
285
- "[class*='resume']",
286
- "[class*='work']",
287
- "[class*='project']",
288
- "[class*='education']",
289
- "[class*='experience']",
290
- "[class*='item']",
291
- "div"
292
- ].join(",");
293
-
294
- function normalizeScrollMethod(value = "dom-anchor-fallback-input") {
295
- const normalized = normalizeText(value).toLowerCase();
296
- if (["dom", "dom-anchor", "dom_anchor", "anchor"].includes(normalized)) return "dom-anchor";
297
- if (["dom-anchor-fallback-input", "dom_anchor_fallback_input", "dom-fallback-input"].includes(normalized)) {
298
- return "dom-anchor-fallback-input";
299
- }
300
- return "input";
301
- }
302
-
303
- function uniqueNumbers(values = []) {
304
- return Array.from(new Set(values.map((value) => Number(value) || 0).filter(Boolean)));
305
- }
306
-
307
- function pickEvenly(items = [], limit = 1) {
308
- const safeLimit = Math.max(1, Number(limit) || 1);
309
- if (items.length <= safeLimit) return items;
310
- const picked = [];
311
- const last = items.length - 1;
312
- for (let index = 0; index < safeLimit; index += 1) {
313
- const sourceIndex = Math.round((index * last) / Math.max(1, safeLimit - 1));
314
- picked.push(items[sourceIndex]);
315
- }
316
- return Array.from(new Map(picked.map((item) => [item.node_id, item])).values());
317
- }
318
-
319
- function patternLabel(pattern) {
320
- if (pattern instanceof RegExp) return pattern.source;
321
- return normalizeText(pattern);
322
- }
323
-
324
- function stopBoundaryPatterns(patterns = []) {
325
- return (Array.isArray(patterns) ? patterns : [patterns])
326
- .filter(Boolean)
327
- .map((pattern) => {
328
- if (pattern instanceof RegExp) {
329
- return {
330
- raw: pattern,
331
- label: pattern.source,
332
- matches: (text) => pattern.test(text)
333
- };
334
- }
335
- const normalized = normalizeText(pattern);
336
- return {
337
- raw: pattern,
338
- label: normalized,
339
- matches: (text) => normalized && text.includes(normalized)
340
- };
341
- });
342
- }
343
-
344
- async function collectStopBoundaryNodes(client, rootNodeId, {
345
- selector = "",
346
- textPatterns = [],
347
- maxProbeNodes = 180,
348
- maxTextLength = 700,
349
- stepTimeoutMs = 45000
350
- } = {}) {
351
- const patterns = stopBoundaryPatterns(textPatterns);
352
- const normalizedSelector = normalizeText(selector);
353
- if (!normalizedSelector && !patterns.length) {
354
- return {
355
- enabled: false,
356
- ok: false,
357
- reason: "not_configured",
358
- nodes: []
359
- };
360
- }
361
- const started = Date.now();
362
- let nodeIds = [];
363
- try {
364
- nodeIds = uniqueNumbers(await querySelectorAll(
365
- client,
366
- rootNodeId,
367
- normalizedSelector || DEFAULT_SCROLL_ANCHOR_SELECTOR
368
- ));
369
- } catch (error) {
370
- return {
371
- enabled: true,
372
- ok: false,
373
- reason: "query_selector_all_failed",
374
- selector: normalizedSelector || DEFAULT_SCROLL_ANCHOR_SELECTOR,
375
- error: error?.message || String(error),
376
- nodes: []
377
- };
378
- }
379
-
380
- const probeLimit = Math.max(1, Number(maxProbeNodes) || 180);
381
- const maxStopTextLength = Math.max(40, Number(maxTextLength) || 700);
382
- const perNodeTimeoutMs = Math.min(1000, Math.max(200, Math.floor((Number(stepTimeoutMs) || 45000) / 40)));
383
- const nodes = [];
384
- for (const nodeId of nodeIds.slice(0, probeLimit)) {
385
- try {
386
- let text = "";
387
- let matchedPattern = null;
388
- if (patterns.length) {
389
- const outerHTML = await withCaptureTimeout(getOuterHTML(client, nodeId), {
390
- label: `stop_boundary_html_${nodeId}`,
391
- timeoutMs: perNodeTimeoutMs
392
- });
393
- text = normalizeText(htmlToText(outerHTML));
394
- if (!text || text.length > maxStopTextLength) continue;
395
- matchedPattern = patterns.find((pattern) => pattern.matches(text));
396
- if (!matchedPattern) continue;
397
- }
398
- nodes.push({
399
- node_id: nodeId,
400
- text_preview: text.slice(0, 120),
401
- matched_pattern: matchedPattern ? patternLabel(matchedPattern.raw) : null
402
- });
403
- } catch {}
404
- }
405
-
406
- return {
407
- enabled: true,
408
- ok: nodes.length > 0,
409
- reason: nodes.length ? null : "no_matching_stop_boundary_nodes",
410
- selector: normalizedSelector || DEFAULT_SCROLL_ANCHOR_SELECTOR,
411
- elapsed_ms: Date.now() - started,
412
- discovered_node_count: nodeIds.length,
413
- probed_node_count: Math.min(nodeIds.length, probeLimit),
414
- match_count: nodes.length,
415
- pattern_labels: patterns.map((pattern) => pattern.label),
416
- nodes
417
- };
418
- }
419
-
420
- async function resolveVisibleStopBoundary(client, stopBoundaryPlan, clip, {
421
- topPadding = 8,
422
- minCaptureHeight = 180,
423
- stepTimeoutMs = 45000
424
- } = {}) {
425
- if (!stopBoundaryPlan?.nodes?.length || !clip) return null;
426
- const clipTop = Number(clip.y) || 0;
427
- const clipBottom = clipTop + (Number(clip.height) || 0);
428
- const safePadding = Math.max(0, Number(topPadding) || 0);
429
- const safeMinHeight = Math.max(1, Number(minCaptureHeight) || 180);
430
- const perNodeTimeoutMs = Math.min(900, Math.max(180, Math.floor((Number(stepTimeoutMs) || 45000) / 50)));
431
- const visible = [];
432
-
433
- for (const node of stopBoundaryPlan.nodes) {
434
- try {
435
- const box = await withCaptureTimeout(getNodeBox(client, node.node_id), {
436
- label: `stop_boundary_box_${node.node_id}`,
437
- timeoutMs: perNodeTimeoutMs
438
- });
439
- const rect = box?.rect || {};
440
- const width = Number(rect.width) || 0;
441
- const height = Number(rect.height) || 0;
442
- if (width < 40 || height < 6) continue;
443
- const top = Number(rect.y) || 0;
444
- const bottom = top + height;
445
- if (bottom <= clipTop + 1) {
446
- return {
447
- action: "stop_before_capture",
448
- reason: "stop_boundary_above_clip",
449
- node_id: node.node_id,
450
- matched_pattern: node.matched_pattern,
451
- text_preview: node.text_preview,
452
- rect,
453
- clip
454
- };
455
- }
456
- if (top < clipBottom && bottom > clipTop) {
457
- visible.push({
458
- ...node,
459
- rect,
460
- top,
461
- bottom
462
- });
463
- }
464
- } catch {}
465
- }
466
- if (!visible.length) return null;
467
-
468
- visible.sort((a, b) => a.top - b.top);
469
- const boundary = visible[0];
470
- const boundaryY = Math.max(clipTop, boundary.top - safePadding);
471
- const adjustedHeight = Math.max(0, boundaryY - clipTop);
472
- if (adjustedHeight < safeMinHeight) {
473
- return {
474
- action: "stop_before_capture",
475
- reason: "stop_boundary_near_clip_top",
476
- node_id: boundary.node_id,
477
- matched_pattern: boundary.matched_pattern,
478
- text_preview: boundary.text_preview,
479
- rect: boundary.rect,
480
- clip,
481
- adjusted_height: adjustedHeight,
482
- min_capture_height: safeMinHeight
483
- };
484
- }
485
-
486
- return {
487
- action: "capture_then_stop",
488
- reason: "stop_boundary_visible",
489
- node_id: boundary.node_id,
490
- matched_pattern: boundary.matched_pattern,
491
- text_preview: boundary.text_preview,
492
- rect: boundary.rect,
493
- clip,
494
- adjusted_clip: {
495
- ...clip,
496
- height: adjustedHeight
497
- },
498
- adjusted_height: adjustedHeight,
499
- min_capture_height: safeMinHeight
500
- };
501
- }
502
-
503
- async function collectDomScrollAnchors(client, rootNodeId, {
504
- selector = DEFAULT_SCROLL_ANCHOR_SELECTOR,
505
- maxScreenshots = 6,
506
- maxProbeNodes = 260,
507
- minAnchorGap = 180,
508
- stepTimeoutMs = 45000
509
- } = {}) {
510
- const started = Date.now();
511
- let nodeIds = [];
512
- try {
513
- nodeIds = uniqueNumbers(await querySelectorAll(client, rootNodeId, selector));
514
- } catch (error) {
515
- return {
516
- ok: false,
517
- method: "dom-anchor",
518
- reason: "query_selector_all_failed",
519
- error: error?.message || String(error)
520
- };
521
- }
522
- if (!nodeIds.length) {
523
- return {
524
- ok: false,
525
- method: "dom-anchor",
526
- reason: "no_anchor_nodes"
527
- };
528
- }
529
-
530
- const probeLimit = Math.max(1, Number(maxProbeNodes) || 260);
531
- const perNodeTimeoutMs = Math.min(1200, Math.max(250, Math.floor((Number(stepTimeoutMs) || 45000) / 30)));
532
- const measured = [];
533
- for (const nodeId of nodeIds.slice(0, probeLimit)) {
534
- try {
535
- const box = await withCaptureTimeout(getNodeBox(client, nodeId), {
536
- label: `anchor_box_${nodeId}`,
537
- timeoutMs: perNodeTimeoutMs
538
- });
539
- const rect = box?.rect || {};
540
- if ((Number(rect.width) || 0) < 80 || (Number(rect.height) || 0) < 8) continue;
541
- measured.push({
542
- node_id: nodeId,
543
- y: Math.round(Number(rect.y) || 0),
544
- height: Math.round(Number(rect.height) || 0)
545
- });
546
- } catch {}
547
- }
548
-
549
- let anchors = [];
550
- if (measured.length) {
551
- const sorted = measured.sort((a, b) => a.y - b.y);
552
- for (const item of sorted) {
553
- const last = anchors[anchors.length - 1];
554
- if (!last || Math.abs(item.y - last.y) >= Math.max(40, Number(minAnchorGap) || 180)) {
555
- anchors.push(item);
556
- }
557
- }
558
- }
559
-
560
- if (anchors.length < 2) {
561
- anchors = nodeIds.slice(0, probeLimit).map((nodeId, index) => ({
562
- node_id: nodeId,
563
- y: null,
564
- height: null,
565
- document_order: index
566
- }));
567
- }
568
-
569
- anchors = pickEvenly(anchors, Math.max(1, Number(maxScreenshots) || 1));
570
- return {
571
- ok: anchors.length > 0,
572
- method: "dom-anchor",
573
- elapsed_ms: Date.now() - started,
574
- selector,
575
- discovered_node_count: nodeIds.length,
576
- measured_node_count: measured.length,
577
- anchor_count: anchors.length,
578
- anchors
579
- };
580
- }
581
-
582
- async function scrollDomAnchorIntoView(client, nodeId, {
583
- timeoutMs = 10000,
584
- label = "dom_scroll_anchor"
585
- } = {}) {
586
- if (client.DOM && typeof client.DOM.scrollIntoViewIfNeeded === "function") {
587
- return withCaptureTimeout(client.DOM.scrollIntoViewIfNeeded({ nodeId }), { label, timeoutMs });
588
- }
589
- if (typeof client.send === "function") {
590
- return withCaptureTimeout(client.send("DOM.scrollIntoViewIfNeeded", { nodeId }), { label, timeoutMs });
591
- }
592
- throw new Error("CDP client does not expose DOM.scrollIntoViewIfNeeded");
593
- }
594
-
595
- async function optimizeScreenshotBuffer(buffer, {
596
- enabled = false,
597
- format = "png",
598
- quality,
599
- resizeMaxWidth = 0
600
- } = {}) {
601
- if (!enabled && !resizeMaxWidth) {
602
- return {
603
- buffer,
604
- optimized: false,
605
- optimization_error: null
606
- };
607
- }
608
- try {
609
- const normalizedFormat = format === "jpg" ? "jpeg" : format;
610
- let pipeline = sharp(buffer, { failOn: "none" });
611
- const metadata = await pipeline.metadata();
612
- const width = Number(metadata.width) || 0;
613
- const safeMaxWidth = Math.max(0, Number(resizeMaxWidth) || 0);
614
- if (safeMaxWidth > 0 && width > safeMaxWidth) {
615
- pipeline = pipeline.resize({
616
- width: safeMaxWidth,
617
- withoutEnlargement: true
618
- });
619
- }
620
- if (normalizedFormat === "jpeg") {
621
- pipeline = pipeline.jpeg({
622
- quality: quality == null ? 72 : Math.max(35, Math.min(95, Number(quality) || 72)),
623
- mozjpeg: true
624
- });
625
- } else if (normalizedFormat === "webp") {
626
- pipeline = pipeline.webp({
627
- quality: quality == null ? 76 : Math.max(35, Math.min(95, Number(quality) || 76))
628
- });
629
- } else {
630
- pipeline = pipeline.png({
631
- compressionLevel: 9,
632
- adaptiveFiltering: true
633
- });
634
- }
635
- const optimizedBuffer = await pipeline.toBuffer();
636
- return {
637
- buffer: optimizedBuffer,
638
- optimized: true,
639
- original_byte_length: buffer.length,
640
- optimization_error: null
641
- };
642
- } catch (error) {
643
- return {
644
- buffer,
645
- optimized: false,
646
- original_byte_length: buffer.length,
647
- optimization_error: error?.message || String(error)
648
- };
649
- }
650
- }
651
-
652
- async function composeScreenshotsForLlm(screenshots = [], {
653
- basePath,
654
- pagesPerImage = 3,
655
- resizeMaxWidth = 1100,
656
- quality = 72
657
- } = {}) {
658
- const fileScreenshots = screenshots.filter((item) => item?.file_path);
659
- if (!basePath || fileScreenshots.length <= 1) {
660
- return {
661
- llm_file_paths: fileScreenshots.map((item) => item.file_path),
662
- llm_screenshots: [],
663
- llm_total_byte_length: 0,
664
- llm_original_total_byte_length: 0,
665
- llm_composition_error: null
666
- };
667
- }
668
-
669
- const safePagesPerImage = Math.max(1, Math.min(5, Number(pagesPerImage) || 3));
670
- const safeWidth = Math.max(700, Math.min(1400, Number(resizeMaxWidth) || 1100));
671
- const safeQuality = Math.max(45, Math.min(90, Number(quality) || 72));
672
- const llmScreenshots = [];
673
-
674
- try {
675
- for (let index = 0; index < fileScreenshots.length; index += safePagesPerImage) {
676
- const group = fileScreenshots.slice(index, index + safePagesPerImage);
677
- const prepared = [];
678
- for (const item of group) {
679
- const sourceBuffer = fs.readFileSync(item.file_path);
680
- const { data, info } = await sharp(sourceBuffer, { failOn: "none" })
681
- .resize({
682
- width: safeWidth,
683
- withoutEnlargement: true
684
- })
685
- .jpeg({
686
- quality: safeQuality,
687
- mozjpeg: true
688
- })
689
- .toBuffer({ resolveWithObject: true });
690
- prepared.push({
691
- input: data,
692
- width: info.width,
693
- height: info.height,
694
- source_file_path: item.file_path
695
- });
696
- }
697
-
698
- const width = Math.max(...prepared.map((item) => item.width), 1);
699
- const height = prepared.reduce((sum, item) => sum + item.height, 0);
700
- let top = 0;
701
- const composites = prepared.map((item) => {
702
- const layer = {
703
- input: item.input,
704
- left: 0,
705
- top
706
- };
707
- top += item.height;
708
- return layer;
709
- });
710
- const outputBuffer = await sharp({
711
- create: {
712
- width,
713
- height,
714
- channels: 3,
715
- background: "#ffffff"
716
- }
717
- })
718
- .composite(composites)
719
- .jpeg({
720
- quality: safeQuality,
721
- mozjpeg: true
722
- })
723
- .toBuffer();
724
- const outputPath = filePathForLlmSequence(basePath, llmScreenshots.length);
725
- fs.writeFileSync(outputPath, outputBuffer);
726
- llmScreenshots.push({
727
- index: llmScreenshots.length,
728
- file_path: outputPath,
729
- byte_length: outputBuffer.length,
730
- source_file_paths: prepared.map((item) => item.source_file_path),
731
- source_page_count: prepared.length,
732
- width,
733
- height,
734
- format: "jpeg",
735
- mime_type: "image/jpeg"
736
- });
737
- }
738
- } catch (error) {
739
- return {
740
- llm_file_paths: fileScreenshots.map((item) => item.file_path),
741
- llm_screenshots: [],
742
- llm_total_byte_length: 0,
743
- llm_original_total_byte_length: fileScreenshots.reduce((sum, item) => sum + (Number(item.byte_length) || 0), 0),
744
- llm_composition_error: error?.message || String(error)
745
- };
746
- }
747
-
748
- return {
749
- llm_file_paths: llmScreenshots.map((item) => item.file_path),
750
- llm_screenshots: llmScreenshots,
751
- llm_total_byte_length: llmScreenshots.reduce((sum, item) => sum + (Number(item.byte_length) || 0), 0),
752
- llm_original_total_byte_length: fileScreenshots.reduce((sum, item) => sum + (Number(item.byte_length) || 0), 0),
753
- llm_composition_error: null
754
- };
755
- }
756
-
757
- export async function captureScrolledNodeScreenshots(client, nodeId, {
758
- filePath,
759
- format = "png",
760
- quality,
761
- padding = 0,
762
- captureBeyondViewport = true,
763
- fromSurface = true,
764
- captureViewport = false,
765
- maxScreenshots = 6,
766
- wheelDeltaY = 650,
767
- settleMs = 900,
768
- duplicateStopCount = 2,
769
- skipDuplicateScreenshots = false,
770
- optimize = false,
771
- resizeMaxWidth = 0,
772
- composeForLlm = false,
773
- llmPagesPerImage = 3,
774
- llmResizeMaxWidth = 1100,
775
- llmQuality = 72,
776
- stepTimeoutMs = 45000,
777
- totalTimeoutMs = 90000,
778
- scrollMethod = "dom-anchor-fallback-input",
779
- scrollAnchorSelector = DEFAULT_SCROLL_ANCHOR_SELECTOR,
780
- scrollAnchorMaxProbeNodes = 260,
781
- scrollAnchorMinGap = 180,
782
- scrollDeltaJitterEnabled = false,
783
- scrollDeltaJitterMinRatio = 0.65,
784
- scrollDeltaJitterMaxRatio = 0.9,
785
- scrollDeltaJitterMinOverlapRatio = 0.2,
786
- scrollDeltaJitterPreserveCoverage = true,
787
- scrollDeltaJitterRandom = Math.random,
788
- stopBoundarySelector = "",
789
- stopBoundaryTextPatterns = [],
790
- stopBoundaryMaxProbeNodes = 180,
791
- stopBoundaryMaxTextLength = 700,
792
- stopBoundaryTopPadding = 8,
793
- stopBoundaryMinCaptureHeight = 180,
794
- metadata = {}
795
- } = {}) {
796
- if (!nodeId) throw new Error("captureScrolledNodeScreenshots requires nodeId");
797
- const sequenceStarted = Date.now();
798
- const normalizedScrollMethod = normalizeScrollMethod(scrollMethod);
799
- const maxScreenshotCount = Math.max(1, Number(maxScreenshots) || 1);
800
- const scrollDeltaJitter = normalizeScrollDeltaJitter({
801
- enabled: scrollDeltaJitterEnabled,
802
- minRatio: scrollDeltaJitterMinRatio,
803
- maxRatio: scrollDeltaJitterMaxRatio,
804
- minOverlapRatio: scrollDeltaJitterMinOverlapRatio,
805
- preserveCoverage: scrollDeltaJitterPreserveCoverage,
806
- random: scrollDeltaJitterRandom
807
- });
808
- const maxCaptureIterations = scrollDeltaJitter.enabled && scrollDeltaJitter.preserve_coverage
809
- ? Math.max(maxScreenshotCount, Math.ceil(maxScreenshotCount / scrollDeltaJitter.min_ratio))
810
- : maxScreenshotCount;
811
- const anchorPlan = normalizedScrollMethod !== "input"
812
- ? await collectDomScrollAnchors(client, nodeId, {
813
- selector: scrollAnchorSelector,
814
- maxScreenshots: maxCaptureIterations,
815
- maxProbeNodes: scrollAnchorMaxProbeNodes,
816
- minAnchorGap: scrollAnchorMinGap,
817
- stepTimeoutMs
818
- })
819
- : null;
820
- const stopBoundaryEnabled = Boolean(
821
- normalizeText(stopBoundarySelector)
822
- || (Array.isArray(stopBoundaryTextPatterns)
823
- ? stopBoundaryTextPatterns.length
824
- : stopBoundaryTextPatterns)
825
- );
826
- let stopBoundaryPlan = {
827
- enabled: false,
828
- ok: false,
829
- reason: "not_configured",
830
- nodes: []
831
- };
832
- const stopBoundaryChecks = [];
833
- const screenshots = [];
834
- let consecutiveDuplicates = 0;
835
- let previousHash = "";
836
- let captureCount = 0;
837
- let droppedDuplicateCount = 0;
838
- let forceInputScrollAfterDuplicate = false;
839
- let stopBoundaryResult = null;
840
- let currentScrollMetadata = {
841
- before_capture: "initial",
842
- method: normalizedScrollMethod,
843
- anchor_plan: anchorPlan
844
- ? {
845
- ok: Boolean(anchorPlan.ok),
846
- reason: anchorPlan.reason || null,
847
- discovered_node_count: anchorPlan.discovered_node_count || 0,
848
- measured_node_count: anchorPlan.measured_node_count || 0,
849
- anchor_count: anchorPlan.anchor_count || 0,
850
- elapsed_ms: anchorPlan.elapsed_ms || 0
851
- }
852
- : null
853
- };
854
-
855
- if (anchorPlan?.anchors?.[0]?.node_id && normalizedScrollMethod !== "input") {
856
- try {
857
- await scrollDomAnchorIntoView(client, anchorPlan.anchors[0].node_id, {
858
- label: "scroll_dom_anchor_initial",
859
- timeoutMs: Math.min(Math.max(3000, Number(stepTimeoutMs) || 45000), 10000)
860
- });
861
- currentScrollMetadata = {
862
- before_capture: "dom_anchor_initial",
863
- method: "DOM.scrollIntoViewIfNeeded",
864
- anchor_node_id: anchorPlan.anchors[0].node_id,
865
- anchor_y: anchorPlan.anchors[0].y,
866
- anchor_height: anchorPlan.anchors[0].height,
867
- anchor_plan: currentScrollMetadata.anchor_plan
868
- };
869
- } catch (error) {
870
- if (normalizedScrollMethod === "dom-anchor") {
871
- throw error;
872
- }
873
- currentScrollMetadata = {
874
- before_capture: "dom_anchor_initial_failed",
875
- method: "DOM.scrollIntoViewIfNeeded",
876
- anchor_node_id: anchorPlan.anchors[0].node_id,
877
- error: error?.message || String(error),
878
- anchor_plan: currentScrollMetadata.anchor_plan
879
- };
880
- }
881
- }
882
-
883
- for (let index = 0; index < maxCaptureIterations; index += 1) {
884
- assertCaptureTotalBudget(sequenceStarted, totalTimeoutMs, `capture_page_${index + 1}`);
885
- captureCount += 1;
886
- const captureStarted = Date.now();
887
- const box = await withCaptureTimeout(getNodeBox(client, nodeId), {
888
- label: `get_box_${index + 1}`,
889
- timeoutMs: stepTimeoutMs
890
- });
891
- const clip = withPadding(box.rect, padding);
892
- let visibleStopBoundary = null;
893
- if (stopBoundaryEnabled) {
894
- stopBoundaryPlan = await collectStopBoundaryNodes(client, nodeId, {
895
- selector: stopBoundarySelector,
896
- textPatterns: stopBoundaryTextPatterns,
897
- maxProbeNodes: stopBoundaryMaxProbeNodes,
898
- maxTextLength: stopBoundaryMaxTextLength,
899
- stepTimeoutMs
900
- });
901
- stopBoundaryChecks.push({
902
- capture_index: index,
903
- ok: Boolean(stopBoundaryPlan.ok),
904
- reason: stopBoundaryPlan.reason || null,
905
- discovered_node_count: stopBoundaryPlan.discovered_node_count || 0,
906
- probed_node_count: stopBoundaryPlan.probed_node_count || 0,
907
- match_count: stopBoundaryPlan.match_count || 0,
908
- elapsed_ms: stopBoundaryPlan.elapsed_ms || 0
909
- });
910
- visibleStopBoundary = await resolveVisibleStopBoundary(client, stopBoundaryPlan, clip, {
911
- topPadding: stopBoundaryTopPadding,
912
- minCaptureHeight: stopBoundaryMinCaptureHeight,
913
- stepTimeoutMs
914
- });
915
- }
916
- if (visibleStopBoundary?.action === "stop_before_capture") {
917
- stopBoundaryResult = visibleStopBoundary;
918
- break;
919
- }
920
- const effectiveClip = visibleStopBoundary?.adjusted_clip || clip;
921
- const effectiveCaptureViewport = Boolean(captureViewport && !visibleStopBoundary?.adjusted_clip);
922
- const captureOptions = effectiveCaptureViewport ? {
923
- format,
924
- fromSurface,
925
- captureBeyondViewport: false
926
- } : {
927
- format,
928
- fromSurface,
929
- captureBeyondViewport,
930
- clip: effectiveClip
931
- };
932
- if (quality != null) {
933
- captureOptions.quality = quality;
934
- }
935
- const screenshot = await withCaptureTimeout(client.Page.captureScreenshot(captureOptions), {
936
- label: `capture_screenshot_${index + 1}`,
937
- timeoutMs: stepTimeoutMs
938
- });
939
- const originalBuffer = Buffer.from(screenshot.data || "", "base64");
940
- const optimized = await withCaptureTimeout(optimizeScreenshotBuffer(originalBuffer, {
941
- enabled: optimize,
942
- format,
943
- quality,
944
- resizeMaxWidth
945
- }), {
946
- label: `optimize_screenshot_${index + 1}`,
947
- timeoutMs: stepTimeoutMs
948
- });
949
- const buffer = optimized.buffer;
950
- const hash = screenshotHash(buffer);
951
- const duplicateOfPrevious = previousHash && previousHash === hash;
952
- if (duplicateOfPrevious) {
953
- consecutiveDuplicates += 1;
954
- } else {
955
- consecutiveDuplicates = 0;
956
- }
957
-
958
- let outputPath = null;
959
- if (duplicateOfPrevious && skipDuplicateScreenshots) {
960
- droppedDuplicateCount += 1;
961
- } else {
962
- outputPath = filePath ? filePathForSequence(filePath, screenshots.length, format) : null;
963
- if (outputPath) {
964
- fs.writeFileSync(outputPath, buffer);
965
- }
966
-
967
- screenshots.push({
968
- index: screenshots.length,
969
- capture_index: index,
970
- source: "image",
971
- captured_at: nowIso(),
972
- node_id: nodeId,
973
- format,
974
- mime_type: `image/${format === "jpeg" ? "jpeg" : "png"}`,
975
- byte_length: buffer.length,
976
- original_byte_length: optimized.original_byte_length || originalBuffer.length,
977
- optimized: Boolean(optimized.optimized),
978
- optimization_error: optimized.optimization_error || null,
979
- elapsed_ms: Date.now() - captureStarted,
980
- file_path: outputPath,
981
- sha256: hash,
982
- duplicate_of_previous: Boolean(duplicateOfPrevious),
983
- clip: effectiveClip,
984
- capture_viewport: effectiveCaptureViewport,
985
- node_rect: box.rect,
986
- scroll: currentScrollMetadata,
987
- stop_boundary: visibleStopBoundary || null,
988
- metadata
989
- });
990
- }
991
-
992
- if (visibleStopBoundary?.action === "capture_then_stop") {
993
- stopBoundaryResult = visibleStopBoundary;
994
- break;
995
- }
996
-
997
- previousHash = hash;
998
- forceInputScrollAfterDuplicate = Boolean(
999
- duplicateOfPrevious
1000
- && normalizedScrollMethod === "dom-anchor-fallback-input"
1001
- && currentScrollMetadata?.method === "DOM.scrollIntoViewIfNeeded"
1002
- );
1003
- if (
1004
- consecutiveDuplicates >= Math.max(1, Number(duplicateStopCount) || 1)
1005
- && !forceInputScrollAfterDuplicate
1006
- ) {
1007
- break;
1008
- }
1009
-
1010
- if (index < maxCaptureIterations - 1) {
1011
- assertCaptureTotalBudget(sequenceStarted, totalTimeoutMs, `scroll_after_page_${index + 1}`);
1012
- let scrolledByDomAnchor = false;
1013
- const nextAnchor = anchorPlan?.anchors?.[index + 1] || null;
1014
- if (nextAnchor?.node_id && normalizedScrollMethod !== "input" && !forceInputScrollAfterDuplicate) {
1015
- try {
1016
- await scrollDomAnchorIntoView(client, nextAnchor.node_id, {
1017
- label: `scroll_dom_anchor_${index + 1}`,
1018
- timeoutMs: Math.min(Math.max(3000, Number(stepTimeoutMs) || 45000), 10000)
1019
- });
1020
- scrolledByDomAnchor = true;
1021
- currentScrollMetadata = {
1022
- before_capture: `dom_anchor_${index + 1}`,
1023
- method: "DOM.scrollIntoViewIfNeeded",
1024
- anchor_node_id: nextAnchor.node_id,
1025
- anchor_y: nextAnchor.y,
1026
- anchor_height: nextAnchor.height
1027
- };
1028
- } catch (error) {
1029
- if (normalizedScrollMethod === "dom-anchor") {
1030
- throw error;
1031
- }
1032
- currentScrollMetadata = {
1033
- before_capture: `dom_anchor_${index + 1}_failed`,
1034
- method: "DOM.scrollIntoViewIfNeeded",
1035
- anchor_node_id: nextAnchor.node_id,
1036
- error: error?.message || String(error)
1037
- };
1038
- }
1039
- } else if (normalizedScrollMethod === "dom-anchor") {
1040
- break;
1041
- }
1042
-
1043
- if (!scrolledByDomAnchor && normalizedScrollMethod !== "dom-anchor") {
1044
- const x = box.center.x;
1045
- const y = box.center.y;
1046
- const scrollDelta = resolveCoverageSafeScrollDelta({
1047
- baseDelta: wheelDeltaY,
1048
- clipHeight: effectiveClip.height,
1049
- jitter: scrollDeltaJitter
1050
- });
1051
- await withCaptureTimeout(client.Input.dispatchMouseEvent({ type: "mouseMoved", x, y, button: "none" }), {
1052
- label: `scroll_mouse_move_${index + 1}`,
1053
- timeoutMs: Math.min(Math.max(3000, Number(stepTimeoutMs) || 45000), 10000)
1054
- });
1055
- await withCaptureTimeout(client.Input.dispatchMouseEvent({
1056
- type: "mouseWheel",
1057
- x,
1058
- y,
1059
- deltaX: 0,
1060
- deltaY: scrollDelta.deltaY
1061
- }), {
1062
- label: `scroll_wheel_${index + 1}`,
1063
- timeoutMs: Math.min(Math.max(3000, Number(stepTimeoutMs) || 45000), 10000)
1064
- });
1065
- currentScrollMetadata = {
1066
- before_capture: `wheel_down_${index + 1}`,
1067
- method: "Input.dispatchMouseEvent",
1068
- fallback_from_dom_anchor: Boolean(anchorPlan && normalizedScrollMethod === "dom-anchor-fallback-input"),
1069
- wheel_delta_y: scrollDelta.deltaY,
1070
- wheel_delta_base_y: scrollDelta.base_delta_y,
1071
- wheel_delta_jitter: scrollDelta.jittered ? scrollDelta : null
1072
- };
1073
- }
1074
- if (settleMs > 0) await sleep(settleMs);
1075
- }
1076
- }
1077
-
1078
- const llmComposition = composeForLlm
1079
- ? await withCaptureTimeout(composeScreenshotsForLlm(screenshots, {
1080
- basePath: filePath,
1081
- pagesPerImage: llmPagesPerImage,
1082
- resizeMaxWidth: llmResizeMaxWidth,
1083
- quality: llmQuality
1084
- }), {
1085
- label: "compose_llm_screenshots",
1086
- timeoutMs: stepTimeoutMs
1087
- })
1088
- : {
1089
- llm_file_paths: screenshots.map((item) => item.file_path).filter(Boolean),
1090
- llm_screenshots: [],
1091
- llm_total_byte_length: 0,
1092
- llm_original_total_byte_length: 0,
1093
- llm_composition_error: null
1094
- };
1095
-
1096
- return {
1097
- schema_version: 1,
1098
- ok: true,
1099
- source: "image-scroll-sequence",
1100
- captured_at: nowIso(),
1101
- node_id: nodeId,
1102
- elapsed_ms: Date.now() - sequenceStarted,
1103
- capture_count: captureCount,
1104
- screenshot_count: screenshots.length,
1105
- unique_screenshot_count: new Set(screenshots.map((item) => item.sha256)).size,
1106
- duplicate_screenshot_count: captureCount - new Set(screenshots.map((item) => item.sha256)).size,
1107
- dropped_duplicate_count: droppedDuplicateCount,
1108
- total_byte_length: screenshots.reduce((sum, item) => sum + (Number(item.byte_length) || 0), 0),
1109
- original_total_byte_length: screenshots.reduce((sum, item) => sum + (Number(item.original_byte_length) || 0), 0),
1110
- llm_file_paths: llmComposition.llm_file_paths,
1111
- llm_screenshot_count: llmComposition.llm_file_paths.length,
1112
- llm_total_byte_length: llmComposition.llm_total_byte_length,
1113
- llm_original_total_byte_length: llmComposition.llm_original_total_byte_length,
1114
- llm_composition_error: llmComposition.llm_composition_error,
1115
- llm_screenshots: llmComposition.llm_screenshots,
1116
- optimization: {
1117
- enabled: Boolean(optimize),
1118
- resize_max_width: Math.max(0, Number(resizeMaxWidth) || 0),
1119
- capture_viewport: Boolean(captureViewport),
1120
- format,
1121
- quality: quality ?? null,
1122
- llm_compose_enabled: Boolean(composeForLlm),
1123
- llm_pages_per_image: Math.max(1, Math.min(5, Number(llmPagesPerImage) || 3)),
1124
- llm_resize_max_width: Math.max(0, Number(llmResizeMaxWidth) || 0),
1125
- llm_quality: llmQuality ?? null,
1126
- step_timeout_ms: Math.max(0, Number(stepTimeoutMs) || 0),
1127
- total_timeout_ms: Math.max(0, Number(totalTimeoutMs) || 0),
1128
- scroll_method: normalizedScrollMethod,
1129
- requested_max_screenshots: maxScreenshotCount,
1130
- effective_max_screenshots: maxCaptureIterations,
1131
- scroll_anchor_selector: scrollAnchorSelector,
1132
- scroll_anchor_max_probe_nodes: Math.max(1, Number(scrollAnchorMaxProbeNodes) || 260),
1133
- scroll_anchor_min_gap: Math.max(0, Number(scrollAnchorMinGap) || 0),
1134
- scroll_delta_jitter: {
1135
- enabled: scrollDeltaJitter.enabled,
1136
- min_ratio: scrollDeltaJitter.min_ratio,
1137
- max_ratio: scrollDeltaJitter.max_ratio,
1138
- min_overlap_ratio: scrollDeltaJitter.min_overlap_ratio,
1139
- preserve_coverage: scrollDeltaJitter.preserve_coverage
1140
- }
1141
- },
1142
- scroll_anchor_plan: anchorPlan,
1143
- stop_boundary_plan: stopBoundaryPlan,
1144
- stop_boundary_checks: stopBoundaryChecks,
1145
- stop_boundary_result: stopBoundaryResult,
1146
- file_paths: screenshots.map((item) => item.file_path).filter(Boolean),
1147
- screenshots,
1148
- metadata
1149
- };
1150
- }
1151
-
1152
- export async function captureCandidateEvidence(client, {
1153
- nodeId,
1154
- domain = "unknown",
1155
- source = "dom",
1156
- screenshotPath,
1157
- includeHtml = true,
1158
- includeScreenshot = false,
1159
- screenshotMode = "scroll",
1160
- screenshotOptions = {},
1161
- metadata = {}
1162
- } = {}) {
1163
- if (!nodeId) throw new Error("captureCandidateEvidence requires nodeId");
1164
- const evidence = {
1165
- schema_version: 1,
1166
- domain: normalizeText(domain) || "unknown",
1167
- source,
1168
- captured_at: nowIso(),
1169
- node_id: nodeId,
1170
- html: null,
1171
- image: null,
1172
- metadata
1173
- };
1174
- if (includeHtml) {
1175
- evidence.html = await captureNodeHtml(client, nodeId, {
1176
- domain,
1177
- source: "dom",
1178
- metadata
1179
- });
1180
- }
1181
- if (includeScreenshot) {
1182
- evidence.image = screenshotMode === "single"
1183
- ? await captureNodeScreenshot(client, nodeId, {
1184
- ...screenshotOptions,
1185
- filePath: screenshotPath,
1186
- metadata: {
1187
- ...metadata,
1188
- capture_mode: "single_visible_clip"
1189
- }
1190
- })
1191
- : await captureScrolledNodeScreenshots(client, nodeId, {
1192
- ...screenshotOptions,
1193
- filePath: screenshotPath,
1194
- metadata: {
1195
- ...metadata,
1196
- capture_mode: "scroll_sequence"
1197
- }
1198
- });
1199
- }
1200
- return evidence;
1201
- }
1
+ import fs from "node:fs";
2
+ import crypto from "node:crypto";
3
+ import path from "node:path";
4
+ import sharp from "sharp";
5
+ import {
6
+ getAttributesMap,
7
+ getNodeBox,
8
+ getOuterHTML,
9
+ querySelectorAll,
10
+ sleep
11
+ } from "../browser/index.js";
12
+ import {
13
+ htmlToText,
14
+ normalizeText
15
+ } from "../screening/index.js";
16
+
17
+ function nowIso() {
18
+ return new Date().toISOString();
19
+ }
20
+
21
+ function resolveOutputPath(filePath) {
22
+ if (!filePath) return null;
23
+ const resolved = path.resolve(filePath);
24
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
25
+ return resolved;
26
+ }
27
+
28
+ function withPadding(rect, padding = 0) {
29
+ const safePadding = Math.max(0, Number(padding) || 0);
30
+ const x = Math.max(0, rect.x - safePadding);
31
+ const y = Math.max(0, rect.y - safePadding);
32
+ return {
33
+ x,
34
+ y,
35
+ width: Math.max(1, rect.width + safePadding * 2 - (rect.x - x)),
36
+ height: Math.max(1, rect.height + safePadding * 2 - (rect.y - y)),
37
+ scale: 1
38
+ };
39
+ }
40
+
41
+ const captureTransactions = new WeakMap();
42
+ const unsafeCaptureSessions = new WeakMap();
43
+
44
+ function captureConnectionEpoch(client) {
45
+ const value = Number(client?.__connectionEpoch);
46
+ return Number.isFinite(value) ? value : null;
47
+ }
48
+
49
+ function activeUnsafeCaptureSession(client) {
50
+ return unsafeCaptureSessions.get(client) || null;
51
+ }
52
+
53
+ function clearUnsafeCaptureSessionAfterSettlement(client, unsafe) {
54
+ if (!unsafe?.raw_capture_settled || !unsafe?.abandonment_settled) return false;
55
+ if (unsafeCaptureSessions.get(client) !== unsafe) return false;
56
+ unsafe.cleared_at = nowIso();
57
+ unsafeCaptureSessions.delete(client);
58
+ return true;
59
+ }
60
+
61
+ function createUnsafeCaptureSessionError(unsafe) {
62
+ const error = new Error(
63
+ "Screenshot capture is blocked because the prior timed-out request was not safely abandoned"
64
+ );
65
+ error.code = "IMAGE_CAPTURE_SESSION_UNSAFE";
66
+ error.capture_outcome_unknown = true;
67
+ error.screenshot_replay_suppressed = true;
68
+ error.blocked_by_capture_operation_id = unsafe?.operation_id || null;
69
+ error.blocked_connection_epoch = unsafe?.connection_epoch ?? null;
70
+ error.blocked_since = unsafe?.blocked_since || null;
71
+ error.blocked_reason = unsafe?.reason || "abandonment_unverified";
72
+ return error;
73
+ }
74
+
75
+ function assertCaptureSessionSafe(client) {
76
+ const unsafe = activeUnsafeCaptureSession(client);
77
+ if (unsafe) throw createUnsafeCaptureSessionError(unsafe);
78
+ }
79
+
80
+ function finiteNumber(value, fallback = 0) {
81
+ const parsed = Number(value);
82
+ return Number.isFinite(parsed) ? parsed : fallback;
83
+ }
84
+
85
+ function normalizeRect(rect = {}) {
86
+ return {
87
+ x: finiteNumber(rect.x),
88
+ y: finiteNumber(rect.y),
89
+ width: Math.max(0, finiteNumber(rect.width)),
90
+ height: Math.max(0, finiteNumber(rect.height))
91
+ };
92
+ }
93
+
94
+ function rectFromQuad(quad = []) {
95
+ if (!Array.isArray(quad) || quad.length < 8) return null;
96
+ const values = quad.slice(0, 8).map(Number);
97
+ if (values.some((value) => !Number.isFinite(value))) return null;
98
+ const xs = [values[0], values[2], values[4], values[6]];
99
+ const ys = [values[1], values[3], values[5], values[7]];
100
+ const x = Math.min(...xs);
101
+ const y = Math.min(...ys);
102
+ return {
103
+ x,
104
+ y,
105
+ width: Math.max(...xs) - x,
106
+ height: Math.max(...ys) - y
107
+ };
108
+ }
109
+
110
+ function expandRect(rect = {}, padding = 0) {
111
+ const normalized = normalizeRect(rect);
112
+ const safePadding = Math.max(0, finiteNumber(padding));
113
+ return {
114
+ x: normalized.x - safePadding,
115
+ y: normalized.y - safePadding,
116
+ width: normalized.width + safePadding * 2,
117
+ height: normalized.height + safePadding * 2
118
+ };
119
+ }
120
+
121
+ function intersectRects(left = {}, right = {}) {
122
+ const a = normalizeRect(left);
123
+ const b = normalizeRect(right);
124
+ const x = Math.max(a.x, b.x);
125
+ const y = Math.max(a.y, b.y);
126
+ const rightEdge = Math.min(a.x + a.width, b.x + b.width);
127
+ const bottomEdge = Math.min(a.y + a.height, b.y + b.height);
128
+ return {
129
+ x,
130
+ y,
131
+ width: Math.max(0, rightEdge - x),
132
+ height: Math.max(0, bottomEdge - y)
133
+ };
134
+ }
135
+
136
+ function rectArea(rect = {}) {
137
+ const normalized = normalizeRect(rect);
138
+ return normalized.width * normalized.height;
139
+ }
140
+
141
+ function viewportFromLayoutMetrics(metrics = {}) {
142
+ const visual = metrics.cssVisualViewport || metrics.visualViewport || {};
143
+ const layout = metrics.cssLayoutViewport || metrics.layoutViewport || {};
144
+ const offsetX = finiteNumber(visual.offsetX);
145
+ const offsetY = finiteNumber(visual.offsetY);
146
+ const width = finiteNumber(visual.clientWidth, finiteNumber(layout.clientWidth));
147
+ const height = finiteNumber(visual.clientHeight, finiteNumber(layout.clientHeight));
148
+ const hasVisualPageX = visual.pageX != null && Number.isFinite(Number(visual.pageX));
149
+ const hasVisualPageY = visual.pageY != null && Number.isFinite(Number(visual.pageY));
150
+ return {
151
+ // cssVisualViewport.pageX/pageY already include the visual offset. Only
152
+ // synthesize layout + offset when those fields are absent.
153
+ x: hasVisualPageX
154
+ ? Number(visual.pageX)
155
+ : finiteNumber(layout.pageX) + offsetX,
156
+ y: hasVisualPageY
157
+ ? Number(visual.pageY)
158
+ : finiteNumber(layout.pageY) + offsetY,
159
+ offset_x: offsetX,
160
+ offset_y: offsetY,
161
+ width: Math.max(0, width),
162
+ height: Math.max(0, height),
163
+ page_scale: finiteNumber(visual.scale, 1),
164
+ zoom: finiteNumber(visual.zoom, 1),
165
+ source: metrics.cssVisualViewport
166
+ ? "cssVisualViewport"
167
+ : metrics.visualViewport
168
+ ? "visualViewport"
169
+ : metrics.cssLayoutViewport
170
+ ? "cssLayoutViewport"
171
+ : "layoutViewport"
172
+ };
173
+ }
174
+
175
+ function viewportRelativeCandidates(rect, viewport, iframeOwnerRect = null) {
176
+ const normalized = normalizeRect(rect);
177
+ // DOM.getBoxModel quads are expressed in the node frame's viewport
178
+ // coordinate space. Top-level quads therefore need only the visual
179
+ // viewport offset removed; pageX/pageY are retained as a low-priority
180
+ // compatibility fallback for older/alternate CDP implementations.
181
+ const viewportRelative = {
182
+ ...normalized,
183
+ x: normalized.x - viewport.offset_x,
184
+ y: normalized.y - viewport.offset_y,
185
+ coordinate_space: "viewport",
186
+ coordinate_priority: 2
187
+ };
188
+ const pageRelative = {
189
+ ...normalized,
190
+ x: normalized.x - viewport.x,
191
+ y: normalized.y - viewport.y,
192
+ coordinate_space: "page",
193
+ coordinate_priority: 1
194
+ };
195
+ const candidates = [viewportRelative, pageRelative];
196
+ if (iframeOwnerRect) {
197
+ const owner = normalizeRect(iframeOwnerRect);
198
+ const ownerCandidates = viewportRelativeCandidates(owner, viewport);
199
+ const viewportBounds = { x: 0, y: 0, width: viewport.width, height: viewport.height };
200
+ const resolvedOwner = ownerCandidates
201
+ .map((candidate) => ({
202
+ ...candidate,
203
+ visible_area: rectArea(intersectRects(candidate, viewportBounds))
204
+ }))
205
+ .sort((left, right) => (
206
+ right.visible_area - left.visible_area
207
+ || right.coordinate_priority - left.coordinate_priority
208
+ ))[0];
209
+ candidates.push({
210
+ ...normalized,
211
+ x: resolvedOwner.x + normalized.x,
212
+ y: resolvedOwner.y + normalized.y,
213
+ coordinate_space: "iframe-local",
214
+ coordinate_priority: 0,
215
+ iframe_owner_viewport_rect: normalizeRect(resolvedOwner)
216
+ });
217
+ }
218
+ return candidates;
219
+ }
220
+
221
+ function rectForCoordinateSpace(rect, viewport, coordinateSpace, iframeOwnerRect = null) {
222
+ const candidates = viewportRelativeCandidates(rect, viewport, iframeOwnerRect);
223
+ return normalizeRect(
224
+ candidates.find((candidate) => candidate.coordinate_space === coordinateSpace)
225
+ || candidates.find((candidate) => candidate.coordinate_space === "viewport")
226
+ || candidates[0]
227
+ );
228
+ }
229
+
230
+ function resolveVisibleTargetRect(rect, viewport, {
231
+ padding = 0,
232
+ iframeOwnerRect = null
233
+ } = {}) {
234
+ const viewportBounds = { x: 0, y: 0, width: viewport.width, height: viewport.height };
235
+ const ownerViewportRect = iframeOwnerRect
236
+ ? normalizeRect(viewportRelativeCandidates(iframeOwnerRect, viewport)
237
+ .map((candidate) => ({
238
+ ...candidate,
239
+ visible_area: rectArea(intersectRects(candidate, viewportBounds))
240
+ }))
241
+ .sort((left, right) => (
242
+ right.visible_area - left.visible_area
243
+ || right.coordinate_priority - left.coordinate_priority
244
+ ))[0])
245
+ : null;
246
+ const visibleOwnerRect = ownerViewportRect
247
+ ? intersectRects(ownerViewportRect, viewportBounds)
248
+ : null;
249
+ const candidates = viewportRelativeCandidates(rect, viewport, iframeOwnerRect)
250
+ .map((candidate) => {
251
+ const padded = expandRect(candidate, padding);
252
+ const viewportIntersection = intersectRects(padded, viewportBounds);
253
+ const intersection = visibleOwnerRect
254
+ ? intersectRects(viewportIntersection, visibleOwnerRect)
255
+ : viewportIntersection;
256
+ const ownerIntersectionArea = visibleOwnerRect ? rectArea(intersection) : 0;
257
+ return {
258
+ ...candidate,
259
+ padded,
260
+ intersection,
261
+ intersection_area: rectArea(intersection),
262
+ owner_intersection_area: ownerIntersectionArea
263
+ };
264
+ })
265
+ .sort((left, right) => (
266
+ right.owner_intersection_area - left.owner_intersection_area
267
+ || right.intersection_area - left.intersection_area
268
+ || right.coordinate_priority - left.coordinate_priority
269
+ ));
270
+ // Modern Chrome reports nested-frame box quads already translated into the
271
+ // main visual viewport. Only use iframe-local translation when neither
272
+ // direct coordinate interpretation intersects the iframe owner.
273
+ const directOwnerCandidates = iframeOwnerRect
274
+ ? candidates.filter((candidate) => (
275
+ candidate.coordinate_space !== "iframe-local"
276
+ && candidate.intersection.width >= 2
277
+ && candidate.intersection.height >= 2
278
+ ))
279
+ : [];
280
+ const selected = directOwnerCandidates[0] || candidates[0] || null;
281
+ if (!selected || selected.intersection.width < 2 || selected.intersection.height < 2) {
282
+ const error = new Error("CV capture target does not intersect the visible viewport");
283
+ error.code = "IMAGE_CAPTURE_TARGET_OUT_OF_VIEW";
284
+ error.target_rect = normalizeRect(rect);
285
+ error.viewport = viewport;
286
+ throw error;
287
+ }
288
+ return {
289
+ coordinate_space: selected.coordinate_space,
290
+ requested_rect: selected.padded,
291
+ visible_rect: selected.intersection,
292
+ visible_ratio: rectArea(selected.padded) > 0
293
+ ? selected.intersection_area / rectArea(selected.padded)
294
+ : 0,
295
+ candidates: candidates.map((candidate) => ({
296
+ coordinate_space: candidate.coordinate_space,
297
+ intersection_area: candidate.intersection_area,
298
+ owner_intersection_area: candidate.owner_intersection_area,
299
+ coordinate_priority: candidate.coordinate_priority
300
+ })),
301
+ iframe_owner_visible_rect: visibleOwnerRect
302
+ };
303
+ }
304
+
305
+ async function readCaptureViewportState(client) {
306
+ if (!client?.Page || typeof client.Page.getLayoutMetrics !== "function") {
307
+ const error = new Error("Page.getLayoutMetrics is required for clipless node capture");
308
+ error.code = "IMAGE_CAPTURE_VIEWPORT_UNREADABLE";
309
+ throw error;
310
+ }
311
+ const metrics = await client.Page.getLayoutMetrics();
312
+ const viewport = viewportFromLayoutMetrics(metrics);
313
+ if (viewport.width < 2 || viewport.height < 2) {
314
+ const error = new Error("Visible viewport dimensions are unreadable");
315
+ error.code = "IMAGE_CAPTURE_VIEWPORT_UNREADABLE";
316
+ error.viewport = viewport;
317
+ throw error;
318
+ }
319
+ let browserWindow = null;
320
+ if (client?.Browser && typeof client.Browser.getWindowForTarget === "function") {
321
+ try {
322
+ const windowInfo = await client.Browser.getWindowForTarget({});
323
+ const windowId = windowInfo?.windowId;
324
+ const bounds = windowInfo?.bounds || (windowId != null && typeof client.Browser.getWindowBounds === "function"
325
+ ? (await client.Browser.getWindowBounds({ windowId }))?.bounds
326
+ : null);
327
+ browserWindow = {
328
+ window_id: windowId ?? null,
329
+ left: finiteNumber(bounds?.left),
330
+ top: finiteNumber(bounds?.top),
331
+ width: Math.max(0, finiteNumber(bounds?.width)),
332
+ height: Math.max(0, finiteNumber(bounds?.height)),
333
+ window_state: bounds?.windowState || null
334
+ };
335
+ } catch {
336
+ browserWindow = null;
337
+ }
338
+ }
339
+ return {
340
+ captured_at: nowIso(),
341
+ viewport,
342
+ browser_window: browserWindow
343
+ };
344
+ }
345
+
346
+ function sameBrowserBounds(left = null, right = null) {
347
+ if (!left || !right) return true;
348
+ return left.window_id === right.window_id
349
+ && left.left === right.left
350
+ && left.top === right.top
351
+ && left.width === right.width
352
+ && left.height === right.height
353
+ && left.window_state === right.window_state;
354
+ }
355
+
356
+ function compareCaptureViewportState(baseline, current, tolerance = 1) {
357
+ if (!baseline?.viewport || !current?.viewport) {
358
+ return { ok: false, reason: "unreadable" };
359
+ }
360
+ const safeTolerance = Math.max(0, finiteNumber(tolerance, 1));
361
+ const widthDelta = current.viewport.width - baseline.viewport.width;
362
+ const heightDelta = current.viewport.height - baseline.viewport.height;
363
+ const windowChanged = !sameBrowserBounds(baseline.browser_window, current.browser_window);
364
+ return {
365
+ ok: Math.abs(widthDelta) <= safeTolerance && Math.abs(heightDelta) <= safeTolerance,
366
+ reason: windowChanged ? "browser_window_changed" : null,
367
+ width_delta: widthDelta,
368
+ height_delta: heightDelta,
369
+ browser_window_changed: windowChanged
370
+ };
371
+ }
372
+
373
+ function createViewportDriftError(baseline, current, comparison, captureIndex = null) {
374
+ const error = new Error("Viewport geometry changed during CV screenshot capture");
375
+ error.code = "IMAGE_CAPTURE_VIEWPORT_DRIFT";
376
+ error.capture_index = captureIndex;
377
+ error.viewport_baseline = baseline;
378
+ error.viewport_current = current;
379
+ error.viewport_comparison = comparison;
380
+ return error;
381
+ }
382
+
383
+ function readableBrowserWindow(windowState = null) {
384
+ return Boolean(
385
+ windowState
386
+ && windowState.window_id != null
387
+ && Number.isFinite(Number(windowState.width))
388
+ && Number(windowState.width) > 0
389
+ && Number.isFinite(Number(windowState.height))
390
+ && Number(windowState.height) > 0
391
+ );
392
+ }
393
+
394
+ function isExternalBrowserResize(previous = null, current = null) {
395
+ if (!readableBrowserWindow(previous) || !readableBrowserWindow(current)) return false;
396
+ if (previous.window_id !== current.window_id) return false;
397
+ return previous.width !== current.width
398
+ || previous.height !== current.height
399
+ || previous.window_state !== current.window_state;
400
+ }
401
+
402
+ async function verifyCaptureWindowRebaseline(client, baseline, firstReading, {
403
+ viewportTolerance = 1,
404
+ stepTimeoutMs = 45000,
405
+ captureIndex = null
406
+ } = {}) {
407
+ const externalResize = isExternalBrowserResize(
408
+ baseline?.browser_window,
409
+ firstReading?.browser_window
410
+ );
411
+ if (!externalResize) {
412
+ return {
413
+ verified: false,
414
+ reason: "browser_bounds_change_not_verified_external_resize",
415
+ first_reading: firstReading,
416
+ second_reading: null,
417
+ stability: null
418
+ };
419
+ }
420
+ const secondReading = await withCaptureTimeout(readCaptureViewportState(client), {
421
+ label: `verify_window_rebaseline_${captureIndex ?? "unknown"}`,
422
+ timeoutMs: stepTimeoutMs
423
+ });
424
+ const stability = compareCaptureViewportState(
425
+ firstReading,
426
+ secondReading,
427
+ viewportTolerance
428
+ );
429
+ const boundsStable = sameBrowserBounds(
430
+ firstReading?.browser_window,
431
+ secondReading?.browser_window
432
+ );
433
+ return {
434
+ verified: Boolean(stability.ok && !stability.browser_window_changed && boundsStable),
435
+ reason: stability.ok && !stability.browser_window_changed && boundsStable
436
+ ? "verified_external_resize_two_stable_readings"
437
+ : "external_resize_not_stable_across_two_readings",
438
+ first_reading: firstReading,
439
+ second_reading: secondReading,
440
+ stability,
441
+ bounds_stable: boundsStable
442
+ };
443
+ }
444
+
445
+ async function encodeImagePipeline(pipeline, format, quality) {
446
+ const normalizedFormat = format === "jpg" ? "jpeg" : format;
447
+ if (normalizedFormat === "jpeg") {
448
+ return pipeline.jpeg({
449
+ quality: quality == null ? 72 : Math.max(35, Math.min(95, finiteNumber(quality, 72))),
450
+ mozjpeg: true
451
+ });
452
+ }
453
+ if (normalizedFormat === "webp") {
454
+ return pipeline.webp({
455
+ quality: quality == null ? 76 : Math.max(35, Math.min(95, finiteNumber(quality, 76)))
456
+ });
457
+ }
458
+ return pipeline.png({ compressionLevel: 9, adaptiveFiltering: true });
459
+ }
460
+
461
+ async function cropViewportScreenshotBuffer(buffer, {
462
+ format = "png",
463
+ quality,
464
+ visibleRect,
465
+ viewport,
466
+ resizeMaxWidth = 0
467
+ } = {}) {
468
+ const image = sharp(buffer, { failOn: "none" });
469
+ const imageMetadata = await image.metadata();
470
+ const imageWidth = Math.max(1, finiteNumber(imageMetadata.width));
471
+ const imageHeight = Math.max(1, finiteNumber(imageMetadata.height));
472
+ const scaleX = imageWidth / Math.max(1, viewport.width);
473
+ const scaleY = imageHeight / Math.max(1, viewport.height);
474
+ const rect = normalizeRect(visibleRect);
475
+ const left = Math.max(0, Math.min(imageWidth - 1, Math.floor(rect.x * scaleX)));
476
+ const top = Math.max(0, Math.min(imageHeight - 1, Math.floor(rect.y * scaleY)));
477
+ const right = Math.max(left + 1, Math.min(imageWidth, Math.ceil((rect.x + rect.width) * scaleX)));
478
+ const bottom = Math.max(top + 1, Math.min(imageHeight, Math.ceil((rect.y + rect.height) * scaleY)));
479
+ const pixelCrop = {
480
+ left,
481
+ top,
482
+ width: right - left,
483
+ height: bottom - top
484
+ };
485
+ let pipeline = sharp(buffer, { failOn: "none" }).extract(pixelCrop);
486
+ const safeMaxWidth = Math.max(0, finiteNumber(resizeMaxWidth));
487
+ if (safeMaxWidth > 0 && pixelCrop.width > safeMaxWidth) {
488
+ pipeline = pipeline.resize({ width: safeMaxWidth, withoutEnlargement: true });
489
+ }
490
+ pipeline = await encodeImagePipeline(pipeline, format, quality);
491
+ const croppedBuffer = await pipeline.toBuffer();
492
+ return {
493
+ buffer: croppedBuffer,
494
+ viewport_byte_length: buffer.length,
495
+ image_width: imageWidth,
496
+ image_height: imageHeight,
497
+ scale_x: scaleX,
498
+ scale_y: scaleY,
499
+ pixel_crop: pixelCrop,
500
+ resized: safeMaxWidth > 0 && pixelCrop.width > safeMaxWidth
501
+ };
502
+ }
503
+
504
+ function createCliplessScreenshotOptions({ format = "png", quality } = {}) {
505
+ const options = {
506
+ format,
507
+ fromSurface: true,
508
+ captureBeyondViewport: false
509
+ };
510
+ if (quality != null) options.quality = quality;
511
+ return options;
512
+ }
513
+
514
+ async function captureViewportAtomically(client, captureOptions, {
515
+ label = "capture_screenshot",
516
+ timeoutMs = 0
517
+ } = {}) {
518
+ assertCaptureSessionSafe(client);
519
+ const operationId = crypto.randomUUID();
520
+ const queuedAt = Date.now();
521
+ const prior = captureTransactions.get(client) || null;
522
+ let releaseGate;
523
+ const transaction = {
524
+ gate: new Promise((resolve) => {
525
+ releaseGate = resolve;
526
+ }),
527
+ released: false,
528
+ release_reason: null,
529
+ release(reason) {
530
+ if (transaction.released) return;
531
+ transaction.released = true;
532
+ transaction.release_reason = reason;
533
+ releaseGate();
534
+ if (captureTransactions.get(client) === transaction) {
535
+ captureTransactions.delete(client);
536
+ }
537
+ }
538
+ };
539
+ // Reserve our place synchronously, before yielding to the previous capture.
540
+ // Without this reservation two callers can both observe an empty WeakMap and
541
+ // issue overlapping Page.captureScreenshot requests.
542
+ captureTransactions.set(client, transaction);
543
+ if (prior) {
544
+ await prior.gate;
545
+ try {
546
+ assertCaptureSessionSafe(client);
547
+ } catch (error) {
548
+ transaction.release("predecessor_session_unsafe");
549
+ throw error;
550
+ }
551
+ }
552
+ const requestStartedAt = Date.now();
553
+ const connectionEpoch = captureConnectionEpoch(client);
554
+ let rawCaptureSettled = false;
555
+ let unsafeCaptureSession = null;
556
+ const rawCapture = Promise.resolve().then(() => client.Page.captureScreenshot(captureOptions));
557
+ const noteRawCaptureSettled = () => {
558
+ rawCaptureSettled = true;
559
+ if (unsafeCaptureSession) {
560
+ unsafeCaptureSession.raw_capture_settled = true;
561
+ unsafeCaptureSession.raw_capture_settled_at = nowIso();
562
+ clearUnsafeCaptureSessionAfterSettlement(client, unsafeCaptureSession);
563
+ }
564
+ transaction.release("request_settled");
565
+ };
566
+ rawCapture.then(
567
+ noteRawCaptureSettled,
568
+ noteRawCaptureSettled
569
+ );
570
+ try {
571
+ const result = await withCaptureTimeout(rawCapture, { label, timeoutMs });
572
+ const completedAt = Date.now();
573
+ return {
574
+ ...result,
575
+ __capture_telemetry: {
576
+ operation_id: operationId,
577
+ label,
578
+ connection_epoch: connectionEpoch,
579
+ queued_at: new Date(queuedAt).toISOString(),
580
+ request_started_at: new Date(requestStartedAt).toISOString(),
581
+ completed_at: new Date(completedAt).toISOString(),
582
+ queue_elapsed_ms: requestStartedAt - queuedAt,
583
+ transport_elapsed_ms: completedAt - requestStartedAt,
584
+ total_elapsed_ms: completedAt - queuedAt,
585
+ release_reason: transaction.release_reason || "request_settled"
586
+ }
587
+ };
588
+ } catch (error) {
589
+ const failedAt = Date.now();
590
+ error.capture_operation = {
591
+ operation_id: operationId,
592
+ label,
593
+ connection_epoch: connectionEpoch,
594
+ queued_at: new Date(queuedAt).toISOString(),
595
+ request_started_at: new Date(requestStartedAt).toISOString(),
596
+ failed_at: new Date(failedAt).toISOString(),
597
+ queue_elapsed_ms: requestStartedAt - queuedAt,
598
+ transport_elapsed_ms: failedAt - requestStartedAt,
599
+ total_elapsed_ms: failedAt - queuedAt,
600
+ release_reason: transaction.release_reason
601
+ };
602
+ if (error?.code === "IMAGE_CAPTURE_TIMEOUT") {
603
+ error.capture_outcome_unknown = true;
604
+ error.screenshot_replay_suppressed = true;
605
+ const canAttemptAbandonment = typeof client?.__abandonAndReconnect === "function";
606
+ const unsafe = {
607
+ operation_id: operationId,
608
+ connection_epoch: connectionEpoch,
609
+ blocked_since: nowIso(),
610
+ reason: canAttemptAbandonment
611
+ ? "abandonment_pending"
612
+ : "abandonment_unavailable",
613
+ raw_capture_settled: rawCaptureSettled,
614
+ raw_capture_settled_at: rawCaptureSettled ? nowIso() : null,
615
+ abandonment_attempted: canAttemptAbandonment,
616
+ abandonment_settled: !canAttemptAbandonment,
617
+ abandonment_settled_at: canAttemptAbandonment ? null : nowIso()
618
+ };
619
+ unsafeCaptureSession = unsafe;
620
+ unsafeCaptureSessions.set(client, unsafe);
621
+ clearUnsafeCaptureSessionAfterSettlement(client, unsafe);
622
+ let safelyAbandoned = false;
623
+ if (canAttemptAbandonment) {
624
+ const abandonmentAttempt = Promise.resolve().then(() => client.__abandonAndReconnect({
625
+ reason: `${label}:timeout_unknown_outcome`
626
+ }));
627
+ const noteAbandonmentSettled = (outcome) => {
628
+ unsafe.abandonment_settled = true;
629
+ unsafe.abandonment_outcome = outcome;
630
+ unsafe.abandonment_settled_at = nowIso();
631
+ clearUnsafeCaptureSessionAfterSettlement(client, unsafe);
632
+ };
633
+ abandonmentAttempt.then(
634
+ () => noteAbandonmentSettled("fulfilled"),
635
+ () => noteAbandonmentSettled("rejected")
636
+ );
637
+ try {
638
+ const abandonTimeoutMs = Math.max(
639
+ 250,
640
+ Math.min(5000, Math.floor((Math.max(0, Number(timeoutMs) || 0) || 4000) / 4))
641
+ );
642
+ error.capture_reconnect = await withCaptureTimeout(
643
+ abandonmentAttempt,
644
+ {
645
+ label: `${label}_abandon_session`,
646
+ timeoutMs: abandonTimeoutMs
647
+ }
648
+ );
649
+ const epochAfterAbandon = captureConnectionEpoch(client);
650
+ const reportedPreviousEpoch = Number(error.capture_reconnect?.previous_connection_epoch);
651
+ const reportedNextEpoch = Number(error.capture_reconnect?.connection_epoch);
652
+ const verifiedEpochChange = Boolean(
653
+ (connectionEpoch != null
654
+ && epochAfterAbandon != null
655
+ && epochAfterAbandon !== connectionEpoch)
656
+ || (Number.isFinite(reportedPreviousEpoch)
657
+ && Number.isFinite(reportedNextEpoch)
658
+ && reportedNextEpoch !== reportedPreviousEpoch)
659
+ );
660
+ if (error.capture_reconnect?.reconnected === true && verifiedEpochChange) {
661
+ safelyAbandoned = true;
662
+ unsafeCaptureSessions.delete(client);
663
+ transaction.release("session_abandoned");
664
+ }
665
+ } catch (reconnectError) {
666
+ error.capture_reconnect_error = reconnectError?.message || String(reconnectError);
667
+ }
668
+ }
669
+ if (!safelyAbandoned) {
670
+ unsafe.reason = error.capture_reconnect_error
671
+ ? "abandonment_failed"
672
+ : (canAttemptAbandonment ? "abandonment_unverified" : "abandonment_unavailable");
673
+ const remainsUnsafe = unsafeCaptureSessions.get(client) === unsafe;
674
+ if (remainsUnsafe) {
675
+ error.capture_session_unsafe = true;
676
+ error.capture_session_block = unsafe;
677
+ }
678
+ // Wake queued callers so they fail closed immediately. The separate
679
+ // unsafe-session sentinel prevents them (and later workflow retries)
680
+ // from starting another screenshot until both the original request and
681
+ // the abandonment attempt (if any) have settled.
682
+ transaction.release(remainsUnsafe ? "session_unsafe" : "timed_out_work_settled");
683
+ }
684
+ }
685
+ const rawEpochAfter = captureConnectionEpoch(client);
686
+ error.capture_operation.release_reason = transaction.release_reason;
687
+ error.capture_operation.connection_epoch_after = rawEpochAfter;
688
+ throw error;
689
+ }
690
+ }
691
+
692
+ async function readVisibleCaptureGeometry(client, nodeId, {
693
+ padding = 0,
694
+ iframeOwnerNodeId = null,
695
+ allowReposition = true
696
+ } = {}) {
697
+ async function read() {
698
+ const [box, viewportState, iframeOwnerBox] = await Promise.all([
699
+ getNodeBox(client, nodeId),
700
+ readCaptureViewportState(client),
701
+ iframeOwnerNodeId ? getNodeBox(client, iframeOwnerNodeId).catch(() => null) : Promise.resolve(null)
702
+ ]);
703
+ const iframeOwnerRect = iframeOwnerBox
704
+ ? (rectFromQuad(iframeOwnerBox.model?.content) || iframeOwnerBox.rect)
705
+ : null;
706
+ const resolved = resolveVisibleTargetRect(box.rect, viewportState.viewport, {
707
+ padding,
708
+ iframeOwnerRect
709
+ });
710
+ return {
711
+ box,
712
+ iframe_owner_box: iframeOwnerBox,
713
+ iframe_owner_rect: iframeOwnerRect,
714
+ viewport_state: viewportState,
715
+ resolved
716
+ };
717
+ }
718
+
719
+ try {
720
+ return await read();
721
+ } catch (error) {
722
+ if (
723
+ !allowReposition
724
+ || error?.code !== "IMAGE_CAPTURE_TARGET_OUT_OF_VIEW"
725
+ || !client?.DOM
726
+ || typeof client.DOM.scrollIntoViewIfNeeded !== "function"
727
+ ) {
728
+ throw error;
729
+ }
730
+ await client.DOM.scrollIntoViewIfNeeded({ nodeId });
731
+ return read();
732
+ }
733
+ }
734
+
735
+ function normalizeRandom(random) {
736
+ return typeof random === "function" ? random : Math.random;
737
+ }
738
+
739
+ function randomBetween(random, min, max) {
740
+ const lower = Number(min) || 0;
741
+ const upper = Number(max) || lower;
742
+ if (upper <= lower) return lower;
743
+ return lower + normalizeRandom(random)() * (upper - lower);
744
+ }
745
+
746
+ function normalizeRatio(raw, fallback, { min = 0, max = 1 } = {}) {
747
+ const parsed = Number(raw);
748
+ const value = Number.isFinite(parsed) ? parsed : fallback;
749
+ return Math.min(max, Math.max(min, value));
750
+ }
751
+
752
+ function normalizeScrollDeltaJitter({
753
+ enabled = false,
754
+ minRatio = 0.65,
755
+ maxRatio = 0.9,
756
+ minOverlapRatio = 0.2,
757
+ preserveCoverage = true,
758
+ random = Math.random
759
+ } = {}) {
760
+ const safeMinRatio = normalizeRatio(minRatio, 0.65, { min: 0.1, max: 1 });
761
+ const safeMaxRatio = Math.max(safeMinRatio, normalizeRatio(maxRatio, 0.9, { min: safeMinRatio, max: 1 }));
762
+ return {
763
+ enabled: enabled === true,
764
+ min_ratio: safeMinRatio,
765
+ max_ratio: safeMaxRatio,
766
+ min_overlap_ratio: normalizeRatio(minOverlapRatio, 0.2, { min: 0, max: 0.8 }),
767
+ preserve_coverage: preserveCoverage !== false,
768
+ random: normalizeRandom(random)
769
+ };
770
+ }
771
+
772
+ function resolveCoverageSafeScrollDelta({
773
+ baseDelta,
774
+ clipHeight,
775
+ jitter
776
+ } = {}) {
777
+ const safeBase = Math.max(1, Number(baseDelta) || 650);
778
+ const safeClipHeight = Math.max(1, Number(clipHeight) || 1);
779
+ const minOverlapRatio = normalizeRatio(jitter?.min_overlap_ratio, 0.2, { min: 0, max: 0.8 });
780
+ const maxDeltaForOverlap = Math.max(1, Math.floor(safeClipHeight * (1 - minOverlapRatio)));
781
+ if (!jitter?.enabled) {
782
+ return {
783
+ deltaY: Math.min(safeBase, maxDeltaForOverlap),
784
+ jittered: false,
785
+ base_delta_y: safeBase,
786
+ min_overlap_ratio: minOverlapRatio,
787
+ clip_height: safeClipHeight,
788
+ max_delta_for_overlap: maxDeltaForOverlap,
789
+ preserve_coverage: true
790
+ };
791
+ }
792
+ const upper = Math.max(1, Math.min(Math.round(safeBase * jitter.max_ratio), maxDeltaForOverlap));
793
+ const lower = Math.min(upper, Math.max(1, Math.round(safeBase * jitter.min_ratio)));
794
+ const deltaY = Math.max(1, Math.round(randomBetween(jitter.random, lower, upper)));
795
+ return {
796
+ deltaY,
797
+ jittered: true,
798
+ base_delta_y: safeBase,
799
+ min_delta_y: lower,
800
+ max_delta_y: upper,
801
+ min_ratio: jitter.min_ratio,
802
+ max_ratio: jitter.max_ratio,
803
+ min_overlap_ratio: jitter.min_overlap_ratio,
804
+ clip_height: safeClipHeight,
805
+ max_delta_for_overlap: maxDeltaForOverlap,
806
+ preserve_coverage: jitter.preserve_coverage
807
+ };
808
+ }
809
+
810
+ export async function captureNodeHtml(client, nodeId, {
811
+ domain = "unknown",
812
+ source = "dom",
813
+ metadata = {}
814
+ } = {}) {
815
+ const [attributes, outerHTML] = await Promise.all([
816
+ getAttributesMap(client, nodeId),
817
+ getOuterHTML(client, nodeId)
818
+ ]);
819
+ const text = htmlToText(outerHTML);
820
+ return {
821
+ schema_version: 1,
822
+ domain: normalizeText(domain) || "unknown",
823
+ source,
824
+ captured_at: nowIso(),
825
+ node_id: nodeId,
826
+ attributes,
827
+ outer_html_length: outerHTML.length,
828
+ text_length: text.length,
829
+ text,
830
+ outer_html: outerHTML,
831
+ metadata
832
+ };
833
+ }
834
+
835
+ export async function captureNodeScreenshot(client, nodeId, {
836
+ filePath,
837
+ format = "png",
838
+ quality,
839
+ padding = 0,
840
+ captureBeyondViewport: requestedCaptureBeyondViewport = false,
841
+ fromSurface: requestedFromSurface = true,
842
+ iframeOwnerNodeId = null,
843
+ resizeMaxWidth = 0,
844
+ stepTimeoutMs = 45000,
845
+ metadata = {}
846
+ } = {}) {
847
+ const operationStartedAt = Date.now();
848
+ const geometry = await withCaptureTimeout(readVisibleCaptureGeometry(client, nodeId, {
849
+ padding,
850
+ iframeOwnerNodeId
851
+ }), {
852
+ label: "get_capture_geometry",
853
+ timeoutMs: stepTimeoutMs
854
+ });
855
+ const geometryElapsedMs = Date.now() - operationStartedAt;
856
+ const captureOptions = createCliplessScreenshotOptions({ format, quality });
857
+ const screenshot = await captureViewportAtomically(client, captureOptions, {
858
+ label: "capture_node_screenshot",
859
+ timeoutMs: stepTimeoutMs
860
+ });
861
+ const viewportBuffer = Buffer.from(screenshot.data || "", "base64");
862
+ const afterViewportState = await withCaptureTimeout(readCaptureViewportState(client), {
863
+ label: "get_post_capture_viewport",
864
+ timeoutMs: stepTimeoutMs
865
+ });
866
+ const viewportComparison = compareCaptureViewportState(geometry.viewport_state, afterViewportState);
867
+ if (!viewportComparison.ok || viewportComparison.browser_window_changed) {
868
+ const driftError = createViewportDriftError(
869
+ geometry.viewport_state,
870
+ afterViewportState,
871
+ viewportComparison,
872
+ 0
873
+ );
874
+ driftError.capture_operation = screenshot.__capture_telemetry || null;
875
+ driftError.target_geometry = {
876
+ node_rect: geometry.box.rect,
877
+ iframe_owner_rect: geometry.iframe_owner_rect || null
878
+ };
879
+ throw driftError;
880
+ }
881
+ const cropStartedAt = Date.now();
882
+ const cropped = await cropViewportScreenshotBuffer(viewportBuffer, {
883
+ format,
884
+ quality,
885
+ visibleRect: geometry.resolved.visible_rect,
886
+ viewport: geometry.viewport_state.viewport,
887
+ resizeMaxWidth
888
+ });
889
+ const localCropElapsedMs = Date.now() - cropStartedAt;
890
+ const buffer = cropped.buffer;
891
+ const resolvedPath = resolveOutputPath(filePath);
892
+ if (resolvedPath) {
893
+ fs.writeFileSync(resolvedPath, buffer);
894
+ }
895
+ return {
896
+ schema_version: 1,
897
+ source: "image",
898
+ captured_at: nowIso(),
899
+ node_id: nodeId,
900
+ format,
901
+ mime_type: `image/${format === "jpeg" ? "jpeg" : "png"}`,
902
+ byte_length: buffer.length,
903
+ file_path: resolvedPath,
904
+ clip: geometry.resolved.visible_rect,
905
+ crop: {
906
+ ...geometry.resolved,
907
+ ...cropped,
908
+ buffer: undefined
909
+ },
910
+ node_rect: geometry.box.rect,
911
+ iframe_owner_rect: geometry.iframe_owner_rect || null,
912
+ viewport_before: geometry.viewport_state,
913
+ viewport_after: afterViewportState,
914
+ viewport_comparison: viewportComparison,
915
+ capture_operation: screenshot.__capture_telemetry || null,
916
+ timing: {
917
+ geometry_elapsed_ms: geometryElapsedMs,
918
+ transport_elapsed_ms: screenshot.__capture_telemetry?.transport_elapsed_ms ?? null,
919
+ local_crop_elapsed_ms: localCropElapsedMs,
920
+ total_elapsed_ms: Date.now() - operationStartedAt
921
+ },
922
+ browser_clip_used: false,
923
+ capture_beyond_viewport: false,
924
+ requested_capture_beyond_viewport: Boolean(requestedCaptureBeyondViewport),
925
+ requested_from_surface: Boolean(requestedFromSurface),
926
+ metadata
927
+ };
928
+ }
929
+
930
+ export async function captureViewportScreenshot(client, {
931
+ format = "png",
932
+ quality,
933
+ captureBeyondViewport: requestedCaptureBeyondViewport = false,
934
+ fromSurface: requestedFromSurface = true,
935
+ stepTimeoutMs = 45000,
936
+ metadata = {}
937
+ } = {}) {
938
+ const operationStartedAt = Date.now();
939
+ const captureOptions = createCliplessScreenshotOptions({ format, quality });
940
+ const screenshot = await captureViewportAtomically(client, captureOptions, {
941
+ label: "capture_viewport_screenshot",
942
+ timeoutMs: stepTimeoutMs
943
+ });
944
+ const buffer = Buffer.from(screenshot.data || "", "base64");
945
+ return {
946
+ schema_version: 1,
947
+ source: "viewport-image",
948
+ captured_at: nowIso(),
949
+ format,
950
+ mime_type: `image/${format === "jpeg" ? "jpeg" : "png"}`,
951
+ byte_length: buffer.length,
952
+ file_path: null,
953
+ persistence: "forbidden_uncropped_viewport",
954
+ capture_operation: screenshot.__capture_telemetry || null,
955
+ timing: {
956
+ transport_elapsed_ms: screenshot.__capture_telemetry?.transport_elapsed_ms ?? null,
957
+ total_elapsed_ms: Date.now() - operationStartedAt
958
+ },
959
+ capture_beyond_viewport: false,
960
+ requested_capture_beyond_viewport: Boolean(requestedCaptureBeyondViewport),
961
+ requested_from_surface: Boolean(requestedFromSurface),
962
+ browser_clip_used: false,
963
+ metadata
964
+ };
965
+ }
966
+
967
+ function filePathForSequence(basePath, index, extension) {
968
+ const resolved = resolveOutputPath(basePath);
969
+ if (!resolved) return null;
970
+ const parsed = path.parse(resolved);
971
+ const page = String(index + 1).padStart(2, "0");
972
+ return path.join(parsed.dir, `${parsed.name}-page-${page}${parsed.ext || `.${extension}`}`);
973
+ }
974
+
975
+ function filePathForLlmSequence(basePath, index) {
976
+ const resolved = resolveOutputPath(basePath);
977
+ if (!resolved) return null;
978
+ const parsed = path.parse(resolved);
979
+ const page = String(index + 1).padStart(2, "0");
980
+ return path.join(parsed.dir, `${parsed.name}-llm-${page}.jpg`);
981
+ }
982
+
983
+ function screenshotHash(buffer) {
984
+ return crypto.createHash("sha256").update(buffer).digest("hex");
985
+ }
986
+
987
+ function createCaptureTimeoutError(label, timeoutMs) {
988
+ const error = new Error(`Image fallback capture timed out during ${label} after ${timeoutMs}ms`);
989
+ error.code = "IMAGE_CAPTURE_TIMEOUT";
990
+ error.capture_step = label;
991
+ error.timeout_ms = timeoutMs;
992
+ return error;
993
+ }
994
+
995
+ async function withCaptureTimeout(promise, {
996
+ label = "capture_step",
997
+ timeoutMs = 0
998
+ } = {}) {
999
+ const safeTimeout = Math.max(0, Number(timeoutMs) || 0);
1000
+ if (!safeTimeout) return promise;
1001
+ let timer = null;
1002
+ try {
1003
+ return await Promise.race([
1004
+ promise,
1005
+ new Promise((_, reject) => {
1006
+ timer = setTimeout(() => reject(createCaptureTimeoutError(label, safeTimeout)), safeTimeout);
1007
+ })
1008
+ ]);
1009
+ } finally {
1010
+ if (timer) clearTimeout(timer);
1011
+ }
1012
+ }
1013
+
1014
+ function assertCaptureTotalBudget(started, totalTimeoutMs, label) {
1015
+ const safeTimeout = Math.max(0, Number(totalTimeoutMs) || 0);
1016
+ if (!safeTimeout) return;
1017
+ const elapsed = Date.now() - started;
1018
+ if (elapsed <= safeTimeout) return;
1019
+ const error = createCaptureTimeoutError(label, safeTimeout);
1020
+ error.elapsed_ms = elapsed;
1021
+ error.code = "IMAGE_CAPTURE_TOTAL_TIMEOUT";
1022
+ throw error;
1023
+ }
1024
+
1025
+ const DEFAULT_SCROLL_ANCHOR_SELECTOR = [
1026
+ "h1",
1027
+ "h2",
1028
+ "h3",
1029
+ "h4",
1030
+ "h5",
1031
+ "p",
1032
+ "li",
1033
+ "section",
1034
+ "article",
1035
+ "table",
1036
+ "tr",
1037
+ "dl",
1038
+ "dt",
1039
+ "dd",
1040
+ "[class*='resume']",
1041
+ "[class*='work']",
1042
+ "[class*='project']",
1043
+ "[class*='education']",
1044
+ "[class*='experience']",
1045
+ "[class*='item']",
1046
+ "div"
1047
+ ].join(",");
1048
+
1049
+ function normalizeScrollMethod(value = "dom-anchor-fallback-input") {
1050
+ const normalized = normalizeText(value).toLowerCase();
1051
+ if (["dom", "dom-anchor", "dom_anchor", "anchor"].includes(normalized)) return "dom-anchor";
1052
+ if (["dom-anchor-fallback-input", "dom_anchor_fallback_input", "dom-fallback-input"].includes(normalized)) {
1053
+ return "dom-anchor-fallback-input";
1054
+ }
1055
+ return "input";
1056
+ }
1057
+
1058
+ function uniqueNumbers(values = []) {
1059
+ return Array.from(new Set(values.map((value) => Number(value) || 0).filter(Boolean)));
1060
+ }
1061
+
1062
+ function pickEvenly(items = [], limit = 1) {
1063
+ const safeLimit = Math.max(1, Number(limit) || 1);
1064
+ if (items.length <= safeLimit) return items;
1065
+ const picked = [];
1066
+ const last = items.length - 1;
1067
+ for (let index = 0; index < safeLimit; index += 1) {
1068
+ const sourceIndex = Math.round((index * last) / Math.max(1, safeLimit - 1));
1069
+ picked.push(items[sourceIndex]);
1070
+ }
1071
+ return Array.from(new Map(picked.map((item) => [item.node_id, item])).values());
1072
+ }
1073
+
1074
+ function stableAnchorSignature(anchor = {}, attributes = {}) {
1075
+ const normalizedAttributes = Object.fromEntries(
1076
+ Object.entries(attributes || {})
1077
+ .map(([key, value]) => [String(key), normalizeText(value)])
1078
+ .sort(([left], [right]) => left.localeCompare(right))
1079
+ );
1080
+ const identity = {
1081
+ document_order: Number.isFinite(Number(anchor.document_order))
1082
+ ? Number(anchor.document_order)
1083
+ : null,
1084
+ width: Math.round(Number(anchor.width) || 0),
1085
+ height: Math.round(Number(anchor.height) || 0),
1086
+ attributes: normalizedAttributes
1087
+ };
1088
+ return crypto.createHash("sha256").update(JSON.stringify(identity)).digest("hex");
1089
+ }
1090
+
1091
+ function patternLabel(pattern) {
1092
+ if (pattern instanceof RegExp) return pattern.source;
1093
+ return normalizeText(pattern);
1094
+ }
1095
+
1096
+ function stopBoundaryPatterns(patterns = []) {
1097
+ return (Array.isArray(patterns) ? patterns : [patterns])
1098
+ .filter(Boolean)
1099
+ .map((pattern) => {
1100
+ if (pattern instanceof RegExp) {
1101
+ return {
1102
+ raw: pattern,
1103
+ label: pattern.source,
1104
+ matches: (text) => pattern.test(text)
1105
+ };
1106
+ }
1107
+ const normalized = normalizeText(pattern);
1108
+ return {
1109
+ raw: pattern,
1110
+ label: normalized,
1111
+ matches: (text) => normalized && text.includes(normalized)
1112
+ };
1113
+ });
1114
+ }
1115
+
1116
+ async function collectStopBoundaryNodes(client, rootNodeId, {
1117
+ selector = "",
1118
+ textPatterns = [],
1119
+ maxProbeNodes = 180,
1120
+ maxTextLength = 700,
1121
+ stepTimeoutMs = 45000
1122
+ } = {}) {
1123
+ const patterns = stopBoundaryPatterns(textPatterns);
1124
+ const normalizedSelector = normalizeText(selector);
1125
+ if (!normalizedSelector && !patterns.length) {
1126
+ return {
1127
+ enabled: false,
1128
+ ok: false,
1129
+ reason: "not_configured",
1130
+ nodes: []
1131
+ };
1132
+ }
1133
+ const started = Date.now();
1134
+ let nodeIds = [];
1135
+ try {
1136
+ nodeIds = uniqueNumbers(await querySelectorAll(
1137
+ client,
1138
+ rootNodeId,
1139
+ normalizedSelector || DEFAULT_SCROLL_ANCHOR_SELECTOR
1140
+ ));
1141
+ } catch (error) {
1142
+ return {
1143
+ enabled: true,
1144
+ ok: false,
1145
+ reason: "query_selector_all_failed",
1146
+ selector: normalizedSelector || DEFAULT_SCROLL_ANCHOR_SELECTOR,
1147
+ error: error?.message || String(error),
1148
+ nodes: []
1149
+ };
1150
+ }
1151
+
1152
+ const probeLimit = Math.max(1, Number(maxProbeNodes) || 180);
1153
+ const maxStopTextLength = Math.max(40, Number(maxTextLength) || 700);
1154
+ const perNodeTimeoutMs = Math.min(1000, Math.max(200, Math.floor((Number(stepTimeoutMs) || 45000) / 40)));
1155
+ const nodes = [];
1156
+ for (const nodeId of nodeIds.slice(0, probeLimit)) {
1157
+ try {
1158
+ let text = "";
1159
+ let matchedPattern = null;
1160
+ if (patterns.length) {
1161
+ const outerHTML = await withCaptureTimeout(getOuterHTML(client, nodeId), {
1162
+ label: `stop_boundary_html_${nodeId}`,
1163
+ timeoutMs: perNodeTimeoutMs
1164
+ });
1165
+ text = normalizeText(htmlToText(outerHTML));
1166
+ if (!text || text.length > maxStopTextLength) continue;
1167
+ matchedPattern = patterns.find((pattern) => pattern.matches(text));
1168
+ if (!matchedPattern) continue;
1169
+ }
1170
+ nodes.push({
1171
+ node_id: nodeId,
1172
+ text_preview: text.slice(0, 120),
1173
+ matched_pattern: matchedPattern ? patternLabel(matchedPattern.raw) : null
1174
+ });
1175
+ } catch {}
1176
+ }
1177
+
1178
+ return {
1179
+ enabled: true,
1180
+ ok: nodes.length > 0,
1181
+ reason: nodes.length ? null : "no_matching_stop_boundary_nodes",
1182
+ selector: normalizedSelector || DEFAULT_SCROLL_ANCHOR_SELECTOR,
1183
+ elapsed_ms: Date.now() - started,
1184
+ discovered_node_count: nodeIds.length,
1185
+ probed_node_count: Math.min(nodeIds.length, probeLimit),
1186
+ match_count: nodes.length,
1187
+ pattern_labels: patterns.map((pattern) => pattern.label),
1188
+ nodes
1189
+ };
1190
+ }
1191
+
1192
+ async function resolveVisibleStopBoundary(client, stopBoundaryPlan, clip, {
1193
+ topPadding = 8,
1194
+ minCaptureHeight = 180,
1195
+ stepTimeoutMs = 45000,
1196
+ viewport = null,
1197
+ coordinateSpace = "viewport",
1198
+ iframeOwnerRect = null
1199
+ } = {}) {
1200
+ if (!stopBoundaryPlan?.nodes?.length || !clip) return null;
1201
+ const clipTop = Number(clip.y) || 0;
1202
+ const clipBottom = clipTop + (Number(clip.height) || 0);
1203
+ const safePadding = Math.max(0, Number(topPadding) || 0);
1204
+ const safeMinHeight = Math.max(1, Number(minCaptureHeight) || 180);
1205
+ const perNodeTimeoutMs = Math.min(900, Math.max(180, Math.floor((Number(stepTimeoutMs) || 45000) / 50)));
1206
+ const visible = [];
1207
+
1208
+ for (const node of stopBoundaryPlan.nodes) {
1209
+ try {
1210
+ const box = await withCaptureTimeout(getNodeBox(client, node.node_id), {
1211
+ label: `stop_boundary_box_${node.node_id}`,
1212
+ timeoutMs: perNodeTimeoutMs
1213
+ });
1214
+ const rect = viewport
1215
+ ? rectForCoordinateSpace(
1216
+ box?.rect || {},
1217
+ viewport,
1218
+ coordinateSpace,
1219
+ iframeOwnerRect
1220
+ )
1221
+ : (box?.rect || {});
1222
+ const width = Number(rect.width) || 0;
1223
+ const height = Number(rect.height) || 0;
1224
+ if (width < 40 || height < 6) continue;
1225
+ const top = Number(rect.y) || 0;
1226
+ const bottom = top + height;
1227
+ if (bottom <= clipTop + 1) {
1228
+ return {
1229
+ action: "stop_before_capture",
1230
+ reason: "stop_boundary_above_clip",
1231
+ node_id: node.node_id,
1232
+ matched_pattern: node.matched_pattern,
1233
+ text_preview: node.text_preview,
1234
+ rect,
1235
+ clip
1236
+ };
1237
+ }
1238
+ if (top < clipBottom && bottom > clipTop) {
1239
+ visible.push({
1240
+ ...node,
1241
+ rect,
1242
+ top,
1243
+ bottom
1244
+ });
1245
+ }
1246
+ } catch {}
1247
+ }
1248
+ if (!visible.length) return null;
1249
+
1250
+ visible.sort((a, b) => a.top - b.top);
1251
+ const boundary = visible[0];
1252
+ const boundaryY = Math.max(clipTop, boundary.top - safePadding);
1253
+ const adjustedHeight = Math.max(0, boundaryY - clipTop);
1254
+ if (adjustedHeight < safeMinHeight) {
1255
+ return {
1256
+ action: "stop_before_capture",
1257
+ reason: "stop_boundary_near_clip_top",
1258
+ node_id: boundary.node_id,
1259
+ matched_pattern: boundary.matched_pattern,
1260
+ text_preview: boundary.text_preview,
1261
+ rect: boundary.rect,
1262
+ clip,
1263
+ adjusted_height: adjustedHeight,
1264
+ min_capture_height: safeMinHeight
1265
+ };
1266
+ }
1267
+
1268
+ return {
1269
+ action: "capture_then_stop",
1270
+ reason: "stop_boundary_visible",
1271
+ node_id: boundary.node_id,
1272
+ matched_pattern: boundary.matched_pattern,
1273
+ text_preview: boundary.text_preview,
1274
+ rect: boundary.rect,
1275
+ clip,
1276
+ adjusted_clip: {
1277
+ ...clip,
1278
+ height: adjustedHeight
1279
+ },
1280
+ adjusted_height: adjustedHeight,
1281
+ min_capture_height: safeMinHeight
1282
+ };
1283
+ }
1284
+
1285
+ async function collectDomScrollAnchors(client, rootNodeId, {
1286
+ selector = DEFAULT_SCROLL_ANCHOR_SELECTOR,
1287
+ maxScreenshots = 6,
1288
+ maxProbeNodes = 260,
1289
+ minAnchorGap = 180,
1290
+ stepTimeoutMs = 45000
1291
+ } = {}) {
1292
+ const started = Date.now();
1293
+ let nodeIds = [];
1294
+ try {
1295
+ nodeIds = uniqueNumbers(await querySelectorAll(client, rootNodeId, selector));
1296
+ } catch (error) {
1297
+ return {
1298
+ ok: false,
1299
+ method: "dom-anchor",
1300
+ reason: "query_selector_all_failed",
1301
+ error: error?.message || String(error)
1302
+ };
1303
+ }
1304
+ if (!nodeIds.length) {
1305
+ return {
1306
+ ok: false,
1307
+ method: "dom-anchor",
1308
+ reason: "no_anchor_nodes"
1309
+ };
1310
+ }
1311
+
1312
+ const probeLimit = Math.max(1, Number(maxProbeNodes) || 260);
1313
+ const perNodeTimeoutMs = Math.min(1200, Math.max(250, Math.floor((Number(stepTimeoutMs) || 45000) / 30)));
1314
+ const measured = [];
1315
+ for (const [documentOrder, nodeId] of nodeIds.slice(0, probeLimit).entries()) {
1316
+ try {
1317
+ const box = await withCaptureTimeout(getNodeBox(client, nodeId), {
1318
+ label: `anchor_box_${nodeId}`,
1319
+ timeoutMs: perNodeTimeoutMs
1320
+ });
1321
+ const rect = box?.rect || {};
1322
+ if ((Number(rect.width) || 0) < 80 || (Number(rect.height) || 0) < 8) continue;
1323
+ measured.push({
1324
+ node_id: nodeId,
1325
+ document_order: documentOrder,
1326
+ y: Math.round(Number(rect.y) || 0),
1327
+ width: Math.round(Number(rect.width) || 0),
1328
+ height: Math.round(Number(rect.height) || 0)
1329
+ });
1330
+ } catch {}
1331
+ }
1332
+
1333
+ let anchors = [];
1334
+ let bottomAnchor = null;
1335
+ if (measured.length) {
1336
+ const sorted = measured.sort((a, b) => a.y - b.y);
1337
+ bottomAnchor = sorted[sorted.length - 1] || null;
1338
+ if (bottomAnchor) {
1339
+ let attributes = {};
1340
+ try {
1341
+ attributes = await withCaptureTimeout(getAttributesMap(client, bottomAnchor.node_id), {
1342
+ label: `anchor_attributes_${bottomAnchor.node_id}`,
1343
+ timeoutMs: perNodeTimeoutMs
1344
+ });
1345
+ } catch {}
1346
+ bottomAnchor = {
1347
+ ...bottomAnchor,
1348
+ structural_signature: stableAnchorSignature(bottomAnchor, attributes)
1349
+ };
1350
+ }
1351
+ for (const item of sorted) {
1352
+ const last = anchors[anchors.length - 1];
1353
+ if (!last || Math.abs(item.y - last.y) >= Math.max(40, Number(minAnchorGap) || 180)) {
1354
+ anchors.push(item);
1355
+ }
1356
+ }
1357
+ }
1358
+
1359
+ if (anchors.length < 2) {
1360
+ anchors = nodeIds.slice(0, probeLimit).map((nodeId, index) => ({
1361
+ node_id: nodeId,
1362
+ y: null,
1363
+ height: null,
1364
+ document_order: index
1365
+ }));
1366
+ }
1367
+
1368
+ anchors = pickEvenly(anchors, Math.max(1, Number(maxScreenshots) || 1));
1369
+ return {
1370
+ ok: anchors.length > 0,
1371
+ method: "dom-anchor",
1372
+ elapsed_ms: Date.now() - started,
1373
+ selector,
1374
+ discovered_node_count: nodeIds.length,
1375
+ measured_node_count: measured.length,
1376
+ anchor_count: anchors.length,
1377
+ bottom_anchor: bottomAnchor,
1378
+ anchors
1379
+ };
1380
+ }
1381
+
1382
+ function resolveAnchorProgressEvidence(anchorPlan, previousEvidence = null, tolerance = 1) {
1383
+ const anchor = anchorPlan?.bottom_anchor || null;
1384
+ const y = Number(anchor?.y);
1385
+ const height = Number(anchor?.height);
1386
+ const available = Boolean(
1387
+ anchor?.node_id
1388
+ && anchor?.y != null
1389
+ && anchor?.height != null
1390
+ && Number.isFinite(y)
1391
+ && Number.isFinite(height)
1392
+ );
1393
+ const previousAvailable = Boolean(previousEvidence?.available);
1394
+ const sameAnchor = Boolean(
1395
+ available
1396
+ && previousAvailable
1397
+ && Number(previousEvidence.node_id) === Number(anchor.node_id)
1398
+ );
1399
+ const positionComparable = Boolean(available && previousAvailable);
1400
+ const deltaY = positionComparable ? y - Number(previousEvidence.y) : null;
1401
+ const heightDelta = positionComparable ? height - Number(previousEvidence.height) : null;
1402
+ const safeTolerance = Math.max(0, Number(tolerance) || 0);
1403
+ const stationary = Boolean(
1404
+ positionComparable
1405
+ && Math.abs(deltaY) <= safeTolerance
1406
+ && Math.abs(heightDelta) <= safeTolerance
1407
+ );
1408
+ return {
1409
+ available,
1410
+ node_id: available ? Number(anchor.node_id) : null,
1411
+ document_order: available && Number.isFinite(Number(anchor.document_order))
1412
+ ? Number(anchor.document_order)
1413
+ : null,
1414
+ structural_signature: available ? (anchor.structural_signature || null) : null,
1415
+ y: available ? y : null,
1416
+ height: available ? height : null,
1417
+ previous_available: previousAvailable,
1418
+ previous_node_id: previousAvailable ? Number(previousEvidence.node_id) : null,
1419
+ same_anchor: sameAnchor,
1420
+ position_comparable: positionComparable,
1421
+ delta_y: deltaY,
1422
+ height_delta: heightDelta,
1423
+ stationary,
1424
+ tolerance: safeTolerance,
1425
+ reason: !available
1426
+ ? (anchorPlan?.reason || "anchor_geometry_unavailable")
1427
+ : !previousAvailable
1428
+ ? "no_previous_anchor_sample"
1429
+ : stationary
1430
+ ? (sameAnchor ? "bottom_anchor_stationary" : "bottom_anchor_reacquired_stationary")
1431
+ : (sameAnchor ? "bottom_anchor_moved" : "bottom_anchor_reacquired_moved")
1432
+ };
1433
+ }
1434
+
1435
+ function resolveCoverageOverlap(previousEntry, visibleRect, scrollMetadata, anchorEvidence) {
1436
+ if (!previousEntry) return null;
1437
+ const previousRect = normalizeRect(previousEntry.visible_crop || {});
1438
+ const currentRect = normalizeRect(visibleRect || {});
1439
+ const comparableHeight = Math.min(previousRect.height, currentRect.height);
1440
+ let estimatedScrollDelta = null;
1441
+ let source = "unavailable";
1442
+ if (
1443
+ anchorEvidence?.position_comparable
1444
+ && Number.isFinite(Number(anchorEvidence.delta_y))
1445
+ ) {
1446
+ estimatedScrollDelta = Math.abs(Number(anchorEvidence.delta_y));
1447
+ source = "bottom_anchor_delta";
1448
+ } else if (Number.isFinite(Number(scrollMetadata?.wheel_delta_y))) {
1449
+ estimatedScrollDelta = Math.abs(Number(scrollMetadata.wheel_delta_y));
1450
+ source = "wheel_delta_request";
1451
+ }
1452
+ const overlapCss = estimatedScrollDelta == null
1453
+ ? null
1454
+ : Math.max(0, comparableHeight - estimatedScrollDelta);
1455
+ return {
1456
+ source,
1457
+ previous_capture_index: previousEntry.capture_index,
1458
+ comparable_height_css: comparableHeight,
1459
+ estimated_scroll_delta_y_css: estimatedScrollDelta,
1460
+ estimated_overlap_css: overlapCss,
1461
+ estimated_overlap_ratio: overlapCss == null || comparableHeight <= 0
1462
+ ? null
1463
+ : overlapCss / comparableHeight
1464
+ };
1465
+ }
1466
+
1467
+ const SESSION_SCOPED_NODE_ID_KEYS = new Set([
1468
+ "node_id",
1469
+ "anchor_node_id",
1470
+ "previous_node_id"
1471
+ ]);
1472
+
1473
+ function sanitizeCoverageCheckpointValue(value, key = "") {
1474
+ if (SESSION_SCOPED_NODE_ID_KEYS.has(key)) return null;
1475
+ if (value == null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
1476
+ return value;
1477
+ }
1478
+ if (typeof value === "bigint") return String(value);
1479
+ if (Buffer.isBuffer(value)) return undefined;
1480
+ if (Array.isArray(value)) {
1481
+ return value
1482
+ .map((item) => sanitizeCoverageCheckpointValue(item))
1483
+ .filter((item) => item !== undefined);
1484
+ }
1485
+ if (typeof value === "object") {
1486
+ const sanitized = {};
1487
+ for (const [childKey, childValue] of Object.entries(value)) {
1488
+ const next = sanitizeCoverageCheckpointValue(childValue, childKey);
1489
+ if (next !== undefined) sanitized[childKey] = next;
1490
+ }
1491
+ return sanitized;
1492
+ }
1493
+ return undefined;
1494
+ }
1495
+
1496
+ function normalizeCoverageResumeCheckpoint(checkpoint, {
1497
+ maxUniqueScreenshots
1498
+ } = {}) {
1499
+ if (!checkpoint) {
1500
+ return {
1501
+ used: false,
1502
+ checkpoint_id: null,
1503
+ screenshots: [],
1504
+ coverage_ledger: [],
1505
+ anchor_plan_history: [],
1506
+ stop_boundary_checks: [],
1507
+ viewport_events: [],
1508
+ previous_hash: "",
1509
+ dropped_duplicate_count: 0,
1510
+ next_capture_index: 0,
1511
+ current_pending_scroll_metadata: null,
1512
+ continuity_target: null
1513
+ };
1514
+ }
1515
+ if (typeof checkpoint !== "object" || Array.isArray(checkpoint)) {
1516
+ const error = new Error("resumeCheckpoint must be a coverage checkpoint object");
1517
+ error.code = "IMAGE_CAPTURE_CHECKPOINT_INVALID";
1518
+ throw error;
1519
+ }
1520
+ if (
1521
+ checkpoint.kind !== "cv_capture_coverage_checkpoint"
1522
+ || Number(checkpoint.schema_version) !== 1
1523
+ ) {
1524
+ const error = new Error("resumeCheckpoint has an unsupported schema or kind");
1525
+ error.code = "IMAGE_CAPTURE_CHECKPOINT_INVALID";
1526
+ throw error;
1527
+ }
1528
+ const screenshots = sanitizeCoverageCheckpointValue(
1529
+ Array.isArray(checkpoint.screenshots) ? checkpoint.screenshots : []
1530
+ );
1531
+ const coverageLedger = sanitizeCoverageCheckpointValue(
1532
+ Array.isArray(checkpoint.coverage_ledger) ? checkpoint.coverage_ledger : []
1533
+ ).filter((entry) => entry?.accepted_for_coverage !== false);
1534
+ const uniqueHashes = new Set(
1535
+ screenshots.map((item) => String(item?.sha256 || "")).filter(Boolean)
1536
+ );
1537
+ const safeMaxUnique = Math.max(1, Number(maxUniqueScreenshots) || 1);
1538
+ if (uniqueHashes.size > safeMaxUnique) {
1539
+ const error = new Error("resumeCheckpoint exceeds the configured unique screenshot limit");
1540
+ error.code = "IMAGE_CAPTURE_CHECKPOINT_INVALID";
1541
+ error.unique_screenshot_count = uniqueHashes.size;
1542
+ error.max_unique_screenshots = safeMaxUnique;
1543
+ throw error;
1544
+ }
1545
+ const ledgerNextIndex = coverageLedger.reduce((next, entry) => (
1546
+ Math.max(next, (Number(entry?.capture_index) || 0) + 1)
1547
+ ), 0);
1548
+ const requestedNextIndex = Math.max(0, Number(checkpoint.next_capture_index) || 0);
1549
+ const previousHash = String(checkpoint.previous_hash || "");
1550
+ const lastConfirmedEntry = [...coverageLedger]
1551
+ .reverse()
1552
+ .find((entry) => String(entry?.sha256 || "") === previousHash)
1553
+ || coverageLedger[coverageLedger.length - 1]
1554
+ || null;
1555
+ const checkpointContinuityTarget = sanitizeCoverageCheckpointValue(
1556
+ checkpoint.resume_continuity_target || null
1557
+ );
1558
+ return {
1559
+ used: true,
1560
+ checkpoint_id: String(checkpoint.checkpoint_id || "") || null,
1561
+ screenshots,
1562
+ coverage_ledger: coverageLedger,
1563
+ anchor_plan_history: sanitizeCoverageCheckpointValue(
1564
+ Array.isArray(checkpoint.anchor_plan_history) ? checkpoint.anchor_plan_history : []
1565
+ ),
1566
+ stop_boundary_checks: sanitizeCoverageCheckpointValue(
1567
+ Array.isArray(checkpoint.stop_boundary_checks) ? checkpoint.stop_boundary_checks : []
1568
+ ),
1569
+ viewport_events: sanitizeCoverageCheckpointValue(
1570
+ Array.isArray(checkpoint.viewport_events) ? checkpoint.viewport_events : []
1571
+ ),
1572
+ previous_hash: previousHash,
1573
+ dropped_duplicate_count: Math.max(0, Number(checkpoint.dropped_duplicate_count) || 0),
1574
+ next_capture_index: Math.max(ledgerNextIndex, requestedNextIndex),
1575
+ current_pending_scroll_metadata: sanitizeCoverageCheckpointValue(
1576
+ checkpoint.current_pending_scroll_metadata || null
1577
+ ),
1578
+ continuity_target: checkpointContinuityTarget || (previousHash ? {
1579
+ sha256: previousHash,
1580
+ capture_index: lastConfirmedEntry?.capture_index ?? null,
1581
+ visible_crop: lastConfirmedEntry?.visible_crop || null,
1582
+ anchor: lastConfirmedEntry?.anchor_evidence || lastConfirmedEntry?.anchor_plan?.bottom_anchor || null
1583
+ } : null)
1584
+ };
1585
+ }
1586
+
1587
+ function createResumeContinuityTarget(screenshots = [], coverageLedger = [], previousHash = "") {
1588
+ const targetHash = String(previousHash || "");
1589
+ if (!targetHash) return null;
1590
+ const lastConfirmedEntry = [...coverageLedger]
1591
+ .reverse()
1592
+ .find((entry) => String(entry?.sha256 || "") === targetHash)
1593
+ || coverageLedger[coverageLedger.length - 1]
1594
+ || null;
1595
+ const screenshot = [...screenshots]
1596
+ .reverse()
1597
+ .find((item) => String(item?.sha256 || "") === targetHash)
1598
+ || null;
1599
+ return sanitizeCoverageCheckpointValue({
1600
+ sha256: targetHash,
1601
+ capture_index: lastConfirmedEntry?.capture_index ?? screenshot?.capture_index ?? null,
1602
+ screenshot_file_path: screenshot?.file_path || null,
1603
+ visible_crop: lastConfirmedEntry?.visible_crop || screenshot?.crop?.visible_rect || null,
1604
+ anchor: lastConfirmedEntry?.anchor_evidence || lastConfirmedEntry?.anchor_plan?.bottom_anchor || null
1605
+ });
1606
+ }
1607
+
1608
+ function createCoverageCheckpoint({
1609
+ screenshots,
1610
+ coverageLedger,
1611
+ anchorPlanHistory,
1612
+ stopBoundaryChecks,
1613
+ viewportEvents,
1614
+ previousHash,
1615
+ droppedDuplicateCount,
1616
+ currentScrollMetadata,
1617
+ maxUniqueScreenshots,
1618
+ format,
1619
+ filePath,
1620
+ sourceCheckpointId = null,
1621
+ resumeContinuity = null
1622
+ } = {}) {
1623
+ const sanitizedScreenshots = sanitizeCoverageCheckpointValue(screenshots || []);
1624
+ const sanitizedLedger = sanitizeCoverageCheckpointValue(coverageLedger || [])
1625
+ .filter((entry) => entry?.accepted_for_coverage !== false);
1626
+ const nextCaptureIndex = sanitizedLedger.reduce((next, entry) => (
1627
+ Math.max(next, (Number(entry?.capture_index) || 0) + 1)
1628
+ ), 0);
1629
+ return {
1630
+ schema_version: 1,
1631
+ kind: "cv_capture_coverage_checkpoint",
1632
+ checkpoint_id: crypto.randomUUID(),
1633
+ source_checkpoint_id: sourceCheckpointId,
1634
+ created_at: nowIso(),
1635
+ session_scoped_node_ids_reset: true,
1636
+ node_id: null,
1637
+ format,
1638
+ file_path: filePath ? path.resolve(filePath) : null,
1639
+ max_unique_screenshots: Math.max(1, Number(maxUniqueScreenshots) || 1),
1640
+ next_capture_index: nextCaptureIndex,
1641
+ confirmed_capture_count: sanitizedLedger.length,
1642
+ unique_screenshot_count: new Set(
1643
+ sanitizedScreenshots.map((item) => String(item?.sha256 || "")).filter(Boolean)
1644
+ ).size,
1645
+ previous_hash: String(previousHash || ""),
1646
+ dropped_duplicate_count: Math.max(0, Number(droppedDuplicateCount) || 0),
1647
+ current_pending_scroll_metadata: sanitizeCoverageCheckpointValue(currentScrollMetadata || null),
1648
+ resume_continuity_target: createResumeContinuityTarget(
1649
+ sanitizedScreenshots,
1650
+ sanitizedLedger,
1651
+ previousHash
1652
+ ),
1653
+ last_resume_continuity: sanitizeCoverageCheckpointValue(resumeContinuity || null),
1654
+ screenshots: sanitizedScreenshots,
1655
+ coverage_ledger: sanitizedLedger,
1656
+ anchor_plan_history: sanitizeCoverageCheckpointValue(anchorPlanHistory || []),
1657
+ stop_boundary_checks: sanitizeCoverageCheckpointValue(stopBoundaryChecks || []),
1658
+ viewport_events: sanitizeCoverageCheckpointValue(viewportEvents || [])
1659
+ };
1660
+ }
1661
+
1662
+ function resumeAnchorMatches(targetAnchor = null, freshAnchor = null, tolerance = 1) {
1663
+ if (!targetAnchor || !freshAnchor?.node_id) return false;
1664
+ const safeTolerance = Math.max(0, Number(tolerance) || 0);
1665
+ const targetY = Number(targetAnchor.y);
1666
+ const freshY = Number(freshAnchor.y);
1667
+ const targetHeight = Number(targetAnchor.height);
1668
+ const freshHeight = Number(freshAnchor.height);
1669
+ if (![targetY, freshY, targetHeight, freshHeight].every(Number.isFinite)) return false;
1670
+ const signatureMatch = Boolean(
1671
+ targetAnchor.structural_signature
1672
+ && freshAnchor.structural_signature
1673
+ && targetAnchor.structural_signature === freshAnchor.structural_signature
1674
+ );
1675
+ const orderMatch = Boolean(
1676
+ Number.isFinite(Number(targetAnchor.document_order))
1677
+ && Number.isFinite(Number(freshAnchor.document_order))
1678
+ && Number(targetAnchor.document_order) === Number(freshAnchor.document_order)
1679
+ );
1680
+ if (!signatureMatch && !orderMatch) return false;
1681
+ return Math.abs(targetY - freshY) <= safeTolerance
1682
+ && Math.abs(targetHeight - freshHeight) <= safeTolerance;
1683
+ }
1684
+
1685
+ async function captureResumeContinuityProbe(client, nodeId, {
1686
+ format,
1687
+ quality,
1688
+ padding,
1689
+ iframeOwnerNodeId,
1690
+ resizeMaxWidth,
1691
+ stepTimeoutMs,
1692
+ scrollAnchorSelector,
1693
+ maxScreenshots,
1694
+ scrollAnchorMaxProbeNodes,
1695
+ scrollAnchorMinGap,
1696
+ viewportTolerance,
1697
+ attempt,
1698
+ direction
1699
+ } = {}) {
1700
+ const geometry = await withCaptureTimeout(readVisibleCaptureGeometry(client, nodeId, {
1701
+ padding,
1702
+ iframeOwnerNodeId
1703
+ }), {
1704
+ label: `resume_geometry_${attempt}`,
1705
+ timeoutMs: stepTimeoutMs
1706
+ });
1707
+ const screenshot = await captureViewportAtomically(
1708
+ client,
1709
+ createCliplessScreenshotOptions({ format, quality }),
1710
+ {
1711
+ label: `resume_probe_${attempt}`,
1712
+ timeoutMs: stepTimeoutMs
1713
+ }
1714
+ );
1715
+ const viewportBuffer = Buffer.from(screenshot.data || "", "base64");
1716
+ const afterViewportState = await withCaptureTimeout(readCaptureViewportState(client), {
1717
+ label: `resume_post_viewport_${attempt}`,
1718
+ timeoutMs: stepTimeoutMs
1719
+ });
1720
+ const viewportComparison = compareCaptureViewportState(
1721
+ geometry.viewport_state,
1722
+ afterViewportState,
1723
+ viewportTolerance
1724
+ );
1725
+ if (!viewportComparison.ok || viewportComparison.browser_window_changed) {
1726
+ const error = createViewportDriftError(
1727
+ geometry.viewport_state,
1728
+ afterViewportState,
1729
+ viewportComparison,
1730
+ `resume_${attempt}`
1731
+ );
1732
+ error.capture_operation = screenshot.__capture_telemetry || null;
1733
+ throw error;
1734
+ }
1735
+ const processed = await withCaptureTimeout(cropViewportScreenshotBuffer(viewportBuffer, {
1736
+ format,
1737
+ quality,
1738
+ visibleRect: geometry.resolved.visible_rect,
1739
+ viewport: geometry.viewport_state.viewport,
1740
+ resizeMaxWidth
1741
+ }), {
1742
+ label: `resume_crop_${attempt}`,
1743
+ timeoutMs: stepTimeoutMs
1744
+ });
1745
+ const anchorPlan = await collectDomScrollAnchors(client, nodeId, {
1746
+ selector: scrollAnchorSelector,
1747
+ maxScreenshots,
1748
+ maxProbeNodes: scrollAnchorMaxProbeNodes,
1749
+ minAnchorGap: scrollAnchorMinGap,
1750
+ stepTimeoutMs
1751
+ });
1752
+ return {
1753
+ attempt,
1754
+ direction,
1755
+ sha256: screenshotHash(processed.buffer),
1756
+ geometry,
1757
+ anchor_plan: anchorPlan,
1758
+ anchor_evidence: resolveAnchorProgressEvidence(anchorPlan, null, viewportTolerance),
1759
+ capture_operation: screenshot.__capture_telemetry || null
1760
+ };
1761
+ }
1762
+
1763
+ function summarizeResumeProbe(probe, target, matchKind = null, progress = null) {
1764
+ return {
1765
+ attempt: probe.attempt,
1766
+ direction: probe.direction,
1767
+ sha256: probe.sha256,
1768
+ target_hash_match: probe.sha256 === target?.sha256,
1769
+ target_anchor_match: resumeAnchorMatches(
1770
+ target?.anchor,
1771
+ probe.anchor_plan?.bottom_anchor,
1772
+ target?.viewport_tolerance
1773
+ ),
1774
+ match_kind: matchKind,
1775
+ progress,
1776
+ anchor: probe.anchor_evidence,
1777
+ capture_operation: probe.capture_operation,
1778
+ visible_crop: probe.geometry?.resolved?.visible_rect || null
1779
+ };
1780
+ }
1781
+
1782
+ async function dispatchResumeWheel(client, probe, deltaY, {
1783
+ label,
1784
+ stepTimeoutMs
1785
+ } = {}) {
1786
+ const rect = probe?.geometry?.resolved?.visible_rect;
1787
+ if (!rect || rect.width < 2 || rect.height < 2) {
1788
+ const error = new Error("Resume continuity scroll target is unreadable");
1789
+ error.code = "IMAGE_CAPTURE_RESUME_CONTINUITY_UNPROVEN";
1790
+ throw error;
1791
+ }
1792
+ const x = rect.x + rect.width / 2;
1793
+ const y = rect.y + rect.height / 2;
1794
+ const timeoutMs = Math.min(Math.max(3000, Number(stepTimeoutMs) || 45000), 10000);
1795
+ await withCaptureTimeout(client.Input.dispatchMouseEvent({
1796
+ type: "mouseMoved",
1797
+ x,
1798
+ y,
1799
+ button: "none"
1800
+ }), { label: `${label}_move`, timeoutMs });
1801
+ await withCaptureTimeout(client.Input.dispatchMouseEvent({
1802
+ type: "mouseWheel",
1803
+ x,
1804
+ y,
1805
+ deltaX: 0,
1806
+ deltaY
1807
+ }), { label: `${label}_wheel`, timeoutMs });
1808
+ return { x, y, delta_y: deltaY };
1809
+ }
1810
+
1811
+ async function restoreResumeContinuity(client, nodeId, {
1812
+ target,
1813
+ format,
1814
+ quality,
1815
+ padding,
1816
+ iframeOwnerNodeId,
1817
+ resizeMaxWidth,
1818
+ stepTimeoutMs,
1819
+ totalTimeoutMs,
1820
+ sequenceStarted,
1821
+ scrollAnchorSelector,
1822
+ maxScreenshots,
1823
+ scrollAnchorMaxProbeNodes,
1824
+ scrollAnchorMinGap,
1825
+ viewportTolerance,
1826
+ wheelDeltaY,
1827
+ historicalScrollDeltas = [],
1828
+ continuationDeltaY = null,
1829
+ scrollDeltaJitter,
1830
+ settleMs
1831
+ } = {}) {
1832
+ if (!target?.sha256) {
1833
+ const error = new Error("Resume checkpoint has no last confirmed visual position");
1834
+ error.code = "IMAGE_CAPTURE_RESUME_CONTINUITY_UNPROVEN";
1835
+ throw error;
1836
+ }
1837
+ target.viewport_tolerance = viewportTolerance;
1838
+ const probes = [];
1839
+ let attempt = 0;
1840
+ let continuityBaseline = null;
1841
+ const maxDirectionalAttempts = Math.max(4, Math.max(1, Number(maxScreenshots) || 1) * 2 + 2);
1842
+
1843
+ const takeProbe = async (direction) => {
1844
+ assertCaptureTotalBudget(sequenceStarted, totalTimeoutMs, `resume_continuity_${direction}_${attempt + 1}`);
1845
+ attempt += 1;
1846
+ const probe = await captureResumeContinuityProbe(client, nodeId, {
1847
+ format,
1848
+ quality,
1849
+ padding,
1850
+ iframeOwnerNodeId,
1851
+ resizeMaxWidth,
1852
+ stepTimeoutMs,
1853
+ scrollAnchorSelector,
1854
+ maxScreenshots,
1855
+ scrollAnchorMaxProbeNodes,
1856
+ scrollAnchorMinGap,
1857
+ viewportTolerance,
1858
+ attempt,
1859
+ direction
1860
+ });
1861
+ if (!continuityBaseline) {
1862
+ continuityBaseline = probe.geometry.viewport_state;
1863
+ } else {
1864
+ const comparison = compareCaptureViewportState(
1865
+ continuityBaseline,
1866
+ probe.geometry.viewport_state,
1867
+ viewportTolerance
1868
+ );
1869
+ if (!comparison.ok || comparison.browser_window_changed) {
1870
+ throw createViewportDriftError(
1871
+ continuityBaseline,
1872
+ probe.geometry.viewport_state,
1873
+ comparison,
1874
+ `resume_${attempt}`
1875
+ );
1876
+ }
1877
+ }
1878
+ return probe;
1879
+ };
1880
+
1881
+ const matchProbe = async (probe) => {
1882
+ if (probe.sha256 === target.sha256) return { matched: true, kind: "sha256" };
1883
+ if (!resumeAnchorMatches(target.anchor, probe.anchor_plan?.bottom_anchor, viewportTolerance)) {
1884
+ return { matched: false, kind: null };
1885
+ }
1886
+ // A structural anchor match is accepted only after an independent fresh
1887
+ // read at the same position. Node ids are deliberately not retained.
1888
+ const verification = await takeProbe("anchor_verification");
1889
+ const verified = resumeAnchorMatches(
1890
+ target.anchor,
1891
+ verification.anchor_plan?.bottom_anchor,
1892
+ viewportTolerance
1893
+ );
1894
+ probes.push(summarizeResumeProbe(
1895
+ verification,
1896
+ target,
1897
+ verified ? "anchor_stable_second_read" : null,
1898
+ "verification"
1899
+ ));
1900
+ return { matched: verified, kind: verified ? "anchor_stable" : null, probe: verification };
1901
+ };
1902
+
1903
+ let current = await takeProbe("initial");
1904
+ let match = await matchProbe(current);
1905
+ probes.push(summarizeResumeProbe(current, target, match.kind));
1906
+ if (match.probe) current = match.probe;
1907
+
1908
+ const scrollAndProbe = async (direction, deltaY, previous) => {
1909
+ await dispatchResumeWheel(client, previous, deltaY, {
1910
+ label: `resume_${direction}_${attempt + 1}`,
1911
+ stepTimeoutMs
1912
+ });
1913
+ if (settleMs > 0) await sleep(settleMs);
1914
+ const next = await takeProbe(direction);
1915
+ const anchorProgress = resolveAnchorProgressEvidence(
1916
+ next.anchor_plan,
1917
+ previous.anchor_evidence,
1918
+ viewportTolerance
1919
+ );
1920
+ const progressed = next.sha256 !== previous.sha256
1921
+ || Boolean(anchorProgress.available && !anchorProgress.stationary);
1922
+ const nextMatch = await matchProbe(next);
1923
+ probes.push(summarizeResumeProbe(
1924
+ next,
1925
+ target,
1926
+ nextMatch.kind,
1927
+ progressed ? "progress" : "no_progress"
1928
+ ));
1929
+ return {
1930
+ probe: nextMatch.probe || next,
1931
+ match: nextMatch,
1932
+ progressed
1933
+ };
1934
+ };
1935
+
1936
+ const safeDelta = resolveCoverageSafeScrollDelta({
1937
+ baseDelta: wheelDeltaY,
1938
+ clipHeight: current.geometry.resolved.visible_rect.height,
1939
+ jitter: { ...scrollDeltaJitter, enabled: false }
1940
+ });
1941
+
1942
+ if (!match.matched) {
1943
+ let noProgress = 0;
1944
+ for (let index = 0; index < maxDirectionalAttempts && noProgress < 2; index += 1) {
1945
+ const result = await scrollAndProbe("toward_top", -Math.abs(safeDelta.deltaY), current);
1946
+ current = result.probe;
1947
+ match = result.match;
1948
+ if (match.matched) break;
1949
+ noProgress = result.progressed ? 0 : noProgress + 1;
1950
+ }
1951
+ }
1952
+
1953
+ if (!match.matched) {
1954
+ let noProgress = 0;
1955
+ for (let index = 0; index < maxDirectionalAttempts && noProgress < 2; index += 1) {
1956
+ const historicalDelta = Math.abs(Number(historicalScrollDeltas[index]) || 0);
1957
+ const replayDelta = historicalDelta > 0
1958
+ ? resolveCoverageSafeScrollDelta({
1959
+ baseDelta: historicalDelta,
1960
+ clipHeight: current.geometry.resolved.visible_rect.height,
1961
+ jitter: { ...scrollDeltaJitter, enabled: false }
1962
+ }).deltaY
1963
+ : safeDelta.deltaY;
1964
+ const result = await scrollAndProbe("from_top", Math.abs(replayDelta), current);
1965
+ current = result.probe;
1966
+ match = result.match;
1967
+ if (match.matched) break;
1968
+ noProgress = result.progressed ? 0 : noProgress + 1;
1969
+ }
1970
+ }
1971
+
1972
+ if (!match.matched) {
1973
+ const error = new Error("Could not restore the last confirmed CV coverage position after session reacquisition");
1974
+ error.code = "IMAGE_CAPTURE_RESUME_CONTINUITY_UNPROVEN";
1975
+ error.coverage_incomplete = true;
1976
+ error.resume_continuity = {
1977
+ verified: false,
1978
+ target: sanitizeCoverageCheckpointValue(target),
1979
+ probes: [...probes].sort((left, right) => left.attempt - right.attempt)
1980
+ };
1981
+ throw error;
1982
+ }
1983
+
1984
+ const freshAnchorEvidence = resolveAnchorProgressEvidence(
1985
+ current.anchor_plan,
1986
+ null,
1987
+ viewportTolerance
1988
+ );
1989
+ const checkpointContinuationDelta = Math.abs(Number(continuationDeltaY) || 0);
1990
+ const continuationDelta = resolveCoverageSafeScrollDelta({
1991
+ baseDelta: checkpointContinuationDelta || wheelDeltaY,
1992
+ clipHeight: current.geometry.resolved.visible_rect.height,
1993
+ jitter: scrollDeltaJitter
1994
+ });
1995
+ const physicalScroll = await dispatchResumeWheel(
1996
+ client,
1997
+ current,
1998
+ Math.abs(continuationDelta.deltaY),
1999
+ {
2000
+ label: "resume_continue_from_confirmed_position",
2001
+ stepTimeoutMs
2002
+ }
2003
+ );
2004
+ if (settleMs > 0) await sleep(settleMs);
2005
+
2006
+ return {
2007
+ verified: true,
2008
+ match_kind: match.kind,
2009
+ target: sanitizeCoverageCheckpointValue(target),
2010
+ probe_count: probes.length,
2011
+ probes: [...probes].sort((left, right) => left.attempt - right.attempt),
2012
+ restored_anchor: sanitizeCoverageCheckpointValue(freshAnchorEvidence),
2013
+ previous_anchor_evidence: freshAnchorEvidence,
2014
+ continuation_scroll: {
2015
+ method: "Input.dispatchMouseEvent",
2016
+ physically_dispatched: true,
2017
+ checkpoint_delta_physically_reissued: checkpointContinuationDelta > 0,
2018
+ ...physicalScroll,
2019
+ wheel_delta_base_y: continuationDelta.base_delta_y,
2020
+ overlap_ratio_target: continuationDelta.min_overlap_ratio ?? scrollDeltaJitter.min_overlap_ratio
2021
+ },
2022
+ viewport_baseline: current.geometry.viewport_state
2023
+ };
2024
+ }
2025
+
2026
+ async function scrollDomAnchorIntoView(client, nodeId, {
2027
+ timeoutMs = 10000,
2028
+ label = "dom_scroll_anchor"
2029
+ } = {}) {
2030
+ if (client.DOM && typeof client.DOM.scrollIntoViewIfNeeded === "function") {
2031
+ return withCaptureTimeout(client.DOM.scrollIntoViewIfNeeded({ nodeId }), { label, timeoutMs });
2032
+ }
2033
+ if (typeof client.send === "function") {
2034
+ return withCaptureTimeout(client.send("DOM.scrollIntoViewIfNeeded", { nodeId }), { label, timeoutMs });
2035
+ }
2036
+ throw new Error("CDP client does not expose DOM.scrollIntoViewIfNeeded");
2037
+ }
2038
+
2039
+ async function composeScreenshotsForLlm(screenshots = [], {
2040
+ basePath,
2041
+ pagesPerImage = 3,
2042
+ resizeMaxWidth = 1100,
2043
+ quality = 72
2044
+ } = {}) {
2045
+ const fileScreenshots = screenshots.filter((item) => item?.file_path);
2046
+ if (!basePath || fileScreenshots.length <= 1) {
2047
+ return {
2048
+ llm_file_paths: fileScreenshots.map((item) => item.file_path),
2049
+ llm_screenshots: [],
2050
+ llm_total_byte_length: 0,
2051
+ llm_original_total_byte_length: 0,
2052
+ llm_composition_error: null
2053
+ };
2054
+ }
2055
+
2056
+ const safePagesPerImage = Math.max(1, Math.min(5, Number(pagesPerImage) || 3));
2057
+ const safeWidth = Math.max(700, Math.min(1400, Number(resizeMaxWidth) || 1100));
2058
+ const safeQuality = Math.max(45, Math.min(90, Number(quality) || 72));
2059
+ const llmScreenshots = [];
2060
+
2061
+ try {
2062
+ for (let index = 0; index < fileScreenshots.length; index += safePagesPerImage) {
2063
+ const group = fileScreenshots.slice(index, index + safePagesPerImage);
2064
+ const prepared = [];
2065
+ for (const item of group) {
2066
+ const sourceBuffer = fs.readFileSync(item.file_path);
2067
+ const { data, info } = await sharp(sourceBuffer, { failOn: "none" })
2068
+ .resize({
2069
+ width: safeWidth,
2070
+ withoutEnlargement: true
2071
+ })
2072
+ .jpeg({
2073
+ quality: safeQuality,
2074
+ mozjpeg: true
2075
+ })
2076
+ .toBuffer({ resolveWithObject: true });
2077
+ prepared.push({
2078
+ input: data,
2079
+ width: info.width,
2080
+ height: info.height,
2081
+ source_file_path: item.file_path
2082
+ });
2083
+ }
2084
+
2085
+ const width = Math.max(...prepared.map((item) => item.width), 1);
2086
+ const height = prepared.reduce((sum, item) => sum + item.height, 0);
2087
+ let top = 0;
2088
+ const composites = prepared.map((item) => {
2089
+ const layer = {
2090
+ input: item.input,
2091
+ left: 0,
2092
+ top
2093
+ };
2094
+ top += item.height;
2095
+ return layer;
2096
+ });
2097
+ const outputBuffer = await sharp({
2098
+ create: {
2099
+ width,
2100
+ height,
2101
+ channels: 3,
2102
+ background: "#ffffff"
2103
+ }
2104
+ })
2105
+ .composite(composites)
2106
+ .jpeg({
2107
+ quality: safeQuality,
2108
+ mozjpeg: true
2109
+ })
2110
+ .toBuffer();
2111
+ const outputPath = filePathForLlmSequence(basePath, llmScreenshots.length);
2112
+ fs.writeFileSync(outputPath, outputBuffer);
2113
+ llmScreenshots.push({
2114
+ index: llmScreenshots.length,
2115
+ file_path: outputPath,
2116
+ byte_length: outputBuffer.length,
2117
+ source_file_paths: prepared.map((item) => item.source_file_path),
2118
+ source_page_count: prepared.length,
2119
+ width,
2120
+ height,
2121
+ format: "jpeg",
2122
+ mime_type: "image/jpeg"
2123
+ });
2124
+ }
2125
+ } catch (error) {
2126
+ return {
2127
+ llm_file_paths: fileScreenshots.map((item) => item.file_path),
2128
+ llm_screenshots: [],
2129
+ llm_total_byte_length: 0,
2130
+ llm_original_total_byte_length: fileScreenshots.reduce((sum, item) => sum + (Number(item.byte_length) || 0), 0),
2131
+ llm_composition_error: error?.message || String(error)
2132
+ };
2133
+ }
2134
+
2135
+ return {
2136
+ llm_file_paths: llmScreenshots.map((item) => item.file_path),
2137
+ llm_screenshots: llmScreenshots,
2138
+ llm_total_byte_length: llmScreenshots.reduce((sum, item) => sum + (Number(item.byte_length) || 0), 0),
2139
+ llm_original_total_byte_length: fileScreenshots.reduce((sum, item) => sum + (Number(item.byte_length) || 0), 0),
2140
+ llm_composition_error: null
2141
+ };
2142
+ }
2143
+
2144
+ export async function captureScrolledNodeScreenshots(client, nodeId, {
2145
+ filePath,
2146
+ format = "png",
2147
+ quality,
2148
+ padding = 0,
2149
+ captureBeyondViewport: requestedCaptureBeyondViewport = false,
2150
+ fromSurface: requestedFromSurface = true,
2151
+ captureViewport: requestedCaptureViewport = false,
2152
+ iframeOwnerNodeId = null,
2153
+ maxScreenshots = 6,
2154
+ wheelDeltaY = 650,
2155
+ settleMs = 900,
2156
+ duplicateStopCount = 2,
2157
+ skipDuplicateScreenshots = false,
2158
+ optimize = false,
2159
+ resizeMaxWidth = 0,
2160
+ composeForLlm = false,
2161
+ llmPagesPerImage = 3,
2162
+ llmResizeMaxWidth = 1100,
2163
+ llmQuality = 72,
2164
+ stepTimeoutMs = 45000,
2165
+ totalTimeoutMs = 90000,
2166
+ scrollMethod = "dom-anchor-fallback-input",
2167
+ scrollAnchorSelector = DEFAULT_SCROLL_ANCHOR_SELECTOR,
2168
+ scrollAnchorMaxProbeNodes = 260,
2169
+ scrollAnchorMinGap = 180,
2170
+ scrollDeltaJitterEnabled = false,
2171
+ scrollDeltaJitterMinRatio = 0.65,
2172
+ scrollDeltaJitterMaxRatio = 0.9,
2173
+ scrollDeltaJitterMinOverlapRatio = 0.2,
2174
+ scrollDeltaJitterPreserveCoverage = true,
2175
+ scrollDeltaJitterRandom = Math.random,
2176
+ stopBoundarySelector = "",
2177
+ stopBoundaryTextPatterns = [],
2178
+ stopBoundaryMaxProbeNodes = 180,
2179
+ stopBoundaryMaxTextLength = 700,
2180
+ stopBoundaryTopPadding = 8,
2181
+ stopBoundaryMinCaptureHeight = 180,
2182
+ requireTerminalProof = true,
2183
+ viewportTolerance = 1,
2184
+ resumeCheckpoint = null,
2185
+ metadata = {}
2186
+ } = {}) {
2187
+ if (!nodeId) throw new Error("captureScrolledNodeScreenshots requires nodeId");
2188
+ const sequenceStarted = Date.now();
2189
+ const normalizedScrollMethod = normalizeScrollMethod(scrollMethod);
2190
+ const maxScreenshotCount = Math.max(1, Number(maxScreenshots) || 1);
2191
+ const requiredNoProgressCount = Math.max(2, Number(duplicateStopCount) || 2);
2192
+ const scrollDeltaJitter = normalizeScrollDeltaJitter({
2193
+ enabled: scrollDeltaJitterEnabled,
2194
+ minRatio: scrollDeltaJitterMinRatio,
2195
+ maxRatio: scrollDeltaJitterMaxRatio,
2196
+ minOverlapRatio: scrollDeltaJitterMinOverlapRatio,
2197
+ preserveCoverage: scrollDeltaJitterPreserveCoverage,
2198
+ random: scrollDeltaJitterRandom
2199
+ });
2200
+ // maxScreenshots is a hard cap on unique persisted evidence pages. The only
2201
+ // extra transport attempts are the required terminal no-progress probes.
2202
+ const coverageIterationLimit = maxScreenshotCount;
2203
+ const maxCaptureIterations = coverageIterationLimit
2204
+ + (requireTerminalProof ? requiredNoProgressCount : 0);
2205
+ const resumeState = normalizeCoverageResumeCheckpoint(resumeCheckpoint, {
2206
+ maxUniqueScreenshots: coverageIterationLimit
2207
+ });
2208
+ // A new session invalidates prior anchor/no-progress proof. Preserve the
2209
+ // unique-page cap, but grant one anchor-baseline capture plus a fresh
2210
+ // terminal-proof window so two independent post-reacquisition no-progress
2211
+ // attempts remain possible even when native anchors are available.
2212
+ const captureIterationCeiling = maxCaptureIterations
2213
+ + (resumeState.used && requireTerminalProof ? requiredNoProgressCount + 1 : 0);
2214
+ let anchorPlan = null;
2215
+ const anchorPlanHistory = [...resumeState.anchor_plan_history];
2216
+ const stopBoundaryEnabled = Boolean(
2217
+ normalizeText(stopBoundarySelector)
2218
+ || (Array.isArray(stopBoundaryTextPatterns)
2219
+ ? stopBoundaryTextPatterns.length
2220
+ : stopBoundaryTextPatterns)
2221
+ );
2222
+ let stopBoundaryPlan = {
2223
+ enabled: false,
2224
+ ok: false,
2225
+ reason: "not_configured",
2226
+ nodes: []
2227
+ };
2228
+ const stopBoundaryChecks = [...resumeState.stop_boundary_checks];
2229
+ const screenshots = [...resumeState.screenshots];
2230
+ const coverageLedger = [...resumeState.coverage_ledger];
2231
+ const viewportEvents = [...resumeState.viewport_events];
2232
+ let consecutiveDuplicates = 0;
2233
+ let consecutiveNoProgress = 0;
2234
+ let previousAnchorEvidence = null;
2235
+ let previousHash = resumeState.previous_hash
2236
+ || String(screenshots[screenshots.length - 1]?.sha256 || "");
2237
+ let captureCount = resumeState.next_capture_index;
2238
+ let droppedDuplicateCount = resumeState.dropped_duplicate_count;
2239
+ let stopBoundaryResult = null;
2240
+ let viewportBaseline = null;
2241
+ let coverageComplete = false;
2242
+ let terminalReason = null;
2243
+ let coverageLimitReached = false;
2244
+ let resumeContinuity = resumeState.used ? {
2245
+ verified: false,
2246
+ target: resumeState.continuity_target,
2247
+ checkpoint_pending_scroll_ignored: Boolean(resumeState.current_pending_scroll_metadata)
2248
+ } : null;
2249
+ let currentScrollMetadata = {
2250
+ before_capture: "initial",
2251
+ method: "none",
2252
+ requested_scroll_method: normalizedScrollMethod,
2253
+ anchor_plan: null
2254
+ };
2255
+
2256
+ try {
2257
+ if (resumeState.used && screenshots.length > 0) {
2258
+ resumeContinuity = await restoreResumeContinuity(client, nodeId, {
2259
+ target: resumeState.continuity_target,
2260
+ format,
2261
+ quality,
2262
+ padding,
2263
+ iframeOwnerNodeId,
2264
+ resizeMaxWidth,
2265
+ stepTimeoutMs,
2266
+ totalTimeoutMs,
2267
+ sequenceStarted,
2268
+ scrollAnchorSelector,
2269
+ maxScreenshots: coverageIterationLimit,
2270
+ scrollAnchorMaxProbeNodes,
2271
+ scrollAnchorMinGap,
2272
+ viewportTolerance,
2273
+ wheelDeltaY,
2274
+ historicalScrollDeltas: resumeState.coverage_ledger
2275
+ .slice(1)
2276
+ .map((entry) => {
2277
+ const wheelDelta = Math.abs(Number(entry?.scroll?.wheel_delta_y) || 0);
2278
+ if (wheelDelta > 0) return wheelDelta;
2279
+ return Math.abs(Number(entry?.anchor_evidence?.delta_y) || 0);
2280
+ }),
2281
+ continuationDeltaY: resumeState.current_pending_scroll_metadata?.wheel_delta_y,
2282
+ scrollDeltaJitter,
2283
+ settleMs
2284
+ });
2285
+ previousAnchorEvidence = resumeContinuity.previous_anchor_evidence;
2286
+ viewportBaseline = resumeContinuity.viewport_baseline;
2287
+ currentScrollMetadata = {
2288
+ before_capture: "resume_after_confirmed_position",
2289
+ method: "Input.dispatchMouseEvent",
2290
+ requested_scroll_method: normalizedScrollMethod,
2291
+ wheel_delta_y: resumeContinuity.continuation_scroll.delta_y,
2292
+ wheel_delta_base_y: resumeContinuity.continuation_scroll.wheel_delta_base_y,
2293
+ overlap_ratio_target: resumeContinuity.continuation_scroll.overlap_ratio_target,
2294
+ physically_dispatched: true,
2295
+ resume_continuity_verified: true,
2296
+ resume_match_kind: resumeContinuity.match_kind,
2297
+ checkpoint_pending_scroll_physically_reissued: Boolean(
2298
+ resumeContinuity.continuation_scroll.checkpoint_delta_physically_reissued
2299
+ ),
2300
+ old_pending_delta_used_as_position_proof: false,
2301
+ anchor_node_id: null,
2302
+ anchor_plan: null
2303
+ };
2304
+ viewportEvents.push({
2305
+ capture_index: resumeState.next_capture_index,
2306
+ kind: "resume_continuity_verified",
2307
+ match_kind: resumeContinuity.match_kind,
2308
+ probe_count: resumeContinuity.probe_count,
2309
+ viewport_state: viewportBaseline
2310
+ });
2311
+ }
2312
+ for (let index = resumeState.next_capture_index; index < captureIterationCeiling; index += 1) {
2313
+ assertCaptureTotalBudget(sequenceStarted, totalTimeoutMs, `capture_page_${index + 1}`);
2314
+ captureCount += 1;
2315
+ const captureStarted = Date.now();
2316
+ const geometry = await withCaptureTimeout(readVisibleCaptureGeometry(client, nodeId, {
2317
+ padding,
2318
+ iframeOwnerNodeId
2319
+ }), {
2320
+ label: `get_capture_geometry_${index + 1}`,
2321
+ timeoutMs: stepTimeoutMs
2322
+ });
2323
+ const geometryElapsedMs = Date.now() - captureStarted;
2324
+ const box = geometry.box;
2325
+ const clip = withPadding(box.rect, padding);
2326
+ if (!viewportBaseline) {
2327
+ viewportBaseline = geometry.viewport_state;
2328
+ viewportEvents.push({
2329
+ capture_index: index,
2330
+ kind: "baseline",
2331
+ viewport_state: viewportBaseline
2332
+ });
2333
+ } else {
2334
+ const beforeComparison = compareCaptureViewportState(viewportBaseline, geometry.viewport_state, viewportTolerance);
2335
+ if (beforeComparison.browser_window_changed) {
2336
+ const rebaselineVerification = await verifyCaptureWindowRebaseline(
2337
+ client,
2338
+ viewportBaseline,
2339
+ geometry.viewport_state,
2340
+ {
2341
+ viewportTolerance,
2342
+ stepTimeoutMs,
2343
+ captureIndex: index
2344
+ }
2345
+ );
2346
+ if (!rebaselineVerification.verified) {
2347
+ const driftError = createViewportDriftError(
2348
+ viewportBaseline,
2349
+ rebaselineVerification.second_reading || geometry.viewport_state,
2350
+ {
2351
+ ...beforeComparison,
2352
+ rebaseline_verification: rebaselineVerification
2353
+ },
2354
+ index
2355
+ );
2356
+ driftError.rebaseline_verification = rebaselineVerification;
2357
+ throw driftError;
2358
+ }
2359
+ viewportEvents.push({
2360
+ capture_index: index,
2361
+ kind: "verified_window_rebaseline",
2362
+ previous_baseline: viewportBaseline,
2363
+ viewport_state: rebaselineVerification.second_reading,
2364
+ comparison: beforeComparison,
2365
+ verification: rebaselineVerification
2366
+ });
2367
+ viewportBaseline = rebaselineVerification.second_reading;
2368
+ } else if (!beforeComparison.ok) {
2369
+ throw createViewportDriftError(viewportBaseline, geometry.viewport_state, beforeComparison, index);
2370
+ }
2371
+ }
2372
+ let visibleStopBoundary = null;
2373
+ if (stopBoundaryEnabled) {
2374
+ stopBoundaryPlan = await collectStopBoundaryNodes(client, nodeId, {
2375
+ selector: stopBoundarySelector,
2376
+ textPatterns: stopBoundaryTextPatterns,
2377
+ maxProbeNodes: stopBoundaryMaxProbeNodes,
2378
+ maxTextLength: stopBoundaryMaxTextLength,
2379
+ stepTimeoutMs
2380
+ });
2381
+ stopBoundaryChecks.push({
2382
+ capture_index: index,
2383
+ ok: Boolean(stopBoundaryPlan.ok),
2384
+ reason: stopBoundaryPlan.reason || null,
2385
+ discovered_node_count: stopBoundaryPlan.discovered_node_count || 0,
2386
+ probed_node_count: stopBoundaryPlan.probed_node_count || 0,
2387
+ match_count: stopBoundaryPlan.match_count || 0,
2388
+ elapsed_ms: stopBoundaryPlan.elapsed_ms || 0
2389
+ });
2390
+ visibleStopBoundary = await resolveVisibleStopBoundary(
2391
+ client,
2392
+ stopBoundaryPlan,
2393
+ geometry.resolved.visible_rect,
2394
+ {
2395
+ topPadding: stopBoundaryTopPadding,
2396
+ minCaptureHeight: stopBoundaryMinCaptureHeight,
2397
+ stepTimeoutMs,
2398
+ viewport: geometry.viewport_state.viewport,
2399
+ coordinateSpace: geometry.resolved.coordinate_space,
2400
+ iframeOwnerRect: geometry.iframe_owner_rect || null
2401
+ });
2402
+ }
2403
+ if (visibleStopBoundary?.action === "stop_before_capture") {
2404
+ stopBoundaryResult = visibleStopBoundary;
2405
+ coverageComplete = screenshots.length > 0;
2406
+ terminalReason = coverageComplete
2407
+ ? (visibleStopBoundary.reason || "stop_boundary_before_capture")
2408
+ : "stop_boundary_before_first_capture";
2409
+ break;
2410
+ }
2411
+ const effectiveClip = visibleStopBoundary?.adjusted_clip || clip;
2412
+ const effectiveCaptureViewport = false;
2413
+ const resolvedCrop = visibleStopBoundary?.adjusted_clip
2414
+ ? {
2415
+ ...geometry.resolved,
2416
+ requested_rect: effectiveClip,
2417
+ visible_rect: effectiveClip,
2418
+ visible_ratio: 1
2419
+ }
2420
+ : geometry.resolved;
2421
+ const captureOptions = createCliplessScreenshotOptions({ format, quality });
2422
+ const screenshot = await captureViewportAtomically(client, captureOptions, {
2423
+ label: `capture_screenshot_${index + 1}`,
2424
+ timeoutMs: stepTimeoutMs
2425
+ });
2426
+ const viewportBuffer = Buffer.from(screenshot.data || "", "base64");
2427
+ const afterViewportState = await withCaptureTimeout(readCaptureViewportState(client), {
2428
+ label: `get_post_capture_viewport_${index + 1}`,
2429
+ timeoutMs: stepTimeoutMs
2430
+ });
2431
+ const viewportComparison = compareCaptureViewportState(
2432
+ geometry.viewport_state,
2433
+ afterViewportState,
2434
+ viewportTolerance
2435
+ );
2436
+ if (!viewportComparison.ok || viewportComparison.browser_window_changed) {
2437
+ const driftError = createViewportDriftError(
2438
+ geometry.viewport_state,
2439
+ afterViewportState,
2440
+ viewportComparison,
2441
+ index
2442
+ );
2443
+ driftError.capture_operation = screenshot.__capture_telemetry || null;
2444
+ driftError.target_geometry = {
2445
+ node_rect: geometry.box.rect,
2446
+ iframe_owner_rect: geometry.iframe_owner_rect || null
2447
+ };
2448
+ throw driftError;
2449
+ }
2450
+ const localProcessingStartedAt = Date.now();
2451
+ const processed = await withCaptureTimeout(cropViewportScreenshotBuffer(viewportBuffer, {
2452
+ format,
2453
+ quality,
2454
+ visibleRect: resolvedCrop.visible_rect,
2455
+ viewport: geometry.viewport_state.viewport,
2456
+ resizeMaxWidth
2457
+ }), {
2458
+ label: `crop_screenshot_${index + 1}`,
2459
+ timeoutMs: stepTimeoutMs
2460
+ });
2461
+ const localProcessingElapsedMs = Date.now() - localProcessingStartedAt;
2462
+ const buffer = processed.buffer;
2463
+ const hash = screenshotHash(buffer);
2464
+ const duplicateOfPrevious = previousHash && previousHash === hash;
2465
+ const isNewUniqueScreenshot = !screenshots.some((item) => item?.sha256 === hash);
2466
+ if (duplicateOfPrevious) {
2467
+ consecutiveDuplicates += 1;
2468
+ } else {
2469
+ consecutiveDuplicates = 0;
2470
+ }
2471
+
2472
+ // Re-query after every preceding scroll. Node ids from an earlier DOM
2473
+ // snapshot are never reused as terminal-coverage evidence.
2474
+ anchorPlan = await collectDomScrollAnchors(client, nodeId, {
2475
+ selector: scrollAnchorSelector,
2476
+ maxScreenshots: coverageIterationLimit,
2477
+ maxProbeNodes: scrollAnchorMaxProbeNodes,
2478
+ minAnchorGap: scrollAnchorMinGap,
2479
+ stepTimeoutMs
2480
+ });
2481
+ const anchorPlanSummary = {
2482
+ capture_index: index,
2483
+ phase: "after_capture",
2484
+ ok: Boolean(anchorPlan.ok),
2485
+ reason: anchorPlan.reason || null,
2486
+ discovered_node_count: anchorPlan.discovered_node_count || 0,
2487
+ measured_node_count: anchorPlan.measured_node_count || 0,
2488
+ anchor_count: anchorPlan.anchor_count || 0,
2489
+ bottom_anchor: anchorPlan.bottom_anchor || null,
2490
+ elapsed_ms: anchorPlan.elapsed_ms || 0
2491
+ };
2492
+ anchorPlanHistory.push(anchorPlanSummary);
2493
+ const anchorEvidence = resolveAnchorProgressEvidence(
2494
+ anchorPlan,
2495
+ previousAnchorEvidence,
2496
+ viewportTolerance
2497
+ );
2498
+ const noProgress = Boolean(
2499
+ duplicateOfPrevious
2500
+ && (!anchorEvidence.available || anchorEvidence.stationary)
2501
+ );
2502
+ consecutiveNoProgress = noProgress ? consecutiveNoProgress + 1 : 0;
2503
+ previousAnchorEvidence = anchorEvidence;
2504
+ const visibleCrop = resolvedCrop?.visible_rect || geometry.resolved.visible_rect;
2505
+ const overlapWithPrevious = resolveCoverageOverlap(
2506
+ coverageLedger[coverageLedger.length - 1] || null,
2507
+ visibleCrop,
2508
+ currentScrollMetadata,
2509
+ anchorEvidence
2510
+ );
2511
+
2512
+ const coverageEntry = {
2513
+ capture_index: index,
2514
+ captured_at: nowIso(),
2515
+ capture_operation_id: screenshot.__capture_telemetry?.operation_id || null,
2516
+ connection_epoch: screenshot.__capture_telemetry?.connection_epoch ?? null,
2517
+ scroll_attempt: index,
2518
+ scroll: currentScrollMetadata,
2519
+ node_rect: box.rect,
2520
+ iframe_owner_rect: geometry.iframe_owner_rect || null,
2521
+ target_geometry: {
2522
+ node_rect: box.rect,
2523
+ iframe_owner_rect: geometry.iframe_owner_rect || null,
2524
+ coordinate_space: resolvedCrop?.coordinate_space || "viewport"
2525
+ },
2526
+ requested_crop: effectiveClip,
2527
+ visible_crop: visibleCrop,
2528
+ pixel_crop: processed.pixel_crop || null,
2529
+ crop_geometry: {
2530
+ requested_crop: effectiveClip,
2531
+ visible_crop: visibleCrop,
2532
+ pixel_crop: processed.pixel_crop || null
2533
+ },
2534
+ image_dimensions: {
2535
+ viewport_width: processed.image_width || null,
2536
+ viewport_height: processed.image_height || null,
2537
+ scale_x: processed.scale_x || null,
2538
+ scale_y: processed.scale_y || null
2539
+ },
2540
+ coordinate_space: resolvedCrop?.coordinate_space || "viewport",
2541
+ overlap_ratio_target: scrollDeltaJitter.min_overlap_ratio,
2542
+ sha256: hash,
2543
+ duplicate_of_previous: Boolean(duplicateOfPrevious),
2544
+ new_unique_screenshot: isNewUniqueScreenshot,
2545
+ visual_duplicate_count: consecutiveDuplicates,
2546
+ no_progress: noProgress,
2547
+ consecutive_no_progress: consecutiveNoProgress,
2548
+ overlap_with_previous: overlapWithPrevious,
2549
+ viewport_before: geometry.viewport_state,
2550
+ viewport_after: afterViewportState,
2551
+ viewport_comparison: viewportComparison,
2552
+ capture_operation: screenshot.__capture_telemetry || null,
2553
+ timing: {
2554
+ geometry_elapsed_ms: geometryElapsedMs,
2555
+ queue_elapsed_ms: screenshot.__capture_telemetry?.queue_elapsed_ms ?? null,
2556
+ transport_elapsed_ms: screenshot.__capture_telemetry?.transport_elapsed_ms ?? null,
2557
+ local_processing_elapsed_ms: localProcessingElapsedMs,
2558
+ total_elapsed_ms: Date.now() - captureStarted
2559
+ },
2560
+ anchor_evidence: anchorEvidence,
2561
+ anchor_plan: anchorPlanSummary,
2562
+ stop_boundary: visibleStopBoundary || null
2563
+ };
2564
+ let outputPath = null;
2565
+ if (duplicateOfPrevious && skipDuplicateScreenshots) {
2566
+ droppedDuplicateCount += 1;
2567
+ } else if (
2568
+ isNewUniqueScreenshot
2569
+ && new Set(screenshots.map((item) => item?.sha256).filter(Boolean)).size >= coverageIterationLimit
2570
+ ) {
2571
+ coverageLimitReached = true;
2572
+ } else {
2573
+ outputPath = filePath ? filePathForSequence(filePath, screenshots.length, format) : null;
2574
+ if (outputPath) {
2575
+ fs.writeFileSync(outputPath, buffer);
2576
+ }
2577
+
2578
+ screenshots.push({
2579
+ index: screenshots.length,
2580
+ capture_index: index,
2581
+ source: "image",
2582
+ captured_at: nowIso(),
2583
+ node_id: nodeId,
2584
+ format,
2585
+ mime_type: `image/${format === "jpeg" ? "jpeg" : "png"}`,
2586
+ byte_length: buffer.length,
2587
+ original_byte_length: processed.original_byte_length || processed.viewport_byte_length || viewportBuffer.length,
2588
+ viewport_byte_length: viewportBuffer.length,
2589
+ optimized: true,
2590
+ optimization_error: processed.optimization_error || null,
2591
+ elapsed_ms: Date.now() - captureStarted,
2592
+ file_path: outputPath,
2593
+ sha256: hash,
2594
+ duplicate_of_previous: Boolean(duplicateOfPrevious),
2595
+ clip: effectiveClip,
2596
+ crop: resolvedCrop ? {
2597
+ ...resolvedCrop,
2598
+ pixel_crop: processed.pixel_crop || null,
2599
+ image_width: processed.image_width || null,
2600
+ image_height: processed.image_height || null,
2601
+ scale_x: processed.scale_x || null,
2602
+ scale_y: processed.scale_y || null
2603
+ } : null,
2604
+ capture_viewport: effectiveCaptureViewport,
2605
+ browser_clip_used: false,
2606
+ capture_beyond_viewport: false,
2607
+ node_rect: box.rect,
2608
+ iframe_owner_rect: geometry.iframe_owner_rect || null,
2609
+ scroll: currentScrollMetadata,
2610
+ stop_boundary: visibleStopBoundary || null,
2611
+ viewport_before: geometry.viewport_state,
2612
+ viewport_after: afterViewportState,
2613
+ viewport_comparison: viewportComparison,
2614
+ capture_operation: screenshot.__capture_telemetry || null,
2615
+ timing: {
2616
+ geometry_elapsed_ms: geometryElapsedMs,
2617
+ queue_elapsed_ms: screenshot.__capture_telemetry?.queue_elapsed_ms ?? null,
2618
+ transport_elapsed_ms: screenshot.__capture_telemetry?.transport_elapsed_ms ?? null,
2619
+ local_processing_elapsed_ms: localProcessingElapsedMs,
2620
+ total_elapsed_ms: Date.now() - captureStarted
2621
+ },
2622
+ metadata
2623
+ });
2624
+ }
2625
+ coverageEntry.accepted_for_coverage = !coverageLimitReached;
2626
+ coverageLedger.push(coverageEntry);
2627
+
2628
+ if (coverageLimitReached) {
2629
+ terminalReason = "coverage_limit_reached_without_terminal_proof";
2630
+ break;
2631
+ }
2632
+
2633
+ if (visibleStopBoundary?.action === "capture_then_stop") {
2634
+ stopBoundaryResult = visibleStopBoundary;
2635
+ coverageComplete = true;
2636
+ terminalReason = visibleStopBoundary.reason || "stop_boundary_after_capture";
2637
+ break;
2638
+ }
2639
+
2640
+ previousHash = hash;
2641
+ if (
2642
+ consecutiveNoProgress >= requiredNoProgressCount
2643
+ ) {
2644
+ coverageComplete = true;
2645
+ terminalReason = anchorEvidence.available
2646
+ ? "consecutive_image_and_anchor_no_progress"
2647
+ : "consecutive_image_no_progress_anchor_unavailable";
2648
+ break;
2649
+ }
2650
+
2651
+ if (index < captureIterationCeiling - 1) {
2652
+ assertCaptureTotalBudget(sequenceStarted, totalTimeoutMs, `scroll_after_page_${index + 1}`);
2653
+ if (normalizedScrollMethod === "dom-anchor") {
2654
+ const nextAnchor = anchorPlan?.anchors?.[index + 1] || null;
2655
+ if (!nextAnchor?.node_id) break;
2656
+ await scrollDomAnchorIntoView(client, nextAnchor.node_id, {
2657
+ label: `scroll_dom_anchor_${index + 1}`,
2658
+ timeoutMs: Math.min(Math.max(3000, Number(stepTimeoutMs) || 45000), 10000)
2659
+ });
2660
+ currentScrollMetadata = {
2661
+ before_capture: `dom_anchor_${index + 1}`,
2662
+ method: "DOM.scrollIntoViewIfNeeded",
2663
+ requested_scroll_method: normalizedScrollMethod,
2664
+ anchor_node_id: nextAnchor.node_id,
2665
+ anchor_y: nextAnchor.y,
2666
+ anchor_height: nextAnchor.height,
2667
+ anchor_plan: anchorPlanHistory[anchorPlanHistory.length - 1] || null
2668
+ };
2669
+ } else {
2670
+ const visibleRect = resolvedCrop?.visible_rect || geometry.resolved.visible_rect;
2671
+ const x = visibleRect.x + visibleRect.width / 2;
2672
+ const y = visibleRect.y + visibleRect.height / 2;
2673
+ const scrollDelta = resolveCoverageSafeScrollDelta({
2674
+ baseDelta: wheelDeltaY,
2675
+ clipHeight: visibleRect.height,
2676
+ jitter: scrollDeltaJitter
2677
+ });
2678
+ await withCaptureTimeout(client.Input.dispatchMouseEvent({ type: "mouseMoved", x, y, button: "none" }), {
2679
+ label: `scroll_mouse_move_${index + 1}`,
2680
+ timeoutMs: Math.min(Math.max(3000, Number(stepTimeoutMs) || 45000), 10000)
2681
+ });
2682
+ await withCaptureTimeout(client.Input.dispatchMouseEvent({
2683
+ type: "mouseWheel",
2684
+ x,
2685
+ y,
2686
+ deltaX: 0,
2687
+ deltaY: scrollDelta.deltaY
2688
+ }), {
2689
+ label: `scroll_wheel_${index + 1}`,
2690
+ timeoutMs: Math.min(Math.max(3000, Number(stepTimeoutMs) || 45000), 10000)
2691
+ });
2692
+ currentScrollMetadata = {
2693
+ before_capture: `wheel_down_${index + 1}`,
2694
+ method: "Input.dispatchMouseEvent",
2695
+ requested_scroll_method: normalizedScrollMethod,
2696
+ wheel_delta_y: scrollDelta.deltaY,
2697
+ wheel_delta_base_y: scrollDelta.base_delta_y,
2698
+ wheel_delta_jitter: scrollDelta.jittered ? scrollDelta : null,
2699
+ overlap_ratio_target: scrollDelta.min_overlap_ratio ?? scrollDeltaJitter.min_overlap_ratio,
2700
+ anchor_plan: anchorPlanHistory[anchorPlanHistory.length - 1] || null
2701
+ };
2702
+ }
2703
+ if (settleMs > 0) await sleep(settleMs);
2704
+ }
2705
+ }
2706
+ } catch (error) {
2707
+ const captureError = error instanceof Error ? error : new Error(String(error));
2708
+ captureError.capture_checkpoint = createCoverageCheckpoint({
2709
+ screenshots,
2710
+ coverageLedger,
2711
+ anchorPlanHistory,
2712
+ stopBoundaryChecks,
2713
+ viewportEvents,
2714
+ previousHash,
2715
+ droppedDuplicateCount,
2716
+ currentScrollMetadata,
2717
+ maxUniqueScreenshots: coverageIterationLimit,
2718
+ format,
2719
+ filePath,
2720
+ sourceCheckpointId: resumeState.checkpoint_id,
2721
+ resumeContinuity: captureError.resume_continuity || resumeContinuity
2722
+ });
2723
+ throw captureError;
2724
+ }
2725
+
2726
+ if (!coverageComplete && !requireTerminalProof && !coverageLimitReached) {
2727
+ coverageComplete = true;
2728
+ terminalReason = "terminal_proof_not_required";
2729
+ }
2730
+ if (!terminalReason) {
2731
+ terminalReason = coverageLimitReached
2732
+ ? "coverage_limit_reached_without_terminal_proof"
2733
+ : "capture_iteration_limit_without_terminal_proof";
2734
+ }
2735
+
2736
+ let llmComposition;
2737
+ try {
2738
+ llmComposition = coverageComplete && composeForLlm
2739
+ ? await withCaptureTimeout(composeScreenshotsForLlm(screenshots, {
2740
+ basePath: filePath,
2741
+ pagesPerImage: llmPagesPerImage,
2742
+ resizeMaxWidth: llmResizeMaxWidth,
2743
+ quality: llmQuality
2744
+ }), {
2745
+ label: "compose_llm_screenshots",
2746
+ timeoutMs: stepTimeoutMs
2747
+ })
2748
+ : {
2749
+ llm_file_paths: coverageComplete
2750
+ ? screenshots.map((item) => item.file_path).filter(Boolean)
2751
+ : [],
2752
+ llm_screenshots: [],
2753
+ llm_total_byte_length: 0,
2754
+ llm_original_total_byte_length: 0,
2755
+ llm_composition_error: null
2756
+ };
2757
+ } catch (error) {
2758
+ const captureError = error instanceof Error ? error : new Error(String(error));
2759
+ captureError.capture_checkpoint = createCoverageCheckpoint({
2760
+ screenshots,
2761
+ coverageLedger,
2762
+ anchorPlanHistory,
2763
+ stopBoundaryChecks,
2764
+ viewportEvents,
2765
+ previousHash,
2766
+ droppedDuplicateCount,
2767
+ currentScrollMetadata,
2768
+ maxUniqueScreenshots: coverageIterationLimit,
2769
+ format,
2770
+ filePath,
2771
+ sourceCheckpointId: resumeState.checkpoint_id,
2772
+ resumeContinuity
2773
+ });
2774
+ throw captureError;
2775
+ }
2776
+
2777
+ return {
2778
+ schema_version: 1,
2779
+ ok: coverageComplete,
2780
+ source: "image-scroll-sequence",
2781
+ captured_at: nowIso(),
2782
+ node_id: nodeId,
2783
+ resumed_from_checkpoint: resumeState.used,
2784
+ resume_checkpoint_id: resumeState.checkpoint_id,
2785
+ resume_confirmed_screenshot_count: resumeState.screenshots.length,
2786
+ resume_confirmed_ledger_count: resumeState.coverage_ledger.length,
2787
+ resume_continuity: sanitizeCoverageCheckpointValue(resumeContinuity),
2788
+ elapsed_ms: Date.now() - sequenceStarted,
2789
+ capture_count: captureCount,
2790
+ screenshot_count: screenshots.length,
2791
+ unique_screenshot_count: new Set(screenshots.map((item) => item.sha256)).size,
2792
+ duplicate_screenshot_count: captureCount - new Set(screenshots.map((item) => item.sha256)).size,
2793
+ dropped_duplicate_count: droppedDuplicateCount,
2794
+ coverage_complete: coverageComplete,
2795
+ coverage_terminal_reason: terminalReason,
2796
+ coverage_limit_reached: coverageLimitReached,
2797
+ coverage_required_no_progress_count: requiredNoProgressCount,
2798
+ coverage_iteration_limit: coverageIterationLimit,
2799
+ capture_iteration_limit: captureIterationCeiling,
2800
+ base_capture_iteration_limit: maxCaptureIterations,
2801
+ error_code: coverageComplete ? null : "IMAGE_CAPTURE_COVERAGE_INCOMPLETE",
2802
+ error: coverageComplete
2803
+ ? null
2804
+ : "CV image capture reached its safety limit without terminal coverage proof",
2805
+ total_byte_length: screenshots.reduce((sum, item) => sum + (Number(item.byte_length) || 0), 0),
2806
+ original_total_byte_length: screenshots.reduce((sum, item) => sum + (Number(item.original_byte_length) || 0), 0),
2807
+ llm_file_paths: llmComposition.llm_file_paths,
2808
+ llm_screenshot_count: llmComposition.llm_file_paths.length,
2809
+ llm_total_byte_length: llmComposition.llm_total_byte_length,
2810
+ llm_original_total_byte_length: llmComposition.llm_original_total_byte_length,
2811
+ llm_composition_error: llmComposition.llm_composition_error,
2812
+ llm_screenshots: llmComposition.llm_screenshots,
2813
+ optimization: {
2814
+ enabled: Boolean(optimize),
2815
+ resize_max_width: Math.max(0, Number(resizeMaxWidth) || 0),
2816
+ capture_viewport: false,
2817
+ requested_capture_viewport: Boolean(requestedCaptureViewport),
2818
+ format,
2819
+ quality: quality ?? null,
2820
+ llm_compose_enabled: Boolean(composeForLlm),
2821
+ llm_pages_per_image: Math.max(1, Math.min(5, Number(llmPagesPerImage) || 3)),
2822
+ llm_resize_max_width: Math.max(0, Number(llmResizeMaxWidth) || 0),
2823
+ llm_quality: llmQuality ?? null,
2824
+ step_timeout_ms: Math.max(0, Number(stepTimeoutMs) || 0),
2825
+ total_timeout_ms: Math.max(0, Number(totalTimeoutMs) || 0),
2826
+ scroll_method: normalizedScrollMethod,
2827
+ requested_max_screenshots: maxScreenshotCount,
2828
+ effective_max_screenshots: coverageIterationLimit,
2829
+ capture_iteration_limit: captureIterationCeiling,
2830
+ base_capture_iteration_limit: maxCaptureIterations,
2831
+ require_terminal_proof: Boolean(requireTerminalProof),
2832
+ required_no_progress_count: requiredNoProgressCount,
2833
+ resumed_from_checkpoint: resumeState.used,
2834
+ resume_checkpoint_id: resumeState.checkpoint_id,
2835
+ browser_clip_used: false,
2836
+ capture_beyond_viewport: false,
2837
+ requested_capture_beyond_viewport: Boolean(requestedCaptureBeyondViewport),
2838
+ requested_from_surface: Boolean(requestedFromSurface),
2839
+ scroll_anchor_selector: scrollAnchorSelector,
2840
+ scroll_anchor_max_probe_nodes: Math.max(1, Number(scrollAnchorMaxProbeNodes) || 260),
2841
+ scroll_anchor_min_gap: Math.max(0, Number(scrollAnchorMinGap) || 0),
2842
+ scroll_delta_jitter: {
2843
+ enabled: scrollDeltaJitter.enabled,
2844
+ min_ratio: scrollDeltaJitter.min_ratio,
2845
+ max_ratio: scrollDeltaJitter.max_ratio,
2846
+ min_overlap_ratio: scrollDeltaJitter.min_overlap_ratio,
2847
+ preserve_coverage: scrollDeltaJitter.preserve_coverage
2848
+ }
2849
+ },
2850
+ scroll_anchor_plan: anchorPlan,
2851
+ scroll_anchor_plan_history: anchorPlanHistory,
2852
+ stop_boundary_plan: stopBoundaryPlan,
2853
+ stop_boundary_checks: stopBoundaryChecks,
2854
+ stop_boundary_result: stopBoundaryResult,
2855
+ coverage_ledger: coverageLedger,
2856
+ coverage_checkpoint: coverageComplete
2857
+ ? null
2858
+ : createCoverageCheckpoint({
2859
+ screenshots,
2860
+ coverageLedger,
2861
+ anchorPlanHistory,
2862
+ stopBoundaryChecks,
2863
+ viewportEvents,
2864
+ previousHash,
2865
+ droppedDuplicateCount,
2866
+ currentScrollMetadata,
2867
+ maxUniqueScreenshots: coverageIterationLimit,
2868
+ format,
2869
+ filePath,
2870
+ sourceCheckpointId: resumeState.checkpoint_id,
2871
+ resumeContinuity
2872
+ }),
2873
+ viewport_baseline: viewportBaseline,
2874
+ viewport_events: viewportEvents,
2875
+ file_paths: screenshots.map((item) => item.file_path).filter(Boolean),
2876
+ screenshots,
2877
+ metadata
2878
+ };
2879
+ }
2880
+
2881
+ export async function captureCandidateEvidence(client, {
2882
+ nodeId,
2883
+ domain = "unknown",
2884
+ source = "dom",
2885
+ screenshotPath,
2886
+ includeHtml = true,
2887
+ includeScreenshot = false,
2888
+ screenshotMode = "scroll",
2889
+ screenshotOptions = {},
2890
+ metadata = {}
2891
+ } = {}) {
2892
+ if (!nodeId) throw new Error("captureCandidateEvidence requires nodeId");
2893
+ const evidence = {
2894
+ schema_version: 1,
2895
+ domain: normalizeText(domain) || "unknown",
2896
+ source,
2897
+ captured_at: nowIso(),
2898
+ node_id: nodeId,
2899
+ html: null,
2900
+ image: null,
2901
+ metadata
2902
+ };
2903
+ if (includeHtml) {
2904
+ evidence.html = await captureNodeHtml(client, nodeId, {
2905
+ domain,
2906
+ source: "dom",
2907
+ metadata
2908
+ });
2909
+ }
2910
+ if (includeScreenshot) {
2911
+ evidence.image = screenshotMode === "single"
2912
+ ? await captureNodeScreenshot(client, nodeId, {
2913
+ ...screenshotOptions,
2914
+ filePath: screenshotPath,
2915
+ metadata: {
2916
+ ...metadata,
2917
+ capture_mode: "single_visible_clip"
2918
+ }
2919
+ })
2920
+ : await captureScrolledNodeScreenshots(client, nodeId, {
2921
+ ...screenshotOptions,
2922
+ filePath: screenshotPath,
2923
+ metadata: {
2924
+ ...metadata,
2925
+ capture_mode: "scroll_sequence"
2926
+ }
2927
+ });
2928
+ }
2929
+ return evidence;
2930
+ }