@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/dist/scraper.js CHANGED
@@ -43,11 +43,16 @@ import { retry as retryable } from '@d-zero/shared/retry';
43
43
  import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
44
44
  import { resourceLog, scraperLog } from './debug.js';
45
45
  import { DEFAULT_DOM_EVALUATION_TIMEOUT, getAnchorList, getImageList, getMeta, } from './dom-evaluation.js';
46
+ import { getMainContents } from './get-main-contents.js';
46
47
  import { isError } from './is-error.js';
48
+ import { isHtmlContentType } from './is-html-content-type.js';
47
49
  import { keywordCheck } from './keyword-check.js';
50
+ import { measureScrollHeight } from './measure-scroll-height.js';
48
51
  import { emptyMeta } from './meta/classify.js';
49
52
  import { findDisconnectionFailures } from './network-disconnection.js';
50
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';
51
56
  const pid = `${process.pid}`;
52
57
  const log = scraperLog.extend(pid);
53
58
  const rLog = resourceLog.extend(pid);
@@ -95,7 +100,10 @@ let Scraper = (() => {
95
100
  })];
96
101
  _private_fetchImages_decorators = [retryable({
97
102
  timeout: 20 * 60 * 1000,
98
- fallback: [],
103
+ fallback: {
104
+ imageList: [],
105
+ scrollHeight: { desktop: null, mobile: null },
106
+ },
99
107
  onWait(determinedInterval, retryCount, methodName, error) {
100
108
  void this.emit('changePhase', {
101
109
  pid: process.pid,
@@ -115,17 +123,21 @@ let Scraper = (() => {
115
123
  });
116
124
  },
117
125
  })];
118
- __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) {
119
127
  const parseOpts = options?.disableQueries == null
120
128
  ? undefined
121
129
  : { disableQueries: options.disableQueries };
122
130
  const domEvaluationTimeout = options?.domEvaluationTimeout ?? DEFAULT_DOM_EVALUATION_TIMEOUT;
123
131
  const networkLogs = {};
132
+ // Tracks in-flight `toConsoleLogEntry()` resolutions (async `jsonValue()`
133
+ // extraction) so callers can await them before reading `consoleLogs`.
134
+ const pendingConsoleWork = [];
124
135
  // Clear stale state from previous retries (@retryable may re-invoke this method
125
136
  // with the same page and mutable arrays, so we must reset to avoid accumulation)
126
137
  this.#cleanupPageListeners();
127
138
  failedRequests.length = 0;
128
139
  resources.length = 0;
140
+ consoleLogs.length = 0;
129
141
  // Define named listeners so they can be individually removed on retry/cleanup
130
142
  const onDialog = async (dialog) => {
131
143
  log(`Appear ${dialog.type()} dialog: ${dialog.message()}`);
@@ -141,6 +153,8 @@ let Scraper = (() => {
141
153
  let onRequest = null;
142
154
  let onResponse = null;
143
155
  let onRequestFailed = null;
156
+ let onConsole = null;
157
+ let onPageError = null;
144
158
  if (!isExternal) {
145
159
  onRequest = (request) => {
146
160
  const url = parseUrl(request.url(), parseOpts);
@@ -215,9 +229,23 @@ let Scraper = (() => {
215
229
  rLog('Request failed: %s (%s)', request.url(), errorText);
216
230
  failedRequests.push({ url: request.url(), errorText });
217
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
+ };
218
244
  page.on('request', onRequest);
219
245
  page.on('response', onResponse);
220
246
  page.on('requestfailed', onRequestFailed);
247
+ page.on('console', onConsole);
248
+ page.on('pageerror', onPageError);
221
249
  }
222
250
  // Store cleanup function for retry/post-fetch removal
223
251
  this.#pageListenerCleanup = () => {
@@ -228,93 +256,184 @@ let Scraper = (() => {
228
256
  page.off('response', onResponse);
229
257
  if (onRequestFailed)
230
258
  page.off('requestfailed', onRequestFailed);
259
+ if (onConsole)
260
+ page.off('console', onConsole);
261
+ if (onPageError)
262
+ page.off('pageerror', onPageError);
231
263
  };
232
264
  const navigationTimeout = options?.navigationTimeout ?? 60_000;
233
- void this.emit('changePhase', {
234
- pid: process.pid,
235
- name: 'openPage',
236
- url,
237
- isExternal,
238
- message: `%countdown(${navigationTimeout},openPage_${url.withoutHash},s)%s`,
239
- });
240
- if (url.username && url.password) {
241
- await page.setExtraHTTPHeaders({
242
- 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`,
243
278
  });
244
- }
245
- const res = await page.goto(url.withoutHashAndAuth, { timeout: navigationTimeout });
246
- if (!res) {
247
- throw new Error('The method Page.goto returned null');
248
- }
249
- const destUrl = parseUrl(page.url(), parseOpts);
250
- const redirectPaths = new Set();
251
- if (url.withoutHash !== destUrl.withoutHash) {
252
- const redirectChain = res
253
- .request()
254
- .redirectChain()
255
- .map((req) => req.url());
256
- for (const redirectPath of redirectChain) {
257
- 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
+ });
258
283
  }
259
- redirectPaths.add(destUrl.withoutHash);
260
- }
261
- if (destUrl.hostname !== url.hostname) {
262
- isExternal = true;
263
- }
264
- const status = res.status();
265
- const statusText = res.statusText();
266
- const responseHeaders = res.headers();
267
- const contentType = responseHeaders['content-type']?.split(';')[0] || null;
268
- const _contentLength = Number.parseInt(responseHeaders['content-length'] ?? '');
269
- const contentLength = Number.isFinite(_contentLength) ? _contentLength : null;
270
- if (contentType !== 'text/html') {
271
- 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',
272
332
  url,
273
- isTarget: false,
274
333
  isExternal,
275
- redirectPaths: [...redirectPaths],
276
- status,
277
- statusText,
278
- contentType,
279
- contentLength,
280
- responseHeaders,
281
- meta: emptyMeta(),
282
- imageList: [],
283
- anchorList: [],
284
- html: '',
285
- isSkipped: false,
286
- };
287
- }
288
- void this.emit('changePhase', {
289
- pid: process.pid,
290
- name: 'loadDOMContent',
291
- url,
292
- isExternal,
293
- message: '',
294
- });
295
- await page
296
- .waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 5000 })
297
- .catch(() => { });
298
- void this.emit('changePhase', {
299
- pid: process.pid,
300
- name: 'getHTML',
301
- url,
302
- isExternal,
303
- message: '',
304
- });
305
- const { title, html } = await page.evaluate(() => {
306
- /* global document */
307
- return {
308
- title: document.title,
309
- html: document.documentElement.outerHTML,
310
- };
311
- });
312
- if (isExternal) {
313
- const externalMeta = emptyMeta();
314
- 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
+ }
315
434
  return {
316
435
  url,
317
- isTarget: false,
436
+ isTarget: true,
318
437
  isExternal,
319
438
  redirectPaths: [...redirectPaths],
320
439
  status,
@@ -322,80 +441,18 @@ let Scraper = (() => {
322
441
  contentType,
323
442
  contentLength,
324
443
  responseHeaders,
325
- meta: externalMeta,
326
- imageList: [],
327
- anchorList: [],
444
+ meta,
445
+ anchorList,
446
+ imageList,
328
447
  html,
448
+ mainContents,
449
+ scrollHeight,
329
450
  isSkipped: false,
330
451
  };
331
452
  }
332
- void this.emit('changePhase', {
333
- pid: process.pid,
334
- name: 'waitNetworkIdle',
335
- url,
336
- isExternal,
337
- message: '',
338
- });
339
- await page
340
- .waitForNavigation({ waitUntil: 'networkidle0', timeout: 5000 })
341
- .catch(() => { });
342
- // Check for network disconnection errors in failed requests
343
- const disconnectionFailures = findDisconnectionFailures(failedRequests);
344
- if (disconnectionFailures.length > 0) {
345
- const errorSummary = disconnectionFailures
346
- .map((r) => `${r.url} (${r.errorText})`)
347
- .join(', ');
348
- throw new Error(`Network disconnection detected during page load: ${errorSummary}`);
453
+ finally {
454
+ await Promise.all(pendingConsoleWork);
349
455
  }
350
- void this.emit('changePhase', {
351
- pid: process.pid,
352
- name: 'getAnchors',
353
- url,
354
- isExternal,
355
- message: `%countdown(${domEvaluationTimeout},getAnchors_${url.withoutHash},s)%s`,
356
- });
357
- const anchorList = await getAnchorList(page, parseOpts, domEvaluationTimeout);
358
- void this.emit('changePhase', {
359
- pid: process.pid,
360
- name: 'getMeta',
361
- url,
362
- isExternal,
363
- message: `%countdown(${domEvaluationTimeout},getMeta_${url.withoutHash},s)%s`,
364
- });
365
- const meta = await getMeta(page, {
366
- url: url.withoutHashAndAuth,
367
- html,
368
- statusCode: status,
369
- headers: responseHeaders ?? undefined,
370
- }, domEvaluationTimeout);
371
- const imageList = captureImages
372
- ? await (async () => {
373
- void this.emit('changePhase', {
374
- pid: process.pid,
375
- name: 'extractImages',
376
- url,
377
- isExternal,
378
- message: `%countdown(${domEvaluationTimeout},extractImages_${url.withoutHash},s)%s`,
379
- });
380
- return this.#fetchImages(page, url.withoutHashAndAuth, isExternal, imageLoadTimeout, domEvaluationTimeout);
381
- })()
382
- : [];
383
- return {
384
- url,
385
- isTarget: true,
386
- isExternal,
387
- redirectPaths: [...redirectPaths],
388
- status,
389
- statusText,
390
- contentType,
391
- contentLength,
392
- responseHeaders,
393
- meta,
394
- anchorList,
395
- imageList,
396
- html,
397
- isSkipped: false,
398
- };
399
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);
400
457
  __esDecorate(this, _private_fetchImages_descriptor = { value: __setFunctionName(async function (page, url, isExternal, imageLoadTimeout, domEvaluationTimeout) {
401
458
  const listener = this.#createPageScanListener(isExternal);
@@ -404,7 +461,9 @@ let Scraper = (() => {
404
461
  { key: 'mobile-small', preset: devicePresets['mobile-small'] },
405
462
  ];
406
463
  const imageList = [];
464
+ const scrollHeight = { desktop: null, mobile: null };
407
465
  for (const { key, preset } of devices) {
466
+ const scrollKey = key === 'desktop-compact' ? 'desktop' : 'mobile';
408
467
  try {
409
468
  void this.emit('changePhase', {
410
469
  pid: process.pid,
@@ -421,6 +480,7 @@ let Scraper = (() => {
421
480
  timeout: 5000,
422
481
  maxScrollHeight: MAX_SCROLL_HEIGHT,
423
482
  });
483
+ scrollHeight[scrollKey] = scanResult.scrollHeight;
424
484
  if (!scanResult.scrolled) {
425
485
  void this.emit('changePhase', {
426
486
  pid: process.pid,
@@ -465,7 +525,7 @@ let Scraper = (() => {
465
525
  });
466
526
  }
467
527
  }
468
- return imageList;
528
+ return { imageList, scrollHeight };
469
529
  }, "#fetchImages") }, _private_fetchImages_decorators, { kind: "method", name: "#fetchImages", static: false, private: true, access: { has: obj => #fetchImages in obj, get: obj => obj.#fetchImages }, metadata: _metadata }, null, _instanceExtraInitializers);
470
530
  if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
471
531
  }
@@ -482,7 +542,10 @@ let Scraper = (() => {
482
542
  * - `type: "error"` with `error` details when scraping fails
483
543
  *
484
544
  * Sub-resources are collected via the `resourceResponse` event and
485
- * 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`.
486
549
  * @param page - The Puppeteer page instance to use for navigation and DOM evaluation.
487
550
  * @param url - The extended URL to scrape.
488
551
  * @param options - Optional scraper configuration overriding defaults.
@@ -497,6 +560,7 @@ let Scraper = (() => {
497
560
  const metadataOnly = options?.metadataOnly ?? false;
498
561
  const imageLoadTimeout = options?.imageLoadTimeout ?? 5000;
499
562
  const resources = [];
563
+ const consoleLogs = [];
500
564
  const failedRequests = [];
501
565
  void this.emit('changePhase', {
502
566
  pid: process.pid,
@@ -517,6 +581,7 @@ let Scraper = (() => {
517
581
  return {
518
582
  type: 'skipped',
519
583
  resources,
584
+ consoleLogs,
520
585
  ignored: {
521
586
  url,
522
587
  matchedText: url.pathname || '',
@@ -540,6 +605,8 @@ let Scraper = (() => {
540
605
  imageList: [],
541
606
  anchorList: [],
542
607
  html: '',
608
+ mainContents: null,
609
+ scrollHeight: null,
543
610
  isSkipped: false,
544
611
  };
545
612
  void this.emit('changePhase', {
@@ -549,7 +616,7 @@ let Scraper = (() => {
549
616
  isExternal,
550
617
  message: '',
551
618
  });
552
- return { type: 'success', pageData: result, resources };
619
+ return { type: 'success', pageData: result, resources, consoleLogs };
553
620
  }
554
621
  let headResult = options?.headCheckResult ?? null;
555
622
  if (headResult && metadataOnly) {
@@ -565,12 +632,15 @@ let Scraper = (() => {
565
632
  pageData: {
566
633
  ...headResult,
567
634
  isTarget: false,
635
+ mainContents: headResult.mainContents ?? null,
636
+ scrollHeight: headResult.scrollHeight ?? null,
568
637
  },
569
638
  resources,
639
+ consoleLogs,
570
640
  };
571
641
  }
572
- if (headResult === null || headResult.contentType === 'text/html') {
573
- const fetchResult = await this.#fetchData(page, url, isExternal, captureImages, imageLoadTimeout, resources, failedRequests, options).catch((error) => {
642
+ if (headResult === null || isHtmlContentType(headResult.contentType)) {
643
+ const fetchResult = await this.#fetchData(page, url, isExternal, captureImages, imageLoadTimeout, resources, consoleLogs, failedRequests, options).catch((error) => {
574
644
  if (error instanceof Error) {
575
645
  return error;
576
646
  }
@@ -582,6 +652,7 @@ let Scraper = (() => {
582
652
  return {
583
653
  type: 'error',
584
654
  resources,
655
+ consoleLogs,
585
656
  failedRequests: failedRequests.length > 0 ? failedRequests : undefined,
586
657
  error: {
587
658
  name: fetchResult.name,
@@ -612,6 +683,7 @@ let Scraper = (() => {
612
683
  return {
613
684
  type: 'skipped',
614
685
  resources,
686
+ consoleLogs,
615
687
  ignored: {
616
688
  url,
617
689
  matchedText: url.pathname || '',
@@ -629,6 +701,7 @@ let Scraper = (() => {
629
701
  return {
630
702
  type: 'skipped',
631
703
  resources,
704
+ consoleLogs,
632
705
  ignored: {
633
706
  url,
634
707
  matchedText: headResult.matched.text,
@@ -648,6 +721,7 @@ let Scraper = (() => {
648
721
  type: 'success',
649
722
  pageData: headResult,
650
723
  resources,
724
+ consoleLogs,
651
725
  failedRequests: failedRequests.length > 0 ? failedRequests : undefined,
652
726
  };
653
727
  }
@@ -703,7 +777,8 @@ let Scraper = (() => {
703
777
  * Frame" or "Session closed".
704
778
  *
705
779
  * Flow:
706
- * 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)
707
782
  * 2. Navigate to URL via `page.goto()` and track redirect chain
708
783
  * 3. Wait for DOM content and network idle
709
784
  * 4. Check for network disconnection errors and throw to trigger retry
@@ -715,6 +790,7 @@ let Scraper = (() => {
715
790
  * @param captureImages - Whether to run the image extraction pipeline
716
791
  * @param imageLoadTimeout - Timeout (ms) for waiting lazy-loaded images to complete
717
792
  * @param resources - Mutable array to collect captured sub-resources into
793
+ * @param consoleLogs - Mutable array to collect captured console messages / page errors into
718
794
  * @param failedRequests - Mutable array to collect failed sub-resource requests into
719
795
  * @param options - Additional scraper options (e.g. `disableQueries`, `navigationTimeout`)
720
796
  * @returns Full page data or skipped page data if an exclusion rule matched
@@ -732,12 +808,12 @@ let Scraper = (() => {
732
808
  * changes and triggers a reload. Isolating each device preset allows partial
733
809
  * results — if one viewport fails, the other can still succeed.
734
810
  *
735
- * WHY retryable with 20-min timeout and `fallback: []`: Image extraction is
736
- * best-effort. If all retries fail, an empty array is returned rather than
737
- * failing the entire page scrape. The 20-min wall clock accommodates pages
738
- * whose mobile-small `scrollHeight` reaches ~300k px (observed on
739
- * responsive data tables, which take ~5 min to scroll). A shorter timeout
740
- * causes a second retry to start while the previous attempt's
811
+ * WHY retryable with 20-min timeout and empty fallback: Image extraction is
812
+ * best-effort. If all retries fail, empty images and null scroll heights are
813
+ * returned rather than failing the entire page scrape. The 20-min wall clock
814
+ * accommodates pages whose mobile-small `scrollHeight` reaches ~300k px
815
+ * (observed on responsive data tables, which take ~5 min to scroll). A shorter
816
+ * timeout causes a second retry to start while the previous attempt's
741
817
  * `scrollAllOver` is still running its `page.evaluate` calls in the
742
818
  * background — `Promise.race` in `retry.ts` does not cancel `fn()`. The
743
819
  * collision then surfaces as "Attempted to use detached Frame" or
@@ -749,12 +825,15 @@ let Scraper = (() => {
749
825
  * preset entirely keeps the timeout-vs-background-evaluate collision from
750
826
  * ever being triggered, at the cost of losing that viewport's image data
751
827
  * for those pages. See {@link MAX_SCROLL_HEIGHT} for the chosen threshold.
828
+ *
829
+ * Scroll heights are still recorded when scrolling is skipped for the
830
+ * height limit — the measurement from `beforePageScan` is kept.
752
831
  * @param page - Puppeteer page instance
753
832
  * @param url - The page URL string (without hash and auth)
754
833
  * @param isExternal - Whether the page is external
755
834
  * @param imageLoadTimeout - Timeout (ms) for waiting images to complete loading
756
835
  * @param domEvaluationTimeout - Timeout (ms) for the in-page image extraction `page.evaluate`
757
- * @returns Array of image elements from all device presets (may be partial if some viewports failed)
836
+ * @returns Image elements plus desktop/mobile scroll heights from the scan path
758
837
  */
759
838
  get #fetchImages() { return _private_fetchImages_descriptor.value; }
760
839
  };
@@ -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
+ }