@d-zero/beholder 3.1.6 → 4.0.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.
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Main-content quantitative extraction for scraped HTML pages.
3
+ *
4
+ * Runs entirely against a `Document` (Puppeteer page realm or jsdom) so the
5
+ * same logic is unit-tested without a browser. Puppeteer callers use
6
+ * {@link getMainContents}, which passes this function into `page.evaluate`.
7
+ *
8
+ * WHY no `@medv/finder`: selector strings are diagnostic only; a tag+id+class // cspell:disable-line
9
+ * path is enough and avoids a Node-only dependency inside the page realm.
10
+ * @module
11
+ */
12
+
13
+ import type { MainContentsData } from './types.js';
14
+ import type { Page } from 'puppeteer';
15
+
16
+ /**
17
+ * Extract main-content metrics from a `Document`.
18
+ *
19
+ * This function must remain free of closure over imports so Puppeteer can
20
+ * serialize it into `page.evaluate`.
21
+ *
22
+ * Argument order matches `page.evaluate(fn, mainContentSelector)`: the selector
23
+ * is the first argument; `doc` defaults to the page-realm global `document`
24
+ * when omitted (jsdom tests pass an explicit `Document` as the second argument).
25
+ * @param mainContentSelector - Optional selector prepended to the default list.
26
+ * @param doc - The document to inspect (defaults to the global `document`).
27
+ * @returns Quantitative main-content data (never `null`; empty when no main region).
28
+ * @example
29
+ * ```ts
30
+ * // In page.evaluate / browser:
31
+ * const data = extractMainContentsFromDocument('#page-body');
32
+ * // In jsdom tests:
33
+ * const data = extractMainContentsFromDocument('#page-body', document);
34
+ * ```
35
+ */
36
+ export function extractMainContentsFromDocument(
37
+ mainContentSelector: string | null = null,
38
+ doc: Document = document,
39
+ ): MainContentsData {
40
+ /**
41
+ * @param text
42
+ */
43
+ function removeSpaces(text: string | null): string | null {
44
+ return text?.trim().replaceAll(/\s+/g, '') || null;
45
+ }
46
+
47
+ /**
48
+ * @param value
49
+ */
50
+ function cssEscape(value: string): string {
51
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
52
+ return CSS.escape(value);
53
+ }
54
+ return value.replaceAll(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
55
+ }
56
+
57
+ /**
58
+ * @param el
59
+ */
60
+ function buildSelector(el: Element): string {
61
+ const tag = el.nodeName.toLowerCase();
62
+ const id = el.id ? `#${cssEscape(el.id)}` : '';
63
+ const classes = [...el.classList].map((c) => `.${cssEscape(c)}`).join('');
64
+ return `${tag}${id}${classes}`;
65
+ }
66
+
67
+ /**
68
+ * @param media
69
+ */
70
+ function resolveMediaSrc(media: HTMLMediaElement): string {
71
+ if (media.currentSrc) {
72
+ return media.currentSrc;
73
+ }
74
+ const attrSrc = media.getAttribute('src');
75
+ if (attrSrc) {
76
+ try {
77
+ return new URL(attrSrc, doc.baseURI).href;
78
+ } catch {
79
+ return attrSrc;
80
+ }
81
+ }
82
+ const source = media.querySelector('source[src]');
83
+ const sourceSrc = source?.getAttribute('src');
84
+ if (sourceSrc) {
85
+ try {
86
+ return new URL(sourceSrc, doc.baseURI).href;
87
+ } catch {
88
+ return sourceSrc;
89
+ }
90
+ }
91
+ return '';
92
+ }
93
+
94
+ const title = doc.title?.trim() ?? '';
95
+ const bodyWordCount = removeSpaces(doc.body?.textContent ?? null)?.length ?? 0;
96
+
97
+ const selectors = [
98
+ 'main',
99
+ '[role="main"]',
100
+ '#main',
101
+ '.main',
102
+ '#content',
103
+ '.content',
104
+ '#contents',
105
+ '.contents',
106
+ '#main-content',
107
+ '.main-content',
108
+ '#main_content',
109
+ '.main_content',
110
+ '#mainContent',
111
+ '.mainContent',
112
+ ];
113
+ if (mainContentSelector) {
114
+ selectors.unshift(mainContentSelector);
115
+ }
116
+
117
+ let $main: Element | null = null;
118
+ try {
119
+ $main = doc.querySelector(selectors.join(','));
120
+ } catch {
121
+ // Invalid custom selector: retry without it so built-in selectors still run.
122
+ if (mainContentSelector) {
123
+ selectors.shift();
124
+ $main = doc.querySelector(selectors.join(','));
125
+ }
126
+ }
127
+
128
+ if (!$main) {
129
+ const fallbackSelectors = [
130
+ '[id*="main" i]',
131
+ '[class*="main" i]',
132
+ '[id*="content" i]',
133
+ '[class*="content" i]',
134
+ ];
135
+ for (const sel of fallbackSelectors) {
136
+ const candidate = doc.querySelector(sel);
137
+ if (candidate && candidate !== doc.body && candidate !== doc.documentElement) {
138
+ $main = candidate;
139
+ break;
140
+ }
141
+ }
142
+ }
143
+
144
+ if (!$main) {
145
+ return {
146
+ title,
147
+ main: null,
148
+ wordCount: 0,
149
+ bodyWordCount,
150
+ headings: [],
151
+ images: [],
152
+ tables: [],
153
+ buttons: [],
154
+ iframes: [],
155
+ videos: [],
156
+ audios: [],
157
+ canvases: [],
158
+ };
159
+ }
160
+
161
+ const headings: MainContentsData['headings'] = [];
162
+ for (const $heading of $main.querySelectorAll<HTMLHeadingElement>(
163
+ 'h1, h2, h3, h4, h5, h6',
164
+ )) {
165
+ headings.push({
166
+ text: removeSpaces($heading.textContent),
167
+ level: Number.parseInt($heading.nodeName.replace(/h/i, ''), 10) as
168
+ 1 | 2 | 3 | 4 | 5 | 6,
169
+ });
170
+ }
171
+
172
+ const images: MainContentsData['images'] = [];
173
+ for (const $img of $main.querySelectorAll<HTMLImageElement>(
174
+ 'img, input[type="image"]',
175
+ )) {
176
+ images.push({
177
+ src: $img.src,
178
+ alt: $img.alt,
179
+ });
180
+ }
181
+
182
+ const tables: MainContentsData['tables'] = [];
183
+ for (const $table of $main.querySelectorAll<HTMLTableElement>('table')) {
184
+ tables.push({
185
+ rows: $table.querySelectorAll('tr').length,
186
+ cols: $table.querySelector('tr')?.querySelectorAll('th, td').length || 0,
187
+ hasHeader: !!$table.querySelector('thead'),
188
+ hasFooter: !!$table.querySelector('tfoot'),
189
+ hasMergedCell: !!$table.querySelector('[colspan], [rowspan]'),
190
+ });
191
+ }
192
+
193
+ const win = doc.defaultView;
194
+ const HTMLButton = win?.HTMLButtonElement;
195
+ const HTMLInput = win?.HTMLInputElement;
196
+
197
+ const buttons: MainContentsData['buttons'] = [];
198
+ for (const $el of $main.querySelectorAll(
199
+ 'button, [role="button"], [class*="button"], [class*="btn"]',
200
+ )) {
201
+ const isButton = HTMLButton ? $el instanceof HTMLButton : $el.nodeName === 'BUTTON';
202
+ const isInput = HTMLInput ? $el instanceof HTMLInput : $el.nodeName === 'INPUT';
203
+ const isFormControl = isButton || isInput;
204
+ let text = removeSpaces($el.textContent);
205
+ if (!text && isInput) {
206
+ text = removeSpaces($el.getAttribute('value'));
207
+ }
208
+ const disabledProp =
209
+ isFormControl && 'disabled' in $el
210
+ ? Boolean(($el as HTMLButtonElement).disabled)
211
+ : false;
212
+ buttons.push({
213
+ nodeName: $el.nodeName,
214
+ role: $el.getAttribute('role'),
215
+ type: isFormControl ? ($el as HTMLButtonElement | HTMLInputElement).type : null,
216
+ text,
217
+ disabled: disabledProp || $el.getAttribute('aria-disabled') === 'true',
218
+ });
219
+ }
220
+
221
+ const iframes: MainContentsData['iframes'] = [];
222
+ for (const $el of $main.querySelectorAll<HTMLIFrameElement>('iframe')) {
223
+ iframes.push({
224
+ src: $el.src,
225
+ title: $el.hasAttribute('title') ? $el.getAttribute('title') : null,
226
+ width: $el.getAttribute('width'),
227
+ height: $el.getAttribute('height'),
228
+ });
229
+ }
230
+
231
+ const videos: MainContentsData['videos'] = [];
232
+ for (const $el of $main.querySelectorAll<HTMLVideoElement>('video')) {
233
+ const posterAttr = $el.getAttribute('poster');
234
+ let poster: string | null = null;
235
+ if (posterAttr) {
236
+ try {
237
+ poster = new URL(posterAttr, doc.baseURI).href;
238
+ } catch {
239
+ poster = posterAttr;
240
+ }
241
+ }
242
+ videos.push({
243
+ src: resolveMediaSrc($el),
244
+ poster,
245
+ width: $el.width,
246
+ height: $el.height,
247
+ });
248
+ }
249
+
250
+ const audios: MainContentsData['audios'] = [];
251
+ for (const $el of $main.querySelectorAll<HTMLAudioElement>('audio')) {
252
+ audios.push({
253
+ src: resolveMediaSrc($el),
254
+ });
255
+ }
256
+
257
+ const canvases: MainContentsData['canvases'] = [];
258
+ for (const $el of $main.querySelectorAll<HTMLCanvasElement>('canvas')) {
259
+ canvases.push({
260
+ width: $el.width,
261
+ height: $el.height,
262
+ });
263
+ }
264
+
265
+ return {
266
+ title,
267
+ main: {
268
+ nodeName: $main.nodeName,
269
+ id: $main.id || null,
270
+ classList: [...$main.classList],
271
+ role: $main.getAttribute('role'),
272
+ selector: buildSelector($main),
273
+ },
274
+ wordCount: removeSpaces($main.textContent)?.length ?? 0,
275
+ bodyWordCount,
276
+ headings,
277
+ images,
278
+ tables,
279
+ buttons,
280
+ iframes,
281
+ videos,
282
+ audios,
283
+ canvases,
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Extract main-content metrics from a Puppeteer page via a single `page.evaluate`.
289
+ * @param page - Puppeteer page whose DOM has finished loading.
290
+ * @param options - Optional main-content selector override.
291
+ * @param options.mainContentSelector
292
+ * @returns Quantitative main-content data.
293
+ * @example
294
+ * ```ts
295
+ * const mainContents = await getMainContents(page, { mainContentSelector: '#page-body' });
296
+ * ```
297
+ */
298
+ export async function getMainContents(
299
+ page: Page,
300
+ options?: { mainContentSelector?: string | null },
301
+ ): Promise<MainContentsData> {
302
+ return page.evaluate(
303
+ extractMainContentsFromDocument,
304
+ options?.mainContentSelector ?? null,
305
+ );
306
+ }
package/src/index.ts CHANGED
@@ -27,6 +27,17 @@ export type {
27
27
  ImageElement,
28
28
  SkippedPageData,
29
29
  NetworkLog,
30
+ ScrollHeightData,
31
+ MainContentsData,
32
+ MainContentsMainTag,
33
+ MainContentsHeading,
34
+ MainContentsImage,
35
+ MainContentsTable,
36
+ MainContentsButton,
37
+ MainContentsIframe,
38
+ MainContentsVideo,
39
+ MainContentsAudio,
40
+ MainContentsCanvas,
30
41
  OpenGraphMeta,
31
42
  OgArticleMeta,
32
43
  OgBookMeta,
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { isHtmlContentType } from './is-html-content-type.js';
4
+
5
+ describe('isHtmlContentType', () => {
6
+ it('accepts text/html in any letter case and with surrounding whitespace', () => {
7
+ expect(isHtmlContentType('text/html')).toBe(true);
8
+ expect(isHtmlContentType('Text/HTML')).toBe(true);
9
+ expect(isHtmlContentType(' text/html ')).toBe(true);
10
+ });
11
+
12
+ it('rejects non-HTML and null', () => {
13
+ expect(isHtmlContentType('application/pdf')).toBe(false);
14
+ expect(isHtmlContentType('text/plain')).toBe(false);
15
+ expect(isHtmlContentType(null)).toBe(false);
16
+ });
17
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Determine whether a Content-Type media type is HTML.
3
+ *
4
+ * MIME types are case-insensitive (RFC 2045). Surrounding whitespace after
5
+ * parameter stripping is also tolerated so `text/html `, `Text/HTML`, and
6
+ * `text/html; charset=utf-8` (after `;` split) all classify as HTML.
7
+ * @param contentType - The media type portion of a Content-Type header
8
+ * (parameters already stripped), or `null` when unknown.
9
+ * @returns `true` when the media type is `text/html` in any letter case.
10
+ * @example
11
+ * ```ts
12
+ * isHtmlContentType('text/html'); // true
13
+ * isHtmlContentType('Text/HTML'); // true
14
+ * isHtmlContentType('application/pdf'); // false
15
+ * // Callers should strip parameters first (e.g. split on ';') before calling.
16
+ * ```
17
+ */
18
+ export function isHtmlContentType(contentType: string | null): boolean {
19
+ return contentType !== null && contentType.trim().toLowerCase() === 'text/html';
20
+ }
@@ -0,0 +1,47 @@
1
+ import type { Page } from 'puppeteer';
2
+
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { measureScrollHeight } from './measure-scroll-height.js';
6
+
7
+ describe('measureScrollHeight', () => {
8
+ let page: Page;
9
+ let scrollHeights: number[];
10
+
11
+ beforeEach(() => {
12
+ scrollHeights = [2400, 5200];
13
+ let call = 0;
14
+ page = {
15
+ setViewport: vi.fn(() => Promise.resolve()),
16
+ evaluate: vi.fn(() => Promise.resolve(scrollHeights[call++] ?? 0)),
17
+ } as unknown as Page;
18
+ });
19
+
20
+ it('returns desktop and mobile heights without scrolling the page', async () => {
21
+ const result = await measureScrollHeight(page);
22
+
23
+ expect(result).toEqual({ desktop: 2400, mobile: 5200 });
24
+ expect(page.setViewport).toHaveBeenCalledTimes(2);
25
+ expect(page.setViewport).toHaveBeenNthCalledWith(1, {
26
+ width: 1280,
27
+ height: 800,
28
+ deviceScaleFactor: 1,
29
+ });
30
+ expect(page.setViewport).toHaveBeenNthCalledWith(2, {
31
+ width: 320,
32
+ height: 800,
33
+ deviceScaleFactor: 2,
34
+ });
35
+ });
36
+
37
+ it('returns null for a preset that throws', async () => {
38
+ page.setViewport = vi
39
+ .fn()
40
+ .mockResolvedValueOnce()
41
+ .mockRejectedValueOnce(new Error('viewport failed'));
42
+
43
+ const result = await measureScrollHeight(page);
44
+
45
+ expect(result).toEqual({ desktop: 2400, mobile: null });
46
+ });
47
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Lightweight scrollHeight measurement without `scrollAllOver`.
3
+ *
4
+ * Used when `captureImages` is false so callers still receive desktop/mobile
5
+ * heights without the cost of full-page scrolling for image extraction.
6
+ * @module
7
+ */
8
+
9
+ import type { ScrollHeightData } from './types.js';
10
+ import type { Page } from 'puppeteer';
11
+
12
+ import { devicePresets } from '@d-zero/puppeteer-page-scan';
13
+
14
+ /**
15
+ * Measure `document.body.scrollHeight` at desktop-compact and mobile-small viewports.
16
+ *
17
+ * Sets each viewport, reads height once, and does **not** call `scrollAllOver`.
18
+ * A failed preset yields `null` for that side only.
19
+ * @param page - Puppeteer page whose DOM has finished loading.
20
+ * @returns Heights for desktop and mobile presets.
21
+ * @example
22
+ * ```ts
23
+ * const scrollHeight = await measureScrollHeight(page);
24
+ * // { desktop: 2400, mobile: 5200 }
25
+ * ```
26
+ */
27
+ export async function measureScrollHeight(page: Page): Promise<ScrollHeightData> {
28
+ const desktop = await measureAtPreset(page, 'desktop-compact');
29
+ const mobile = await measureAtPreset(page, 'mobile-small');
30
+ return { desktop, mobile };
31
+ }
32
+
33
+ /**
34
+ * @param page
35
+ * @param key
36
+ */
37
+ async function measureAtPreset(
38
+ page: Page,
39
+ key: 'desktop-compact' | 'mobile-small',
40
+ ): Promise<number | null> {
41
+ const preset: { width: number; resolution?: number } = devicePresets[key];
42
+ try {
43
+ await page.setViewport({
44
+ width: preset.width,
45
+ height: 800,
46
+ deviceScaleFactor: preset.resolution ?? 1,
47
+ });
48
+ return await page.evaluate(() => document.body.scrollHeight);
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
package/src/scraper.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  PageData,
11
11
  ParseURLOptions,
12
12
  Resource,
13
+ ScrollHeightData,
13
14
  SkippedPageData,
14
15
  } from './types.js';
15
16
  import type { PageScanPhase } from '@d-zero/puppeteer-page-scan';
@@ -28,8 +29,11 @@ import {
28
29
  getImageList,
29
30
  getMeta,
30
31
  } from './dom-evaluation.js';
32
+ import { getMainContents } from './get-main-contents.js';
31
33
  import { isError } from './is-error.js';
34
+ import { isHtmlContentType } from './is-html-content-type.js';
32
35
  import { keywordCheck } from './keyword-check.js';
36
+ import { measureScrollHeight } from './measure-scroll-height.js';
33
37
  import { emptyMeta } from './meta/classify.js';
34
38
  import { findDisconnectionFailures } from './network-disconnection.js';
35
39
  import { parseUrl } from './parse-url.js';
@@ -148,6 +152,8 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
148
152
  imageList: [],
149
153
  anchorList: [],
150
154
  html: '',
155
+ mainContents: null,
156
+ scrollHeight: null,
151
157
  isSkipped: false,
152
158
  };
153
159
 
@@ -176,12 +182,14 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
176
182
  pageData: {
177
183
  ...headResult,
178
184
  isTarget: false,
185
+ mainContents: headResult.mainContents ?? null,
186
+ scrollHeight: headResult.scrollHeight ?? null,
179
187
  },
180
188
  resources,
181
189
  };
182
190
  }
183
191
 
184
- if (headResult === null || headResult.contentType === 'text/html') {
192
+ if (headResult === null || isHtmlContentType(headResult.contentType)) {
185
193
  const fetchResult = await this.#fetchData(
186
194
  page,
187
195
  url,
@@ -555,7 +563,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
555
563
  const _contentLength = Number.parseInt(responseHeaders['content-length'] ?? '');
556
564
  const contentLength = Number.isFinite(_contentLength) ? _contentLength : null;
557
565
 
558
- if (contentType !== 'text/html') {
566
+ if (!isHtmlContentType(contentType)) {
559
567
  return {
560
568
  url,
561
569
  isTarget: false,
@@ -570,6 +578,8 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
570
578
  imageList: [],
571
579
  anchorList: [],
572
580
  html: '',
581
+ mainContents: null,
582
+ scrollHeight: null,
573
583
  isSkipped: false,
574
584
  };
575
585
  }
@@ -619,6 +629,8 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
619
629
  imageList: [],
620
630
  anchorList: [],
621
631
  html,
632
+ mainContents: null,
633
+ scrollHeight: null,
622
634
  isSkipped: false,
623
635
  };
624
636
  }
@@ -644,6 +656,10 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
644
656
  throw new Error(`Network disconnection detected during page load: ${errorSummary}`);
645
657
  }
646
658
 
659
+ const mainContents = await getMainContents(page, {
660
+ mainContentSelector: options?.mainContentSelector,
661
+ });
662
+
647
663
  void this.emit('changePhase', {
648
664
  pid: process.pid,
649
665
  name: 'getAnchors',
@@ -671,24 +687,29 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
671
687
  domEvaluationTimeout,
672
688
  );
673
689
 
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
- : [];
690
+ let imageList: ImageElement[] = [];
691
+ let scrollHeight: ScrollHeightData | null = null;
692
+
693
+ if (captureImages) {
694
+ void this.emit('changePhase', {
695
+ pid: process.pid,
696
+ name: 'extractImages',
697
+ url,
698
+ isExternal,
699
+ message: `%countdown(${domEvaluationTimeout},extractImages_${url.withoutHash},s)%s`,
700
+ });
701
+ const fetched = await this.#fetchImages(
702
+ page,
703
+ url.withoutHashAndAuth,
704
+ isExternal,
705
+ imageLoadTimeout,
706
+ domEvaluationTimeout,
707
+ );
708
+ imageList = fetched.imageList;
709
+ scrollHeight = fetched.scrollHeight;
710
+ } else {
711
+ scrollHeight = await measureScrollHeight(page);
712
+ }
692
713
 
693
714
  return {
694
715
  url,
@@ -704,6 +725,8 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
704
725
  anchorList,
705
726
  imageList,
706
727
  html,
728
+ mainContents,
729
+ scrollHeight,
707
730
  isSkipped: false,
708
731
  };
709
732
  }
@@ -719,12 +742,12 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
719
742
  * changes and triggers a reload. Isolating each device preset allows partial
720
743
  * results — if one viewport fails, the other can still succeed.
721
744
  *
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
745
+ * WHY retryable with 20-min timeout and empty fallback: Image extraction is
746
+ * best-effort. If all retries fail, empty images and null scroll heights are
747
+ * returned rather than failing the entire page scrape. The 20-min wall clock
748
+ * accommodates pages whose mobile-small `scrollHeight` reaches ~300k px
749
+ * (observed on responsive data tables, which take ~5 min to scroll). A shorter
750
+ * timeout causes a second retry to start while the previous attempt's
728
751
  * `scrollAllOver` is still running its `page.evaluate` calls in the
729
752
  * background — `Promise.race` in `retry.ts` does not cancel `fn()`. The
730
753
  * collision then surfaces as "Attempted to use detached Frame" or
@@ -736,16 +759,22 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
736
759
  * preset entirely keeps the timeout-vs-background-evaluate collision from
737
760
  * ever being triggered, at the cost of losing that viewport's image data
738
761
  * for those pages. See {@link MAX_SCROLL_HEIGHT} for the chosen threshold.
762
+ *
763
+ * Scroll heights are still recorded when scrolling is skipped for the
764
+ * height limit — the measurement from `beforePageScan` is kept.
739
765
  * @param page - Puppeteer page instance
740
766
  * @param url - The page URL string (without hash and auth)
741
767
  * @param isExternal - Whether the page is external
742
768
  * @param imageLoadTimeout - Timeout (ms) for waiting images to complete loading
743
769
  * @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)
770
+ * @returns Image elements plus desktop/mobile scroll heights from the scan path
745
771
  */
746
772
  @retryable({
747
773
  timeout: 20 * 60 * 1000,
748
- fallback: [],
774
+ fallback: {
775
+ imageList: [],
776
+ scrollHeight: { desktop: null, mobile: null },
777
+ },
749
778
  onWait(this: Scraper, determinedInterval, retryCount, methodName, error) {
750
779
  void this.emit('changePhase', {
751
780
  pid: process.pid,
@@ -771,15 +800,20 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
771
800
  isExternal: boolean,
772
801
  imageLoadTimeout: number,
773
802
  domEvaluationTimeout: number,
774
- ): Promise<ImageElement[]> {
803
+ ): Promise<{ imageList: ImageElement[]; scrollHeight: ScrollHeightData }> {
775
804
  const listener = this.#createPageScanListener(isExternal);
776
- const devices: { key: string; preset: { width: number; resolution?: number } }[] = [
805
+ const devices: {
806
+ key: 'desktop-compact' | 'mobile-small';
807
+ preset: { width: number; resolution?: number };
808
+ }[] = [
777
809
  { key: 'desktop-compact', preset: devicePresets['desktop-compact'] },
778
810
  { key: 'mobile-small', preset: devicePresets['mobile-small'] },
779
811
  ];
780
812
  const imageList: ImageElement[] = [];
813
+ const scrollHeight: ScrollHeightData = { desktop: null, mobile: null };
781
814
 
782
815
  for (const { key, preset } of devices) {
816
+ const scrollKey = key === 'desktop-compact' ? 'desktop' : 'mobile';
783
817
  try {
784
818
  void this.emit('changePhase', {
785
819
  pid: process.pid,
@@ -798,6 +832,8 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
798
832
  maxScrollHeight: MAX_SCROLL_HEIGHT,
799
833
  });
800
834
 
835
+ scrollHeight[scrollKey] = scanResult.scrollHeight;
836
+
801
837
  if (!scanResult.scrolled) {
802
838
  void this.emit('changePhase', {
803
839
  pid: process.pid,
@@ -845,6 +881,6 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
845
881
  }
846
882
  }
847
883
 
848
- return imageList;
884
+ return { imageList, scrollHeight };
849
885
  }
850
886
  }