@reconcrap/boss-recommend-mcp 2.1.23 → 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.
- package/README.md +5 -0
- package/bin/boss-recommend-mcp.js +4 -4
- package/package.json +6 -1
- package/scripts/install-macos.sh +280 -280
- package/scripts/postinstall.cjs +44 -44
- package/skills/boss-chat/README.md +43 -42
- package/skills/boss-chat/SKILL.md +107 -106
- package/skills/boss-recommend-pipeline/README.md +13 -13
- package/skills/boss-recommend-pipeline/SKILL.md +47 -47
- package/skills/boss-recruit-pipeline/README.md +19 -19
- package/skills/boss-recruit-pipeline/SKILL.md +89 -89
- package/src/chat-mcp.js +301 -127
- package/src/core/boss-cards/index.js +199 -199
- package/src/core/browser/index.js +286 -136
- package/src/core/capture/index.js +2930 -1201
- package/src/core/cv-acquisition/index.js +512 -238
- package/src/core/cv-capture-target/index.js +513 -299
- package/src/core/greet-quota/index.js +71 -71
- package/src/core/infinite-list/index.js +31 -31
- package/src/core/reporting/legacy-csv.js +12 -12
- package/src/core/run/detached-launcher.js +305 -0
- package/src/core/run/index.js +105 -52
- package/src/core/run/timing.js +33 -33
- package/src/core/run/windows-detached-worker.ps1 +106 -0
- package/src/core/screening/index.js +2135 -2135
- package/src/core/self-heal/index.js +989 -973
- package/src/core/self-heal/viewport.js +1505 -564
- package/src/detached-worker.js +99 -99
- package/src/domains/chat/action-journal.js +443 -0
- package/src/domains/chat/cards.js +137 -137
- package/src/domains/chat/constants.js +9 -9
- package/src/domains/chat/detail.js +1684 -401
- package/src/domains/chat/index.js +8 -7
- package/src/domains/chat/jobs.js +620 -620
- package/src/domains/chat/page-guard.js +157 -122
- package/src/domains/chat/roots.js +56 -56
- package/src/domains/chat/run-service.js +1801 -760
- package/src/domains/common/account-rights-panel.js +314 -314
- package/src/domains/common/recovery-settle.js +159 -159
- package/src/domains/recommend/actions.js +515 -472
- package/src/domains/recommend/cards.js +243 -243
- package/src/domains/recommend/colleague-contact.js +333 -333
- package/src/domains/recommend/constants.js +92 -92
- package/src/domains/recommend/detail.js +12 -12
- package/src/domains/recommend/filters.js +611 -611
- package/src/domains/recommend/index.js +9 -9
- package/src/domains/recommend/jobs.js +542 -542
- package/src/domains/recommend/location.js +736 -736
- package/src/domains/recommend/refresh.js +422 -422
- package/src/domains/recommend/roots.js +80 -80
- package/src/domains/recommend/run-service.js +1791 -1205
- package/src/domains/recommend/scopes.js +246 -246
- package/src/domains/recruit/actions.js +277 -277
- package/src/domains/recruit/cards.js +74 -74
- package/src/domains/recruit/constants.js +236 -236
- package/src/domains/recruit/detail.js +588 -588
- package/src/domains/recruit/index.js +9 -9
- package/src/domains/recruit/instruction-parser.js +866 -866
- package/src/domains/recruit/refresh.js +45 -45
- package/src/domains/recruit/roots.js +68 -68
- package/src/domains/recruit/run-service.js +1817 -1620
- package/src/domains/recruit/search.js +3229 -3229
- package/src/index.js +16 -1
- package/src/parser.js +1296 -1296
- package/src/recommend-mcp.js +551 -362
- package/src/recommend-scheduler.js +75 -75
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
const
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
if (
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
return
|
|
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
|
+
}
|