@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.
- package/CHANGELOG.md +16 -0
- package/README.md +48 -3
- package/dist/get-main-contents.d.ts +48 -0
- package/dist/get-main-contents.js +270 -0
- package/dist/index.d.ts +2 -2
- 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.d.ts +4 -1
- package/dist/scraper.js +242 -163
- package/dist/to-console-log-entry.d.ts +14 -0
- package/dist/to-console-log-entry.js +33 -0
- package/dist/to-page-error-entry.d.ts +10 -0
- package/dist/to-page-error-entry.js +19 -0
- package/dist/types.d.ts +218 -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 +12 -1
- 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 +277 -183
- package/src/to-console-log-entry.spec.ts +108 -0
- package/src/to-console-log-entry.ts +40 -0
- package/src/to-page-error-entry.spec.ts +23 -0
- package/src/to-page-error-entry.ts +22 -0
- package/src/types.ts +228 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/scraper.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ChangePhaseEvent,
|
|
3
|
+
ConsoleLogEntry,
|
|
3
4
|
ResourceEntry,
|
|
4
5
|
ScraperEventTypes,
|
|
5
6
|
ScraperOptions,
|
|
@@ -10,10 +11,11 @@ import type {
|
|
|
10
11
|
PageData,
|
|
11
12
|
ParseURLOptions,
|
|
12
13
|
Resource,
|
|
14
|
+
ScrollHeightData,
|
|
13
15
|
SkippedPageData,
|
|
14
16
|
} from './types.js';
|
|
15
17
|
import type { PageScanPhase } from '@d-zero/puppeteer-page-scan';
|
|
16
|
-
import type { Dialog, HTTPRequest, HTTPResponse, Page } from 'puppeteer';
|
|
18
|
+
import type { ConsoleMessage, Dialog, HTTPRequest, HTTPResponse, Page } from 'puppeteer';
|
|
17
19
|
|
|
18
20
|
import { beforePageScan, devicePresets } from '@d-zero/puppeteer-page-scan';
|
|
19
21
|
import { detectCDN } from '@d-zero/shared/detect-cdn';
|
|
@@ -28,11 +30,16 @@ import {
|
|
|
28
30
|
getImageList,
|
|
29
31
|
getMeta,
|
|
30
32
|
} from './dom-evaluation.js';
|
|
33
|
+
import { getMainContents } from './get-main-contents.js';
|
|
31
34
|
import { isError } from './is-error.js';
|
|
35
|
+
import { isHtmlContentType } from './is-html-content-type.js';
|
|
32
36
|
import { keywordCheck } from './keyword-check.js';
|
|
37
|
+
import { measureScrollHeight } from './measure-scroll-height.js';
|
|
33
38
|
import { emptyMeta } from './meta/classify.js';
|
|
34
39
|
import { findDisconnectionFailures } from './network-disconnection.js';
|
|
35
40
|
import { parseUrl } from './parse-url.js';
|
|
41
|
+
import { toConsoleLogEntry } from './to-console-log-entry.js';
|
|
42
|
+
import { toPageErrorEntry } from './to-page-error-entry.js';
|
|
36
43
|
|
|
37
44
|
const pid = `${process.pid}`;
|
|
38
45
|
const log = scraperLog.extend(pid);
|
|
@@ -82,7 +89,10 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
82
89
|
* - `type: "error"` with `error` details when scraping fails
|
|
83
90
|
*
|
|
84
91
|
* Sub-resources are collected via the `resourceResponse` event and
|
|
85
|
-
* included in the returned `ScrapeResult.resources`.
|
|
92
|
+
* included in the returned `ScrapeResult.resources`. Console messages and
|
|
93
|
+
* uncaught page errors (internal pages only) are collected via
|
|
94
|
+
* Puppeteer's `console`/`pageerror` page events and included in
|
|
95
|
+
* `ScrapeResult.consoleLogs`.
|
|
86
96
|
* @param page - The Puppeteer page instance to use for navigation and DOM evaluation.
|
|
87
97
|
* @param url - The extended URL to scrape.
|
|
88
98
|
* @param options - Optional scraper configuration overriding defaults.
|
|
@@ -102,6 +112,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
102
112
|
const metadataOnly = options?.metadataOnly ?? false;
|
|
103
113
|
const imageLoadTimeout = options?.imageLoadTimeout ?? 5000;
|
|
104
114
|
const resources: ResourceEntry[] = [];
|
|
115
|
+
const consoleLogs: ConsoleLogEntry[] = [];
|
|
105
116
|
const failedRequests: Array<{ url: string; errorText: string }> = [];
|
|
106
117
|
|
|
107
118
|
void this.emit('changePhase', {
|
|
@@ -124,6 +135,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
124
135
|
return {
|
|
125
136
|
type: 'skipped',
|
|
126
137
|
resources,
|
|
138
|
+
consoleLogs,
|
|
127
139
|
ignored: {
|
|
128
140
|
url,
|
|
129
141
|
matchedText: url.pathname || '',
|
|
@@ -148,6 +160,8 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
148
160
|
imageList: [],
|
|
149
161
|
anchorList: [],
|
|
150
162
|
html: '',
|
|
163
|
+
mainContents: null,
|
|
164
|
+
scrollHeight: null,
|
|
151
165
|
isSkipped: false,
|
|
152
166
|
};
|
|
153
167
|
|
|
@@ -158,7 +172,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
158
172
|
isExternal,
|
|
159
173
|
message: '',
|
|
160
174
|
});
|
|
161
|
-
return { type: 'success', pageData: result, resources };
|
|
175
|
+
return { type: 'success', pageData: result, resources, consoleLogs };
|
|
162
176
|
}
|
|
163
177
|
|
|
164
178
|
let headResult: PageData | SkippedPageData | null = options?.headCheckResult ?? null;
|
|
@@ -176,12 +190,15 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
176
190
|
pageData: {
|
|
177
191
|
...headResult,
|
|
178
192
|
isTarget: false,
|
|
193
|
+
mainContents: headResult.mainContents ?? null,
|
|
194
|
+
scrollHeight: headResult.scrollHeight ?? null,
|
|
179
195
|
},
|
|
180
196
|
resources,
|
|
197
|
+
consoleLogs,
|
|
181
198
|
};
|
|
182
199
|
}
|
|
183
200
|
|
|
184
|
-
if (headResult === null || headResult.contentType
|
|
201
|
+
if (headResult === null || isHtmlContentType(headResult.contentType)) {
|
|
185
202
|
const fetchResult = await this.#fetchData(
|
|
186
203
|
page,
|
|
187
204
|
url,
|
|
@@ -189,6 +206,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
189
206
|
captureImages,
|
|
190
207
|
imageLoadTimeout,
|
|
191
208
|
resources,
|
|
209
|
+
consoleLogs,
|
|
192
210
|
failedRequests,
|
|
193
211
|
options,
|
|
194
212
|
).catch((error) => {
|
|
@@ -204,6 +222,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
204
222
|
return {
|
|
205
223
|
type: 'error',
|
|
206
224
|
resources,
|
|
225
|
+
consoleLogs,
|
|
207
226
|
failedRequests: failedRequests.length > 0 ? failedRequests : undefined,
|
|
208
227
|
error: {
|
|
209
228
|
name: fetchResult.name,
|
|
@@ -238,6 +257,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
238
257
|
return {
|
|
239
258
|
type: 'skipped',
|
|
240
259
|
resources,
|
|
260
|
+
consoleLogs,
|
|
241
261
|
ignored: {
|
|
242
262
|
url,
|
|
243
263
|
matchedText: url.pathname || '',
|
|
@@ -255,6 +275,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
255
275
|
return {
|
|
256
276
|
type: 'skipped',
|
|
257
277
|
resources,
|
|
278
|
+
consoleLogs,
|
|
258
279
|
ignored: {
|
|
259
280
|
url,
|
|
260
281
|
matchedText: headResult.matched.text,
|
|
@@ -276,6 +297,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
276
297
|
type: 'success',
|
|
277
298
|
pageData: headResult,
|
|
278
299
|
resources,
|
|
300
|
+
consoleLogs,
|
|
279
301
|
failedRequests: failedRequests.length > 0 ? failedRequests : undefined,
|
|
280
302
|
};
|
|
281
303
|
}
|
|
@@ -334,7 +356,8 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
334
356
|
* Frame" or "Session closed".
|
|
335
357
|
*
|
|
336
358
|
* Flow:
|
|
337
|
-
* 1. Register request/response/requestfailed listeners to
|
|
359
|
+
* 1. Register request/response/requestfailed/console/pageerror listeners to
|
|
360
|
+
* capture sub-resources and console output (internal pages only)
|
|
338
361
|
* 2. Navigate to URL via `page.goto()` and track redirect chain
|
|
339
362
|
* 3. Wait for DOM content and network idle
|
|
340
363
|
* 4. Check for network disconnection errors and throw to trigger retry
|
|
@@ -346,6 +369,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
346
369
|
* @param captureImages - Whether to run the image extraction pipeline
|
|
347
370
|
* @param imageLoadTimeout - Timeout (ms) for waiting lazy-loaded images to complete
|
|
348
371
|
* @param resources - Mutable array to collect captured sub-resources into
|
|
372
|
+
* @param consoleLogs - Mutable array to collect captured console messages / page errors into
|
|
349
373
|
* @param failedRequests - Mutable array to collect failed sub-resource requests into
|
|
350
374
|
* @param options - Additional scraper options (e.g. `disableQueries`, `navigationTimeout`)
|
|
351
375
|
* @returns Full page data or skipped page data if an exclusion rule matched
|
|
@@ -378,6 +402,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
378
402
|
captureImages: boolean,
|
|
379
403
|
imageLoadTimeout: number,
|
|
380
404
|
resources: ResourceEntry[],
|
|
405
|
+
consoleLogs: ConsoleLogEntry[],
|
|
381
406
|
failedRequests: Array<{ url: string; errorText: string }>,
|
|
382
407
|
options?: Partial<ScraperOptions>,
|
|
383
408
|
): Promise<PageData | SkippedPageData> {
|
|
@@ -388,12 +413,16 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
388
413
|
const domEvaluationTimeout =
|
|
389
414
|
options?.domEvaluationTimeout ?? DEFAULT_DOM_EVALUATION_TIMEOUT;
|
|
390
415
|
const networkLogs: Record<string, NetworkLog> = {};
|
|
416
|
+
// Tracks in-flight `toConsoleLogEntry()` resolutions (async `jsonValue()`
|
|
417
|
+
// extraction) so callers can await them before reading `consoleLogs`.
|
|
418
|
+
const pendingConsoleWork: Promise<void>[] = [];
|
|
391
419
|
|
|
392
420
|
// Clear stale state from previous retries (@retryable may re-invoke this method
|
|
393
421
|
// with the same page and mutable arrays, so we must reset to avoid accumulation)
|
|
394
422
|
this.#cleanupPageListeners();
|
|
395
423
|
failedRequests.length = 0;
|
|
396
424
|
resources.length = 0;
|
|
425
|
+
consoleLogs.length = 0;
|
|
397
426
|
|
|
398
427
|
// Define named listeners so they can be individually removed on retry/cleanup
|
|
399
428
|
const onDialog = async (dialog: Dialog) => {
|
|
@@ -410,6 +439,8 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
410
439
|
let onRequest: ((req: HTTPRequest) => void) | null = null;
|
|
411
440
|
let onResponse: ((res: HTTPResponse) => void) | null = null;
|
|
412
441
|
let onRequestFailed: ((req: HTTPRequest) => void) | null = null;
|
|
442
|
+
let onConsole: ((msg: ConsoleMessage) => void) | null = null;
|
|
443
|
+
let onPageError: ((error: unknown) => void) | null = null;
|
|
413
444
|
|
|
414
445
|
if (!isExternal) {
|
|
415
446
|
onRequest = (request: HTTPRequest) => {
|
|
@@ -495,9 +526,30 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
495
526
|
failedRequests.push({ url: request.url(), errorText });
|
|
496
527
|
};
|
|
497
528
|
|
|
529
|
+
onConsole = (msg: ConsoleMessage) => {
|
|
530
|
+
pendingConsoleWork.push(
|
|
531
|
+
toConsoleLogEntry(msg, url.withoutHash).then(
|
|
532
|
+
(entry) => {
|
|
533
|
+
consoleLogs.push(entry);
|
|
534
|
+
},
|
|
535
|
+
(error) => {
|
|
536
|
+
// A single malformed console message must not fail the whole
|
|
537
|
+
// scrape (e.g. mid-navigation execution-context teardown).
|
|
538
|
+
log('Error(CONSOLE_LOG): %s', error);
|
|
539
|
+
},
|
|
540
|
+
),
|
|
541
|
+
);
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
onPageError = (error: unknown) => {
|
|
545
|
+
consoleLogs.push(toPageErrorEntry(error, url.withoutHash));
|
|
546
|
+
};
|
|
547
|
+
|
|
498
548
|
page.on('request', onRequest);
|
|
499
549
|
page.on('response', onResponse);
|
|
500
550
|
page.on('requestfailed', onRequestFailed);
|
|
551
|
+
page.on('console', onConsole);
|
|
552
|
+
page.on('pageerror', onPageError);
|
|
501
553
|
}
|
|
502
554
|
|
|
503
555
|
// Store cleanup function for retry/post-fetch removal
|
|
@@ -506,108 +558,217 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
506
558
|
if (onRequest) page.off('request', onRequest);
|
|
507
559
|
if (onResponse) page.off('response', onResponse);
|
|
508
560
|
if (onRequestFailed) page.off('requestfailed', onRequestFailed);
|
|
561
|
+
if (onConsole) page.off('console', onConsole);
|
|
562
|
+
if (onPageError) page.off('pageerror', onPageError);
|
|
509
563
|
};
|
|
510
564
|
|
|
511
565
|
const navigationTimeout = options?.navigationTimeout ?? 60_000;
|
|
512
566
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
567
|
+
// The whole navigation/extraction sequence below is wrapped in `finally`
|
|
568
|
+
// so that `pendingConsoleWork` is always awaited exactly once — on every
|
|
569
|
+
// return AND on every throw (e.g. `Page.goto returned null`, network
|
|
570
|
+
// disconnection). A per-return-statement `await` would miss thrown exits
|
|
571
|
+
// and let a still-resolving `toConsoleLogEntry()` push into `consoleLogs`
|
|
572
|
+
// after a `@retryable` retry has already cleared it for a new attempt.
|
|
573
|
+
try {
|
|
574
|
+
void this.emit('changePhase', {
|
|
575
|
+
pid: process.pid,
|
|
576
|
+
name: 'openPage',
|
|
577
|
+
url,
|
|
578
|
+
isExternal,
|
|
579
|
+
message: `%countdown(${navigationTimeout},openPage_${url.withoutHash},s)%s`,
|
|
524
580
|
});
|
|
525
|
-
}
|
|
526
581
|
|
|
527
|
-
|
|
582
|
+
if (url.username && url.password) {
|
|
583
|
+
await page.setExtraHTTPHeaders({
|
|
584
|
+
Authorization: `Basic ${Buffer.from(`${url.username}:${url.password}`).toString('base64')}`,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
528
587
|
|
|
529
|
-
|
|
530
|
-
throw new Error('The method Page.goto returned null');
|
|
531
|
-
}
|
|
588
|
+
const res = await page.goto(url.withoutHashAndAuth, { timeout: navigationTimeout });
|
|
532
589
|
|
|
533
|
-
|
|
534
|
-
|
|
590
|
+
if (!res) {
|
|
591
|
+
throw new Error('The method Page.goto returned null');
|
|
592
|
+
}
|
|
535
593
|
|
|
536
|
-
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
594
|
+
const destUrl = parseUrl(page.url(), parseOpts)!;
|
|
595
|
+
const redirectPaths = new Set<string>();
|
|
596
|
+
|
|
597
|
+
if (url.withoutHash !== destUrl.withoutHash) {
|
|
598
|
+
const redirectChain = res
|
|
599
|
+
.request()
|
|
600
|
+
.redirectChain()
|
|
601
|
+
.map((req) => req.url());
|
|
602
|
+
for (const redirectPath of redirectChain) {
|
|
603
|
+
redirectPaths.add(redirectPath);
|
|
604
|
+
}
|
|
605
|
+
redirectPaths.add(destUrl.withoutHash);
|
|
543
606
|
}
|
|
544
|
-
redirectPaths.add(destUrl.withoutHash);
|
|
545
|
-
}
|
|
546
607
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
608
|
+
if (destUrl.hostname !== url.hostname) {
|
|
609
|
+
isExternal = true;
|
|
610
|
+
}
|
|
550
611
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
612
|
+
const status = res.status();
|
|
613
|
+
const statusText = res.statusText();
|
|
614
|
+
const responseHeaders = res.headers();
|
|
615
|
+
const contentType = responseHeaders['content-type']?.split(';')[0] || null;
|
|
616
|
+
const _contentLength = Number.parseInt(responseHeaders['content-length'] ?? '');
|
|
617
|
+
const contentLength = Number.isFinite(_contentLength) ? _contentLength : null;
|
|
557
618
|
|
|
558
|
-
|
|
559
|
-
|
|
619
|
+
if (!isHtmlContentType(contentType)) {
|
|
620
|
+
return {
|
|
621
|
+
url,
|
|
622
|
+
isTarget: false,
|
|
623
|
+
isExternal,
|
|
624
|
+
redirectPaths: [...redirectPaths],
|
|
625
|
+
status,
|
|
626
|
+
statusText,
|
|
627
|
+
contentType,
|
|
628
|
+
contentLength,
|
|
629
|
+
responseHeaders,
|
|
630
|
+
meta: emptyMeta(),
|
|
631
|
+
imageList: [],
|
|
632
|
+
anchorList: [],
|
|
633
|
+
html: '',
|
|
634
|
+
mainContents: null,
|
|
635
|
+
scrollHeight: null,
|
|
636
|
+
isSkipped: false,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
void this.emit('changePhase', {
|
|
641
|
+
pid: process.pid,
|
|
642
|
+
name: 'loadDOMContent',
|
|
560
643
|
url,
|
|
561
|
-
isTarget: false,
|
|
562
644
|
isExternal,
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
statusText,
|
|
566
|
-
contentType,
|
|
567
|
-
contentLength,
|
|
568
|
-
responseHeaders,
|
|
569
|
-
meta: emptyMeta(),
|
|
570
|
-
imageList: [],
|
|
571
|
-
anchorList: [],
|
|
572
|
-
html: '',
|
|
573
|
-
isSkipped: false,
|
|
574
|
-
};
|
|
575
|
-
}
|
|
645
|
+
message: '',
|
|
646
|
+
});
|
|
576
647
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
url,
|
|
581
|
-
isExternal,
|
|
582
|
-
message: '',
|
|
583
|
-
});
|
|
648
|
+
await page
|
|
649
|
+
.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 5000 })
|
|
650
|
+
.catch(() => {});
|
|
584
651
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
652
|
+
void this.emit('changePhase', {
|
|
653
|
+
pid: process.pid,
|
|
654
|
+
name: 'getHTML',
|
|
655
|
+
url,
|
|
656
|
+
isExternal,
|
|
657
|
+
message: '',
|
|
658
|
+
});
|
|
588
659
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
660
|
+
const { title, html } = await page.evaluate(() => {
|
|
661
|
+
/* global document */
|
|
662
|
+
return {
|
|
663
|
+
title: document.title,
|
|
664
|
+
html: document.documentElement.outerHTML,
|
|
665
|
+
};
|
|
666
|
+
});
|
|
596
667
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
668
|
+
if (isExternal) {
|
|
669
|
+
const externalMeta = emptyMeta();
|
|
670
|
+
externalMeta.title = title;
|
|
671
|
+
return {
|
|
672
|
+
url,
|
|
673
|
+
isTarget: false,
|
|
674
|
+
isExternal,
|
|
675
|
+
redirectPaths: [...redirectPaths],
|
|
676
|
+
status,
|
|
677
|
+
statusText,
|
|
678
|
+
contentType,
|
|
679
|
+
contentLength,
|
|
680
|
+
responseHeaders,
|
|
681
|
+
meta: externalMeta,
|
|
682
|
+
imageList: [],
|
|
683
|
+
anchorList: [],
|
|
684
|
+
html,
|
|
685
|
+
mainContents: null,
|
|
686
|
+
scrollHeight: null,
|
|
687
|
+
isSkipped: false,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
void this.emit('changePhase', {
|
|
692
|
+
pid: process.pid,
|
|
693
|
+
name: 'waitNetworkIdle',
|
|
694
|
+
url,
|
|
695
|
+
isExternal,
|
|
696
|
+
message: '',
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
await page
|
|
700
|
+
.waitForNavigation({ waitUntil: 'networkidle0', timeout: 5000 })
|
|
701
|
+
.catch(() => {});
|
|
702
|
+
|
|
703
|
+
// Check for network disconnection errors in failed requests
|
|
704
|
+
const disconnectionFailures = findDisconnectionFailures(failedRequests);
|
|
705
|
+
if (disconnectionFailures.length > 0) {
|
|
706
|
+
const errorSummary = disconnectionFailures
|
|
707
|
+
.map((r) => `${r.url} (${r.errorText})`)
|
|
708
|
+
.join(', ');
|
|
709
|
+
throw new Error(
|
|
710
|
+
`Network disconnection detected during page load: ${errorSummary}`,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const mainContents = await getMainContents(page, {
|
|
715
|
+
mainContentSelector: options?.mainContentSelector,
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
void this.emit('changePhase', {
|
|
719
|
+
pid: process.pid,
|
|
720
|
+
name: 'getAnchors',
|
|
721
|
+
url,
|
|
722
|
+
isExternal,
|
|
723
|
+
message: `%countdown(${domEvaluationTimeout},getAnchors_${url.withoutHash},s)%s`,
|
|
724
|
+
});
|
|
725
|
+
const anchorList = await getAnchorList(page, parseOpts, domEvaluationTimeout);
|
|
726
|
+
|
|
727
|
+
void this.emit('changePhase', {
|
|
728
|
+
pid: process.pid,
|
|
729
|
+
name: 'getMeta',
|
|
730
|
+
url,
|
|
731
|
+
isExternal,
|
|
732
|
+
message: `%countdown(${domEvaluationTimeout},getMeta_${url.withoutHash},s)%s`,
|
|
733
|
+
});
|
|
734
|
+
const meta = await getMeta(
|
|
735
|
+
page,
|
|
736
|
+
{
|
|
737
|
+
url: url.withoutHashAndAuth,
|
|
738
|
+
html,
|
|
739
|
+
statusCode: status,
|
|
740
|
+
headers: responseHeaders ?? undefined,
|
|
741
|
+
},
|
|
742
|
+
domEvaluationTimeout,
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
let imageList: ImageElement[] = [];
|
|
746
|
+
let scrollHeight: ScrollHeightData | null = null;
|
|
747
|
+
|
|
748
|
+
if (captureImages) {
|
|
749
|
+
void this.emit('changePhase', {
|
|
750
|
+
pid: process.pid,
|
|
751
|
+
name: 'extractImages',
|
|
752
|
+
url,
|
|
753
|
+
isExternal,
|
|
754
|
+
message: `%countdown(${domEvaluationTimeout},extractImages_${url.withoutHash},s)%s`,
|
|
755
|
+
});
|
|
756
|
+
const fetched = await this.#fetchImages(
|
|
757
|
+
page,
|
|
758
|
+
url.withoutHashAndAuth,
|
|
759
|
+
isExternal,
|
|
760
|
+
imageLoadTimeout,
|
|
761
|
+
domEvaluationTimeout,
|
|
762
|
+
);
|
|
763
|
+
imageList = fetched.imageList;
|
|
764
|
+
scrollHeight = fetched.scrollHeight;
|
|
765
|
+
} else {
|
|
766
|
+
scrollHeight = await measureScrollHeight(page);
|
|
767
|
+
}
|
|
604
768
|
|
|
605
|
-
if (isExternal) {
|
|
606
|
-
const externalMeta = emptyMeta();
|
|
607
|
-
externalMeta.title = title;
|
|
608
769
|
return {
|
|
609
770
|
url,
|
|
610
|
-
isTarget:
|
|
771
|
+
isTarget: true,
|
|
611
772
|
isExternal,
|
|
612
773
|
redirectPaths: [...redirectPaths],
|
|
613
774
|
status,
|
|
@@ -615,97 +776,17 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
615
776
|
contentType,
|
|
616
777
|
contentLength,
|
|
617
778
|
responseHeaders,
|
|
618
|
-
meta
|
|
619
|
-
|
|
620
|
-
|
|
779
|
+
meta,
|
|
780
|
+
anchorList,
|
|
781
|
+
imageList,
|
|
621
782
|
html,
|
|
783
|
+
mainContents,
|
|
784
|
+
scrollHeight,
|
|
622
785
|
isSkipped: false,
|
|
623
786
|
};
|
|
787
|
+
} finally {
|
|
788
|
+
await Promise.all(pendingConsoleWork);
|
|
624
789
|
}
|
|
625
|
-
|
|
626
|
-
void this.emit('changePhase', {
|
|
627
|
-
pid: process.pid,
|
|
628
|
-
name: 'waitNetworkIdle',
|
|
629
|
-
url,
|
|
630
|
-
isExternal,
|
|
631
|
-
message: '',
|
|
632
|
-
});
|
|
633
|
-
|
|
634
|
-
await page
|
|
635
|
-
.waitForNavigation({ waitUntil: 'networkidle0', timeout: 5000 })
|
|
636
|
-
.catch(() => {});
|
|
637
|
-
|
|
638
|
-
// Check for network disconnection errors in failed requests
|
|
639
|
-
const disconnectionFailures = findDisconnectionFailures(failedRequests);
|
|
640
|
-
if (disconnectionFailures.length > 0) {
|
|
641
|
-
const errorSummary = disconnectionFailures
|
|
642
|
-
.map((r) => `${r.url} (${r.errorText})`)
|
|
643
|
-
.join(', ');
|
|
644
|
-
throw new Error(`Network disconnection detected during page load: ${errorSummary}`);
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
void this.emit('changePhase', {
|
|
648
|
-
pid: process.pid,
|
|
649
|
-
name: 'getAnchors',
|
|
650
|
-
url,
|
|
651
|
-
isExternal,
|
|
652
|
-
message: `%countdown(${domEvaluationTimeout},getAnchors_${url.withoutHash},s)%s`,
|
|
653
|
-
});
|
|
654
|
-
const anchorList = await getAnchorList(page, parseOpts, domEvaluationTimeout);
|
|
655
|
-
|
|
656
|
-
void this.emit('changePhase', {
|
|
657
|
-
pid: process.pid,
|
|
658
|
-
name: 'getMeta',
|
|
659
|
-
url,
|
|
660
|
-
isExternal,
|
|
661
|
-
message: `%countdown(${domEvaluationTimeout},getMeta_${url.withoutHash},s)%s`,
|
|
662
|
-
});
|
|
663
|
-
const meta = await getMeta(
|
|
664
|
-
page,
|
|
665
|
-
{
|
|
666
|
-
url: url.withoutHashAndAuth,
|
|
667
|
-
html,
|
|
668
|
-
statusCode: status,
|
|
669
|
-
headers: responseHeaders ?? undefined,
|
|
670
|
-
},
|
|
671
|
-
domEvaluationTimeout,
|
|
672
|
-
);
|
|
673
|
-
|
|
674
|
-
const imageList = captureImages
|
|
675
|
-
? await (async () => {
|
|
676
|
-
void this.emit('changePhase', {
|
|
677
|
-
pid: process.pid,
|
|
678
|
-
name: 'extractImages',
|
|
679
|
-
url,
|
|
680
|
-
isExternal,
|
|
681
|
-
message: `%countdown(${domEvaluationTimeout},extractImages_${url.withoutHash},s)%s`,
|
|
682
|
-
});
|
|
683
|
-
return this.#fetchImages(
|
|
684
|
-
page,
|
|
685
|
-
url.withoutHashAndAuth,
|
|
686
|
-
isExternal,
|
|
687
|
-
imageLoadTimeout,
|
|
688
|
-
domEvaluationTimeout,
|
|
689
|
-
);
|
|
690
|
-
})()
|
|
691
|
-
: [];
|
|
692
|
-
|
|
693
|
-
return {
|
|
694
|
-
url,
|
|
695
|
-
isTarget: true,
|
|
696
|
-
isExternal,
|
|
697
|
-
redirectPaths: [...redirectPaths],
|
|
698
|
-
status,
|
|
699
|
-
statusText,
|
|
700
|
-
contentType,
|
|
701
|
-
contentLength,
|
|
702
|
-
responseHeaders,
|
|
703
|
-
meta,
|
|
704
|
-
anchorList,
|
|
705
|
-
imageList,
|
|
706
|
-
html,
|
|
707
|
-
isSkipped: false,
|
|
708
|
-
};
|
|
709
790
|
}
|
|
710
791
|
/**
|
|
711
792
|
* Extracts image data from the page across multiple device presets.
|
|
@@ -719,12 +800,12 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
719
800
|
* changes and triggers a reload. Isolating each device preset allows partial
|
|
720
801
|
* results — if one viewport fails, the other can still succeed.
|
|
721
802
|
*
|
|
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
|
|
803
|
+
* WHY retryable with 20-min timeout and empty fallback: Image extraction is
|
|
804
|
+
* best-effort. If all retries fail, empty images and null scroll heights are
|
|
805
|
+
* returned rather than failing the entire page scrape. The 20-min wall clock
|
|
806
|
+
* accommodates pages whose mobile-small `scrollHeight` reaches ~300k px
|
|
807
|
+
* (observed on responsive data tables, which take ~5 min to scroll). A shorter
|
|
808
|
+
* timeout causes a second retry to start while the previous attempt's
|
|
728
809
|
* `scrollAllOver` is still running its `page.evaluate` calls in the
|
|
729
810
|
* background — `Promise.race` in `retry.ts` does not cancel `fn()`. The
|
|
730
811
|
* collision then surfaces as "Attempted to use detached Frame" or
|
|
@@ -736,16 +817,22 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
736
817
|
* preset entirely keeps the timeout-vs-background-evaluate collision from
|
|
737
818
|
* ever being triggered, at the cost of losing that viewport's image data
|
|
738
819
|
* for those pages. See {@link MAX_SCROLL_HEIGHT} for the chosen threshold.
|
|
820
|
+
*
|
|
821
|
+
* Scroll heights are still recorded when scrolling is skipped for the
|
|
822
|
+
* height limit — the measurement from `beforePageScan` is kept.
|
|
739
823
|
* @param page - Puppeteer page instance
|
|
740
824
|
* @param url - The page URL string (without hash and auth)
|
|
741
825
|
* @param isExternal - Whether the page is external
|
|
742
826
|
* @param imageLoadTimeout - Timeout (ms) for waiting images to complete loading
|
|
743
827
|
* @param domEvaluationTimeout - Timeout (ms) for the in-page image extraction `page.evaluate`
|
|
744
|
-
* @returns
|
|
828
|
+
* @returns Image elements plus desktop/mobile scroll heights from the scan path
|
|
745
829
|
*/
|
|
746
830
|
@retryable({
|
|
747
831
|
timeout: 20 * 60 * 1000,
|
|
748
|
-
fallback:
|
|
832
|
+
fallback: {
|
|
833
|
+
imageList: [],
|
|
834
|
+
scrollHeight: { desktop: null, mobile: null },
|
|
835
|
+
},
|
|
749
836
|
onWait(this: Scraper, determinedInterval, retryCount, methodName, error) {
|
|
750
837
|
void this.emit('changePhase', {
|
|
751
838
|
pid: process.pid,
|
|
@@ -771,15 +858,20 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
771
858
|
isExternal: boolean,
|
|
772
859
|
imageLoadTimeout: number,
|
|
773
860
|
domEvaluationTimeout: number,
|
|
774
|
-
): Promise<ImageElement[]> {
|
|
861
|
+
): Promise<{ imageList: ImageElement[]; scrollHeight: ScrollHeightData }> {
|
|
775
862
|
const listener = this.#createPageScanListener(isExternal);
|
|
776
|
-
const devices: {
|
|
863
|
+
const devices: {
|
|
864
|
+
key: 'desktop-compact' | 'mobile-small';
|
|
865
|
+
preset: { width: number; resolution?: number };
|
|
866
|
+
}[] = [
|
|
777
867
|
{ key: 'desktop-compact', preset: devicePresets['desktop-compact'] },
|
|
778
868
|
{ key: 'mobile-small', preset: devicePresets['mobile-small'] },
|
|
779
869
|
];
|
|
780
870
|
const imageList: ImageElement[] = [];
|
|
871
|
+
const scrollHeight: ScrollHeightData = { desktop: null, mobile: null };
|
|
781
872
|
|
|
782
873
|
for (const { key, preset } of devices) {
|
|
874
|
+
const scrollKey = key === 'desktop-compact' ? 'desktop' : 'mobile';
|
|
783
875
|
try {
|
|
784
876
|
void this.emit('changePhase', {
|
|
785
877
|
pid: process.pid,
|
|
@@ -798,6 +890,8 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
798
890
|
maxScrollHeight: MAX_SCROLL_HEIGHT,
|
|
799
891
|
});
|
|
800
892
|
|
|
893
|
+
scrollHeight[scrollKey] = scanResult.scrollHeight;
|
|
894
|
+
|
|
801
895
|
if (!scanResult.scrolled) {
|
|
802
896
|
void this.emit('changePhase', {
|
|
803
897
|
pid: process.pid,
|
|
@@ -845,6 +939,6 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
|
|
|
845
939
|
}
|
|
846
940
|
}
|
|
847
941
|
|
|
848
|
-
return imageList;
|
|
942
|
+
return { imageList, scrollHeight };
|
|
849
943
|
}
|
|
850
944
|
}
|