@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/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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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:
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
444
|
+
meta,
|
|
445
|
+
anchorList,
|
|
446
|
+
imageList,
|
|
328
447
|
html,
|
|
448
|
+
mainContents,
|
|
449
|
+
scrollHeight,
|
|
329
450
|
isSkipped: false,
|
|
330
451
|
};
|
|
331
452
|
}
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
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
|
|
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
|
|
736
|
-
* best-effort. If all retries fail,
|
|
737
|
-
* failing the entire page scrape. The 20-min wall clock
|
|
738
|
-
* whose mobile-small `scrollHeight` reaches ~300k px
|
|
739
|
-
* responsive data tables, which take ~5 min to scroll). A shorter
|
|
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
|
|
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
|
+
}
|