@d-zero/beholder 4.0.0 → 4.1.1
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 +10 -0
- package/README.md +10 -0
- package/dist/index.d.ts +1 -1
- package/dist/scraper.d.ts +4 -1
- package/dist/scraper.js +216 -167
- 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 +35 -0
- package/package.json +4 -4
- package/src/index.ts +1 -1
- package/src/scraper.ts +242 -184
- 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 +32 -0
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
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.1](https://github.com/d-zero-dev/tools/compare/@d-zero/beholder@4.1.0...@d-zero/beholder@4.1.1) (2026-07-29)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @d-zero/beholder
|
|
9
|
+
|
|
10
|
+
# [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)
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- **beholder:** capture console messages and page errors ([f271bbb](https://github.com/d-zero-dev/tools/commit/f271bbb033f8cc6ccb21a3c2981d1c27b684e551))
|
|
15
|
+
|
|
6
16
|
# [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
17
|
|
|
8
18
|
- 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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
isExternal
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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:
|
|
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
|
|
334
|
-
|
|
335
|
-
|
|
444
|
+
meta,
|
|
445
|
+
anchorList,
|
|
446
|
+
imageList,
|
|
336
447
|
html,
|
|
337
|
-
mainContents
|
|
338
|
-
scrollHeight
|
|
448
|
+
mainContents,
|
|
449
|
+
scrollHeight,
|
|
339
450
|
isSkipped: false,
|
|
340
451
|
};
|
|
341
452
|
}
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
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;
|