@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,108 @@
1
+ import type { ConsoleMessage, ConsoleMessageLocation, JSHandle } from 'puppeteer';
2
+
3
+ import { describe, expect, it, vi } from 'vitest';
4
+
5
+ import { toConsoleLogEntry } from './to-console-log-entry.js';
6
+
7
+ /**
8
+ * Builds a minimal `JSHandle` mock whose `jsonValue()` resolves with the given value.
9
+ * @param value
10
+ */
11
+ function mockArg(value: unknown): JSHandle {
12
+ return {
13
+ jsonValue: () => Promise.resolve(value),
14
+ dispose: () => Promise.resolve(),
15
+ } as unknown as JSHandle;
16
+ }
17
+
18
+ /**
19
+ * Builds a `JSHandle` mock whose `jsonValue()` rejects, simulating a handle
20
+ * that can no longer be resolved (e.g. a destroyed execution context).
21
+ * @param reason
22
+ */
23
+ function mockRejectingArg(reason: unknown): JSHandle {
24
+ return {
25
+ jsonValue: () => Promise.reject(reason),
26
+ dispose: () => Promise.resolve(),
27
+ } as unknown as JSHandle;
28
+ }
29
+
30
+ /**
31
+ * Builds a minimal `ConsoleMessage` mock.
32
+ * @param options
33
+ * @param options.type
34
+ * @param options.text
35
+ * @param options.args
36
+ * @param options.location
37
+ */
38
+ function mockConsoleMessage(options: {
39
+ type?: string;
40
+ text?: string;
41
+ args?: JSHandle[];
42
+ location?: ConsoleMessageLocation;
43
+ }): ConsoleMessage {
44
+ return {
45
+ type: () => options.type ?? 'log',
46
+ text: () => options.text ?? '',
47
+ args: () => options.args ?? [],
48
+ location: () => options.location ?? {},
49
+ } as unknown as ConsoleMessage;
50
+ }
51
+
52
+ describe('toConsoleLogEntry', () => {
53
+ it('resolves text, type, and pageUrl', async () => {
54
+ const msg = mockConsoleMessage({ type: 'warn', text: 'careful' });
55
+ const entry = await toConsoleLogEntry(msg, 'https://example.com/');
56
+
57
+ expect(entry.pageUrl).toBe('https://example.com/');
58
+ expect(entry.type).toBe('warn');
59
+ expect(entry.text).toBe('careful');
60
+ expect(entry.args).toEqual([]);
61
+ });
62
+
63
+ it('resolves each argument via jsonValue()', async () => {
64
+ const msg = mockConsoleMessage({
65
+ args: [mockArg('a'), mockArg(1), mockArg({ b: 2 })],
66
+ });
67
+ const entry = await toConsoleLogEntry(msg, 'https://example.com/');
68
+
69
+ expect(entry.args).toEqual(['a', 1, { b: 2 }]);
70
+ });
71
+
72
+ it('falls back to undefined for an argument whose jsonValue() rejects', async () => {
73
+ const msg = mockConsoleMessage({
74
+ args: [mockArg('ok'), mockRejectingArg(new Error('destroyed context'))],
75
+ });
76
+ const entry = await toConsoleLogEntry(msg, 'https://example.com/');
77
+
78
+ expect(entry.args).toEqual(['ok', undefined]);
79
+ });
80
+
81
+ it('disposes each argument handle after resolving', async () => {
82
+ const arg = mockArg('a');
83
+ const disposeSpy = vi.spyOn(arg, 'dispose');
84
+ const msg = mockConsoleMessage({ args: [arg] });
85
+ await toConsoleLogEntry(msg, 'https://example.com/');
86
+
87
+ expect(disposeSpy).toHaveBeenCalledOnce();
88
+ });
89
+
90
+ it('keeps location when the message has a source URL', async () => {
91
+ const location = {
92
+ url: 'https://example.com/app.js',
93
+ lineNumber: 3,
94
+ columnNumber: 7,
95
+ };
96
+ const msg = mockConsoleMessage({ location });
97
+ const entry = await toConsoleLogEntry(msg, 'https://example.com/');
98
+
99
+ expect(entry.location).toEqual(location);
100
+ });
101
+
102
+ it('omits location when the message has no source URL', async () => {
103
+ const msg = mockConsoleMessage({ location: {} });
104
+ const entry = await toConsoleLogEntry(msg, 'https://example.com/');
105
+
106
+ expect(entry.location).toBeUndefined();
107
+ });
108
+ });
@@ -0,0 +1,40 @@
1
+ import type { ConsoleLogEntry } from './types.js';
2
+ import type { ConsoleMessage } from 'puppeteer';
3
+
4
+ /**
5
+ * Converts a Puppeteer `ConsoleMessage` into a `ConsoleLogEntry`.
6
+ *
7
+ * WHY per-argument try/catch: `JSHandle.jsonValue()` can reject when the
8
+ * page's execution context has been destroyed (e.g. mid-navigation) or when
9
+ * the value cannot be serialized (e.g. a circular reference). A single
10
+ * failing argument must not drop the rest of the message's arguments.
11
+ * @param msg - The console message captured via `page.on('console')`
12
+ * @param pageUrl - The URL (without hash) of the page that produced the message
13
+ * @returns The resolved console log entry
14
+ */
15
+ export async function toConsoleLogEntry(
16
+ msg: ConsoleMessage,
17
+ pageUrl: string,
18
+ ): Promise<ConsoleLogEntry> {
19
+ const args = await Promise.all(
20
+ msg.args().map(async (arg) => {
21
+ try {
22
+ return await arg.jsonValue();
23
+ } catch {
24
+ return;
25
+ } finally {
26
+ await arg.dispose().catch(() => {});
27
+ }
28
+ }),
29
+ );
30
+ const loc = msg.location();
31
+
32
+ return {
33
+ pageUrl,
34
+ type: msg.type(),
35
+ text: msg.text(),
36
+ args,
37
+ location: loc.url === undefined ? undefined : loc,
38
+ ts: Date.now(),
39
+ };
40
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { toPageErrorEntry } from './to-page-error-entry.js';
4
+
5
+ describe('toPageErrorEntry', () => {
6
+ it('extracts message and stack from an Error', () => {
7
+ const error = new Error('boom');
8
+ const entry = toPageErrorEntry(error, 'https://example.com/');
9
+
10
+ expect(entry.pageUrl).toBe('https://example.com/');
11
+ expect(entry.type).toBe('pageerror');
12
+ expect(entry.text).toBe('boom');
13
+ expect(entry.stack).toBe(error.stack);
14
+ expect(entry.args).toEqual([]);
15
+ });
16
+
17
+ it('stringifies a non-Error throw value and omits stack', () => {
18
+ const entry = toPageErrorEntry('oops', 'https://example.com/');
19
+
20
+ expect(entry.text).toBe('oops');
21
+ expect(entry.stack).toBeUndefined();
22
+ });
23
+ });
@@ -0,0 +1,22 @@
1
+ import type { ConsoleLogEntry } from './types.js';
2
+
3
+ /**
4
+ * Converts an uncaught exception / unhandled Promise rejection (captured via
5
+ * `page.on('pageerror')`) into a `ConsoleLogEntry`.
6
+ * @param error - The value emitted by `page.on('pageerror')`; typed as `unknown`
7
+ * because Puppeteer allows non-`Error` throw values (e.g. `throw 'oops'`)
8
+ * @param pageUrl - The URL (without hash) of the page that produced the error
9
+ * @returns The console log entry, with `type: 'pageerror'`
10
+ */
11
+ export function toPageErrorEntry(error: unknown, pageUrl: string): ConsoleLogEntry {
12
+ const isErr = error instanceof Error;
13
+
14
+ return {
15
+ pageUrl,
16
+ type: 'pageerror',
17
+ text: isErr ? error.message : String(error),
18
+ args: [],
19
+ stack: isErr ? error.stack : undefined,
20
+ ts: Date.now(),
21
+ };
22
+ }
package/src/types.ts CHANGED
@@ -59,6 +59,7 @@ import type { Meta } from './meta/types.js';
59
59
  import type { CDNType } from '@d-zero/shared/detect-cdn';
60
60
  import type { CompressType } from '@d-zero/shared/detect-compress';
61
61
  import type { ExURL } from '@d-zero/shared/parse-url';
62
+ import type { ConsoleMessageType } from 'puppeteer';
62
63
 
63
64
  /**
64
65
  * Scraped page data returned by the scraper after successfully processing a page.
@@ -103,10 +104,201 @@ export type PageData = {
103
104
  /** HTML snapshot of the rendered DOM. */
104
105
  html: string;
105
106
 
107
+ /**
108
+ * Quantitative main-content metrics extracted in the browser after load.
109
+ * `null` when the page is non-HTML, external, non-HTTP, or otherwise not fully scraped.
110
+ * When HTML is scraped but no main region is found, this is still an object with empty arrays and `wordCount: 0`.
111
+ */
112
+ mainContents: MainContentsData | null;
113
+
114
+ /**
115
+ * `document.body.scrollHeight` at desktop-compact and mobile-small viewports.
116
+ * `null` when not measured (non-HTML, external, non-HTTP, or skipped).
117
+ */
118
+ scrollHeight: ScrollHeightData | null;
119
+
106
120
  /** Always `false` for successfully scraped pages. See {@link SkippedPageData} for skipped pages. */
107
121
  isSkipped: false;
108
122
  };
109
123
 
124
+ /**
125
+ * `document.body.scrollHeight` measured at the scraper's desktop and mobile device presets.
126
+ */
127
+ export type ScrollHeightData = {
128
+ /** Height at `desktop-compact` (width 1280), or `null` if that preset failed. */
129
+ desktop: number | null;
130
+ /** Height at `mobile-small` (width 320 @ 2x), or `null` if that preset failed. */
131
+ mobile: number | null;
132
+ };
133
+
134
+ /**
135
+ * Quantitative metrics for the detected main content region of a page.
136
+ * @example
137
+ * ```ts
138
+ * const { mainContents } = pageData;
139
+ * if (mainContents) {
140
+ * console.log(mainContents.wordCount, mainContents.headings.length);
141
+ * }
142
+ * ```
143
+ */
144
+ export type MainContentsData = {
145
+ /** `document.title` trimmed. Empty string when the title is empty. */
146
+ title: string;
147
+ /**
148
+ * Identifying info for the detected main region element.
149
+ * `null` when no selector matched (arrays are empty and `wordCount` is `0`).
150
+ */
151
+ main: MainContentsMainTag | null;
152
+ /**
153
+ * Character count of the main region's `textContent` after whitespace removal.
154
+ * `0` when no main region was found or the text is whitespace-only.
155
+ */
156
+ wordCount: number;
157
+ /**
158
+ * Character count of `document.body` `textContent` after whitespace removal.
159
+ * Measured even when no main region is found (`0` if `body` is missing).
160
+ */
161
+ bodyWordCount: number;
162
+ /** Headings (`h1`–`h6`) inside the main region, in DOM order. */
163
+ headings: MainContentsHeading[];
164
+ /** Images (`img`, `input[type=image]`) inside the main region, in DOM order. */
165
+ images: MainContentsImage[];
166
+ /** Tables inside the main region, in DOM order. */
167
+ tables: MainContentsTable[];
168
+ /** Button-like elements inside the main region, in DOM order. */
169
+ buttons: MainContentsButton[];
170
+ /** Iframes inside the main region, in DOM order. */
171
+ iframes: MainContentsIframe[];
172
+ /** Videos inside the main region, in DOM order. */
173
+ videos: MainContentsVideo[];
174
+ /** Audios inside the main region, in DOM order. */
175
+ audios: MainContentsAudio[];
176
+ /** Canvases inside the main region, in DOM order. */
177
+ canvases: MainContentsCanvas[];
178
+ };
179
+
180
+ /**
181
+ * Identifying information about the DOM element used as the main content area.
182
+ */
183
+ export type MainContentsMainTag = {
184
+ /** Element tag name (e.g. `"MAIN"`, `"DIV"`), as reported by `nodeName`. */
185
+ nodeName: string;
186
+ /** The element's `id`, or `null` when empty/absent. */
187
+ id: string | null;
188
+ /** CSS class names on the element. */
189
+ classList: string[];
190
+ /** The WAI-ARIA `role` attribute value, or `null` when absent. */
191
+ role: string | null;
192
+ /**
193
+ * A simple diagnostic CSS selector built from tag + id + classes
194
+ * (not `@medv/finder`). // cspell:disable-line
195
+ */
196
+ selector: string;
197
+ };
198
+
199
+ /**
200
+ * A heading element (`h1`–`h6`) found within the main content area.
201
+ */
202
+ export type MainContentsHeading = {
203
+ /** Heading text after whitespace removal, or `null` when empty. */
204
+ text: string | null;
205
+ /** Heading level from the tag name. */
206
+ level: 1 | 2 | 3 | 4 | 5 | 6;
207
+ };
208
+
209
+ /**
210
+ * An `<img>` or `<input type="image">` element found in the main content.
211
+ */
212
+ export type MainContentsImage = {
213
+ /** Resolved absolute `src` URL. */
214
+ src: string;
215
+ /** `alt` attribute value (may be an empty string). */
216
+ alt: string;
217
+ };
218
+
219
+ /**
220
+ * Structural summary of a `<table>` found within the main content.
221
+ */
222
+ export type MainContentsTable = {
223
+ /** Number of `<tr>` elements. */
224
+ rows: number;
225
+ /** Number of `th`/`td` cells in the first row, or `0` when there is no row. */
226
+ cols: number;
227
+ /** Whether the table contains a `<thead>`. */
228
+ hasHeader: boolean;
229
+ /** Whether the table contains a `<tfoot>`. */
230
+ hasFooter: boolean;
231
+ /** Whether any cell uses `colspan` or `rowspan`. */
232
+ hasMergedCell: boolean;
233
+ };
234
+
235
+ /**
236
+ * A button-like element found in the main content
237
+ * (`button`, `[role=button]`, `[class*=button]`, `[class*=btn]`).
238
+ */
239
+ export type MainContentsButton = {
240
+ /** Element tag name (e.g. `"BUTTON"`, `"A"`, `"DIV"`). */
241
+ nodeName: string;
242
+ /** `role` attribute, or `null` when absent. */
243
+ role: string | null;
244
+ /** `type` for `<button>` / `<input>`, otherwise `null`. */
245
+ type: string | null;
246
+ /** Label text after whitespace removal, or `null` when empty. */
247
+ text: string | null;
248
+ /** `true` when `disabled` or `aria-disabled="true"`. */
249
+ disabled: boolean;
250
+ };
251
+
252
+ /**
253
+ * An `<iframe>` found in the main content.
254
+ */
255
+ export type MainContentsIframe = {
256
+ /** Resolved absolute `src` URL (empty string when unset). */
257
+ src: string;
258
+ /** `title` attribute, or `null` when the attribute is absent. */
259
+ title: string | null;
260
+ /** Raw `width` attribute string, or `null` when absent. */
261
+ width: string | null;
262
+ /** Raw `height` attribute string, or `null` when absent. */
263
+ height: string | null;
264
+ };
265
+
266
+ /**
267
+ * A `<video>` found in the main content.
268
+ */
269
+ export type MainContentsVideo = {
270
+ /**
271
+ * Resolved media URL: `currentSrc`, else `src` attribute, else first `source[src]`, else `""`.
272
+ */
273
+ src: string;
274
+ /** Resolved `poster` URL, or `null` when unset. */
275
+ poster: string | null;
276
+ /** IDL `width` in pixels. */
277
+ width: number;
278
+ /** IDL `height` in pixels. */
279
+ height: number;
280
+ };
281
+
282
+ /**
283
+ * An `<audio>` found in the main content.
284
+ */
285
+ export type MainContentsAudio = {
286
+ /**
287
+ * Resolved media URL: `currentSrc`, else `src` attribute, else first `source[src]`, else `""`.
288
+ */
289
+ src: string;
290
+ };
291
+
292
+ /**
293
+ * A `<canvas>` found in the main content.
294
+ */
295
+ export type MainContentsCanvas = {
296
+ /** IDL bitmap width. */
297
+ width: number;
298
+ /** IDL bitmap height. */
299
+ height: number;
300
+ };
301
+
110
302
  /**
111
303
  * Information about an image element found on a page.
112
304
  */
@@ -286,6 +478,8 @@ export type ScrapeResult = {
286
478
  pageData?: PageData;
287
479
  /** All sub-resources captured during the page load. */
288
480
  resources: ResourceEntry[];
481
+ /** All console messages and uncaught page errors captured during the page load. */
482
+ consoleLogs: ConsoleLogEntry[];
289
483
  /** Details about why the page was ignored, present when `type` is `"skipped"`. */
290
484
  ignored?: { url: ExURL; matchedText: string; excludeKeywords: string[] };
291
485
  /** Error details, present when `type` is `"error"`. */
@@ -307,6 +501,35 @@ export type ResourceEntry = {
307
501
  pageUrl: string;
308
502
  };
309
503
 
504
+ /**
505
+ * A single console message or uncaught page error captured during page scraping.
506
+ * Captured via Puppeteer's `console` and `pageerror` page events; internal pages only.
507
+ */
508
+ export type ConsoleLogEntry = {
509
+ /** The URL (without hash) of the page that produced this message. */
510
+ pageUrl: string;
511
+ /**
512
+ * The console message type (Puppeteer's `ConsoleMessageType`, e.g. `"log"`, `"warn"`, `"error"`),
513
+ * or `"pageerror"` for an uncaught exception / unhandled Promise rejection.
514
+ */
515
+ type: ConsoleMessageType | 'pageerror';
516
+ /** The message text, or the error's `message` for `"pageerror"` entries. */
517
+ text: string;
518
+ /**
519
+ * Arguments passed to the console call, resolved via `JSHandle.jsonValue()`.
520
+ * An individual argument is `undefined` when it could not be resolved (e.g. a
521
+ * destroyed execution context or a value `jsonValue()` cannot serialize).
522
+ * Always empty for `"pageerror"` entries.
523
+ */
524
+ args: unknown[];
525
+ /** Source location of the message, or `undefined` when unavailable (always `undefined` for `"pageerror"`). */
526
+ location?: { url?: string; lineNumber?: number; columnNumber?: number };
527
+ /** Stack trace text, present only for `"pageerror"` entries. */
528
+ stack?: string;
529
+ /** Timestamp (ms since epoch) when the message was captured. */
530
+ ts: number;
531
+ };
532
+
310
533
  /**
311
534
  * Event payload describing a phase transition in the scraping lifecycle.
312
535
  * Phases proceed roughly in order: scrapeStart -> openPage ->
@@ -427,4 +650,9 @@ export type ScraperOptions = {
427
650
  * Default: 180_000 (180s, aligned with the upstream retryable timeout).
428
651
  */
429
652
  domEvaluationTimeout?: number;
653
+ /**
654
+ * CSS selector prepended to the default main-content detection list
655
+ * (highest priority). When omitted or `null`, only the built-in selectors are used.
656
+ */
657
+ mainContentSelector?: string | null;
430
658
  };