@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/CHANGELOG.md CHANGED
@@ -3,6 +3,12 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [4.1.0](https://github.com/d-zero-dev/tools/compare/@d-zero/beholder@4.0.0...@d-zero/beholder@4.1.0) (2026-07-22)
7
+
8
+ ### Features
9
+
10
+ - **beholder:** capture console messages and page errors ([f271bbb](https://github.com/d-zero-dev/tools/commit/f271bbb033f8cc6ccb21a3c2981d1c27b684e551))
11
+
6
12
  # [4.0.0](https://github.com/d-zero-dev/tools/compare/@d-zero/beholder@3.1.6...@d-zero/beholder@4.0.0) (2026-07-21)
7
13
 
8
14
  - feat(beholder)!: enrich PageData with mainContents metrics and scrollHeight ([8a0fc72](https://github.com/d-zero-dev/tools/commit/8a0fc72412295a92850b2c2fa2fa8cd8145eb5f6))
package/README.md CHANGED
@@ -68,6 +68,16 @@ flowchart TD
68
68
  - `scrollHeight`: `{ desktop, mobile }`(各 `number | null`)。未計測時はフィールド全体が `null`
69
69
  - 配列要素のフィールド詳細は型(`MainContents*` / `ScrollHeightData`)を参照
70
70
 
71
+ ## console ログ・未捕捉例外の収集
72
+
73
+ `ScrapeResult.consoleLogs`(`ConsoleLogEntry[]`)に、内部ページ(`isExternal: false`)が出力した `console` メッセージ(全type)と、未捕捉例外・未処理の Promise rejection(`page.on('pageerror')`、`type: 'pageerror'` として区別、スタックトレース付き)が格納される。`resources` と同様に `pageUrl` を持つ戻り値配列で、イベント経由では提供されない。`result.type` が `"success"` / `"skipped"` / `"error"` のいずれであっても常に配列として存在する(該当ログがなければ空配列)ため、エラー発生時のデバッグにもそのまま使える。
74
+
75
+ ```ts
76
+ for (const entry of result.consoleLogs) {
77
+ console.log(entry.type, entry.text, entry.args);
78
+ }
79
+ ```
80
+
71
81
  ## DOM 文字列からメタ抽出(Puppeteer なし)
72
82
 
73
83
  HTML 文字列を jsdom などでパースしてから `Meta` を取り出したい場合、`extractMetaFromDocument` を使う。`Scraper` が内部で呼ぶ `collectHead → detectTags → classify` パイプラインと同じ実装を再利用するため、戻り値の `Meta` 形状は `scrapeStart` と同一。DOM ライブラリ(jsdom 等)はユーザランドの責務。
package/dist/index.d.ts CHANGED
@@ -18,6 +18,6 @@ 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 { Resource, AnchorData, Meta, ImageElement, SkippedPageData, NetworkLog, ScrollHeightData, MainContentsData, MainContentsMainTag, MainContentsHeading, MainContentsImage, MainContentsTable, MainContentsButton, MainContentsIframe, MainContentsVideo, MainContentsAudio, MainContentsCanvas, OpenGraphMeta, OgArticleMeta, OgBookMeta, OgProfileMeta, OgMusicMeta, OgVideoNsMeta, TwitterMeta, FbMeta, FediverseMeta, AppleMeta, MsApplicationMeta, VerificationMeta, GoogleMeta, GeoMeta, CitationMeta, RdfaMeta, MicrodataMeta, AmpMeta, LegacyMeta, MobileMeta, MicroformatsMeta, PinterestMeta, SlackMeta, LinkedInMeta, ExperimentalMeta, WikiMeta, LinkMeta, LinkEntry, JsonLdEntry, OthersBucket, ScriptEntry, IframeEntry, TagsMeta, TagDetail, TagEntry, TagSource, ViewportMeta, RobotsMeta, ReferrerMeta, FormatDetectionMeta, HttpEquivMeta, HttpEquivRefresh, RawHeadEntry, } from './types.js';
package/dist/scraper.d.ts CHANGED
@@ -30,7 +30,10 @@ export default class Scraper extends EventEmitter<ScraperEventTypes> {
30
30
  * - `type: "error"` with `error` details when scraping fails
31
31
  *
32
32
  * Sub-resources are collected via the `resourceResponse` event and
33
- * included in the returned `ScrapeResult.resources`.
33
+ * included in the returned `ScrapeResult.resources`. Console messages and
34
+ * uncaught page errors (internal pages only) are collected via
35
+ * Puppeteer's `console`/`pageerror` page events and included in
36
+ * `ScrapeResult.consoleLogs`.
34
37
  * @param page - The Puppeteer page instance to use for navigation and DOM evaluation.
35
38
  * @param url - The extended URL to scrape.
36
39
  * @param options - Optional scraper configuration overriding defaults.
package/dist/scraper.js CHANGED
@@ -51,6 +51,8 @@ import { measureScrollHeight } from './measure-scroll-height.js';
51
51
  import { emptyMeta } from './meta/classify.js';
52
52
  import { findDisconnectionFailures } from './network-disconnection.js';
53
53
  import { parseUrl } from './parse-url.js';
54
+ import { toConsoleLogEntry } from './to-console-log-entry.js';
55
+ import { toPageErrorEntry } from './to-page-error-entry.js';
54
56
  const pid = `${process.pid}`;
55
57
  const log = scraperLog.extend(pid);
56
58
  const rLog = resourceLog.extend(pid);
@@ -121,17 +123,21 @@ let Scraper = (() => {
121
123
  });
122
124
  },
123
125
  })];
124
- __esDecorate(this, _private_fetchData_descriptor = { value: __setFunctionName(async function (page, url, isExternal, captureImages, imageLoadTimeout, resources, failedRequests, options) {
126
+ __esDecorate(this, _private_fetchData_descriptor = { value: __setFunctionName(async function (page, url, isExternal, captureImages, imageLoadTimeout, resources, consoleLogs, failedRequests, options) {
125
127
  const parseOpts = options?.disableQueries == null
126
128
  ? undefined
127
129
  : { disableQueries: options.disableQueries };
128
130
  const domEvaluationTimeout = options?.domEvaluationTimeout ?? DEFAULT_DOM_EVALUATION_TIMEOUT;
129
131
  const networkLogs = {};
132
+ // Tracks in-flight `toConsoleLogEntry()` resolutions (async `jsonValue()`
133
+ // extraction) so callers can await them before reading `consoleLogs`.
134
+ const pendingConsoleWork = [];
130
135
  // Clear stale state from previous retries (@retryable may re-invoke this method
131
136
  // with the same page and mutable arrays, so we must reset to avoid accumulation)
132
137
  this.#cleanupPageListeners();
133
138
  failedRequests.length = 0;
134
139
  resources.length = 0;
140
+ consoleLogs.length = 0;
135
141
  // Define named listeners so they can be individually removed on retry/cleanup
136
142
  const onDialog = async (dialog) => {
137
143
  log(`Appear ${dialog.type()} dialog: ${dialog.message()}`);
@@ -147,6 +153,8 @@ let Scraper = (() => {
147
153
  let onRequest = null;
148
154
  let onResponse = null;
149
155
  let onRequestFailed = null;
156
+ let onConsole = null;
157
+ let onPageError = null;
150
158
  if (!isExternal) {
151
159
  onRequest = (request) => {
152
160
  const url = parseUrl(request.url(), parseOpts);
@@ -221,9 +229,23 @@ let Scraper = (() => {
221
229
  rLog('Request failed: %s (%s)', request.url(), errorText);
222
230
  failedRequests.push({ url: request.url(), errorText });
223
231
  };
232
+ onConsole = (msg) => {
233
+ pendingConsoleWork.push(toConsoleLogEntry(msg, url.withoutHash).then((entry) => {
234
+ consoleLogs.push(entry);
235
+ }, (error) => {
236
+ // A single malformed console message must not fail the whole
237
+ // scrape (e.g. mid-navigation execution-context teardown).
238
+ log('Error(CONSOLE_LOG): %s', error);
239
+ }));
240
+ };
241
+ onPageError = (error) => {
242
+ consoleLogs.push(toPageErrorEntry(error, url.withoutHash));
243
+ };
224
244
  page.on('request', onRequest);
225
245
  page.on('response', onResponse);
226
246
  page.on('requestfailed', onRequestFailed);
247
+ page.on('console', onConsole);
248
+ page.on('pageerror', onPageError);
227
249
  }
228
250
  // Store cleanup function for retry/post-fetch removal
229
251
  this.#pageListenerCleanup = () => {
@@ -234,95 +256,184 @@ let Scraper = (() => {
234
256
  page.off('response', onResponse);
235
257
  if (onRequestFailed)
236
258
  page.off('requestfailed', onRequestFailed);
259
+ if (onConsole)
260
+ page.off('console', onConsole);
261
+ if (onPageError)
262
+ page.off('pageerror', onPageError);
237
263
  };
238
264
  const navigationTimeout = options?.navigationTimeout ?? 60_000;
239
- void this.emit('changePhase', {
240
- pid: process.pid,
241
- name: 'openPage',
242
- url,
243
- isExternal,
244
- message: `%countdown(${navigationTimeout},openPage_${url.withoutHash},s)%s`,
245
- });
246
- if (url.username && url.password) {
247
- await page.setExtraHTTPHeaders({
248
- Authorization: `Basic ${Buffer.from(`${url.username}:${url.password}`).toString('base64')}`,
265
+ // The whole navigation/extraction sequence below is wrapped in `finally`
266
+ // so that `pendingConsoleWork` is always awaited exactly once — on every
267
+ // return AND on every throw (e.g. `Page.goto returned null`, network
268
+ // disconnection). A per-return-statement `await` would miss thrown exits
269
+ // and let a still-resolving `toConsoleLogEntry()` push into `consoleLogs`
270
+ // after a `@retryable` retry has already cleared it for a new attempt.
271
+ try {
272
+ void this.emit('changePhase', {
273
+ pid: process.pid,
274
+ name: 'openPage',
275
+ url,
276
+ isExternal,
277
+ message: `%countdown(${navigationTimeout},openPage_${url.withoutHash},s)%s`,
249
278
  });
250
- }
251
- const res = await page.goto(url.withoutHashAndAuth, { timeout: navigationTimeout });
252
- if (!res) {
253
- throw new Error('The method Page.goto returned null');
254
- }
255
- const destUrl = parseUrl(page.url(), parseOpts);
256
- const redirectPaths = new Set();
257
- if (url.withoutHash !== destUrl.withoutHash) {
258
- const redirectChain = res
259
- .request()
260
- .redirectChain()
261
- .map((req) => req.url());
262
- for (const redirectPath of redirectChain) {
263
- redirectPaths.add(redirectPath);
279
+ if (url.username && url.password) {
280
+ await page.setExtraHTTPHeaders({
281
+ Authorization: `Basic ${Buffer.from(`${url.username}:${url.password}`).toString('base64')}`,
282
+ });
264
283
  }
265
- redirectPaths.add(destUrl.withoutHash);
266
- }
267
- if (destUrl.hostname !== url.hostname) {
268
- isExternal = true;
269
- }
270
- const status = res.status();
271
- const statusText = res.statusText();
272
- const responseHeaders = res.headers();
273
- const contentType = responseHeaders['content-type']?.split(';')[0] || null;
274
- const _contentLength = Number.parseInt(responseHeaders['content-length'] ?? '');
275
- const contentLength = Number.isFinite(_contentLength) ? _contentLength : null;
276
- if (!isHtmlContentType(contentType)) {
277
- return {
284
+ const res = await page.goto(url.withoutHashAndAuth, { timeout: navigationTimeout });
285
+ if (!res) {
286
+ throw new Error('The method Page.goto returned null');
287
+ }
288
+ const destUrl = parseUrl(page.url(), parseOpts);
289
+ const redirectPaths = new Set();
290
+ if (url.withoutHash !== destUrl.withoutHash) {
291
+ const redirectChain = res
292
+ .request()
293
+ .redirectChain()
294
+ .map((req) => req.url());
295
+ for (const redirectPath of redirectChain) {
296
+ redirectPaths.add(redirectPath);
297
+ }
298
+ redirectPaths.add(destUrl.withoutHash);
299
+ }
300
+ if (destUrl.hostname !== url.hostname) {
301
+ isExternal = true;
302
+ }
303
+ const status = res.status();
304
+ const statusText = res.statusText();
305
+ const responseHeaders = res.headers();
306
+ const contentType = responseHeaders['content-type']?.split(';')[0] || null;
307
+ const _contentLength = Number.parseInt(responseHeaders['content-length'] ?? '');
308
+ const contentLength = Number.isFinite(_contentLength) ? _contentLength : null;
309
+ if (!isHtmlContentType(contentType)) {
310
+ return {
311
+ url,
312
+ isTarget: false,
313
+ isExternal,
314
+ redirectPaths: [...redirectPaths],
315
+ status,
316
+ statusText,
317
+ contentType,
318
+ contentLength,
319
+ responseHeaders,
320
+ meta: emptyMeta(),
321
+ imageList: [],
322
+ anchorList: [],
323
+ html: '',
324
+ mainContents: null,
325
+ scrollHeight: null,
326
+ isSkipped: false,
327
+ };
328
+ }
329
+ void this.emit('changePhase', {
330
+ pid: process.pid,
331
+ name: 'loadDOMContent',
278
332
  url,
279
- isTarget: false,
280
333
  isExternal,
281
- redirectPaths: [...redirectPaths],
282
- status,
283
- statusText,
284
- contentType,
285
- contentLength,
286
- responseHeaders,
287
- meta: emptyMeta(),
288
- imageList: [],
289
- anchorList: [],
290
- html: '',
291
- mainContents: null,
292
- scrollHeight: null,
293
- isSkipped: false,
294
- };
295
- }
296
- void this.emit('changePhase', {
297
- pid: process.pid,
298
- name: 'loadDOMContent',
299
- url,
300
- isExternal,
301
- message: '',
302
- });
303
- await page
304
- .waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 5000 })
305
- .catch(() => { });
306
- void this.emit('changePhase', {
307
- pid: process.pid,
308
- name: 'getHTML',
309
- url,
310
- isExternal,
311
- message: '',
312
- });
313
- const { title, html } = await page.evaluate(() => {
314
- /* global document */
315
- return {
316
- title: document.title,
317
- html: document.documentElement.outerHTML,
318
- };
319
- });
320
- if (isExternal) {
321
- const externalMeta = emptyMeta();
322
- externalMeta.title = title;
334
+ message: '',
335
+ });
336
+ await page
337
+ .waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 5000 })
338
+ .catch(() => { });
339
+ void this.emit('changePhase', {
340
+ pid: process.pid,
341
+ name: 'getHTML',
342
+ url,
343
+ isExternal,
344
+ message: '',
345
+ });
346
+ const { title, html } = await page.evaluate(() => {
347
+ /* global document */
348
+ return {
349
+ title: document.title,
350
+ html: document.documentElement.outerHTML,
351
+ };
352
+ });
353
+ if (isExternal) {
354
+ const externalMeta = emptyMeta();
355
+ externalMeta.title = title;
356
+ return {
357
+ url,
358
+ isTarget: false,
359
+ isExternal,
360
+ redirectPaths: [...redirectPaths],
361
+ status,
362
+ statusText,
363
+ contentType,
364
+ contentLength,
365
+ responseHeaders,
366
+ meta: externalMeta,
367
+ imageList: [],
368
+ anchorList: [],
369
+ html,
370
+ mainContents: null,
371
+ scrollHeight: null,
372
+ isSkipped: false,
373
+ };
374
+ }
375
+ void this.emit('changePhase', {
376
+ pid: process.pid,
377
+ name: 'waitNetworkIdle',
378
+ url,
379
+ isExternal,
380
+ message: '',
381
+ });
382
+ await page
383
+ .waitForNavigation({ waitUntil: 'networkidle0', timeout: 5000 })
384
+ .catch(() => { });
385
+ // Check for network disconnection errors in failed requests
386
+ const disconnectionFailures = findDisconnectionFailures(failedRequests);
387
+ if (disconnectionFailures.length > 0) {
388
+ const errorSummary = disconnectionFailures
389
+ .map((r) => `${r.url} (${r.errorText})`)
390
+ .join(', ');
391
+ throw new Error(`Network disconnection detected during page load: ${errorSummary}`);
392
+ }
393
+ const mainContents = await getMainContents(page, {
394
+ mainContentSelector: options?.mainContentSelector,
395
+ });
396
+ void this.emit('changePhase', {
397
+ pid: process.pid,
398
+ name: 'getAnchors',
399
+ url,
400
+ isExternal,
401
+ message: `%countdown(${domEvaluationTimeout},getAnchors_${url.withoutHash},s)%s`,
402
+ });
403
+ const anchorList = await getAnchorList(page, parseOpts, domEvaluationTimeout);
404
+ void this.emit('changePhase', {
405
+ pid: process.pid,
406
+ name: 'getMeta',
407
+ url,
408
+ isExternal,
409
+ message: `%countdown(${domEvaluationTimeout},getMeta_${url.withoutHash},s)%s`,
410
+ });
411
+ const meta = await getMeta(page, {
412
+ url: url.withoutHashAndAuth,
413
+ html,
414
+ statusCode: status,
415
+ headers: responseHeaders ?? undefined,
416
+ }, domEvaluationTimeout);
417
+ let imageList = [];
418
+ let scrollHeight = null;
419
+ if (captureImages) {
420
+ void this.emit('changePhase', {
421
+ pid: process.pid,
422
+ name: 'extractImages',
423
+ url,
424
+ isExternal,
425
+ message: `%countdown(${domEvaluationTimeout},extractImages_${url.withoutHash},s)%s`,
426
+ });
427
+ const fetched = await this.#fetchImages(page, url.withoutHashAndAuth, isExternal, imageLoadTimeout, domEvaluationTimeout);
428
+ imageList = fetched.imageList;
429
+ scrollHeight = fetched.scrollHeight;
430
+ }
431
+ else {
432
+ scrollHeight = await measureScrollHeight(page);
433
+ }
323
434
  return {
324
435
  url,
325
- isTarget: false,
436
+ isTarget: true,
326
437
  isExternal,
327
438
  redirectPaths: [...redirectPaths],
328
439
  status,
@@ -330,92 +441,18 @@ let Scraper = (() => {
330
441
  contentType,
331
442
  contentLength,
332
443
  responseHeaders,
333
- meta: externalMeta,
334
- imageList: [],
335
- anchorList: [],
444
+ meta,
445
+ anchorList,
446
+ imageList,
336
447
  html,
337
- mainContents: null,
338
- scrollHeight: null,
448
+ mainContents,
449
+ scrollHeight,
339
450
  isSkipped: false,
340
451
  };
341
452
  }
342
- void this.emit('changePhase', {
343
- pid: process.pid,
344
- name: 'waitNetworkIdle',
345
- url,
346
- isExternal,
347
- message: '',
348
- });
349
- await page
350
- .waitForNavigation({ waitUntil: 'networkidle0', timeout: 5000 })
351
- .catch(() => { });
352
- // Check for network disconnection errors in failed requests
353
- const disconnectionFailures = findDisconnectionFailures(failedRequests);
354
- if (disconnectionFailures.length > 0) {
355
- const errorSummary = disconnectionFailures
356
- .map((r) => `${r.url} (${r.errorText})`)
357
- .join(', ');
358
- throw new Error(`Network disconnection detected during page load: ${errorSummary}`);
359
- }
360
- const mainContents = await getMainContents(page, {
361
- mainContentSelector: options?.mainContentSelector,
362
- });
363
- void this.emit('changePhase', {
364
- pid: process.pid,
365
- name: 'getAnchors',
366
- url,
367
- isExternal,
368
- message: `%countdown(${domEvaluationTimeout},getAnchors_${url.withoutHash},s)%s`,
369
- });
370
- const anchorList = await getAnchorList(page, parseOpts, domEvaluationTimeout);
371
- void this.emit('changePhase', {
372
- pid: process.pid,
373
- name: 'getMeta',
374
- url,
375
- isExternal,
376
- message: `%countdown(${domEvaluationTimeout},getMeta_${url.withoutHash},s)%s`,
377
- });
378
- const meta = await getMeta(page, {
379
- url: url.withoutHashAndAuth,
380
- html,
381
- statusCode: status,
382
- headers: responseHeaders ?? undefined,
383
- }, domEvaluationTimeout);
384
- let imageList = [];
385
- let scrollHeight = null;
386
- if (captureImages) {
387
- void this.emit('changePhase', {
388
- pid: process.pid,
389
- name: 'extractImages',
390
- url,
391
- isExternal,
392
- message: `%countdown(${domEvaluationTimeout},extractImages_${url.withoutHash},s)%s`,
393
- });
394
- const fetched = await this.#fetchImages(page, url.withoutHashAndAuth, isExternal, imageLoadTimeout, domEvaluationTimeout);
395
- imageList = fetched.imageList;
396
- scrollHeight = fetched.scrollHeight;
397
- }
398
- else {
399
- scrollHeight = await measureScrollHeight(page);
453
+ finally {
454
+ await Promise.all(pendingConsoleWork);
400
455
  }
401
- return {
402
- url,
403
- isTarget: true,
404
- isExternal,
405
- redirectPaths: [...redirectPaths],
406
- status,
407
- statusText,
408
- contentType,
409
- contentLength,
410
- responseHeaders,
411
- meta,
412
- anchorList,
413
- imageList,
414
- html,
415
- mainContents,
416
- scrollHeight,
417
- isSkipped: false,
418
- };
419
456
  }, "#fetchData") }, _private_fetchData_decorators, { kind: "method", name: "#fetchData", static: false, private: true, access: { has: obj => #fetchData in obj, get: obj => obj.#fetchData }, metadata: _metadata }, null, _instanceExtraInitializers);
420
457
  __esDecorate(this, _private_fetchImages_descriptor = { value: __setFunctionName(async function (page, url, isExternal, imageLoadTimeout, domEvaluationTimeout) {
421
458
  const listener = this.#createPageScanListener(isExternal);
@@ -505,7 +542,10 @@ let Scraper = (() => {
505
542
  * - `type: "error"` with `error` details when scraping fails
506
543
  *
507
544
  * Sub-resources are collected via the `resourceResponse` event and
508
- * included in the returned `ScrapeResult.resources`.
545
+ * included in the returned `ScrapeResult.resources`. Console messages and
546
+ * uncaught page errors (internal pages only) are collected via
547
+ * Puppeteer's `console`/`pageerror` page events and included in
548
+ * `ScrapeResult.consoleLogs`.
509
549
  * @param page - The Puppeteer page instance to use for navigation and DOM evaluation.
510
550
  * @param url - The extended URL to scrape.
511
551
  * @param options - Optional scraper configuration overriding defaults.
@@ -520,6 +560,7 @@ let Scraper = (() => {
520
560
  const metadataOnly = options?.metadataOnly ?? false;
521
561
  const imageLoadTimeout = options?.imageLoadTimeout ?? 5000;
522
562
  const resources = [];
563
+ const consoleLogs = [];
523
564
  const failedRequests = [];
524
565
  void this.emit('changePhase', {
525
566
  pid: process.pid,
@@ -540,6 +581,7 @@ let Scraper = (() => {
540
581
  return {
541
582
  type: 'skipped',
542
583
  resources,
584
+ consoleLogs,
543
585
  ignored: {
544
586
  url,
545
587
  matchedText: url.pathname || '',
@@ -574,7 +616,7 @@ let Scraper = (() => {
574
616
  isExternal,
575
617
  message: '',
576
618
  });
577
- return { type: 'success', pageData: result, resources };
619
+ return { type: 'success', pageData: result, resources, consoleLogs };
578
620
  }
579
621
  let headResult = options?.headCheckResult ?? null;
580
622
  if (headResult && metadataOnly) {
@@ -594,10 +636,11 @@ let Scraper = (() => {
594
636
  scrollHeight: headResult.scrollHeight ?? null,
595
637
  },
596
638
  resources,
639
+ consoleLogs,
597
640
  };
598
641
  }
599
642
  if (headResult === null || isHtmlContentType(headResult.contentType)) {
600
- const fetchResult = await this.#fetchData(page, url, isExternal, captureImages, imageLoadTimeout, resources, failedRequests, options).catch((error) => {
643
+ const fetchResult = await this.#fetchData(page, url, isExternal, captureImages, imageLoadTimeout, resources, consoleLogs, failedRequests, options).catch((error) => {
601
644
  if (error instanceof Error) {
602
645
  return error;
603
646
  }
@@ -609,6 +652,7 @@ let Scraper = (() => {
609
652
  return {
610
653
  type: 'error',
611
654
  resources,
655
+ consoleLogs,
612
656
  failedRequests: failedRequests.length > 0 ? failedRequests : undefined,
613
657
  error: {
614
658
  name: fetchResult.name,
@@ -639,6 +683,7 @@ let Scraper = (() => {
639
683
  return {
640
684
  type: 'skipped',
641
685
  resources,
686
+ consoleLogs,
642
687
  ignored: {
643
688
  url,
644
689
  matchedText: url.pathname || '',
@@ -656,6 +701,7 @@ let Scraper = (() => {
656
701
  return {
657
702
  type: 'skipped',
658
703
  resources,
704
+ consoleLogs,
659
705
  ignored: {
660
706
  url,
661
707
  matchedText: headResult.matched.text,
@@ -675,6 +721,7 @@ let Scraper = (() => {
675
721
  type: 'success',
676
722
  pageData: headResult,
677
723
  resources,
724
+ consoleLogs,
678
725
  failedRequests: failedRequests.length > 0 ? failedRequests : undefined,
679
726
  };
680
727
  }
@@ -730,7 +777,8 @@ let Scraper = (() => {
730
777
  * Frame" or "Session closed".
731
778
  *
732
779
  * Flow:
733
- * 1. Register request/response/requestfailed listeners to capture sub-resources (internal pages only)
780
+ * 1. Register request/response/requestfailed/console/pageerror listeners to
781
+ * capture sub-resources and console output (internal pages only)
734
782
  * 2. Navigate to URL via `page.goto()` and track redirect chain
735
783
  * 3. Wait for DOM content and network idle
736
784
  * 4. Check for network disconnection errors and throw to trigger retry
@@ -742,6 +790,7 @@ let Scraper = (() => {
742
790
  * @param captureImages - Whether to run the image extraction pipeline
743
791
  * @param imageLoadTimeout - Timeout (ms) for waiting lazy-loaded images to complete
744
792
  * @param resources - Mutable array to collect captured sub-resources into
793
+ * @param consoleLogs - Mutable array to collect captured console messages / page errors into
745
794
  * @param failedRequests - Mutable array to collect failed sub-resource requests into
746
795
  * @param options - Additional scraper options (e.g. `disableQueries`, `navigationTimeout`)
747
796
  * @returns Full page data or skipped page data if an exclusion rule matched
@@ -0,0 +1,14 @@
1
+ import type { ConsoleLogEntry } from './types.js';
2
+ import type { ConsoleMessage } from 'puppeteer';
3
+ /**
4
+ * Converts a Puppeteer `ConsoleMessage` into a `ConsoleLogEntry`.
5
+ *
6
+ * WHY per-argument try/catch: `JSHandle.jsonValue()` can reject when the
7
+ * page's execution context has been destroyed (e.g. mid-navigation) or when
8
+ * the value cannot be serialized (e.g. a circular reference). A single
9
+ * failing argument must not drop the rest of the message's arguments.
10
+ * @param msg - The console message captured via `page.on('console')`
11
+ * @param pageUrl - The URL (without hash) of the page that produced the message
12
+ * @returns The resolved console log entry
13
+ */
14
+ export declare function toConsoleLogEntry(msg: ConsoleMessage, pageUrl: string): Promise<ConsoleLogEntry>;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Converts a Puppeteer `ConsoleMessage` into a `ConsoleLogEntry`.
3
+ *
4
+ * WHY per-argument try/catch: `JSHandle.jsonValue()` can reject when the
5
+ * page's execution context has been destroyed (e.g. mid-navigation) or when
6
+ * the value cannot be serialized (e.g. a circular reference). A single
7
+ * failing argument must not drop the rest of the message's arguments.
8
+ * @param msg - The console message captured via `page.on('console')`
9
+ * @param pageUrl - The URL (without hash) of the page that produced the message
10
+ * @returns The resolved console log entry
11
+ */
12
+ export async function toConsoleLogEntry(msg, pageUrl) {
13
+ const args = await Promise.all(msg.args().map(async (arg) => {
14
+ try {
15
+ return await arg.jsonValue();
16
+ }
17
+ catch {
18
+ return;
19
+ }
20
+ finally {
21
+ await arg.dispose().catch(() => { });
22
+ }
23
+ }));
24
+ const loc = msg.location();
25
+ return {
26
+ pageUrl,
27
+ type: msg.type(),
28
+ text: msg.text(),
29
+ args,
30
+ location: loc.url === undefined ? undefined : loc,
31
+ ts: Date.now(),
32
+ };
33
+ }
@@ -0,0 +1,10 @@
1
+ import type { ConsoleLogEntry } from './types.js';
2
+ /**
3
+ * Converts an uncaught exception / unhandled Promise rejection (captured via
4
+ * `page.on('pageerror')`) into a `ConsoleLogEntry`.
5
+ * @param error - The value emitted by `page.on('pageerror')`; typed as `unknown`
6
+ * because Puppeteer allows non-`Error` throw values (e.g. `throw 'oops'`)
7
+ * @param pageUrl - The URL (without hash) of the page that produced the error
8
+ * @returns The console log entry, with `type: 'pageerror'`
9
+ */
10
+ export declare function toPageErrorEntry(error: unknown, pageUrl: string): ConsoleLogEntry;