@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.
- package/CHANGELOG.md +10 -0
- package/README.md +38 -3
- package/dist/get-main-contents.d.ts +48 -0
- package/dist/get-main-contents.js +270 -0
- package/dist/index.d.ts +1 -1
- package/dist/is-html-content-type.d.ts +18 -0
- package/dist/is-html-content-type.js +20 -0
- package/dist/measure-scroll-height.d.ts +23 -0
- package/dist/measure-scroll-height.js +44 -0
- package/dist/scraper.js +53 -23
- package/dist/types.d.ts +183 -0
- package/package.json +2 -2
- package/src/get-main-contents.spec.ts +282 -0
- package/src/get-main-contents.ts +306 -0
- package/src/index.ts +11 -0
- package/src/is-html-content-type.spec.ts +17 -0
- package/src/is-html-content-type.ts +20 -0
- package/src/measure-scroll-height.spec.ts +47 -0
- package/src/measure-scroll-height.ts +52 -0
- package/src/scraper.ts +67 -31
- package/src/types.ts +196 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
|
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
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
|
723
|
-
* best-effort. If all retries fail,
|
|
724
|
-
* failing the entire page scrape. The 20-min wall clock
|
|
725
|
-
* whose mobile-small `scrollHeight` reaches ~300k px
|
|
726
|
-
* responsive data tables, which take ~5 min to scroll). A shorter
|
|
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
|
|
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: {
|
|
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
|
}
|