@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,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
@@ -18,7 +18,7 @@ export { detectCompress } from '@d-zero/shared/detect-compress';
18
18
  export type { CompressType } from '@d-zero/shared/detect-compress';
19
19
  export { detectCDN } from '@d-zero/shared/detect-cdn';
20
20
  export type { CDNType } from '@d-zero/shared/detect-cdn';
21
- export type { ScrapeResult, ResourceEntry, PageData } from './types.js';
21
+ export type { ScrapeResult, ResourceEntry, ConsoleLogEntry, PageData } from './types.js';
22
22
  export type { ScraperOptions, ChangePhaseEvent, ScraperEventTypes } from './types.js';
23
23
  export type {
24
24
  Resource,
@@ -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
+ }