@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.
@@ -0,0 +1,10 @@
1
+ import type { ConsoleLogEntry } from './types.js';
2
+ /**
3
+ * Converts an uncaught exception / unhandled Promise rejection (captured via
4
+ * `page.on('pageerror')`) into a `ConsoleLogEntry`.
5
+ * @param error - The value emitted by `page.on('pageerror')`; typed as `unknown`
6
+ * because Puppeteer allows non-`Error` throw values (e.g. `throw 'oops'`)
7
+ * @param pageUrl - The URL (without hash) of the page that produced the error
8
+ * @returns The console log entry, with `type: 'pageerror'`
9
+ */
10
+ export declare function toPageErrorEntry(error: unknown, pageUrl: string): ConsoleLogEntry;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Converts an uncaught exception / unhandled Promise rejection (captured via
3
+ * `page.on('pageerror')`) into a `ConsoleLogEntry`.
4
+ * @param error - The value emitted by `page.on('pageerror')`; typed as `unknown`
5
+ * because Puppeteer allows non-`Error` throw values (e.g. `throw 'oops'`)
6
+ * @param pageUrl - The URL (without hash) of the page that produced the error
7
+ * @returns The console log entry, with `type: 'pageerror'`
8
+ */
9
+ export function toPageErrorEntry(error, pageUrl) {
10
+ const isErr = error instanceof Error;
11
+ return {
12
+ pageUrl,
13
+ type: 'pageerror',
14
+ text: isErr ? error.message : String(error),
15
+ args: [],
16
+ stack: isErr ? error.stack : undefined,
17
+ ts: Date.now(),
18
+ };
19
+ }
package/dist/types.d.ts CHANGED
@@ -12,6 +12,7 @@ import type { Meta } from './meta/types.js';
12
12
  import type { CDNType } from '@d-zero/shared/detect-cdn';
13
13
  import type { CompressType } from '@d-zero/shared/detect-compress';
14
14
  import type { ExURL } from '@d-zero/shared/parse-url';
15
+ import type { ConsoleMessageType } from 'puppeteer';
15
16
  /**
16
17
  * Scraped page data returned by the scraper after successfully processing a page.
17
18
  */
@@ -42,9 +43,187 @@ export type PageData = {
42
43
  imageList: ImageElement[];
43
44
  /** HTML snapshot of the rendered DOM. */
44
45
  html: string;
46
+ /**
47
+ * Quantitative main-content metrics extracted in the browser after load.
48
+ * `null` when the page is non-HTML, external, non-HTTP, or otherwise not fully scraped.
49
+ * When HTML is scraped but no main region is found, this is still an object with empty arrays and `wordCount: 0`.
50
+ */
51
+ mainContents: MainContentsData | null;
52
+ /**
53
+ * `document.body.scrollHeight` at desktop-compact and mobile-small viewports.
54
+ * `null` when not measured (non-HTML, external, non-HTTP, or skipped).
55
+ */
56
+ scrollHeight: ScrollHeightData | null;
45
57
  /** Always `false` for successfully scraped pages. See {@link SkippedPageData} for skipped pages. */
46
58
  isSkipped: false;
47
59
  };
60
+ /**
61
+ * `document.body.scrollHeight` measured at the scraper's desktop and mobile device presets.
62
+ */
63
+ export type ScrollHeightData = {
64
+ /** Height at `desktop-compact` (width 1280), or `null` if that preset failed. */
65
+ desktop: number | null;
66
+ /** Height at `mobile-small` (width 320 @ 2x), or `null` if that preset failed. */
67
+ mobile: number | null;
68
+ };
69
+ /**
70
+ * Quantitative metrics for the detected main content region of a page.
71
+ * @example
72
+ * ```ts
73
+ * const { mainContents } = pageData;
74
+ * if (mainContents) {
75
+ * console.log(mainContents.wordCount, mainContents.headings.length);
76
+ * }
77
+ * ```
78
+ */
79
+ export type MainContentsData = {
80
+ /** `document.title` trimmed. Empty string when the title is empty. */
81
+ title: string;
82
+ /**
83
+ * Identifying info for the detected main region element.
84
+ * `null` when no selector matched (arrays are empty and `wordCount` is `0`).
85
+ */
86
+ main: MainContentsMainTag | null;
87
+ /**
88
+ * Character count of the main region's `textContent` after whitespace removal.
89
+ * `0` when no main region was found or the text is whitespace-only.
90
+ */
91
+ wordCount: number;
92
+ /**
93
+ * Character count of `document.body` `textContent` after whitespace removal.
94
+ * Measured even when no main region is found (`0` if `body` is missing).
95
+ */
96
+ bodyWordCount: number;
97
+ /** Headings (`h1`–`h6`) inside the main region, in DOM order. */
98
+ headings: MainContentsHeading[];
99
+ /** Images (`img`, `input[type=image]`) inside the main region, in DOM order. */
100
+ images: MainContentsImage[];
101
+ /** Tables inside the main region, in DOM order. */
102
+ tables: MainContentsTable[];
103
+ /** Button-like elements inside the main region, in DOM order. */
104
+ buttons: MainContentsButton[];
105
+ /** Iframes inside the main region, in DOM order. */
106
+ iframes: MainContentsIframe[];
107
+ /** Videos inside the main region, in DOM order. */
108
+ videos: MainContentsVideo[];
109
+ /** Audios inside the main region, in DOM order. */
110
+ audios: MainContentsAudio[];
111
+ /** Canvases inside the main region, in DOM order. */
112
+ canvases: MainContentsCanvas[];
113
+ };
114
+ /**
115
+ * Identifying information about the DOM element used as the main content area.
116
+ */
117
+ export type MainContentsMainTag = {
118
+ /** Element tag name (e.g. `"MAIN"`, `"DIV"`), as reported by `nodeName`. */
119
+ nodeName: string;
120
+ /** The element's `id`, or `null` when empty/absent. */
121
+ id: string | null;
122
+ /** CSS class names on the element. */
123
+ classList: string[];
124
+ /** The WAI-ARIA `role` attribute value, or `null` when absent. */
125
+ role: string | null;
126
+ /**
127
+ * A simple diagnostic CSS selector built from tag + id + classes
128
+ * (not `@medv/finder`). // cspell:disable-line
129
+ */
130
+ selector: string;
131
+ };
132
+ /**
133
+ * A heading element (`h1`–`h6`) found within the main content area.
134
+ */
135
+ export type MainContentsHeading = {
136
+ /** Heading text after whitespace removal, or `null` when empty. */
137
+ text: string | null;
138
+ /** Heading level from the tag name. */
139
+ level: 1 | 2 | 3 | 4 | 5 | 6;
140
+ };
141
+ /**
142
+ * An `<img>` or `<input type="image">` element found in the main content.
143
+ */
144
+ export type MainContentsImage = {
145
+ /** Resolved absolute `src` URL. */
146
+ src: string;
147
+ /** `alt` attribute value (may be an empty string). */
148
+ alt: string;
149
+ };
150
+ /**
151
+ * Structural summary of a `<table>` found within the main content.
152
+ */
153
+ export type MainContentsTable = {
154
+ /** Number of `<tr>` elements. */
155
+ rows: number;
156
+ /** Number of `th`/`td` cells in the first row, or `0` when there is no row. */
157
+ cols: number;
158
+ /** Whether the table contains a `<thead>`. */
159
+ hasHeader: boolean;
160
+ /** Whether the table contains a `<tfoot>`. */
161
+ hasFooter: boolean;
162
+ /** Whether any cell uses `colspan` or `rowspan`. */
163
+ hasMergedCell: boolean;
164
+ };
165
+ /**
166
+ * A button-like element found in the main content
167
+ * (`button`, `[role=button]`, `[class*=button]`, `[class*=btn]`).
168
+ */
169
+ export type MainContentsButton = {
170
+ /** Element tag name (e.g. `"BUTTON"`, `"A"`, `"DIV"`). */
171
+ nodeName: string;
172
+ /** `role` attribute, or `null` when absent. */
173
+ role: string | null;
174
+ /** `type` for `<button>` / `<input>`, otherwise `null`. */
175
+ type: string | null;
176
+ /** Label text after whitespace removal, or `null` when empty. */
177
+ text: string | null;
178
+ /** `true` when `disabled` or `aria-disabled="true"`. */
179
+ disabled: boolean;
180
+ };
181
+ /**
182
+ * An `<iframe>` found in the main content.
183
+ */
184
+ export type MainContentsIframe = {
185
+ /** Resolved absolute `src` URL (empty string when unset). */
186
+ src: string;
187
+ /** `title` attribute, or `null` when the attribute is absent. */
188
+ title: string | null;
189
+ /** Raw `width` attribute string, or `null` when absent. */
190
+ width: string | null;
191
+ /** Raw `height` attribute string, or `null` when absent. */
192
+ height: string | null;
193
+ };
194
+ /**
195
+ * A `<video>` found in the main content.
196
+ */
197
+ export type MainContentsVideo = {
198
+ /**
199
+ * Resolved media URL: `currentSrc`, else `src` attribute, else first `source[src]`, else `""`.
200
+ */
201
+ src: string;
202
+ /** Resolved `poster` URL, or `null` when unset. */
203
+ poster: string | null;
204
+ /** IDL `width` in pixels. */
205
+ width: number;
206
+ /** IDL `height` in pixels. */
207
+ height: number;
208
+ };
209
+ /**
210
+ * An `<audio>` found in the main content.
211
+ */
212
+ export type MainContentsAudio = {
213
+ /**
214
+ * Resolved media URL: `currentSrc`, else `src` attribute, else first `source[src]`, else `""`.
215
+ */
216
+ src: string;
217
+ };
218
+ /**
219
+ * A `<canvas>` found in the main content.
220
+ */
221
+ export type MainContentsCanvas = {
222
+ /** IDL bitmap width. */
223
+ width: number;
224
+ /** IDL bitmap height. */
225
+ height: number;
226
+ };
48
227
  /**
49
228
  * Information about an image element found on a page.
50
229
  */
@@ -189,6 +368,8 @@ export type ScrapeResult = {
189
368
  pageData?: PageData;
190
369
  /** All sub-resources captured during the page load. */
191
370
  resources: ResourceEntry[];
371
+ /** All console messages and uncaught page errors captured during the page load. */
372
+ consoleLogs: ConsoleLogEntry[];
192
373
  /** Details about why the page was ignored, present when `type` is `"skipped"`. */
193
374
  ignored?: {
194
375
  url: ExURL;
@@ -220,6 +401,38 @@ export type ResourceEntry = {
220
401
  /** The URL (without hash) of the page that triggered this resource load. */
221
402
  pageUrl: string;
222
403
  };
404
+ /**
405
+ * A single console message or uncaught page error captured during page scraping.
406
+ * Captured via Puppeteer's `console` and `pageerror` page events; internal pages only.
407
+ */
408
+ export type ConsoleLogEntry = {
409
+ /** The URL (without hash) of the page that produced this message. */
410
+ pageUrl: string;
411
+ /**
412
+ * The console message type (Puppeteer's `ConsoleMessageType`, e.g. `"log"`, `"warn"`, `"error"`),
413
+ * or `"pageerror"` for an uncaught exception / unhandled Promise rejection.
414
+ */
415
+ type: ConsoleMessageType | 'pageerror';
416
+ /** The message text, or the error's `message` for `"pageerror"` entries. */
417
+ text: string;
418
+ /**
419
+ * Arguments passed to the console call, resolved via `JSHandle.jsonValue()`.
420
+ * An individual argument is `undefined` when it could not be resolved (e.g. a
421
+ * destroyed execution context or a value `jsonValue()` cannot serialize).
422
+ * Always empty for `"pageerror"` entries.
423
+ */
424
+ args: unknown[];
425
+ /** Source location of the message, or `undefined` when unavailable (always `undefined` for `"pageerror"`). */
426
+ location?: {
427
+ url?: string;
428
+ lineNumber?: number;
429
+ columnNumber?: number;
430
+ };
431
+ /** Stack trace text, present only for `"pageerror"` entries. */
432
+ stack?: string;
433
+ /** Timestamp (ms since epoch) when the message was captured. */
434
+ ts: number;
435
+ };
223
436
  /**
224
437
  * Event payload describing a phase transition in the scraping lifecycle.
225
438
  * Phases proceed roughly in order: scrapeStart -> openPage ->
@@ -316,4 +529,9 @@ export type ScraperOptions = {
316
529
  * Default: 180_000 (180s, aligned with the upstream retryable timeout).
317
530
  */
318
531
  domEvaluationTimeout?: number;
532
+ /**
533
+ * CSS selector prepended to the default main-content detection list
534
+ * (highest priority). When omitted or `null`, only the built-in selectors are used.
535
+ */
536
+ mainContentSelector?: string | null;
319
537
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d-zero/beholder",
3
- "version": "3.1.6",
3
+ "version": "4.1.0",
4
4
  "description": "Page-level scraper for web crawling and auditing",
5
5
  "author": "D-ZERO",
6
6
  "license": "MIT",
@@ -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": "9c1e3da176d39972eae9903a979e942a59515229"
39
+ "gitHead": "d8d1cf278af98e1779e270ddd08da9496eae71b1"
40
40
  }
@@ -0,0 +1,282 @@
1
+ import type { Page } from 'puppeteer';
2
+
3
+ import { JSDOM } from 'jsdom';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+
6
+ import { extractMainContentsFromDocument, getMainContents } from './get-main-contents.js';
7
+
8
+ /**
9
+ * @param html
10
+ */
11
+ function createDocument(html: string): Document {
12
+ return new JSDOM(html, { url: 'https://example.com/' }).window.document;
13
+ }
14
+
15
+ /**
16
+ * @param html
17
+ * @param mainContentSelector
18
+ */
19
+ function extract(html: string, mainContentSelector: string | null = null) {
20
+ return extractMainContentsFromDocument(mainContentSelector, createDocument(html));
21
+ }
22
+
23
+ describe('extractMainContentsFromDocument', () => {
24
+ it('returns empty main metrics when no main content element is found, but counts body text', () => {
25
+ const result = extract(
26
+ '<html><head><title>T</title></head><body><header>ABC</header><div>No main</div></body></html>',
27
+ );
28
+
29
+ expect(result).toEqual({
30
+ title: 'T',
31
+ main: null,
32
+ wordCount: 0,
33
+ bodyWordCount: 9,
34
+ headings: [],
35
+ images: [],
36
+ tables: [],
37
+ buttons: [],
38
+ iframes: [],
39
+ videos: [],
40
+ audios: [],
41
+ canvases: [],
42
+ });
43
+ });
44
+
45
+ it('detects <main> and counts word length', () => {
46
+ const result = extract('<html><body><main>こんにちは世界</main></body></html>');
47
+
48
+ expect(result.wordCount).toBe(7);
49
+ expect(result.bodyWordCount).toBeGreaterThanOrEqual(7);
50
+ expect(result.main?.nodeName).toBe('MAIN');
51
+ });
52
+
53
+ it('detects [role="main"]', () => {
54
+ const result = extract('<html><body><div role="main">Content</div></body></html>');
55
+
56
+ expect(result.wordCount).toBe(7);
57
+ expect(result.main?.role).toBe('main');
58
+ });
59
+
60
+ it('uses custom mainContentSelector when provided', () => {
61
+ const result = extract(
62
+ '<html><body><section id="page-body">Custom main</section></body></html>',
63
+ '#page-body',
64
+ );
65
+
66
+ expect(result.wordCount).toBe(10);
67
+ expect(result.main?.id).toBe('page-body');
68
+ });
69
+
70
+ it('falls back to built-in selectors when custom selector is invalid', () => {
71
+ const result = extract(
72
+ '<html><body><main>Still works</main></body></html>',
73
+ '[[[invalid',
74
+ );
75
+
76
+ expect(result.main?.nodeName).toBe('MAIN');
77
+ expect(result.wordCount).toBe(10);
78
+ });
79
+
80
+ it('extracts headings from main content', () => {
81
+ const result = extract(`
82
+ <html><body><main>
83
+ <h1>Title</h1>
84
+ <h2>Subtitle</h2>
85
+ <h3>Section</h3>
86
+ </main></body></html>
87
+ `);
88
+
89
+ expect(result.headings).toEqual([
90
+ { text: 'Title', level: 1 },
91
+ { text: 'Subtitle', level: 2 },
92
+ { text: 'Section', level: 3 },
93
+ ]);
94
+ });
95
+
96
+ it('extracts images from main content', () => {
97
+ const result = extract(`
98
+ <html><body><main>
99
+ <img src="a.png" alt="Image A">
100
+ <img src="b.png" alt="Image B">
101
+ <input type="image" src="c.png" alt="Image C">
102
+ </main></body></html>
103
+ `);
104
+
105
+ expect(result.images).toHaveLength(3);
106
+ expect(result.images[0]?.alt).toBe('Image A');
107
+ expect(result.images[0]?.src).toContain('a.png');
108
+ });
109
+
110
+ it('extracts table metadata from main content', () => {
111
+ const result = extract(`
112
+ <html><body><main>
113
+ <table>
114
+ <thead><tr><th>A</th><th>B</th></tr></thead>
115
+ <tfoot><tr><td>Footer</td><td></td></tr></tfoot>
116
+ <tbody>
117
+ <tr><td colspan="2">Merged</td></tr>
118
+ <tr><td>1</td><td>2</td></tr>
119
+ </tbody>
120
+ </table>
121
+ </main></body></html>
122
+ `);
123
+
124
+ expect(result.tables).toEqual([
125
+ {
126
+ rows: 4,
127
+ cols: 2,
128
+ hasHeader: true,
129
+ hasFooter: true,
130
+ hasMergedCell: true,
131
+ },
132
+ ]);
133
+ });
134
+
135
+ it('extracts buttons from main content', () => {
136
+ const result = extract(`
137
+ <html><body><main>
138
+ <button>Save</button>
139
+ <a role="button" href="#">Go</a>
140
+ <span class="btn">Click</span>
141
+ <div class="share-button">Share</div>
142
+ </main></body></html>
143
+ `);
144
+
145
+ expect(result.buttons).toHaveLength(4);
146
+ expect(result.buttons[0]).toMatchObject({
147
+ nodeName: 'BUTTON',
148
+ text: 'Save',
149
+ disabled: false,
150
+ });
151
+ expect(result.buttons[1]?.role).toBe('button');
152
+ });
153
+
154
+ it('extracts iframe, video, audio, and canvas from main content', () => {
155
+ const result = extract(`
156
+ <html><body><main>
157
+ <iframe src="/embed" title="Embed" width="640" height="360"></iframe>
158
+ <video src="/movie.mp4" poster="/poster.jpg" width="320" height="180"></video>
159
+ <audio src="/sound.mp3"></audio>
160
+ <canvas width="100" height="50"></canvas>
161
+ </main></body></html>
162
+ `);
163
+
164
+ expect(result.iframes).toHaveLength(1);
165
+ expect(result.iframes[0]).toMatchObject({
166
+ title: 'Embed',
167
+ width: '640',
168
+ height: '360',
169
+ });
170
+ expect(result.iframes[0]?.src).toContain('/embed');
171
+
172
+ expect(result.videos).toHaveLength(1);
173
+ expect(result.videos[0]?.src).toContain('/movie.mp4');
174
+ expect(result.videos[0]?.poster).toContain('/poster.jpg');
175
+ expect(result.videos[0]?.width).toBe(320);
176
+ expect(result.videos[0]?.height).toBe(180);
177
+
178
+ expect(result.audios).toHaveLength(1);
179
+ expect(result.audios[0]?.src).toContain('/sound.mp3');
180
+
181
+ expect(result.canvases).toEqual([{ width: 100, height: 50 }]);
182
+ });
183
+
184
+ it('treats whitespace-only main text as zero word count', () => {
185
+ const result = extract('<html><body><main> \n\t </main></body></html>');
186
+
187
+ expect(result.wordCount).toBe(0);
188
+ });
189
+
190
+ it('detects #content fallback selector', () => {
191
+ const result = extract(
192
+ '<html><body><div id="content">Fallback content</div></body></html>',
193
+ );
194
+
195
+ expect(result.wordCount).toBe(15);
196
+ });
197
+
198
+ it('detects .main fallback selector', () => {
199
+ const result = extract(
200
+ '<html><body><div class="main">Class-based main</div></body></html>',
201
+ );
202
+
203
+ expect(result.wordCount).toBe(15);
204
+ });
205
+
206
+ it('returns first matching element in DOM order when multiple Phase-1 selectors match', () => {
207
+ const result = extract(`
208
+ <html><body>
209
+ <main>Semantic main</main>
210
+ <div id="content">ID content</div>
211
+ </body></html>
212
+ `);
213
+
214
+ expect(result.main?.nodeName).toBe('MAIN');
215
+ expect(result.wordCount).toBe(12);
216
+ });
217
+
218
+ it('uses Phase-2 class*=main when Phase-1 finds nothing', () => {
219
+ const result = extract(
220
+ '<html><body><div class="page-main-area">Phase two</div></body></html>',
221
+ );
222
+
223
+ expect(result.main?.classList).toContain('page-main-area');
224
+ // "Phase two" → "Phasetwo" (8) after whitespace removal
225
+ expect(result.wordCount).toBe(8);
226
+ });
227
+
228
+ it('uses Phase-2 id*=content case-insensitively when Phase-1 finds nothing', () => {
229
+ const result = extract(
230
+ '<html><body><div id="PrimaryContent">Primary</div></body></html>',
231
+ );
232
+
233
+ expect(result.main?.id).toBe('PrimaryContent');
234
+ expect(result.wordCount).toBe(7);
235
+ });
236
+
237
+ it('prefers Phase-1 <main> over an earlier header-content that would match Phase-2', () => {
238
+ const result = extract(`
239
+ <html><body>
240
+ <div class="header-content">Chrome</div>
241
+ <main>Real main</main>
242
+ </body></html>
243
+ `);
244
+
245
+ expect(result.main?.nodeName).toBe('MAIN');
246
+ expect(result.wordCount).toBe(8);
247
+ });
248
+
249
+ it('may match class*=main on maintenance via Phase-2 (accepted false positive)', () => {
250
+ const result = extract(
251
+ '<html><body><div class="maintenance">Down</div></body></html>',
252
+ );
253
+
254
+ expect(result.main?.classList).toContain('maintenance');
255
+ expect(result.wordCount).toBe(4);
256
+ });
257
+
258
+ it('builds a simple diagnostic selector from tag, id, and classes', () => {
259
+ const result = extract(
260
+ '<html><body><div id="page-body" class="foo bar" role="main">X</div></body></html>',
261
+ );
262
+
263
+ expect(result.main?.selector).toBe('div#page-body.foo.bar');
264
+ });
265
+ });
266
+
267
+ describe('getMainContents', () => {
268
+ it('passes only the selector into page.evaluate so Document defaults in the page realm', async () => {
269
+ const evaluate = vi.fn(
270
+ (fn: typeof extractMainContentsFromDocument, sel: string | null) =>
271
+ Promise.resolve(
272
+ fn(sel, createDocument('<html><body><main>Hi</main></body></html>')),
273
+ ),
274
+ );
275
+ const page = { evaluate } as unknown as Page;
276
+
277
+ const result = await getMainContents(page, { mainContentSelector: '#x' });
278
+
279
+ expect(evaluate).toHaveBeenCalledWith(extractMainContentsFromDocument, '#x');
280
+ expect(result.wordCount).toBe(2);
281
+ });
282
+ });