@d-zero/beholder 3.1.5 → 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.
package/dist/scraper.js CHANGED
@@ -43,8 +43,11 @@ import { retry as retryable } from '@d-zero/shared/retry';
43
43
  import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
44
44
  import { resourceLog, scraperLog } from './debug.js';
45
45
  import { DEFAULT_DOM_EVALUATION_TIMEOUT, getAnchorList, getImageList, getMeta, } from './dom-evaluation.js';
46
+ import { getMainContents } from './get-main-contents.js';
46
47
  import { isError } from './is-error.js';
48
+ import { isHtmlContentType } from './is-html-content-type.js';
47
49
  import { keywordCheck } from './keyword-check.js';
50
+ import { measureScrollHeight } from './measure-scroll-height.js';
48
51
  import { emptyMeta } from './meta/classify.js';
49
52
  import { findDisconnectionFailures } from './network-disconnection.js';
50
53
  import { parseUrl } from './parse-url.js';
@@ -95,7 +98,10 @@ let Scraper = (() => {
95
98
  })];
96
99
  _private_fetchImages_decorators = [retryable({
97
100
  timeout: 20 * 60 * 1000,
98
- fallback: [],
101
+ fallback: {
102
+ imageList: [],
103
+ scrollHeight: { desktop: null, mobile: null },
104
+ },
99
105
  onWait(determinedInterval, retryCount, methodName, error) {
100
106
  void this.emit('changePhase', {
101
107
  pid: process.pid,
@@ -267,7 +273,7 @@ let Scraper = (() => {
267
273
  const contentType = responseHeaders['content-type']?.split(';')[0] || null;
268
274
  const _contentLength = Number.parseInt(responseHeaders['content-length'] ?? '');
269
275
  const contentLength = Number.isFinite(_contentLength) ? _contentLength : null;
270
- if (contentType !== 'text/html') {
276
+ if (!isHtmlContentType(contentType)) {
271
277
  return {
272
278
  url,
273
279
  isTarget: false,
@@ -282,6 +288,8 @@ let Scraper = (() => {
282
288
  imageList: [],
283
289
  anchorList: [],
284
290
  html: '',
291
+ mainContents: null,
292
+ scrollHeight: null,
285
293
  isSkipped: false,
286
294
  };
287
295
  }
@@ -326,6 +334,8 @@ let Scraper = (() => {
326
334
  imageList: [],
327
335
  anchorList: [],
328
336
  html,
337
+ mainContents: null,
338
+ scrollHeight: null,
329
339
  isSkipped: false,
330
340
  };
331
341
  }
@@ -347,6 +357,9 @@ let Scraper = (() => {
347
357
  .join(', ');
348
358
  throw new Error(`Network disconnection detected during page load: ${errorSummary}`);
349
359
  }
360
+ const mainContents = await getMainContents(page, {
361
+ mainContentSelector: options?.mainContentSelector,
362
+ });
350
363
  void this.emit('changePhase', {
351
364
  pid: process.pid,
352
365
  name: 'getAnchors',
@@ -368,18 +381,23 @@ let Scraper = (() => {
368
381
  statusCode: status,
369
382
  headers: responseHeaders ?? undefined,
370
383
  }, domEvaluationTimeout);
371
- const imageList = captureImages
372
- ? await (async () => {
373
- void this.emit('changePhase', {
374
- pid: process.pid,
375
- name: 'extractImages',
376
- url,
377
- isExternal,
378
- message: `%countdown(${domEvaluationTimeout},extractImages_${url.withoutHash},s)%s`,
379
- });
380
- return this.#fetchImages(page, url.withoutHashAndAuth, isExternal, imageLoadTimeout, domEvaluationTimeout);
381
- })()
382
- : [];
384
+ let imageList = [];
385
+ let scrollHeight = null;
386
+ if (captureImages) {
387
+ void this.emit('changePhase', {
388
+ pid: process.pid,
389
+ name: 'extractImages',
390
+ url,
391
+ isExternal,
392
+ message: `%countdown(${domEvaluationTimeout},extractImages_${url.withoutHash},s)%s`,
393
+ });
394
+ const fetched = await this.#fetchImages(page, url.withoutHashAndAuth, isExternal, imageLoadTimeout, domEvaluationTimeout);
395
+ imageList = fetched.imageList;
396
+ scrollHeight = fetched.scrollHeight;
397
+ }
398
+ else {
399
+ scrollHeight = await measureScrollHeight(page);
400
+ }
383
401
  return {
384
402
  url,
385
403
  isTarget: true,
@@ -394,6 +412,8 @@ let Scraper = (() => {
394
412
  anchorList,
395
413
  imageList,
396
414
  html,
415
+ mainContents,
416
+ scrollHeight,
397
417
  isSkipped: false,
398
418
  };
399
419
  }, "#fetchData") }, _private_fetchData_decorators, { kind: "method", name: "#fetchData", static: false, private: true, access: { has: obj => #fetchData in obj, get: obj => obj.#fetchData }, metadata: _metadata }, null, _instanceExtraInitializers);
@@ -404,7 +424,9 @@ let Scraper = (() => {
404
424
  { key: 'mobile-small', preset: devicePresets['mobile-small'] },
405
425
  ];
406
426
  const imageList = [];
427
+ const scrollHeight = { desktop: null, mobile: null };
407
428
  for (const { key, preset } of devices) {
429
+ const scrollKey = key === 'desktop-compact' ? 'desktop' : 'mobile';
408
430
  try {
409
431
  void this.emit('changePhase', {
410
432
  pid: process.pid,
@@ -421,6 +443,7 @@ let Scraper = (() => {
421
443
  timeout: 5000,
422
444
  maxScrollHeight: MAX_SCROLL_HEIGHT,
423
445
  });
446
+ scrollHeight[scrollKey] = scanResult.scrollHeight;
424
447
  if (!scanResult.scrolled) {
425
448
  void this.emit('changePhase', {
426
449
  pid: process.pid,
@@ -465,7 +488,7 @@ let Scraper = (() => {
465
488
  });
466
489
  }
467
490
  }
468
- return imageList;
491
+ return { imageList, scrollHeight };
469
492
  }, "#fetchImages") }, _private_fetchImages_decorators, { kind: "method", name: "#fetchImages", static: false, private: true, access: { has: obj => #fetchImages in obj, get: obj => obj.#fetchImages }, metadata: _metadata }, null, _instanceExtraInitializers);
470
493
  if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
471
494
  }
@@ -540,6 +563,8 @@ let Scraper = (() => {
540
563
  imageList: [],
541
564
  anchorList: [],
542
565
  html: '',
566
+ mainContents: null,
567
+ scrollHeight: null,
543
568
  isSkipped: false,
544
569
  };
545
570
  void this.emit('changePhase', {
@@ -565,11 +590,13 @@ let Scraper = (() => {
565
590
  pageData: {
566
591
  ...headResult,
567
592
  isTarget: false,
593
+ mainContents: headResult.mainContents ?? null,
594
+ scrollHeight: headResult.scrollHeight ?? null,
568
595
  },
569
596
  resources,
570
597
  };
571
598
  }
572
- if (headResult === null || headResult.contentType === 'text/html') {
599
+ if (headResult === null || isHtmlContentType(headResult.contentType)) {
573
600
  const fetchResult = await this.#fetchData(page, url, isExternal, captureImages, imageLoadTimeout, resources, failedRequests, options).catch((error) => {
574
601
  if (error instanceof Error) {
575
602
  return error;
@@ -732,12 +759,12 @@ let Scraper = (() => {
732
759
  * changes and triggers a reload. Isolating each device preset allows partial
733
760
  * results — if one viewport fails, the other can still succeed.
734
761
  *
735
- * WHY retryable with 20-min timeout and `fallback: []`: Image extraction is
736
- * best-effort. If all retries fail, an empty array is returned rather than
737
- * failing the entire page scrape. The 20-min wall clock accommodates pages
738
- * whose mobile-small `scrollHeight` reaches ~300k px (observed on
739
- * responsive data tables, which take ~5 min to scroll). A shorter timeout
740
- * causes a second retry to start while the previous attempt's
762
+ * WHY retryable with 20-min timeout and empty fallback: Image extraction is
763
+ * best-effort. If all retries fail, empty images and null scroll heights are
764
+ * returned rather than failing the entire page scrape. The 20-min wall clock
765
+ * accommodates pages whose mobile-small `scrollHeight` reaches ~300k px
766
+ * (observed on responsive data tables, which take ~5 min to scroll). A shorter
767
+ * timeout causes a second retry to start while the previous attempt's
741
768
  * `scrollAllOver` is still running its `page.evaluate` calls in the
742
769
  * background — `Promise.race` in `retry.ts` does not cancel `fn()`. The
743
770
  * collision then surfaces as "Attempted to use detached Frame" or
@@ -749,12 +776,15 @@ let Scraper = (() => {
749
776
  * preset entirely keeps the timeout-vs-background-evaluate collision from
750
777
  * ever being triggered, at the cost of losing that viewport's image data
751
778
  * for those pages. See {@link MAX_SCROLL_HEIGHT} for the chosen threshold.
779
+ *
780
+ * Scroll heights are still recorded when scrolling is skipped for the
781
+ * height limit — the measurement from `beforePageScan` is kept.
752
782
  * @param page - Puppeteer page instance
753
783
  * @param url - The page URL string (without hash and auth)
754
784
  * @param isExternal - Whether the page is external
755
785
  * @param imageLoadTimeout - Timeout (ms) for waiting images to complete loading
756
786
  * @param domEvaluationTimeout - Timeout (ms) for the in-page image extraction `page.evaluate`
757
- * @returns Array of image elements from all device presets (may be partial if some viewports failed)
787
+ * @returns Image elements plus desktop/mobile scroll heights from the scan path
758
788
  */
759
789
  get #fetchImages() { return _private_fetchImages_descriptor.value; }
760
790
  };
package/dist/types.d.ts CHANGED
@@ -42,9 +42,187 @@ export type PageData = {
42
42
  imageList: ImageElement[];
43
43
  /** HTML snapshot of the rendered DOM. */
44
44
  html: string;
45
+ /**
46
+ * Quantitative main-content metrics extracted in the browser after load.
47
+ * `null` when the page is non-HTML, external, non-HTTP, or otherwise not fully scraped.
48
+ * When HTML is scraped but no main region is found, this is still an object with empty arrays and `wordCount: 0`.
49
+ */
50
+ mainContents: MainContentsData | null;
51
+ /**
52
+ * `document.body.scrollHeight` at desktop-compact and mobile-small viewports.
53
+ * `null` when not measured (non-HTML, external, non-HTTP, or skipped).
54
+ */
55
+ scrollHeight: ScrollHeightData | null;
45
56
  /** Always `false` for successfully scraped pages. See {@link SkippedPageData} for skipped pages. */
46
57
  isSkipped: false;
47
58
  };
59
+ /**
60
+ * `document.body.scrollHeight` measured at the scraper's desktop and mobile device presets.
61
+ */
62
+ export type ScrollHeightData = {
63
+ /** Height at `desktop-compact` (width 1280), or `null` if that preset failed. */
64
+ desktop: number | null;
65
+ /** Height at `mobile-small` (width 320 @ 2x), or `null` if that preset failed. */
66
+ mobile: number | null;
67
+ };
68
+ /**
69
+ * Quantitative metrics for the detected main content region of a page.
70
+ * @example
71
+ * ```ts
72
+ * const { mainContents } = pageData;
73
+ * if (mainContents) {
74
+ * console.log(mainContents.wordCount, mainContents.headings.length);
75
+ * }
76
+ * ```
77
+ */
78
+ export type MainContentsData = {
79
+ /** `document.title` trimmed. Empty string when the title is empty. */
80
+ title: string;
81
+ /**
82
+ * Identifying info for the detected main region element.
83
+ * `null` when no selector matched (arrays are empty and `wordCount` is `0`).
84
+ */
85
+ main: MainContentsMainTag | null;
86
+ /**
87
+ * Character count of the main region's `textContent` after whitespace removal.
88
+ * `0` when no main region was found or the text is whitespace-only.
89
+ */
90
+ wordCount: number;
91
+ /**
92
+ * Character count of `document.body` `textContent` after whitespace removal.
93
+ * Measured even when no main region is found (`0` if `body` is missing).
94
+ */
95
+ bodyWordCount: number;
96
+ /** Headings (`h1`–`h6`) inside the main region, in DOM order. */
97
+ headings: MainContentsHeading[];
98
+ /** Images (`img`, `input[type=image]`) inside the main region, in DOM order. */
99
+ images: MainContentsImage[];
100
+ /** Tables inside the main region, in DOM order. */
101
+ tables: MainContentsTable[];
102
+ /** Button-like elements inside the main region, in DOM order. */
103
+ buttons: MainContentsButton[];
104
+ /** Iframes inside the main region, in DOM order. */
105
+ iframes: MainContentsIframe[];
106
+ /** Videos inside the main region, in DOM order. */
107
+ videos: MainContentsVideo[];
108
+ /** Audios inside the main region, in DOM order. */
109
+ audios: MainContentsAudio[];
110
+ /** Canvases inside the main region, in DOM order. */
111
+ canvases: MainContentsCanvas[];
112
+ };
113
+ /**
114
+ * Identifying information about the DOM element used as the main content area.
115
+ */
116
+ export type MainContentsMainTag = {
117
+ /** Element tag name (e.g. `"MAIN"`, `"DIV"`), as reported by `nodeName`. */
118
+ nodeName: string;
119
+ /** The element's `id`, or `null` when empty/absent. */
120
+ id: string | null;
121
+ /** CSS class names on the element. */
122
+ classList: string[];
123
+ /** The WAI-ARIA `role` attribute value, or `null` when absent. */
124
+ role: string | null;
125
+ /**
126
+ * A simple diagnostic CSS selector built from tag + id + classes
127
+ * (not `@medv/finder`). // cspell:disable-line
128
+ */
129
+ selector: string;
130
+ };
131
+ /**
132
+ * A heading element (`h1`–`h6`) found within the main content area.
133
+ */
134
+ export type MainContentsHeading = {
135
+ /** Heading text after whitespace removal, or `null` when empty. */
136
+ text: string | null;
137
+ /** Heading level from the tag name. */
138
+ level: 1 | 2 | 3 | 4 | 5 | 6;
139
+ };
140
+ /**
141
+ * An `<img>` or `<input type="image">` element found in the main content.
142
+ */
143
+ export type MainContentsImage = {
144
+ /** Resolved absolute `src` URL. */
145
+ src: string;
146
+ /** `alt` attribute value (may be an empty string). */
147
+ alt: string;
148
+ };
149
+ /**
150
+ * Structural summary of a `<table>` found within the main content.
151
+ */
152
+ export type MainContentsTable = {
153
+ /** Number of `<tr>` elements. */
154
+ rows: number;
155
+ /** Number of `th`/`td` cells in the first row, or `0` when there is no row. */
156
+ cols: number;
157
+ /** Whether the table contains a `<thead>`. */
158
+ hasHeader: boolean;
159
+ /** Whether the table contains a `<tfoot>`. */
160
+ hasFooter: boolean;
161
+ /** Whether any cell uses `colspan` or `rowspan`. */
162
+ hasMergedCell: boolean;
163
+ };
164
+ /**
165
+ * A button-like element found in the main content
166
+ * (`button`, `[role=button]`, `[class*=button]`, `[class*=btn]`).
167
+ */
168
+ export type MainContentsButton = {
169
+ /** Element tag name (e.g. `"BUTTON"`, `"A"`, `"DIV"`). */
170
+ nodeName: string;
171
+ /** `role` attribute, or `null` when absent. */
172
+ role: string | null;
173
+ /** `type` for `<button>` / `<input>`, otherwise `null`. */
174
+ type: string | null;
175
+ /** Label text after whitespace removal, or `null` when empty. */
176
+ text: string | null;
177
+ /** `true` when `disabled` or `aria-disabled="true"`. */
178
+ disabled: boolean;
179
+ };
180
+ /**
181
+ * An `<iframe>` found in the main content.
182
+ */
183
+ export type MainContentsIframe = {
184
+ /** Resolved absolute `src` URL (empty string when unset). */
185
+ src: string;
186
+ /** `title` attribute, or `null` when the attribute is absent. */
187
+ title: string | null;
188
+ /** Raw `width` attribute string, or `null` when absent. */
189
+ width: string | null;
190
+ /** Raw `height` attribute string, or `null` when absent. */
191
+ height: string | null;
192
+ };
193
+ /**
194
+ * A `<video>` found in the main content.
195
+ */
196
+ export type MainContentsVideo = {
197
+ /**
198
+ * Resolved media URL: `currentSrc`, else `src` attribute, else first `source[src]`, else `""`.
199
+ */
200
+ src: string;
201
+ /** Resolved `poster` URL, or `null` when unset. */
202
+ poster: string | null;
203
+ /** IDL `width` in pixels. */
204
+ width: number;
205
+ /** IDL `height` in pixels. */
206
+ height: number;
207
+ };
208
+ /**
209
+ * An `<audio>` found in the main content.
210
+ */
211
+ export type MainContentsAudio = {
212
+ /**
213
+ * Resolved media URL: `currentSrc`, else `src` attribute, else first `source[src]`, else `""`.
214
+ */
215
+ src: string;
216
+ };
217
+ /**
218
+ * A `<canvas>` found in the main content.
219
+ */
220
+ export type MainContentsCanvas = {
221
+ /** IDL bitmap width. */
222
+ width: number;
223
+ /** IDL bitmap height. */
224
+ height: number;
225
+ };
48
226
  /**
49
227
  * Information about an image element found on a page.
50
228
  */
@@ -316,4 +494,9 @@ export type ScraperOptions = {
316
494
  * Default: 180_000 (180s, aligned with the upstream retryable timeout).
317
495
  */
318
496
  domEvaluationTimeout?: number;
497
+ /**
498
+ * CSS selector prepended to the default main-content detection list
499
+ * (highest priority). When omitted or `null`, only the built-in selectors are used.
500
+ */
501
+ mainContentSelector?: string | null;
319
502
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d-zero/beholder",
3
- "version": "3.1.5",
3
+ "version": "4.0.0",
4
4
  "description": "Page-level scraper for web crawling and auditing",
5
5
  "author": "D-ZERO",
6
6
  "license": "MIT",
@@ -20,7 +20,7 @@
20
20
  "clean": "tsc --build --clean"
21
21
  },
22
22
  "dependencies": {
23
- "@d-zero/puppeteer-page-scan": "4.6.4",
23
+ "@d-zero/puppeteer-page-scan": "4.6.5",
24
24
  "@d-zero/shared": "0.22.2",
25
25
  "debug": "4.4.3",
26
26
  "puppeteer": "25.3.0",
@@ -36,5 +36,5 @@
36
36
  "url": "https://github.com/d-zero-dev/tools.git",
37
37
  "directory": "packages/@d-zero/beholder"
38
38
  },
39
- "gitHead": "d5bd3c24d9ad6e422d92385645c6f0b3fd3a5ee8"
39
+ "gitHead": "581df4a1796bd864f30980c759e6ada99914ffb7"
40
40
  }