@nitpicker/crawler 0.8.0 → 0.11.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/lib/archive/archive-accessor.d.ts +87 -14
- package/lib/archive/archive-accessor.js +162 -36
- package/lib/archive/archive.d.ts +147 -24
- package/lib/archive/archive.js +252 -86
- package/lib/archive/database.d.ts +209 -25
- package/lib/archive/database.js +928 -108
- package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
- package/lib/archive/filesystem/peek-tar-top-dir.js +65 -0
- package/lib/archive/init-schema.d.ts +53 -2
- package/lib/archive/init-schema.js +247 -15
- package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
- package/lib/archive/meta/assert-compatible-version.js +72 -0
- package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
- package/lib/archive/meta/classify-jsonld-type.js +43 -0
- package/lib/archive/meta/compare-semver.d.ts +23 -0
- package/lib/archive/meta/compare-semver.js +51 -0
- package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
- package/lib/archive/meta/compute-page-denormalized.js +35 -0
- package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
- package/lib/archive/meta/derive-flat-from-meta.js +158 -0
- package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
- package/lib/archive/meta/derive-meta-extras.js +23 -0
- package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
- package/lib/archive/meta/extract-tags-for-archive.js +36 -0
- package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
- package/lib/archive/meta/summarize-jsonld.js +29 -0
- package/lib/archive/meta/summarize-tags.d.ts +16 -0
- package/lib/archive/meta/summarize-tags.js +33 -0
- package/lib/archive/meta/types.d.ts +207 -0
- package/lib/archive/meta/types.js +33 -0
- package/lib/archive/migrate-crawl-errors.d.ts +20 -0
- package/lib/archive/migrate-crawl-errors.js +38 -0
- package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
- package/lib/archive/migrate-html-blob-tables.js +53 -0
- package/lib/archive/migrate-page-errors.d.ts +16 -0
- package/lib/archive/migrate-page-errors.js +35 -0
- package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
- package/lib/archive/migrate-pages-resources-source.js +46 -0
- package/lib/archive/page.d.ts +187 -49
- package/lib/archive/page.js +258 -63
- package/lib/archive/peek-archive-lock.d.ts +40 -0
- package/lib/archive/peek-archive-lock.js +62 -0
- package/lib/archive/resolve-redirect-chain.d.ts +33 -0
- package/lib/archive/resolve-redirect-chain.js +27 -0
- package/lib/archive/types.d.ts +135 -26
- package/lib/crawler/close-browser-safely.d.ts +64 -0
- package/lib/crawler/close-browser-safely.js +73 -0
- package/lib/crawler/crawler.d.ts +4 -1
- package/lib/crawler/crawler.js +290 -32
- package/lib/crawler/create-change-phase-handler.d.ts +54 -0
- package/lib/crawler/create-change-phase-handler.js +44 -0
- package/lib/crawler/derive-page-source.d.ts +23 -0
- package/lib/crawler/derive-page-source.js +28 -0
- package/lib/crawler/derive-resource-source.d.ts +23 -0
- package/lib/crawler/derive-resource-source.js +26 -0
- package/lib/crawler/drain-phase-errors.d.ts +48 -0
- package/lib/crawler/drain-phase-errors.js +35 -0
- package/lib/crawler/fetch-destination.js +38 -2
- package/lib/crawler/format-crawl-progress.d.ts +12 -3
- package/lib/crawler/format-crawl-progress.js +14 -6
- package/lib/crawler/handle-browser-close.d.ts +29 -0
- package/lib/crawler/handle-browser-close.js +28 -0
- package/lib/crawler/is-html-content-type.d.ts +17 -0
- package/lib/crawler/is-html-content-type.js +19 -0
- package/lib/crawler/is-likely-html-url.d.ts +22 -0
- package/lib/crawler/is-likely-html-url.js +65 -0
- package/lib/crawler/kill-process-tree.d.ts +94 -0
- package/lib/crawler/kill-process-tree.js +178 -0
- package/lib/crawler/link-list.js +2 -1
- package/lib/crawler/link-to-page-data.d.ts +13 -5
- package/lib/crawler/link-to-page-data.js +26 -5
- package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
- package/lib/crawler/log-undrained-phase-errors.js +34 -0
- package/lib/crawler/normalize-content-type.d.ts +14 -0
- package/lib/crawler/normalize-content-type.js +20 -0
- package/lib/crawler/partition-urls-by-html.d.ts +16 -0
- package/lib/crawler/partition-urls-by-html.js +23 -0
- package/lib/crawler/redirect-dest-key.d.ts +19 -0
- package/lib/crawler/redirect-dest-key.js +27 -0
- package/lib/crawler/resource-to-page-data.d.ts +28 -0
- package/lib/crawler/resource-to-page-data.js +59 -0
- package/lib/crawler/types.d.ts +122 -1
- package/lib/crawler-orchestrator.d.ts +93 -1
- package/lib/crawler-orchestrator.js +389 -12
- package/lib/crawler.d.ts +5 -0
- package/lib/crawler.js +3 -0
- package/lib/resource-row-to-lookup-result.d.ts +13 -0
- package/lib/resource-row-to-lookup-result.js +20 -0
- package/lib/types.d.ts +11 -1
- package/lib/utils/object/parse-response-headers.d.ts +12 -0
- package/lib/utils/object/parse-response-headers.js +26 -0
- package/package.json +4 -4
package/lib/crawler/crawler.js
CHANGED
|
@@ -9,19 +9,29 @@ import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-awa
|
|
|
9
9
|
import c from 'ansi-colors';
|
|
10
10
|
import pkg from '../../package.json' with { type: 'json' };
|
|
11
11
|
import { crawlerLog } from '../debug.js';
|
|
12
|
+
import { createChangePhaseHandler } from './create-change-phase-handler.js';
|
|
13
|
+
import { derivePageSource } from './derive-page-source.js';
|
|
14
|
+
import { deriveResourceSource } from './derive-resource-source.js';
|
|
12
15
|
import { detectPaginationPattern } from './detect-pagination-pattern.js';
|
|
16
|
+
import { drainPhaseErrors } from './drain-phase-errors.js';
|
|
13
17
|
import { fetchDestination } from './fetch-destination.js';
|
|
14
18
|
import { findScopeEntry } from './find-scope-entry.js';
|
|
15
19
|
import { formatCrawlProgress } from './format-crawl-progress.js';
|
|
16
20
|
import { generatePredictedUrls } from './generate-predicted-urls.js';
|
|
21
|
+
import { handleBrowserClose } from './handle-browser-close.js';
|
|
17
22
|
import { handleIgnoreAndSkip } from './handle-ignore-and-skip.js';
|
|
18
23
|
import { handleResourceResponse } from './handle-resource-response.js';
|
|
19
24
|
import { handleScrapeEnd } from './handle-scrape-end.js';
|
|
20
25
|
import { handleScrapeError } from './handle-scrape-error.js';
|
|
21
26
|
import { injectScopeAuth } from './inject-scope-auth.js';
|
|
27
|
+
import { isHtmlContentType } from './is-html-content-type.js';
|
|
22
28
|
import LinkList from './link-list.js';
|
|
23
29
|
import { linkToPageData } from './link-to-page-data.js';
|
|
30
|
+
import { logUndrainedPhaseErrors } from './log-undrained-phase-errors.js';
|
|
31
|
+
import { partitionUrlsByHtml } from './partition-urls-by-html.js';
|
|
24
32
|
import { protocolAgnosticKey } from './protocol-agnostic-key.js';
|
|
33
|
+
import { redirectDestKey } from './redirect-dest-key.js';
|
|
34
|
+
import { resourceToPageData } from './resource-to-page-data.js';
|
|
25
35
|
import { RobotsChecker } from './robots-checker.js';
|
|
26
36
|
import { shouldDiscardPredicted } from './should-discard-predicted.js';
|
|
27
37
|
import { shouldSkipUrl } from './should-skip-url.js';
|
|
@@ -43,8 +53,18 @@ class Crawler extends EventEmitter {
|
|
|
43
53
|
#linkList = new LinkList();
|
|
44
54
|
/** Merged crawler configuration (user overrides + defaults). */
|
|
45
55
|
#options;
|
|
56
|
+
/**
|
|
57
|
+
* Phase errors observed during {@link Crawler.#launchBrowserAndScrape},
|
|
58
|
+
* buffered per URL href so they can be emitted as `pageError` events
|
|
59
|
+
* AFTER the corresponding `page` / `externalPage` event. This ordering
|
|
60
|
+
* lets the orchestrator's WriteQueue serialise `setPage` before
|
|
61
|
+
* `insertPageError`, so the FK resolution via URL always finds the row.
|
|
62
|
+
*/
|
|
63
|
+
#pendingPhaseErrors = new Map();
|
|
46
64
|
/** Set of resource URLs (without hash) already captured, for deduplication. */
|
|
47
65
|
#resources = new Set();
|
|
66
|
+
/** Number of HTML pages (isTarget=1) scraped in previous sessions, used to seed the progress counter on resume. */
|
|
67
|
+
#resumedPagesScraped = 0;
|
|
48
68
|
/** URLs restored from a previous session that still need to be scraped. */
|
|
49
69
|
#resumedPending = [];
|
|
50
70
|
/** URLs already scraped in a previous session, used to populate the `seen` set in {@link #runDeal}. */
|
|
@@ -53,6 +73,13 @@ class Crawler extends EventEmitter {
|
|
|
53
73
|
#robotsChecker;
|
|
54
74
|
/** Maps hostnames to their scope URLs. Defines the crawl boundary for internal/external classification. */
|
|
55
75
|
#scope = new Map();
|
|
76
|
+
/**
|
|
77
|
+
* Protocol-agnostic keys of redirect destinations already rendered (and stored)
|
|
78
|
+
* during this crawl. When many URLs redirect to one destination, only the first
|
|
79
|
+
* renders it; the rest record the redirect edge and skip the browser (#73).
|
|
80
|
+
* Keyed by {@link redirectDestKey}. Reset at the start of {@link #runDeal}.
|
|
81
|
+
*/
|
|
82
|
+
#scrapedDestinations = new Set();
|
|
56
83
|
/**
|
|
57
84
|
* The AbortSignal associated with this crawler's AbortController.
|
|
58
85
|
*
|
|
@@ -87,6 +114,8 @@ class Crawler extends EventEmitter {
|
|
|
87
114
|
verbose: options?.verbose ?? false,
|
|
88
115
|
userAgent: options?.userAgent || `Nitpicker/${pkg.version}`,
|
|
89
116
|
ignoreRobots: options?.ignoreRobots ?? false,
|
|
117
|
+
lookupResource: options?.lookupResource ?? null,
|
|
118
|
+
inventoryMode: options?.inventoryMode ?? null,
|
|
90
119
|
};
|
|
91
120
|
this.#robotsChecker = new RobotsChecker(this.#options.userAgent, !this.#options.ignoreRobots);
|
|
92
121
|
for (const urlStr of this.#options.roots) {
|
|
@@ -126,10 +155,14 @@ class Crawler extends EventEmitter {
|
|
|
126
155
|
* @param pending - URLs that were pending (not yet scraped) in the previous session.
|
|
127
156
|
* @param scraped - URLs that were already scraped in the previous session.
|
|
128
157
|
* @param resources - Resource URLs that were already captured in the previous session.
|
|
158
|
+
* @param pagesScrapedOffset - Number of HTML pages already rendered in previous
|
|
159
|
+
* sessions, used to seed the session-spanning progress counter. Defaults to 0
|
|
160
|
+
* for callers that don't need cross-session accuracy in the progress display.
|
|
129
161
|
*/
|
|
130
|
-
resume(pending, scraped, resources) {
|
|
162
|
+
resume(pending, scraped, resources, pagesScrapedOffset = 0) {
|
|
131
163
|
this.#resumedPending = this.#linkList.resume(pending, scraped, this.#options);
|
|
132
164
|
this.#resumedScraped = scraped;
|
|
165
|
+
this.#resumedPagesScraped = pagesScrapedOffset;
|
|
133
166
|
for (const resource of resources) {
|
|
134
167
|
this.#resources.add(resource);
|
|
135
168
|
}
|
|
@@ -170,7 +203,12 @@ class Crawler extends EventEmitter {
|
|
|
170
203
|
this.#options.recursive = false;
|
|
171
204
|
this.#options.fromList = true;
|
|
172
205
|
}
|
|
173
|
-
|
|
206
|
+
// A resume can have an empty scraped set — e.g. a crawl interrupted before
|
|
207
|
+
// any page finished, or a `--retry-failed` run where every page in the
|
|
208
|
+
// archive was a failure and got reset to pending. Keying purely on
|
|
209
|
+
// `#resumedScraped` would then mistake the session for a fresh crawl and
|
|
210
|
+
// drop every resumed pending URL, so honour the pending set too.
|
|
211
|
+
const isResuming = this.#resumedScraped.length > 0 || this.#resumedPending.length > 0;
|
|
174
212
|
// Dedupe by the same protocol-agnostic key the dealer uses internally.
|
|
175
213
|
// Append-mode in particular can put the same URL into both
|
|
176
214
|
// `#resumedPending` (via `repromoteExternalPages`) and `urls` (the
|
|
@@ -186,17 +224,38 @@ class Crawler extends EventEmitter {
|
|
|
186
224
|
initialUrls.push(url);
|
|
187
225
|
}
|
|
188
226
|
const resumeOffset = this.#resumedScraped.length;
|
|
227
|
+
const pagesScrapedOffset = this.#resumedPagesScraped;
|
|
189
228
|
if (initialUrls.length === 0) {
|
|
190
229
|
crawlerLog('Crawl End (nothing to resume)');
|
|
191
230
|
void this.emit('crawlEnd', {});
|
|
192
231
|
return;
|
|
193
232
|
}
|
|
194
|
-
void this.#runDeal(initialUrls, resumeOffset).catch((error) => {
|
|
233
|
+
void this.#runDeal(initialUrls, resumeOffset, pagesScrapedOffset).catch((error) => {
|
|
195
234
|
crawlerLog('runDeal error: %O', error);
|
|
196
235
|
this.#emitDealErrors(error, root.href);
|
|
197
236
|
void this.emit('crawlEnd', {});
|
|
198
237
|
});
|
|
199
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* Thin instance-bound adapter over {@link drainPhaseErrors}. Flushes
|
|
241
|
+
* `#pendingPhaseErrors` for `url` as `pageError` events. Idempotent.
|
|
242
|
+
*
|
|
243
|
+
* **Test gap (known)**: this adapter is invoked from the worker body in
|
|
244
|
+
* {@link Crawler.#runDeal} at three call sites — after `#handleResult`,
|
|
245
|
+
* inside the worker's `catch`, and via `logUndrainedPhaseErrors` in
|
|
246
|
+
* `finally`. The drain logic itself is unit-tested in
|
|
247
|
+
* `drain-phase-errors.spec.ts`; the wiring (whether the worker actually
|
|
248
|
+
* calls it on each path) is verified by code review only, because
|
|
249
|
+
* driving the worker requires a Puppeteer + beholder mock stack whose
|
|
250
|
+
* cost outweighs the regression it would catch.
|
|
251
|
+
* @param url - URL whose buffered errors should be flushed.
|
|
252
|
+
* @param isExternal - Whether the URL is external to the crawl scope.
|
|
253
|
+
*/
|
|
254
|
+
#drainPhaseErrors(url, isExternal) {
|
|
255
|
+
drainPhaseErrors(this.#pendingPhaseErrors, url.href, isExternal, (payload) => {
|
|
256
|
+
void this.emit('pageError', payload);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
200
259
|
/**
|
|
201
260
|
* Emits error events for a deal-level failure.
|
|
202
261
|
*
|
|
@@ -224,11 +283,17 @@ class Crawler extends EventEmitter {
|
|
|
224
283
|
* @param resources - Sub-resource entries captured during the page load
|
|
225
284
|
*/
|
|
226
285
|
#handleResources(resources) {
|
|
286
|
+
// `deriveResourceSource` encodes the "sub-resources are never seeds"
|
|
287
|
+
// rule and stays in lockstep with `derivePageSource` if PageSource
|
|
288
|
+
// gains new variants. Computed once outside the loop because the
|
|
289
|
+
// inventoryMode reference does not change mid-batch.
|
|
290
|
+
const subResourceSource = deriveResourceSource(this.#options.inventoryMode);
|
|
227
291
|
for (const { resource, pageUrl } of resources) {
|
|
228
292
|
const { isNew } = handleResourceResponse(resource, this.#resources);
|
|
229
293
|
if (isNew) {
|
|
230
294
|
void this.emit('response', {
|
|
231
295
|
resource: resource,
|
|
296
|
+
source: subResourceSource,
|
|
232
297
|
});
|
|
233
298
|
}
|
|
234
299
|
void this.emit('responseReferrers', {
|
|
@@ -246,20 +311,22 @@ class Crawler extends EventEmitter {
|
|
|
246
311
|
* - `error`: Creates a fallback PageData, marks as done, and emits `error`.
|
|
247
312
|
* @param result - The scrape result from beholder
|
|
248
313
|
* @param url - The URL that was scraped
|
|
249
|
-
* @param
|
|
314
|
+
* @param enqueue - Callback to enqueue newly discovered URLs into the dealer
|
|
315
|
+
* queue, prioritising likely-HTML URLs to the front (see {@link partitionUrlsByHtml}).
|
|
316
|
+
* Accepts a batch so a group of URLs (e.g. predicted pagination) keeps its order.
|
|
250
317
|
* @param paginationState - Mutable state for predicted pagination cascade prevention
|
|
251
318
|
* @param paginationState.lastPushedUrl
|
|
252
319
|
* @param paginationState.lastPushedWasPredicted
|
|
253
320
|
* @param concurrency - Current concurrency level, used to determine predicted URL count
|
|
254
321
|
*/
|
|
255
|
-
#handleResult(result, url,
|
|
322
|
+
#handleResult(result, url, enqueue, paginationState, concurrency) {
|
|
256
323
|
switch (result.type) {
|
|
257
324
|
case 'success': {
|
|
258
325
|
if (!result.pageData)
|
|
259
326
|
break;
|
|
260
327
|
handleScrapeEnd(result.pageData, this.#linkList, this.#scope, this.#options, (newUrl, opts) => {
|
|
261
328
|
this.#linkList.add(newUrl, opts);
|
|
262
|
-
void
|
|
329
|
+
void enqueue(newUrl);
|
|
263
330
|
// Predicted pagination detection
|
|
264
331
|
if (!paginationState || !concurrency)
|
|
265
332
|
return;
|
|
@@ -276,13 +343,18 @@ class Crawler extends EventEmitter {
|
|
|
276
343
|
const pattern = detectPaginationPattern(paginationState.lastPushedUrl, newUrl.withoutHashAndAuth);
|
|
277
344
|
if (pattern) {
|
|
278
345
|
const urls = generatePredictedUrls(pattern, newUrl.withoutHashAndAuth, concurrency);
|
|
346
|
+
const specUrls = [];
|
|
279
347
|
for (const specUrlStr of urls) {
|
|
280
348
|
const specUrl = parseUrl(specUrlStr, this.#options);
|
|
281
349
|
if (specUrl) {
|
|
282
350
|
this.#linkList.add(specUrl, { predicted: true });
|
|
283
|
-
|
|
351
|
+
specUrls.push(specUrl);
|
|
284
352
|
}
|
|
285
353
|
}
|
|
354
|
+
// Enqueue as one batch so ascending page order is kept
|
|
355
|
+
// at the front of the queue (see enqueue in #runDeal).
|
|
356
|
+
if (specUrls.length > 0)
|
|
357
|
+
void enqueue(...specUrls);
|
|
286
358
|
paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
|
|
287
359
|
paginationState.lastPushedWasPredicted = true;
|
|
288
360
|
return;
|
|
@@ -291,11 +363,20 @@ class Crawler extends EventEmitter {
|
|
|
291
363
|
paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
|
|
292
364
|
paginationState.lastPushedWasPredicted = false;
|
|
293
365
|
});
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
366
|
+
{
|
|
367
|
+
const pageSource = derivePageSource(this.#options.inventoryMode, result.pageData.url.withoutHashAndAuth);
|
|
368
|
+
if (result.pageData.isExternal) {
|
|
369
|
+
void this.emit('externalPage', {
|
|
370
|
+
result: result.pageData,
|
|
371
|
+
source: pageSource,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
void this.emit('page', {
|
|
376
|
+
result: result.pageData,
|
|
377
|
+
source: pageSource,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
299
380
|
}
|
|
300
381
|
break;
|
|
301
382
|
}
|
|
@@ -324,11 +405,15 @@ class Crawler extends EventEmitter {
|
|
|
324
405
|
}, this.#linkList, this.#scope, this.#options);
|
|
325
406
|
const isExternal = findScopeEntry(url, this.#scope, this.#options) === null;
|
|
326
407
|
if (pageResult) {
|
|
408
|
+
const pageSource = derivePageSource(this.#options.inventoryMode, pageResult.url.withoutHashAndAuth);
|
|
327
409
|
if (pageResult.isExternal) {
|
|
328
|
-
void this.emit('externalPage', {
|
|
410
|
+
void this.emit('externalPage', {
|
|
411
|
+
result: pageResult,
|
|
412
|
+
source: pageSource,
|
|
413
|
+
});
|
|
329
414
|
}
|
|
330
415
|
else {
|
|
331
|
-
void this.emit('page', { result: pageResult });
|
|
416
|
+
void this.emit('page', { result: pageResult, source: pageSource });
|
|
332
417
|
}
|
|
333
418
|
}
|
|
334
419
|
void this.emit('error', {
|
|
@@ -384,13 +469,13 @@ class Crawler extends EventEmitter {
|
|
|
384
469
|
});
|
|
385
470
|
}
|
|
386
471
|
const scraper = new Scraper();
|
|
387
|
-
scraper.on('changePhase', (
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
});
|
|
472
|
+
scraper.on('changePhase', createChangePhaseHandler({
|
|
473
|
+
emit: (event) => void this.emit('changePhase', event),
|
|
474
|
+
update,
|
|
475
|
+
formatLog: formatPhaseLog,
|
|
476
|
+
buffer: this.#pendingPhaseErrors,
|
|
477
|
+
urlHref: url.href,
|
|
478
|
+
}));
|
|
394
479
|
const result = await scraper.scrapeStart(page, url, {
|
|
395
480
|
isExternal,
|
|
396
481
|
captureImages: !isExternal && this.#options.captureImages,
|
|
@@ -416,7 +501,11 @@ class Crawler extends EventEmitter {
|
|
|
416
501
|
};
|
|
417
502
|
}
|
|
418
503
|
finally {
|
|
419
|
-
|
|
504
|
+
// handleBrowserClose force-kills the underlying Chromium when a
|
|
505
|
+
// graceful close() hangs (e.g. the session died mid-scrape) and
|
|
506
|
+
// guarantees the finally never throws, so the try-block's return
|
|
507
|
+
// value or caught error is never masked.
|
|
508
|
+
await handleBrowserClose(browser, url.href, crawlerLog);
|
|
420
509
|
}
|
|
421
510
|
}
|
|
422
511
|
/**
|
|
@@ -429,16 +518,25 @@ class Crawler extends EventEmitter {
|
|
|
429
518
|
* @param initialUrls - Starting URLs to seed the deal queue
|
|
430
519
|
* @param resumeOffset - Number of URLs already scraped in a previous session,
|
|
431
520
|
* added to the progress counter for accurate display
|
|
521
|
+
* @param pagesScrapedOffset - Number of HTML pages already rendered in previous
|
|
522
|
+
* sessions, used to seed the per-session HTML-pages counter so the display
|
|
523
|
+
* remains accurate across resumes
|
|
432
524
|
*/
|
|
433
|
-
async #runDeal(initialUrls, resumeOffset = 0) {
|
|
525
|
+
async #runDeal(initialUrls, resumeOffset = 0, pagesScrapedOffset = 0) {
|
|
434
526
|
const seen = new Set(initialUrls.map((u) => protocolAgnosticKey(u.withoutHashAndAuth)));
|
|
435
527
|
// Add scraped URLs to seen to prevent re-processing during resume
|
|
436
528
|
for (const url of this.#resumedScraped) {
|
|
437
529
|
seen.add(protocolAgnosticKey(url));
|
|
438
530
|
}
|
|
531
|
+
// Redirect-destination dedup is per-crawl; clear any state from a prior run.
|
|
532
|
+
this.#scrapedDestinations.clear();
|
|
439
533
|
// external URL の追跡(target は deal の total/done から導出)
|
|
440
534
|
const externalUrls = new Set();
|
|
441
535
|
const externalDoneUrls = new Set();
|
|
536
|
+
// HTML ページとしてブラウザでレンダリングし、かつアーカイブに保存されたページ数。
|
|
537
|
+
// HEAD のみ・title 取得のみ・skip・ブラウザ起動失敗・predicted-discard は含まない。
|
|
538
|
+
// 過去セッションぶんは pagesScrapedOffset として init される。
|
|
539
|
+
let pagesScraped = pagesScrapedOffset;
|
|
442
540
|
// 初期 URL を分類(onPush を通らないため)
|
|
443
541
|
for (const url of initialUrls) {
|
|
444
542
|
if (findScopeEntry(url, this.#scope, this.#options) === null) {
|
|
@@ -453,7 +551,7 @@ class Crawler extends EventEmitter {
|
|
|
453
551
|
lastPushedUrl: null,
|
|
454
552
|
lastPushedWasPredicted: false,
|
|
455
553
|
};
|
|
456
|
-
await deal(initialUrls, (url, update, _index, setLineHeader, push) => {
|
|
554
|
+
await deal(initialUrls, (url, update, _index, setLineHeader, push, unshift) => {
|
|
457
555
|
const matchedScope = findScopeEntry(url, this.#scope, this.#options);
|
|
458
556
|
const isExternal = matchedScope === null;
|
|
459
557
|
const urlText = isExternal ? c.dim(url.href) : c.cyan(url.href);
|
|
@@ -463,8 +561,30 @@ class Crawler extends EventEmitter {
|
|
|
463
561
|
}
|
|
464
562
|
this.#linkList.add(url);
|
|
465
563
|
this.#linkList.progress(url);
|
|
564
|
+
// Likely-HTML URLs jump to the front of the queue (unshift) so page
|
|
565
|
+
// crawling advances ahead of asset/document fetches; everything else
|
|
566
|
+
// is appended (push). partitionUrlsByHtml splits the batch by the
|
|
567
|
+
// URL-only heuristic. Variadic so a batch (e.g. predicted pagination)
|
|
568
|
+
// keeps its order: a single unshift(...html) preserves ascending order
|
|
569
|
+
// at the front, whereas unshifting one-by-one would reverse it.
|
|
570
|
+
const enqueue = (...newUrls) => {
|
|
571
|
+
const [html, other] = partitionUrlsByHtml(newUrls);
|
|
572
|
+
const ops = [];
|
|
573
|
+
if (html.length > 0)
|
|
574
|
+
ops.push(unshift(...html));
|
|
575
|
+
if (other.length > 0)
|
|
576
|
+
ops.push(push(...other));
|
|
577
|
+
return Promise.all(ops).then(() => { });
|
|
578
|
+
};
|
|
466
579
|
return async () => {
|
|
467
580
|
const log = createTimedUpdate(update, this.#options.verbose);
|
|
581
|
+
// `#scrapePage` 内のブラウザ HTML レンダーが成功したかをマークするフラグ。
|
|
582
|
+
// 成功時のみ #scrapePage 側で true に設定される。
|
|
583
|
+
// discard 判定後にこのフラグを見てカウントするので、launch 失敗や predicted-discard は除外される。
|
|
584
|
+
let renderedInBrowser = false;
|
|
585
|
+
const markBrowserScrape = () => {
|
|
586
|
+
renderedInBrowser = true;
|
|
587
|
+
};
|
|
468
588
|
try {
|
|
469
589
|
const robotsAllowed = await this.#robotsChecker.isAllowed(url);
|
|
470
590
|
if (!robotsAllowed) {
|
|
@@ -496,24 +616,56 @@ class Crawler extends EventEmitter {
|
|
|
496
616
|
isLowerLayer: false,
|
|
497
617
|
});
|
|
498
618
|
this.#linkList.done(url, this.#scope, { page: pageData }, this.#options);
|
|
499
|
-
void this.emit('externalPage', {
|
|
619
|
+
void this.emit('externalPage', {
|
|
620
|
+
result: pageData,
|
|
621
|
+
source: derivePageSource(this.#options.inventoryMode, url.withoutHashAndAuth),
|
|
622
|
+
});
|
|
500
623
|
log(c.dim('External (skip fetch)'));
|
|
501
624
|
return;
|
|
502
625
|
}
|
|
503
626
|
const metadataOnly = this.#linkList.isMetadataOnly(url.withoutHash);
|
|
504
627
|
const isPredicted = this.#linkList.isPredicted(url.withoutHashAndAuth);
|
|
505
628
|
log('Scraping%dots%');
|
|
506
|
-
const result = await this.#scrapePage(url, log, metadataOnly, _index);
|
|
629
|
+
const result = await this.#scrapePage(url, log, metadataOnly, _index, markBrowserScrape);
|
|
630
|
+
// Redirect convergence (#73): the destination was already
|
|
631
|
+
// rendered during this crawl, so only the redirect edge is
|
|
632
|
+
// recorded and the browser was never launched. Mark the URL
|
|
633
|
+
// done and emit `redirect` (routed to `Archive.setRedirect`,
|
|
634
|
+
// which writes the edge without touching the destination's
|
|
635
|
+
// content). This URL does not count toward pagesScraped.
|
|
636
|
+
if (result.type === 'redirect-edge') {
|
|
637
|
+
// Note: a predicted (speculative) URL that reaches here genuinely
|
|
638
|
+
// redirects (the server returned 3xx), so it is a real URL — we
|
|
639
|
+
// record its edge rather than discard it. This matches the render
|
|
640
|
+
// path, where the first predicted source to a destination renders
|
|
641
|
+
// it and is recorded as a redirect source the same way; only 404 /
|
|
642
|
+
// error predicted URLs are dropped (by `shouldDiscardPredicted`).
|
|
643
|
+
this.#linkList.done(url, this.#scope, { page: result.pageData }, this.#options);
|
|
644
|
+
void this.emit('redirect', { result: result.pageData });
|
|
645
|
+
log(c.dim('Redirect (dest already scraped)'));
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
507
648
|
// Discard predicted URLs that failed (404, error, etc.)
|
|
508
649
|
if (isPredicted && shouldDiscardPredicted(result)) {
|
|
509
650
|
handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
|
|
510
651
|
log(c.dim('Predicted (discarded)'));
|
|
511
652
|
return;
|
|
512
653
|
}
|
|
654
|
+
// Count only after discard check: rendered HTML pages that
|
|
655
|
+
// will be persisted to the archive. Launch failures bypass
|
|
656
|
+
// this point via the catch block; discarded predicted URLs
|
|
657
|
+
// return above without reaching here.
|
|
658
|
+
if (renderedInBrowser) {
|
|
659
|
+
pagesScraped++;
|
|
660
|
+
}
|
|
513
661
|
log('Saving results%dots%');
|
|
514
|
-
this.#handleResult(result, url,
|
|
662
|
+
this.#handleResult(result, url, enqueue, paginationState, concurrency);
|
|
515
663
|
this.#handleResources(result.resources);
|
|
516
664
|
log(formatResultSummary(result));
|
|
665
|
+
// Phase errors must be emitted AFTER 'page' / 'externalPage'
|
|
666
|
+
// so the orchestrator's WriteQueue sees `setPage` before
|
|
667
|
+
// `insertPageError` and the URL→pageId resolution succeeds.
|
|
668
|
+
this.#drainPhaseErrors(url, isExternal);
|
|
517
669
|
}
|
|
518
670
|
catch (error) {
|
|
519
671
|
crawlerLog('Worker error for %s: %O', url.href, error);
|
|
@@ -532,11 +684,21 @@ class Crawler extends EventEmitter {
|
|
|
532
684
|
isExternal,
|
|
533
685
|
error: workerError,
|
|
534
686
|
});
|
|
687
|
+
// Hard-error path: persist whatever phase errors we have
|
|
688
|
+
// already buffered so they are not lost.
|
|
689
|
+
this.#drainPhaseErrors(url, isExternal);
|
|
535
690
|
}
|
|
536
691
|
finally {
|
|
537
692
|
if (isExternal) {
|
|
538
693
|
externalDoneUrls.add(protocolAgnosticKey(url.withoutHashAndAuth));
|
|
539
694
|
}
|
|
695
|
+
// Phase errors still in the buffer here were not drained
|
|
696
|
+
// by the success or catch paths — typically because a
|
|
697
|
+
// predicted URL was discarded before reaching the drain
|
|
698
|
+
// point. The helper logs the drop (observable via
|
|
699
|
+
// DEBUG=Nitpicker:Crawler) and removes the entry so the
|
|
700
|
+
// Map cannot leak across crawls.
|
|
701
|
+
logUndrainedPhaseErrors(this.#pendingPhaseErrors, url.href, crawlerLog);
|
|
540
702
|
}
|
|
541
703
|
};
|
|
542
704
|
}, {
|
|
@@ -551,6 +713,7 @@ class Crawler extends EventEmitter {
|
|
|
551
713
|
resumeOffset,
|
|
552
714
|
externalTotal: externalUrls.size,
|
|
553
715
|
externalDone: externalDoneUrls.size,
|
|
716
|
+
pagesScraped,
|
|
554
717
|
limit,
|
|
555
718
|
});
|
|
556
719
|
},
|
|
@@ -581,14 +744,56 @@ class Crawler extends EventEmitter {
|
|
|
581
744
|
* @param update - Callback for progress messages
|
|
582
745
|
* @param metadataOnly - When true, only extract title metadata without full browser scraping
|
|
583
746
|
* @param laneIndex - The dealer lane index, used to create unique countdown IDs
|
|
747
|
+
* @param markBrowserScrape - Called once **after** the browser successfully
|
|
748
|
+
* renders an HTML page (i.e. `#launchBrowserAndScrape` resolved with
|
|
749
|
+
* `type: 'success'`). Not called for HEAD-only, title-only, captured-resource
|
|
750
|
+
* reuse, non-HTML responses, non-HTTP protocols (mailto:, tel:), browser
|
|
751
|
+
* launch throws (e.g. invalid executablePath), or scraper-returned
|
|
752
|
+
* `type: 'error'` results. The caller is responsible for further filtering
|
|
753
|
+
* (e.g. predicted-discard).
|
|
584
754
|
* @returns The scrape result
|
|
585
755
|
*/
|
|
586
|
-
async #scrapePage(url, update, metadataOnly, laneIndex) {
|
|
756
|
+
async #scrapePage(url, update, metadataOnly, laneIndex, markBrowserScrape) {
|
|
587
757
|
const isExternal = findScopeEntry(url, this.#scope, this.#options) === null;
|
|
588
758
|
// Non-HTTP protocols (mailto:, tel:, etc.) — let the scraper handle early return
|
|
589
759
|
if (!url.isHTTP) {
|
|
590
760
|
return this.#launchBrowserAndScrape(url, update, isExternal, metadataOnly);
|
|
591
761
|
}
|
|
762
|
+
// Reuse captured resource data — when this URL was already observed as a
|
|
763
|
+
// sub-resource during page rendering, its response data is recorded and
|
|
764
|
+
// the HEAD pre-flight is redundant. Only 2xx non-HTML rows are eligible
|
|
765
|
+
// (see resourceToPageData); anything else falls through to the pre-flight.
|
|
766
|
+
// Both URL variants are checked because scope-auth injection adds
|
|
767
|
+
// credentials to queued URLs while browser-captured resource URLs have none.
|
|
768
|
+
// The result is deliberately NOT written to destinationCache: a queued URL
|
|
769
|
+
// is processed at most once (the dealer dedupes by protocol-agnostic key),
|
|
770
|
+
// so a URL that takes this path never reaches fetchDestination again.
|
|
771
|
+
const lookupResource = this.#options.lookupResource;
|
|
772
|
+
if (lookupResource &&
|
|
773
|
+
(this.#resources.has(url.withoutHash) ||
|
|
774
|
+
this.#resources.has(url.withoutHashAndAuth))) {
|
|
775
|
+
update('Checking captured resource%dots%');
|
|
776
|
+
let resource = null;
|
|
777
|
+
try {
|
|
778
|
+
resource = await lookupResource([url.withoutHash, url.withoutHashAndAuth]);
|
|
779
|
+
}
|
|
780
|
+
catch (error) {
|
|
781
|
+
// A lookup failure must never be worse than not having the
|
|
782
|
+
// optimization — fall back to the HEAD pre-flight below.
|
|
783
|
+
crawlerLog('Resource lookup failed for %s, falling back: %O', url.href, error);
|
|
784
|
+
}
|
|
785
|
+
const pageData = resource
|
|
786
|
+
? resourceToPageData({ url, isExternal, resource })
|
|
787
|
+
: null;
|
|
788
|
+
if (pageData) {
|
|
789
|
+
crawlerLog('Reused captured resource for %s', url.href);
|
|
790
|
+
return {
|
|
791
|
+
type: 'success',
|
|
792
|
+
pageData: metadataOnly ? { ...pageData, isTarget: false } : pageData,
|
|
793
|
+
resources: [],
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
}
|
|
592
797
|
// Pre-flight: lightweight HEAD request to check server availability
|
|
593
798
|
update('HEAD request%dots%');
|
|
594
799
|
let headCheckResult;
|
|
@@ -609,10 +814,32 @@ class Crawler extends EventEmitter {
|
|
|
609
814
|
},
|
|
610
815
|
};
|
|
611
816
|
}
|
|
817
|
+
// Redirect convergence (#73): `finalKey` is the destination this URL lands
|
|
818
|
+
// on after following its redirect chain (or the URL itself when it does not
|
|
819
|
+
// redirect). When that destination has already been rendered and stored
|
|
820
|
+
// during this crawl, do NOT process this URL further — record the redirect
|
|
821
|
+
// edge only and skip everything below, regardless of content type. This is
|
|
822
|
+
// the root fix for the many-to-one redirect duplication (#70): every source
|
|
823
|
+
// URL that 301s to one destination otherwise re-renders/re-stores it. The
|
|
824
|
+
// check sits ABOVE the metadata-only and non-HTML branches on purpose — both
|
|
825
|
+
// route their HEAD/title result through `updatePage`, which would funnel a
|
|
826
|
+
// content-less result into `#insertPage` and overwrite the already-rendered
|
|
827
|
+
// destination's title / meta / isExternal. The edge-only path leaves the
|
|
828
|
+
// destination row intact.
|
|
829
|
+
//
|
|
830
|
+
// `finalKey` is also claimed for destinations reached directly (no redirect;
|
|
831
|
+
// see the claim after a successful render below), so a destination that is
|
|
832
|
+
// both linked directly and arrived at via a redirect is rendered by whichever
|
|
833
|
+
// path wins the race, not both.
|
|
834
|
+
const finalKey = redirectDestKey(url, headCheckResult.redirectPaths);
|
|
835
|
+
if (this.#scrapedDestinations.has(finalKey)) {
|
|
836
|
+
crawlerLog('Redirect dest already rendered, edge only: %s', url.href);
|
|
837
|
+
return { type: 'redirect-edge', pageData: headCheckResult };
|
|
838
|
+
}
|
|
612
839
|
// Title-only mode — extract <title> via partial GET for HTML, skip browser
|
|
613
840
|
if (metadataOnly) {
|
|
614
841
|
if (headCheckResult.contentType === null ||
|
|
615
|
-
headCheckResult.contentType
|
|
842
|
+
isHtmlContentType(headCheckResult.contentType)) {
|
|
616
843
|
update('Fetching title%dots%');
|
|
617
844
|
try {
|
|
618
845
|
const titleResult = await fetchDestination({
|
|
@@ -640,15 +867,46 @@ class Crawler extends EventEmitter {
|
|
|
640
867
|
}
|
|
641
868
|
// Non-HTML content — skip browser
|
|
642
869
|
if (headCheckResult.contentType !== null &&
|
|
643
|
-
headCheckResult.contentType
|
|
870
|
+
!isHtmlContentType(headCheckResult.contentType)) {
|
|
644
871
|
return {
|
|
645
872
|
type: 'success',
|
|
646
873
|
pageData: headCheckResult,
|
|
647
874
|
resources: [],
|
|
648
875
|
};
|
|
649
876
|
}
|
|
650
|
-
// HTML or unknown content type — launch browser with preflight result
|
|
651
|
-
|
|
877
|
+
// HTML or unknown content type — launch browser with preflight result.
|
|
878
|
+
// markBrowserScrape() fires only when the result is `success`.
|
|
879
|
+
// `#launchBrowserAndScrape` catches internal errors and returns
|
|
880
|
+
// `{ type: 'error', ... }` instead of throwing (see its catch block),
|
|
881
|
+
// so awaiting alone does NOT prove the page was rendered. The explicit
|
|
882
|
+
// success check excludes navigation failures, scraper exceptions, and
|
|
883
|
+
// shutdown-class errors from the pages-rendered count.
|
|
884
|
+
const browserResult = await this.#launchBrowserAndScrape(url, update, isExternal, metadataOnly, headCheckResult);
|
|
885
|
+
if (browserResult.type === 'success') {
|
|
886
|
+
markBrowserScrape();
|
|
887
|
+
// Claim the destination that was ACTUALLY rendered, keyed off the
|
|
888
|
+
// browser's own redirect resolution rather than the HEAD pre-flight's
|
|
889
|
+
// guess (`finalKey`). The browser is authoritative for what got stored;
|
|
890
|
+
// if HEAD and the browser disagree on the final URL (method-conditional
|
|
891
|
+
// / JS / meta-refresh redirects), keying the claim off the HEAD guess
|
|
892
|
+
// would route a sibling source to an edge pointing at a never-rendered
|
|
893
|
+
// phantom row. By claiming the rendered URL, a divergent sibling simply
|
|
894
|
+
// re-renders (dedup misses) instead — correct, just less optimal. In the
|
|
895
|
+
// common case HEAD and the browser agree, so the keys are identical.
|
|
896
|
+
//
|
|
897
|
+
// Claimed only after a successful render, so a failed render leaves the
|
|
898
|
+
// destination unclaimed and a later source retries it. Concurrent
|
|
899
|
+
// in-flight sources to the same destination (bounded by the concurrency
|
|
900
|
+
// limit) may still each render before any claim lands; the storage-layer
|
|
901
|
+
// replace in `updatePage` (#70) keeps the resulting anchors / images
|
|
902
|
+
// correct (sub-resources may briefly duplicate, far below the pre-#73
|
|
903
|
+
// once-per-source blow-up).
|
|
904
|
+
const renderedKey = browserResult.pageData
|
|
905
|
+
? redirectDestKey(url, browserResult.pageData.redirectPaths)
|
|
906
|
+
: finalKey;
|
|
907
|
+
this.#scrapedDestinations.add(renderedKey);
|
|
908
|
+
}
|
|
909
|
+
return browserResult;
|
|
652
910
|
}
|
|
653
911
|
/**
|
|
654
912
|
* Performs a pre-flight HTTP HEAD request with retry logic.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { BufferedPhaseError } from './drain-phase-errors.js';
|
|
2
|
+
import type { ChangePhaseEvent } from '@d-zero/beholder';
|
|
3
|
+
/**
|
|
4
|
+
* Options for {@link createChangePhaseHandler}. Declared structurally so
|
|
5
|
+
* tests can pass plain `vi.fn()` stubs without spinning up a real Crawler.
|
|
6
|
+
*/
|
|
7
|
+
export interface ChangePhaseHandlerOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Forwards the raw {@link ChangePhaseEvent} so external listeners on the
|
|
10
|
+
* Crawler still see every transition (typically `this.emit.bind(this)`
|
|
11
|
+
* narrowed to the `changePhase` channel).
|
|
12
|
+
*/
|
|
13
|
+
emit: (event: ChangePhaseEvent) => void;
|
|
14
|
+
/** Receives the formatted progress log line. Skipped when empty. */
|
|
15
|
+
update: (log: string) => void;
|
|
16
|
+
/**
|
|
17
|
+
* Renders the human-readable progress message for an event. Injected so
|
|
18
|
+
* the handler stays free of the Crawler's internal log formatter.
|
|
19
|
+
* Returns `null` for events that should not surface to `update`.
|
|
20
|
+
*/
|
|
21
|
+
formatLog: (event: ChangePhaseEvent) => string | null;
|
|
22
|
+
/**
|
|
23
|
+
* Per-URL buffer of `retryExhausted` failures. The handler appends to
|
|
24
|
+
* this map; it does not drain (that is `drainPhaseErrors`'s job).
|
|
25
|
+
*/
|
|
26
|
+
buffer: Map<string, BufferedPhaseError[]>;
|
|
27
|
+
/** URL href used as the buffer key for this scrape. */
|
|
28
|
+
urlHref: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Builds the `scraper.on('changePhase', ...)` listener used by
|
|
32
|
+
* {@link Crawler.#launchBrowserAndScrape}.
|
|
33
|
+
*
|
|
34
|
+
* Three responsibilities:
|
|
35
|
+
* 1. Render the phase log via the injected `formatLog` and pipe it to `update`.
|
|
36
|
+
* 2. Forward the raw event so external consumers (CLI progress UI etc.) see
|
|
37
|
+
* every transition.
|
|
38
|
+
* 3. Buffer `retryExhausted` events into the per-URL phase-error map so they
|
|
39
|
+
* can be drained as `pageError` events AFTER the `page` event fires.
|
|
40
|
+
*
|
|
41
|
+
* WHY a factory: the listener captures per-scrape state (`buffer`, `urlHref`,
|
|
42
|
+
* `update`). Extracting the factory makes the wiring directly unit-testable
|
|
43
|
+
* with plain stubs, instead of requiring a mocked Puppeteer + beholder + dealer
|
|
44
|
+
* stack to drive the worker.
|
|
45
|
+
*
|
|
46
|
+
* **Caller contract**: register the returned handler at most once per
|
|
47
|
+
* `scraper` instance. The Crawler creates a fresh Scraper per URL so this
|
|
48
|
+
* holds today; if scraper pooling is ever introduced, register exactly one
|
|
49
|
+
* handler per scrape and unregister it on completion to avoid duplicate
|
|
50
|
+
* buffer entries.
|
|
51
|
+
* @param options - Wiring dependencies for the handler.
|
|
52
|
+
* @returns A function suitable for `scraper.on('changePhase', ...)`.
|
|
53
|
+
*/
|
|
54
|
+
export declare function createChangePhaseHandler(options: ChangePhaseHandlerOptions): (event: ChangePhaseEvent) => void;
|