@d-zero/beholder 3.1.6 → 4.1.0

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/src/scraper.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  ChangePhaseEvent,
3
+ ConsoleLogEntry,
3
4
  ResourceEntry,
4
5
  ScraperEventTypes,
5
6
  ScraperOptions,
@@ -10,10 +11,11 @@ import type {
10
11
  PageData,
11
12
  ParseURLOptions,
12
13
  Resource,
14
+ ScrollHeightData,
13
15
  SkippedPageData,
14
16
  } from './types.js';
15
17
  import type { PageScanPhase } from '@d-zero/puppeteer-page-scan';
16
- import type { Dialog, HTTPRequest, HTTPResponse, Page } from 'puppeteer';
18
+ import type { ConsoleMessage, Dialog, HTTPRequest, HTTPResponse, Page } from 'puppeteer';
17
19
 
18
20
  import { beforePageScan, devicePresets } from '@d-zero/puppeteer-page-scan';
19
21
  import { detectCDN } from '@d-zero/shared/detect-cdn';
@@ -28,11 +30,16 @@ import {
28
30
  getImageList,
29
31
  getMeta,
30
32
  } from './dom-evaluation.js';
33
+ import { getMainContents } from './get-main-contents.js';
31
34
  import { isError } from './is-error.js';
35
+ import { isHtmlContentType } from './is-html-content-type.js';
32
36
  import { keywordCheck } from './keyword-check.js';
37
+ import { measureScrollHeight } from './measure-scroll-height.js';
33
38
  import { emptyMeta } from './meta/classify.js';
34
39
  import { findDisconnectionFailures } from './network-disconnection.js';
35
40
  import { parseUrl } from './parse-url.js';
41
+ import { toConsoleLogEntry } from './to-console-log-entry.js';
42
+ import { toPageErrorEntry } from './to-page-error-entry.js';
36
43
 
37
44
  const pid = `${process.pid}`;
38
45
  const log = scraperLog.extend(pid);
@@ -82,7 +89,10 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
82
89
  * - `type: "error"` with `error` details when scraping fails
83
90
  *
84
91
  * Sub-resources are collected via the `resourceResponse` event and
85
- * included in the returned `ScrapeResult.resources`.
92
+ * included in the returned `ScrapeResult.resources`. Console messages and
93
+ * uncaught page errors (internal pages only) are collected via
94
+ * Puppeteer's `console`/`pageerror` page events and included in
95
+ * `ScrapeResult.consoleLogs`.
86
96
  * @param page - The Puppeteer page instance to use for navigation and DOM evaluation.
87
97
  * @param url - The extended URL to scrape.
88
98
  * @param options - Optional scraper configuration overriding defaults.
@@ -102,6 +112,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
102
112
  const metadataOnly = options?.metadataOnly ?? false;
103
113
  const imageLoadTimeout = options?.imageLoadTimeout ?? 5000;
104
114
  const resources: ResourceEntry[] = [];
115
+ const consoleLogs: ConsoleLogEntry[] = [];
105
116
  const failedRequests: Array<{ url: string; errorText: string }> = [];
106
117
 
107
118
  void this.emit('changePhase', {
@@ -124,6 +135,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
124
135
  return {
125
136
  type: 'skipped',
126
137
  resources,
138
+ consoleLogs,
127
139
  ignored: {
128
140
  url,
129
141
  matchedText: url.pathname || '',
@@ -148,6 +160,8 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
148
160
  imageList: [],
149
161
  anchorList: [],
150
162
  html: '',
163
+ mainContents: null,
164
+ scrollHeight: null,
151
165
  isSkipped: false,
152
166
  };
153
167
 
@@ -158,7 +172,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
158
172
  isExternal,
159
173
  message: '',
160
174
  });
161
- return { type: 'success', pageData: result, resources };
175
+ return { type: 'success', pageData: result, resources, consoleLogs };
162
176
  }
163
177
 
164
178
  let headResult: PageData | SkippedPageData | null = options?.headCheckResult ?? null;
@@ -176,12 +190,15 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
176
190
  pageData: {
177
191
  ...headResult,
178
192
  isTarget: false,
193
+ mainContents: headResult.mainContents ?? null,
194
+ scrollHeight: headResult.scrollHeight ?? null,
179
195
  },
180
196
  resources,
197
+ consoleLogs,
181
198
  };
182
199
  }
183
200
 
184
- if (headResult === null || headResult.contentType === 'text/html') {
201
+ if (headResult === null || isHtmlContentType(headResult.contentType)) {
185
202
  const fetchResult = await this.#fetchData(
186
203
  page,
187
204
  url,
@@ -189,6 +206,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
189
206
  captureImages,
190
207
  imageLoadTimeout,
191
208
  resources,
209
+ consoleLogs,
192
210
  failedRequests,
193
211
  options,
194
212
  ).catch((error) => {
@@ -204,6 +222,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
204
222
  return {
205
223
  type: 'error',
206
224
  resources,
225
+ consoleLogs,
207
226
  failedRequests: failedRequests.length > 0 ? failedRequests : undefined,
208
227
  error: {
209
228
  name: fetchResult.name,
@@ -238,6 +257,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
238
257
  return {
239
258
  type: 'skipped',
240
259
  resources,
260
+ consoleLogs,
241
261
  ignored: {
242
262
  url,
243
263
  matchedText: url.pathname || '',
@@ -255,6 +275,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
255
275
  return {
256
276
  type: 'skipped',
257
277
  resources,
278
+ consoleLogs,
258
279
  ignored: {
259
280
  url,
260
281
  matchedText: headResult.matched.text,
@@ -276,6 +297,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
276
297
  type: 'success',
277
298
  pageData: headResult,
278
299
  resources,
300
+ consoleLogs,
279
301
  failedRequests: failedRequests.length > 0 ? failedRequests : undefined,
280
302
  };
281
303
  }
@@ -334,7 +356,8 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
334
356
  * Frame" or "Session closed".
335
357
  *
336
358
  * Flow:
337
- * 1. Register request/response/requestfailed listeners to capture sub-resources (internal pages only)
359
+ * 1. Register request/response/requestfailed/console/pageerror listeners to
360
+ * capture sub-resources and console output (internal pages only)
338
361
  * 2. Navigate to URL via `page.goto()` and track redirect chain
339
362
  * 3. Wait for DOM content and network idle
340
363
  * 4. Check for network disconnection errors and throw to trigger retry
@@ -346,6 +369,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
346
369
  * @param captureImages - Whether to run the image extraction pipeline
347
370
  * @param imageLoadTimeout - Timeout (ms) for waiting lazy-loaded images to complete
348
371
  * @param resources - Mutable array to collect captured sub-resources into
372
+ * @param consoleLogs - Mutable array to collect captured console messages / page errors into
349
373
  * @param failedRequests - Mutable array to collect failed sub-resource requests into
350
374
  * @param options - Additional scraper options (e.g. `disableQueries`, `navigationTimeout`)
351
375
  * @returns Full page data or skipped page data if an exclusion rule matched
@@ -378,6 +402,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
378
402
  captureImages: boolean,
379
403
  imageLoadTimeout: number,
380
404
  resources: ResourceEntry[],
405
+ consoleLogs: ConsoleLogEntry[],
381
406
  failedRequests: Array<{ url: string; errorText: string }>,
382
407
  options?: Partial<ScraperOptions>,
383
408
  ): Promise<PageData | SkippedPageData> {
@@ -388,12 +413,16 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
388
413
  const domEvaluationTimeout =
389
414
  options?.domEvaluationTimeout ?? DEFAULT_DOM_EVALUATION_TIMEOUT;
390
415
  const networkLogs: Record<string, NetworkLog> = {};
416
+ // Tracks in-flight `toConsoleLogEntry()` resolutions (async `jsonValue()`
417
+ // extraction) so callers can await them before reading `consoleLogs`.
418
+ const pendingConsoleWork: Promise<void>[] = [];
391
419
 
392
420
  // Clear stale state from previous retries (@retryable may re-invoke this method
393
421
  // with the same page and mutable arrays, so we must reset to avoid accumulation)
394
422
  this.#cleanupPageListeners();
395
423
  failedRequests.length = 0;
396
424
  resources.length = 0;
425
+ consoleLogs.length = 0;
397
426
 
398
427
  // Define named listeners so they can be individually removed on retry/cleanup
399
428
  const onDialog = async (dialog: Dialog) => {
@@ -410,6 +439,8 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
410
439
  let onRequest: ((req: HTTPRequest) => void) | null = null;
411
440
  let onResponse: ((res: HTTPResponse) => void) | null = null;
412
441
  let onRequestFailed: ((req: HTTPRequest) => void) | null = null;
442
+ let onConsole: ((msg: ConsoleMessage) => void) | null = null;
443
+ let onPageError: ((error: unknown) => void) | null = null;
413
444
 
414
445
  if (!isExternal) {
415
446
  onRequest = (request: HTTPRequest) => {
@@ -495,9 +526,30 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
495
526
  failedRequests.push({ url: request.url(), errorText });
496
527
  };
497
528
 
529
+ onConsole = (msg: ConsoleMessage) => {
530
+ pendingConsoleWork.push(
531
+ toConsoleLogEntry(msg, url.withoutHash).then(
532
+ (entry) => {
533
+ consoleLogs.push(entry);
534
+ },
535
+ (error) => {
536
+ // A single malformed console message must not fail the whole
537
+ // scrape (e.g. mid-navigation execution-context teardown).
538
+ log('Error(CONSOLE_LOG): %s', error);
539
+ },
540
+ ),
541
+ );
542
+ };
543
+
544
+ onPageError = (error: unknown) => {
545
+ consoleLogs.push(toPageErrorEntry(error, url.withoutHash));
546
+ };
547
+
498
548
  page.on('request', onRequest);
499
549
  page.on('response', onResponse);
500
550
  page.on('requestfailed', onRequestFailed);
551
+ page.on('console', onConsole);
552
+ page.on('pageerror', onPageError);
501
553
  }
502
554
 
503
555
  // Store cleanup function for retry/post-fetch removal
@@ -506,108 +558,217 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
506
558
  if (onRequest) page.off('request', onRequest);
507
559
  if (onResponse) page.off('response', onResponse);
508
560
  if (onRequestFailed) page.off('requestfailed', onRequestFailed);
561
+ if (onConsole) page.off('console', onConsole);
562
+ if (onPageError) page.off('pageerror', onPageError);
509
563
  };
510
564
 
511
565
  const navigationTimeout = options?.navigationTimeout ?? 60_000;
512
566
 
513
- void this.emit('changePhase', {
514
- pid: process.pid,
515
- name: 'openPage',
516
- url,
517
- isExternal,
518
- message: `%countdown(${navigationTimeout},openPage_${url.withoutHash},s)%s`,
519
- });
520
-
521
- if (url.username && url.password) {
522
- await page.setExtraHTTPHeaders({
523
- Authorization: `Basic ${Buffer.from(`${url.username}:${url.password}`).toString('base64')}`,
567
+ // The whole navigation/extraction sequence below is wrapped in `finally`
568
+ // so that `pendingConsoleWork` is always awaited exactly once — on every
569
+ // return AND on every throw (e.g. `Page.goto returned null`, network
570
+ // disconnection). A per-return-statement `await` would miss thrown exits
571
+ // and let a still-resolving `toConsoleLogEntry()` push into `consoleLogs`
572
+ // after a `@retryable` retry has already cleared it for a new attempt.
573
+ try {
574
+ void this.emit('changePhase', {
575
+ pid: process.pid,
576
+ name: 'openPage',
577
+ url,
578
+ isExternal,
579
+ message: `%countdown(${navigationTimeout},openPage_${url.withoutHash},s)%s`,
524
580
  });
525
- }
526
581
 
527
- const res = await page.goto(url.withoutHashAndAuth, { timeout: navigationTimeout });
582
+ if (url.username && url.password) {
583
+ await page.setExtraHTTPHeaders({
584
+ Authorization: `Basic ${Buffer.from(`${url.username}:${url.password}`).toString('base64')}`,
585
+ });
586
+ }
528
587
 
529
- if (!res) {
530
- throw new Error('The method Page.goto returned null');
531
- }
588
+ const res = await page.goto(url.withoutHashAndAuth, { timeout: navigationTimeout });
532
589
 
533
- const destUrl = parseUrl(page.url(), parseOpts)!;
534
- const redirectPaths = new Set<string>();
590
+ if (!res) {
591
+ throw new Error('The method Page.goto returned null');
592
+ }
535
593
 
536
- if (url.withoutHash !== destUrl.withoutHash) {
537
- const redirectChain = res
538
- .request()
539
- .redirectChain()
540
- .map((req) => req.url());
541
- for (const redirectPath of redirectChain) {
542
- redirectPaths.add(redirectPath);
594
+ const destUrl = parseUrl(page.url(), parseOpts)!;
595
+ const redirectPaths = new Set<string>();
596
+
597
+ if (url.withoutHash !== destUrl.withoutHash) {
598
+ const redirectChain = res
599
+ .request()
600
+ .redirectChain()
601
+ .map((req) => req.url());
602
+ for (const redirectPath of redirectChain) {
603
+ redirectPaths.add(redirectPath);
604
+ }
605
+ redirectPaths.add(destUrl.withoutHash);
543
606
  }
544
- redirectPaths.add(destUrl.withoutHash);
545
- }
546
607
 
547
- if (destUrl.hostname !== url.hostname) {
548
- isExternal = true;
549
- }
608
+ if (destUrl.hostname !== url.hostname) {
609
+ isExternal = true;
610
+ }
550
611
 
551
- const status = res.status();
552
- const statusText = res.statusText();
553
- const responseHeaders = res.headers();
554
- const contentType = responseHeaders['content-type']?.split(';')[0] || null;
555
- const _contentLength = Number.parseInt(responseHeaders['content-length'] ?? '');
556
- const contentLength = Number.isFinite(_contentLength) ? _contentLength : null;
612
+ const status = res.status();
613
+ const statusText = res.statusText();
614
+ const responseHeaders = res.headers();
615
+ const contentType = responseHeaders['content-type']?.split(';')[0] || null;
616
+ const _contentLength = Number.parseInt(responseHeaders['content-length'] ?? '');
617
+ const contentLength = Number.isFinite(_contentLength) ? _contentLength : null;
557
618
 
558
- if (contentType !== 'text/html') {
559
- return {
619
+ if (!isHtmlContentType(contentType)) {
620
+ return {
621
+ url,
622
+ isTarget: false,
623
+ isExternal,
624
+ redirectPaths: [...redirectPaths],
625
+ status,
626
+ statusText,
627
+ contentType,
628
+ contentLength,
629
+ responseHeaders,
630
+ meta: emptyMeta(),
631
+ imageList: [],
632
+ anchorList: [],
633
+ html: '',
634
+ mainContents: null,
635
+ scrollHeight: null,
636
+ isSkipped: false,
637
+ };
638
+ }
639
+
640
+ void this.emit('changePhase', {
641
+ pid: process.pid,
642
+ name: 'loadDOMContent',
560
643
  url,
561
- isTarget: false,
562
644
  isExternal,
563
- redirectPaths: [...redirectPaths],
564
- status,
565
- statusText,
566
- contentType,
567
- contentLength,
568
- responseHeaders,
569
- meta: emptyMeta(),
570
- imageList: [],
571
- anchorList: [],
572
- html: '',
573
- isSkipped: false,
574
- };
575
- }
645
+ message: '',
646
+ });
576
647
 
577
- void this.emit('changePhase', {
578
- pid: process.pid,
579
- name: 'loadDOMContent',
580
- url,
581
- isExternal,
582
- message: '',
583
- });
648
+ await page
649
+ .waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 5000 })
650
+ .catch(() => {});
584
651
 
585
- await page
586
- .waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 5000 })
587
- .catch(() => {});
652
+ void this.emit('changePhase', {
653
+ pid: process.pid,
654
+ name: 'getHTML',
655
+ url,
656
+ isExternal,
657
+ message: '',
658
+ });
588
659
 
589
- void this.emit('changePhase', {
590
- pid: process.pid,
591
- name: 'getHTML',
592
- url,
593
- isExternal,
594
- message: '',
595
- });
660
+ const { title, html } = await page.evaluate(() => {
661
+ /* global document */
662
+ return {
663
+ title: document.title,
664
+ html: document.documentElement.outerHTML,
665
+ };
666
+ });
596
667
 
597
- const { title, html } = await page.evaluate(() => {
598
- /* global document */
599
- return {
600
- title: document.title,
601
- html: document.documentElement.outerHTML,
602
- };
603
- });
668
+ if (isExternal) {
669
+ const externalMeta = emptyMeta();
670
+ externalMeta.title = title;
671
+ return {
672
+ url,
673
+ isTarget: false,
674
+ isExternal,
675
+ redirectPaths: [...redirectPaths],
676
+ status,
677
+ statusText,
678
+ contentType,
679
+ contentLength,
680
+ responseHeaders,
681
+ meta: externalMeta,
682
+ imageList: [],
683
+ anchorList: [],
684
+ html,
685
+ mainContents: null,
686
+ scrollHeight: null,
687
+ isSkipped: false,
688
+ };
689
+ }
690
+
691
+ void this.emit('changePhase', {
692
+ pid: process.pid,
693
+ name: 'waitNetworkIdle',
694
+ url,
695
+ isExternal,
696
+ message: '',
697
+ });
698
+
699
+ await page
700
+ .waitForNavigation({ waitUntil: 'networkidle0', timeout: 5000 })
701
+ .catch(() => {});
702
+
703
+ // Check for network disconnection errors in failed requests
704
+ const disconnectionFailures = findDisconnectionFailures(failedRequests);
705
+ if (disconnectionFailures.length > 0) {
706
+ const errorSummary = disconnectionFailures
707
+ .map((r) => `${r.url} (${r.errorText})`)
708
+ .join(', ');
709
+ throw new Error(
710
+ `Network disconnection detected during page load: ${errorSummary}`,
711
+ );
712
+ }
713
+
714
+ const mainContents = await getMainContents(page, {
715
+ mainContentSelector: options?.mainContentSelector,
716
+ });
717
+
718
+ void this.emit('changePhase', {
719
+ pid: process.pid,
720
+ name: 'getAnchors',
721
+ url,
722
+ isExternal,
723
+ message: `%countdown(${domEvaluationTimeout},getAnchors_${url.withoutHash},s)%s`,
724
+ });
725
+ const anchorList = await getAnchorList(page, parseOpts, domEvaluationTimeout);
726
+
727
+ void this.emit('changePhase', {
728
+ pid: process.pid,
729
+ name: 'getMeta',
730
+ url,
731
+ isExternal,
732
+ message: `%countdown(${domEvaluationTimeout},getMeta_${url.withoutHash},s)%s`,
733
+ });
734
+ const meta = await getMeta(
735
+ page,
736
+ {
737
+ url: url.withoutHashAndAuth,
738
+ html,
739
+ statusCode: status,
740
+ headers: responseHeaders ?? undefined,
741
+ },
742
+ domEvaluationTimeout,
743
+ );
744
+
745
+ let imageList: ImageElement[] = [];
746
+ let scrollHeight: ScrollHeightData | null = null;
747
+
748
+ if (captureImages) {
749
+ void this.emit('changePhase', {
750
+ pid: process.pid,
751
+ name: 'extractImages',
752
+ url,
753
+ isExternal,
754
+ message: `%countdown(${domEvaluationTimeout},extractImages_${url.withoutHash},s)%s`,
755
+ });
756
+ const fetched = await this.#fetchImages(
757
+ page,
758
+ url.withoutHashAndAuth,
759
+ isExternal,
760
+ imageLoadTimeout,
761
+ domEvaluationTimeout,
762
+ );
763
+ imageList = fetched.imageList;
764
+ scrollHeight = fetched.scrollHeight;
765
+ } else {
766
+ scrollHeight = await measureScrollHeight(page);
767
+ }
604
768
 
605
- if (isExternal) {
606
- const externalMeta = emptyMeta();
607
- externalMeta.title = title;
608
769
  return {
609
770
  url,
610
- isTarget: false,
771
+ isTarget: true,
611
772
  isExternal,
612
773
  redirectPaths: [...redirectPaths],
613
774
  status,
@@ -615,97 +776,17 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
615
776
  contentType,
616
777
  contentLength,
617
778
  responseHeaders,
618
- meta: externalMeta,
619
- imageList: [],
620
- anchorList: [],
779
+ meta,
780
+ anchorList,
781
+ imageList,
621
782
  html,
783
+ mainContents,
784
+ scrollHeight,
622
785
  isSkipped: false,
623
786
  };
787
+ } finally {
788
+ await Promise.all(pendingConsoleWork);
624
789
  }
625
-
626
- void this.emit('changePhase', {
627
- pid: process.pid,
628
- name: 'waitNetworkIdle',
629
- url,
630
- isExternal,
631
- message: '',
632
- });
633
-
634
- await page
635
- .waitForNavigation({ waitUntil: 'networkidle0', timeout: 5000 })
636
- .catch(() => {});
637
-
638
- // Check for network disconnection errors in failed requests
639
- const disconnectionFailures = findDisconnectionFailures(failedRequests);
640
- if (disconnectionFailures.length > 0) {
641
- const errorSummary = disconnectionFailures
642
- .map((r) => `${r.url} (${r.errorText})`)
643
- .join(', ');
644
- throw new Error(`Network disconnection detected during page load: ${errorSummary}`);
645
- }
646
-
647
- void this.emit('changePhase', {
648
- pid: process.pid,
649
- name: 'getAnchors',
650
- url,
651
- isExternal,
652
- message: `%countdown(${domEvaluationTimeout},getAnchors_${url.withoutHash},s)%s`,
653
- });
654
- const anchorList = await getAnchorList(page, parseOpts, domEvaluationTimeout);
655
-
656
- void this.emit('changePhase', {
657
- pid: process.pid,
658
- name: 'getMeta',
659
- url,
660
- isExternal,
661
- message: `%countdown(${domEvaluationTimeout},getMeta_${url.withoutHash},s)%s`,
662
- });
663
- const meta = await getMeta(
664
- page,
665
- {
666
- url: url.withoutHashAndAuth,
667
- html,
668
- statusCode: status,
669
- headers: responseHeaders ?? undefined,
670
- },
671
- domEvaluationTimeout,
672
- );
673
-
674
- const imageList = captureImages
675
- ? await (async () => {
676
- void this.emit('changePhase', {
677
- pid: process.pid,
678
- name: 'extractImages',
679
- url,
680
- isExternal,
681
- message: `%countdown(${domEvaluationTimeout},extractImages_${url.withoutHash},s)%s`,
682
- });
683
- return this.#fetchImages(
684
- page,
685
- url.withoutHashAndAuth,
686
- isExternal,
687
- imageLoadTimeout,
688
- domEvaluationTimeout,
689
- );
690
- })()
691
- : [];
692
-
693
- return {
694
- url,
695
- isTarget: true,
696
- isExternal,
697
- redirectPaths: [...redirectPaths],
698
- status,
699
- statusText,
700
- contentType,
701
- contentLength,
702
- responseHeaders,
703
- meta,
704
- anchorList,
705
- imageList,
706
- html,
707
- isSkipped: false,
708
- };
709
790
  }
710
791
  /**
711
792
  * Extracts image data from the page across multiple device presets.
@@ -719,12 +800,12 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
719
800
  * changes and triggers a reload. Isolating each device preset allows partial
720
801
  * results — if one viewport fails, the other can still succeed.
721
802
  *
722
- * WHY retryable with 20-min timeout and `fallback: []`: Image extraction is
723
- * best-effort. If all retries fail, an empty array is returned rather than
724
- * failing the entire page scrape. The 20-min wall clock accommodates pages
725
- * whose mobile-small `scrollHeight` reaches ~300k px (observed on
726
- * responsive data tables, which take ~5 min to scroll). A shorter timeout
727
- * causes a second retry to start while the previous attempt's
803
+ * WHY retryable with 20-min timeout and empty fallback: Image extraction is
804
+ * best-effort. If all retries fail, empty images and null scroll heights are
805
+ * returned rather than failing the entire page scrape. The 20-min wall clock
806
+ * accommodates pages whose mobile-small `scrollHeight` reaches ~300k px
807
+ * (observed on responsive data tables, which take ~5 min to scroll). A shorter
808
+ * timeout causes a second retry to start while the previous attempt's
728
809
  * `scrollAllOver` is still running its `page.evaluate` calls in the
729
810
  * background — `Promise.race` in `retry.ts` does not cancel `fn()`. The
730
811
  * collision then surfaces as "Attempted to use detached Frame" or
@@ -736,16 +817,22 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
736
817
  * preset entirely keeps the timeout-vs-background-evaluate collision from
737
818
  * ever being triggered, at the cost of losing that viewport's image data
738
819
  * for those pages. See {@link MAX_SCROLL_HEIGHT} for the chosen threshold.
820
+ *
821
+ * Scroll heights are still recorded when scrolling is skipped for the
822
+ * height limit — the measurement from `beforePageScan` is kept.
739
823
  * @param page - Puppeteer page instance
740
824
  * @param url - The page URL string (without hash and auth)
741
825
  * @param isExternal - Whether the page is external
742
826
  * @param imageLoadTimeout - Timeout (ms) for waiting images to complete loading
743
827
  * @param domEvaluationTimeout - Timeout (ms) for the in-page image extraction `page.evaluate`
744
- * @returns Array of image elements from all device presets (may be partial if some viewports failed)
828
+ * @returns Image elements plus desktop/mobile scroll heights from the scan path
745
829
  */
746
830
  @retryable({
747
831
  timeout: 20 * 60 * 1000,
748
- fallback: [],
832
+ fallback: {
833
+ imageList: [],
834
+ scrollHeight: { desktop: null, mobile: null },
835
+ },
749
836
  onWait(this: Scraper, determinedInterval, retryCount, methodName, error) {
750
837
  void this.emit('changePhase', {
751
838
  pid: process.pid,
@@ -771,15 +858,20 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
771
858
  isExternal: boolean,
772
859
  imageLoadTimeout: number,
773
860
  domEvaluationTimeout: number,
774
- ): Promise<ImageElement[]> {
861
+ ): Promise<{ imageList: ImageElement[]; scrollHeight: ScrollHeightData }> {
775
862
  const listener = this.#createPageScanListener(isExternal);
776
- const devices: { key: string; preset: { width: number; resolution?: number } }[] = [
863
+ const devices: {
864
+ key: 'desktop-compact' | 'mobile-small';
865
+ preset: { width: number; resolution?: number };
866
+ }[] = [
777
867
  { key: 'desktop-compact', preset: devicePresets['desktop-compact'] },
778
868
  { key: 'mobile-small', preset: devicePresets['mobile-small'] },
779
869
  ];
780
870
  const imageList: ImageElement[] = [];
871
+ const scrollHeight: ScrollHeightData = { desktop: null, mobile: null };
781
872
 
782
873
  for (const { key, preset } of devices) {
874
+ const scrollKey = key === 'desktop-compact' ? 'desktop' : 'mobile';
783
875
  try {
784
876
  void this.emit('changePhase', {
785
877
  pid: process.pid,
@@ -798,6 +890,8 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
798
890
  maxScrollHeight: MAX_SCROLL_HEIGHT,
799
891
  });
800
892
 
893
+ scrollHeight[scrollKey] = scanResult.scrollHeight;
894
+
801
895
  if (!scanResult.scrolled) {
802
896
  void this.emit('changePhase', {
803
897
  pid: process.pid,
@@ -845,6 +939,6 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
845
939
  }
846
940
  }
847
941
 
848
- return imageList;
942
+ return { imageList, scrollHeight };
849
943
  }
850
944
  }