@d-zero/beholder 4.0.0 → 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/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,
@@ -14,7 +15,7 @@ import type {
14
15
  SkippedPageData,
15
16
  } from './types.js';
16
17
  import type { PageScanPhase } from '@d-zero/puppeteer-page-scan';
17
- import type { Dialog, HTTPRequest, HTTPResponse, Page } from 'puppeteer';
18
+ import type { ConsoleMessage, Dialog, HTTPRequest, HTTPResponse, Page } from 'puppeteer';
18
19
 
19
20
  import { beforePageScan, devicePresets } from '@d-zero/puppeteer-page-scan';
20
21
  import { detectCDN } from '@d-zero/shared/detect-cdn';
@@ -37,6 +38,8 @@ import { measureScrollHeight } from './measure-scroll-height.js';
37
38
  import { emptyMeta } from './meta/classify.js';
38
39
  import { findDisconnectionFailures } from './network-disconnection.js';
39
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';
40
43
 
41
44
  const pid = `${process.pid}`;
42
45
  const log = scraperLog.extend(pid);
@@ -86,7 +89,10 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
86
89
  * - `type: "error"` with `error` details when scraping fails
87
90
  *
88
91
  * Sub-resources are collected via the `resourceResponse` event and
89
- * 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`.
90
96
  * @param page - The Puppeteer page instance to use for navigation and DOM evaluation.
91
97
  * @param url - The extended URL to scrape.
92
98
  * @param options - Optional scraper configuration overriding defaults.
@@ -106,6 +112,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
106
112
  const metadataOnly = options?.metadataOnly ?? false;
107
113
  const imageLoadTimeout = options?.imageLoadTimeout ?? 5000;
108
114
  const resources: ResourceEntry[] = [];
115
+ const consoleLogs: ConsoleLogEntry[] = [];
109
116
  const failedRequests: Array<{ url: string; errorText: string }> = [];
110
117
 
111
118
  void this.emit('changePhase', {
@@ -128,6 +135,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
128
135
  return {
129
136
  type: 'skipped',
130
137
  resources,
138
+ consoleLogs,
131
139
  ignored: {
132
140
  url,
133
141
  matchedText: url.pathname || '',
@@ -164,7 +172,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
164
172
  isExternal,
165
173
  message: '',
166
174
  });
167
- return { type: 'success', pageData: result, resources };
175
+ return { type: 'success', pageData: result, resources, consoleLogs };
168
176
  }
169
177
 
170
178
  let headResult: PageData | SkippedPageData | null = options?.headCheckResult ?? null;
@@ -186,6 +194,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
186
194
  scrollHeight: headResult.scrollHeight ?? null,
187
195
  },
188
196
  resources,
197
+ consoleLogs,
189
198
  };
190
199
  }
191
200
 
@@ -197,6 +206,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
197
206
  captureImages,
198
207
  imageLoadTimeout,
199
208
  resources,
209
+ consoleLogs,
200
210
  failedRequests,
201
211
  options,
202
212
  ).catch((error) => {
@@ -212,6 +222,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
212
222
  return {
213
223
  type: 'error',
214
224
  resources,
225
+ consoleLogs,
215
226
  failedRequests: failedRequests.length > 0 ? failedRequests : undefined,
216
227
  error: {
217
228
  name: fetchResult.name,
@@ -246,6 +257,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
246
257
  return {
247
258
  type: 'skipped',
248
259
  resources,
260
+ consoleLogs,
249
261
  ignored: {
250
262
  url,
251
263
  matchedText: url.pathname || '',
@@ -263,6 +275,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
263
275
  return {
264
276
  type: 'skipped',
265
277
  resources,
278
+ consoleLogs,
266
279
  ignored: {
267
280
  url,
268
281
  matchedText: headResult.matched.text,
@@ -284,6 +297,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
284
297
  type: 'success',
285
298
  pageData: headResult,
286
299
  resources,
300
+ consoleLogs,
287
301
  failedRequests: failedRequests.length > 0 ? failedRequests : undefined,
288
302
  };
289
303
  }
@@ -342,7 +356,8 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
342
356
  * Frame" or "Session closed".
343
357
  *
344
358
  * Flow:
345
- * 1. Register request/response/requestfailed listeners to capture sub-resources (internal pages only)
359
+ * 1. Register request/response/requestfailed/console/pageerror listeners to
360
+ * capture sub-resources and console output (internal pages only)
346
361
  * 2. Navigate to URL via `page.goto()` and track redirect chain
347
362
  * 3. Wait for DOM content and network idle
348
363
  * 4. Check for network disconnection errors and throw to trigger retry
@@ -354,6 +369,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
354
369
  * @param captureImages - Whether to run the image extraction pipeline
355
370
  * @param imageLoadTimeout - Timeout (ms) for waiting lazy-loaded images to complete
356
371
  * @param resources - Mutable array to collect captured sub-resources into
372
+ * @param consoleLogs - Mutable array to collect captured console messages / page errors into
357
373
  * @param failedRequests - Mutable array to collect failed sub-resource requests into
358
374
  * @param options - Additional scraper options (e.g. `disableQueries`, `navigationTimeout`)
359
375
  * @returns Full page data or skipped page data if an exclusion rule matched
@@ -386,6 +402,7 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
386
402
  captureImages: boolean,
387
403
  imageLoadTimeout: number,
388
404
  resources: ResourceEntry[],
405
+ consoleLogs: ConsoleLogEntry[],
389
406
  failedRequests: Array<{ url: string; errorText: string }>,
390
407
  options?: Partial<ScraperOptions>,
391
408
  ): Promise<PageData | SkippedPageData> {
@@ -396,12 +413,16 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
396
413
  const domEvaluationTimeout =
397
414
  options?.domEvaluationTimeout ?? DEFAULT_DOM_EVALUATION_TIMEOUT;
398
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>[] = [];
399
419
 
400
420
  // Clear stale state from previous retries (@retryable may re-invoke this method
401
421
  // with the same page and mutable arrays, so we must reset to avoid accumulation)
402
422
  this.#cleanupPageListeners();
403
423
  failedRequests.length = 0;
404
424
  resources.length = 0;
425
+ consoleLogs.length = 0;
405
426
 
406
427
  // Define named listeners so they can be individually removed on retry/cleanup
407
428
  const onDialog = async (dialog: Dialog) => {
@@ -418,6 +439,8 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
418
439
  let onRequest: ((req: HTTPRequest) => void) | null = null;
419
440
  let onResponse: ((res: HTTPResponse) => void) | null = null;
420
441
  let onRequestFailed: ((req: HTTPRequest) => void) | null = null;
442
+ let onConsole: ((msg: ConsoleMessage) => void) | null = null;
443
+ let onPageError: ((error: unknown) => void) | null = null;
421
444
 
422
445
  if (!isExternal) {
423
446
  onRequest = (request: HTTPRequest) => {
@@ -503,9 +526,30 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
503
526
  failedRequests.push({ url: request.url(), errorText });
504
527
  };
505
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
+
506
548
  page.on('request', onRequest);
507
549
  page.on('response', onResponse);
508
550
  page.on('requestfailed', onRequestFailed);
551
+ page.on('console', onConsole);
552
+ page.on('pageerror', onPageError);
509
553
  }
510
554
 
511
555
  // Store cleanup function for retry/post-fetch removal
@@ -514,221 +558,235 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
514
558
  if (onRequest) page.off('request', onRequest);
515
559
  if (onResponse) page.off('response', onResponse);
516
560
  if (onRequestFailed) page.off('requestfailed', onRequestFailed);
561
+ if (onConsole) page.off('console', onConsole);
562
+ if (onPageError) page.off('pageerror', onPageError);
517
563
  };
518
564
 
519
565
  const navigationTimeout = options?.navigationTimeout ?? 60_000;
520
566
 
521
- void this.emit('changePhase', {
522
- pid: process.pid,
523
- name: 'openPage',
524
- url,
525
- isExternal,
526
- message: `%countdown(${navigationTimeout},openPage_${url.withoutHash},s)%s`,
527
- });
528
-
529
- if (url.username && url.password) {
530
- await page.setExtraHTTPHeaders({
531
- Authorization: `Basic ${Buffer.from(`${url.username}:${url.password}`).toString('base64')}`,
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`,
532
580
  });
533
- }
534
581
 
535
- const res = await page.goto(url.withoutHashAndAuth, { timeout: navigationTimeout });
536
-
537
- if (!res) {
538
- throw new Error('The method Page.goto returned null');
539
- }
582
+ if (url.username && url.password) {
583
+ await page.setExtraHTTPHeaders({
584
+ Authorization: `Basic ${Buffer.from(`${url.username}:${url.password}`).toString('base64')}`,
585
+ });
586
+ }
540
587
 
541
- const destUrl = parseUrl(page.url(), parseOpts)!;
542
- const redirectPaths = new Set<string>();
588
+ const res = await page.goto(url.withoutHashAndAuth, { timeout: navigationTimeout });
543
589
 
544
- if (url.withoutHash !== destUrl.withoutHash) {
545
- const redirectChain = res
546
- .request()
547
- .redirectChain()
548
- .map((req) => req.url());
549
- for (const redirectPath of redirectChain) {
550
- redirectPaths.add(redirectPath);
590
+ if (!res) {
591
+ throw new Error('The method Page.goto returned null');
551
592
  }
552
- redirectPaths.add(destUrl.withoutHash);
553
- }
554
593
 
555
- if (destUrl.hostname !== url.hostname) {
556
- isExternal = true;
557
- }
594
+ const destUrl = parseUrl(page.url(), parseOpts)!;
595
+ const redirectPaths = new Set<string>();
558
596
 
559
- const status = res.status();
560
- const statusText = res.statusText();
561
- const responseHeaders = res.headers();
562
- const contentType = responseHeaders['content-type']?.split(';')[0] || null;
563
- const _contentLength = Number.parseInt(responseHeaders['content-length'] ?? '');
564
- const contentLength = Number.isFinite(_contentLength) ? _contentLength : null;
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);
606
+ }
565
607
 
566
- if (!isHtmlContentType(contentType)) {
567
- return {
568
- url,
569
- isTarget: false,
570
- isExternal,
571
- redirectPaths: [...redirectPaths],
572
- status,
573
- statusText,
574
- contentType,
575
- contentLength,
576
- responseHeaders,
577
- meta: emptyMeta(),
578
- imageList: [],
579
- anchorList: [],
580
- html: '',
581
- mainContents: null,
582
- scrollHeight: null,
583
- isSkipped: false,
584
- };
585
- }
608
+ if (destUrl.hostname !== url.hostname) {
609
+ isExternal = true;
610
+ }
586
611
 
587
- void this.emit('changePhase', {
588
- pid: process.pid,
589
- name: 'loadDOMContent',
590
- url,
591
- isExternal,
592
- message: '',
593
- });
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;
594
618
 
595
- await page
596
- .waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 5000 })
597
- .catch(() => {});
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
+ }
598
639
 
599
- void this.emit('changePhase', {
600
- pid: process.pid,
601
- name: 'getHTML',
602
- url,
603
- isExternal,
604
- message: '',
605
- });
640
+ void this.emit('changePhase', {
641
+ pid: process.pid,
642
+ name: 'loadDOMContent',
643
+ url,
644
+ isExternal,
645
+ message: '',
646
+ });
606
647
 
607
- const { title, html } = await page.evaluate(() => {
608
- /* global document */
609
- return {
610
- title: document.title,
611
- html: document.documentElement.outerHTML,
612
- };
613
- });
648
+ await page
649
+ .waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 5000 })
650
+ .catch(() => {});
614
651
 
615
- if (isExternal) {
616
- const externalMeta = emptyMeta();
617
- externalMeta.title = title;
618
- return {
652
+ void this.emit('changePhase', {
653
+ pid: process.pid,
654
+ name: 'getHTML',
619
655
  url,
620
- isTarget: false,
621
656
  isExternal,
622
- redirectPaths: [...redirectPaths],
623
- status,
624
- statusText,
625
- contentType,
626
- contentLength,
627
- responseHeaders,
628
- meta: externalMeta,
629
- imageList: [],
630
- anchorList: [],
631
- html,
632
- mainContents: null,
633
- scrollHeight: null,
634
- isSkipped: false,
635
- };
636
- }
657
+ message: '',
658
+ });
637
659
 
638
- void this.emit('changePhase', {
639
- pid: process.pid,
640
- name: 'waitNetworkIdle',
641
- url,
642
- isExternal,
643
- message: '',
644
- });
660
+ const { title, html } = await page.evaluate(() => {
661
+ /* global document */
662
+ return {
663
+ title: document.title,
664
+ html: document.documentElement.outerHTML,
665
+ };
666
+ });
645
667
 
646
- await page
647
- .waitForNavigation({ waitUntil: 'networkidle0', timeout: 5000 })
648
- .catch(() => {});
649
-
650
- // Check for network disconnection errors in failed requests
651
- const disconnectionFailures = findDisconnectionFailures(failedRequests);
652
- if (disconnectionFailures.length > 0) {
653
- const errorSummary = disconnectionFailures
654
- .map((r) => `${r.url} (${r.errorText})`)
655
- .join(', ');
656
- throw new Error(`Network disconnection detected during page load: ${errorSummary}`);
657
- }
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
+ }
658
690
 
659
- const mainContents = await getMainContents(page, {
660
- mainContentSelector: options?.mainContentSelector,
661
- });
691
+ void this.emit('changePhase', {
692
+ pid: process.pid,
693
+ name: 'waitNetworkIdle',
694
+ url,
695
+ isExternal,
696
+ message: '',
697
+ });
662
698
 
663
- void this.emit('changePhase', {
664
- pid: process.pid,
665
- name: 'getAnchors',
666
- url,
667
- isExternal,
668
- message: `%countdown(${domEvaluationTimeout},getAnchors_${url.withoutHash},s)%s`,
669
- });
670
- const anchorList = await getAnchorList(page, parseOpts, domEvaluationTimeout);
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
+ }
671
713
 
672
- void this.emit('changePhase', {
673
- pid: process.pid,
674
- name: 'getMeta',
675
- url,
676
- isExternal,
677
- message: `%countdown(${domEvaluationTimeout},getMeta_${url.withoutHash},s)%s`,
678
- });
679
- const meta = await getMeta(
680
- page,
681
- {
682
- url: url.withoutHashAndAuth,
683
- html,
684
- statusCode: status,
685
- headers: responseHeaders ?? undefined,
686
- },
687
- domEvaluationTimeout,
688
- );
714
+ const mainContents = await getMainContents(page, {
715
+ mainContentSelector: options?.mainContentSelector,
716
+ });
689
717
 
690
- let imageList: ImageElement[] = [];
691
- let scrollHeight: ScrollHeightData | null = null;
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);
692
726
 
693
- if (captureImages) {
694
727
  void this.emit('changePhase', {
695
728
  pid: process.pid,
696
- name: 'extractImages',
729
+ name: 'getMeta',
697
730
  url,
698
731
  isExternal,
699
- message: `%countdown(${domEvaluationTimeout},extractImages_${url.withoutHash},s)%s`,
732
+ message: `%countdown(${domEvaluationTimeout},getMeta_${url.withoutHash},s)%s`,
700
733
  });
701
- const fetched = await this.#fetchImages(
734
+ const meta = await getMeta(
702
735
  page,
703
- url.withoutHashAndAuth,
704
- isExternal,
705
- imageLoadTimeout,
736
+ {
737
+ url: url.withoutHashAndAuth,
738
+ html,
739
+ statusCode: status,
740
+ headers: responseHeaders ?? undefined,
741
+ },
706
742
  domEvaluationTimeout,
707
743
  );
708
- imageList = fetched.imageList;
709
- scrollHeight = fetched.scrollHeight;
710
- } else {
711
- scrollHeight = await measureScrollHeight(page);
712
- }
713
744
 
714
- return {
715
- url,
716
- isTarget: true,
717
- isExternal,
718
- redirectPaths: [...redirectPaths],
719
- status,
720
- statusText,
721
- contentType,
722
- contentLength,
723
- responseHeaders,
724
- meta,
725
- anchorList,
726
- imageList,
727
- html,
728
- mainContents,
729
- scrollHeight,
730
- isSkipped: false,
731
- };
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
+ }
768
+
769
+ return {
770
+ url,
771
+ isTarget: true,
772
+ isExternal,
773
+ redirectPaths: [...redirectPaths],
774
+ status,
775
+ statusText,
776
+ contentType,
777
+ contentLength,
778
+ responseHeaders,
779
+ meta,
780
+ anchorList,
781
+ imageList,
782
+ html,
783
+ mainContents,
784
+ scrollHeight,
785
+ isSkipped: false,
786
+ };
787
+ } finally {
788
+ await Promise.all(pendingConsoleWork);
789
+ }
732
790
  }
733
791
  /**
734
792
  * Extracts image data from the page across multiple device presets.