@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.
Files changed (92) hide show
  1. package/lib/archive/archive-accessor.d.ts +87 -14
  2. package/lib/archive/archive-accessor.js +162 -36
  3. package/lib/archive/archive.d.ts +147 -24
  4. package/lib/archive/archive.js +252 -86
  5. package/lib/archive/database.d.ts +209 -25
  6. package/lib/archive/database.js +928 -108
  7. package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
  8. package/lib/archive/filesystem/peek-tar-top-dir.js +65 -0
  9. package/lib/archive/init-schema.d.ts +53 -2
  10. package/lib/archive/init-schema.js +247 -15
  11. package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
  12. package/lib/archive/meta/assert-compatible-version.js +72 -0
  13. package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
  14. package/lib/archive/meta/classify-jsonld-type.js +43 -0
  15. package/lib/archive/meta/compare-semver.d.ts +23 -0
  16. package/lib/archive/meta/compare-semver.js +51 -0
  17. package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
  18. package/lib/archive/meta/compute-page-denormalized.js +35 -0
  19. package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
  20. package/lib/archive/meta/derive-flat-from-meta.js +158 -0
  21. package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
  22. package/lib/archive/meta/derive-meta-extras.js +23 -0
  23. package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
  24. package/lib/archive/meta/extract-tags-for-archive.js +36 -0
  25. package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
  26. package/lib/archive/meta/summarize-jsonld.js +29 -0
  27. package/lib/archive/meta/summarize-tags.d.ts +16 -0
  28. package/lib/archive/meta/summarize-tags.js +33 -0
  29. package/lib/archive/meta/types.d.ts +207 -0
  30. package/lib/archive/meta/types.js +33 -0
  31. package/lib/archive/migrate-crawl-errors.d.ts +20 -0
  32. package/lib/archive/migrate-crawl-errors.js +38 -0
  33. package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
  34. package/lib/archive/migrate-html-blob-tables.js +53 -0
  35. package/lib/archive/migrate-page-errors.d.ts +16 -0
  36. package/lib/archive/migrate-page-errors.js +35 -0
  37. package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
  38. package/lib/archive/migrate-pages-resources-source.js +46 -0
  39. package/lib/archive/page.d.ts +187 -49
  40. package/lib/archive/page.js +258 -63
  41. package/lib/archive/peek-archive-lock.d.ts +40 -0
  42. package/lib/archive/peek-archive-lock.js +62 -0
  43. package/lib/archive/resolve-redirect-chain.d.ts +33 -0
  44. package/lib/archive/resolve-redirect-chain.js +27 -0
  45. package/lib/archive/types.d.ts +135 -26
  46. package/lib/crawler/close-browser-safely.d.ts +64 -0
  47. package/lib/crawler/close-browser-safely.js +73 -0
  48. package/lib/crawler/crawler.d.ts +4 -1
  49. package/lib/crawler/crawler.js +290 -32
  50. package/lib/crawler/create-change-phase-handler.d.ts +54 -0
  51. package/lib/crawler/create-change-phase-handler.js +44 -0
  52. package/lib/crawler/derive-page-source.d.ts +23 -0
  53. package/lib/crawler/derive-page-source.js +28 -0
  54. package/lib/crawler/derive-resource-source.d.ts +23 -0
  55. package/lib/crawler/derive-resource-source.js +26 -0
  56. package/lib/crawler/drain-phase-errors.d.ts +48 -0
  57. package/lib/crawler/drain-phase-errors.js +35 -0
  58. package/lib/crawler/fetch-destination.js +38 -2
  59. package/lib/crawler/format-crawl-progress.d.ts +12 -3
  60. package/lib/crawler/format-crawl-progress.js +14 -6
  61. package/lib/crawler/handle-browser-close.d.ts +29 -0
  62. package/lib/crawler/handle-browser-close.js +28 -0
  63. package/lib/crawler/is-html-content-type.d.ts +17 -0
  64. package/lib/crawler/is-html-content-type.js +19 -0
  65. package/lib/crawler/is-likely-html-url.d.ts +22 -0
  66. package/lib/crawler/is-likely-html-url.js +65 -0
  67. package/lib/crawler/kill-process-tree.d.ts +94 -0
  68. package/lib/crawler/kill-process-tree.js +178 -0
  69. package/lib/crawler/link-list.js +2 -1
  70. package/lib/crawler/link-to-page-data.d.ts +13 -5
  71. package/lib/crawler/link-to-page-data.js +26 -5
  72. package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
  73. package/lib/crawler/log-undrained-phase-errors.js +34 -0
  74. package/lib/crawler/normalize-content-type.d.ts +14 -0
  75. package/lib/crawler/normalize-content-type.js +20 -0
  76. package/lib/crawler/partition-urls-by-html.d.ts +16 -0
  77. package/lib/crawler/partition-urls-by-html.js +23 -0
  78. package/lib/crawler/redirect-dest-key.d.ts +19 -0
  79. package/lib/crawler/redirect-dest-key.js +27 -0
  80. package/lib/crawler/resource-to-page-data.d.ts +28 -0
  81. package/lib/crawler/resource-to-page-data.js +59 -0
  82. package/lib/crawler/types.d.ts +122 -1
  83. package/lib/crawler-orchestrator.d.ts +93 -1
  84. package/lib/crawler-orchestrator.js +389 -12
  85. package/lib/crawler.d.ts +5 -0
  86. package/lib/crawler.js +3 -0
  87. package/lib/resource-row-to-lookup-result.d.ts +13 -0
  88. package/lib/resource-row-to-lookup-result.js +20 -0
  89. package/lib/types.d.ts +11 -1
  90. package/lib/utils/object/parse-response-headers.d.ts +12 -0
  91. package/lib/utils/object/parse-response-headers.js +26 -0
  92. package/package.json +4 -4
@@ -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
- const isResuming = this.#resumedScraped.length > 0;
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 push - Dealer's push callback to enqueue newly discovered URLs
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, push, paginationState, concurrency) {
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 push(newUrl);
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
- void push(specUrl);
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
- if (result.pageData.isExternal) {
295
- void this.emit('externalPage', { result: result.pageData });
296
- }
297
- else {
298
- void this.emit('page', { result: result.pageData });
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', { result: pageResult });
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', (e) => {
388
- const msg = formatPhaseLog(e);
389
- if (msg) {
390
- update(msg);
391
- }
392
- void this.emit('changePhase', e);
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
- await browser.close().catch(() => { });
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', { result: pageData });
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, push, paginationState, concurrency);
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 === 'text/html') {
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 !== 'text/html') {
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
- return this.#launchBrowserAndScrape(url, update, isExternal, metadataOnly, headCheckResult);
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;