@nitpicker/crawler 0.15.0 → 0.17.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 (69) hide show
  1. package/lib/archive/archive.d.ts +52 -1
  2. package/lib/archive/archive.js +68 -0
  3. package/lib/archive/create-adjunct-tables.d.ts +3 -0
  4. package/lib/archive/create-adjunct-tables.js +43 -0
  5. package/lib/archive/create-entity-tables.js +10 -0
  6. package/lib/archive/database.d.ts +40 -1
  7. package/lib/archive/database.js +54 -0
  8. package/lib/archive/db-ops/config/get-config.js +1 -0
  9. package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.d.ts +18 -0
  10. package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.js +23 -0
  11. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.d.ts +12 -0
  12. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.js +15 -0
  13. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.d.ts +14 -0
  14. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.js +30 -0
  15. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.d.ts +21 -0
  16. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.js +27 -0
  17. package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
  18. package/lib/archive/db-ops/lifecycle/init.d.ts +4 -2
  19. package/lib/archive/db-ops/lifecycle/init.js +12 -2
  20. package/lib/archive/db-ops/pages/write/insert-inventory-content-items.d.ts +38 -0
  21. package/lib/archive/db-ops/pages/write/insert-inventory-content-items.js +59 -0
  22. package/lib/archive/db-ops/pages/write/insert-inventory-seeds.d.ts +5 -6
  23. package/lib/archive/db-ops/pages/write/insert-inventory-seeds.js +17 -41
  24. package/lib/archive/db-ops/pages/write/insert-inventory-skipped-pages.d.ts +42 -0
  25. package/lib/archive/db-ops/pages/write/insert-inventory-skipped-pages.js +56 -0
  26. package/lib/archive/migrate-content-items-dedupe-cap-event-id.d.ts +41 -0
  27. package/lib/archive/migrate-content-items-dedupe-cap-event-id.js +51 -0
  28. package/lib/archive/migrate-entity-tables.d.ts +10 -0
  29. package/lib/archive/migrate-entity-tables.js +10 -0
  30. package/lib/archive/migrate-inventory-runs-exclude-skipped.d.ts +20 -0
  31. package/lib/archive/migrate-inventory-runs-exclude-skipped.js +33 -0
  32. package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.d.ts +7 -0
  33. package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.js +9 -0
  34. package/lib/archive/types.d.ts +16 -0
  35. package/lib/classify-error-kind.d.ts +1 -0
  36. package/lib/classify-error-kind.js +14 -0
  37. package/lib/crawler/assert-chrome-installed.d.ts +24 -0
  38. package/lib/crawler/assert-chrome-installed.js +43 -0
  39. package/lib/crawler/crawler.d.ts +12 -0
  40. package/lib/crawler/crawler.js +239 -29
  41. package/lib/crawler/decode-auth-credential.d.ts +29 -0
  42. package/lib/crawler/decode-auth-credential.js +39 -0
  43. package/lib/crawler/dedupe/compute-meta-signature.d.ts +30 -0
  44. package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
  45. package/lib/crawler/dedupe/compute-shape-key.d.ts +37 -0
  46. package/lib/crawler/dedupe/compute-shape-key.js +56 -0
  47. package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +84 -0
  48. package/lib/crawler/dedupe/dedupe-cap-tracker.js +185 -0
  49. package/lib/crawler/dedupe/is-predicted-content-duplicate.d.ts +24 -0
  50. package/lib/crawler/dedupe/is-predicted-content-duplicate.js +26 -0
  51. package/lib/crawler/dedupe/is-shape-capped.d.ts +10 -0
  52. package/lib/crawler/dedupe/is-shape-capped.js +12 -0
  53. package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +31 -0
  54. package/lib/crawler/dedupe/resolve-og-url-mismatch.js +40 -0
  55. package/lib/crawler/dedupe/types.d.ts +42 -0
  56. package/lib/crawler/dedupe/types.js +1 -0
  57. package/lib/crawler/fetch-destination.js +14 -2
  58. package/lib/crawler/generate-predicted-urls.d.ts +12 -0
  59. package/lib/crawler/generate-predicted-urls.js +33 -2
  60. package/lib/crawler/is-puppeteer-fallback-candidate.js +3 -0
  61. package/lib/crawler/types.d.ts +38 -0
  62. package/lib/crawler-orchestrator.d.ts +39 -10
  63. package/lib/crawler-orchestrator.js +187 -23
  64. package/lib/crawler.d.ts +2 -0
  65. package/lib/crawler.js +2 -0
  66. package/lib/permanent-error-kinds.d.ts +9 -4
  67. package/lib/permanent-error-kinds.js +10 -4
  68. package/lib/types.d.ts +4 -1
  69. package/package.json +3 -3
@@ -1,11 +1,24 @@
1
1
  import { decomposeUrl } from './decompose-url.js';
2
2
  import { reconstructUrl } from './reconstruct-url.js';
3
+ const DIGITS_ONLY_PATTERN = /^\d+$/;
3
4
  /**
4
5
  * Generates predicted URLs by extrapolating the detected pagination pattern.
5
6
  *
6
7
  * Starting from `currentUrl`, applies the pattern's step `count` times to produce
7
8
  * future page URLs (e.g. if step=1 and currentNumber=2, generates page 3, 4, ...).
8
9
  * These URLs are pushed into the crawl queue and discarded later if they 404.
10
+ *
11
+ * Generation stops at the first token that would lose its original digit-string
12
+ * shape, rather than skipping it and continuing: beyond `Number.MAX_SAFE_INTEGER`,
13
+ * `String()` renders scientific notation (e.g. `"1e+21"`), which is not a valid
14
+ * path/query token and — left unguarded — becomes a self-generated URL that no
15
+ * page on the target site ever linked to (observed in production: a
16
+ * `/news/date/{year}/` pager whose per-anchor `step` was miscalculated from
17
+ * unrelated pages, compounding across rounds until it emitted
18
+ * `1.7715854126052197e+120`). A token growing far beyond its original digit
19
+ * count is equally implausible as a next page number. Both trends are
20
+ * monotonic as `i` increases, so once one prediction is rejected, every later
21
+ * one in the same batch would be too — there is nothing to skip past.
9
22
  * @param pattern - The detected pagination pattern from `detectPaginationPattern()`
10
23
  * @param currentUrl - The URL to extrapolate from (protocol-agnostic, without hash/auth)
11
24
  * @param count - Number of predicted URLs to generate (typically equals concurrency)
@@ -17,11 +30,29 @@ export function generatePredictedUrls(pattern, currentUrl, count) {
17
30
  const decomposed = decomposeUrl(currentUrl);
18
31
  if (!decomposed)
19
32
  return [];
33
+ const { pathSegments, queryValues } = decomposed;
34
+ const originalToken = pattern.tokenIndex < pathSegments.length
35
+ ? pathSegments[pattern.tokenIndex]
36
+ : queryValues[pattern.tokenIndex - pathSegments.length];
37
+ if (originalToken === undefined)
38
+ return [];
39
+ // A pager jumping from 4 digits to 5 (e.g. year 9999 → 10000) is plausible;
40
+ // jumping straight to 6+ digits within the same predicted batch is not.
41
+ const maxDigits = originalToken.length + 1;
20
42
  const results = [];
21
43
  for (let i = 1; i <= count; i++) {
22
44
  const nextNum = pattern.currentNumber + pattern.step * i;
23
- const url = reconstructUrl(decomposed, pattern.tokenIndex, String(nextNum));
24
- results.push(url);
45
+ if (!Number.isSafeInteger(nextNum))
46
+ break;
47
+ const rendered = String(nextNum);
48
+ if (!DIGITS_ONLY_PATTERN.test(rendered))
49
+ break;
50
+ // Preserve zero-padding width (e.g. "01" → "02"), without truncating a
51
+ // value that has legitimately grown past the original width.
52
+ const padded = rendered.padStart(originalToken.length, '0');
53
+ if (padded.length > maxDigits)
54
+ break;
55
+ results.push(reconstructUrl(decomposed, pattern.tokenIndex, padded));
25
56
  }
26
57
  return results;
27
58
  }
@@ -26,6 +26,9 @@ import { classifyErrorKind } from '../classify-error-kind.js';
26
26
  * as `timeout` via the `NetTimeoutError "Timeout: <url>"` shape.
27
27
  * - **local-network** — operator-side connectivity loss; nothing on this
28
28
  * machine will reach the host.
29
+ * - **redirect-loop** — the redirect chain itself never terminates; a
30
+ * puppeteer navigation follows the identical Location-header chain (or
31
+ * hits Chromium's own `ERR_TOO_MANY_REDIRECTS`) and loops the same way.
29
32
  * - **protocol** — puppeteer lifecycle race; bouncing back to puppeteer
30
33
  * reproduces the same race.
31
34
  * - **unknown** — by design. Spinning up a fresh Chromium for every
@@ -190,6 +190,29 @@ export interface CrawlerOptions extends Required<Pick<ParseURLOptions, 'disableQ
190
190
  * without touching the real network.
191
191
  */
192
192
  networkProbe: NetworkProbe | null;
193
+ /**
194
+ * Same-cluster soft-cap threshold (`--dedupe-cap`), or `null` to disable
195
+ * the feature entirely (the default). When set, {@link Crawler} stops
196
+ * enqueueing newly-discovered URLs whose shape (see `computeShapeKey`)
197
+ * has accumulated this many matching-signature observations (see
198
+ * `DedupeCapTracker`).
199
+ */
200
+ dedupeCap: number | null;
201
+ /**
202
+ * Hard cap on the number of distinct URL shapes the same-cluster soft
203
+ * cap tracks at once (`--dedupe-map-cap`); the least-recently-touched
204
+ * shape is evicted beyond this. Only relevant when {@link dedupeCap} is
205
+ * non-null.
206
+ */
207
+ dedupeMapCap: number;
208
+ /**
209
+ * Shape keys already confirmed capped in a prior session
210
+ * (persisted as `dedupe_cap_events.shape_key`), seeded into the
211
+ * tracker's sticky set so `--resume` / `--append` / `--retry-failed` /
212
+ * `--inventory` do not re-admit a trap this crawl already paid the cost
213
+ * of discovering once. Ignored when {@link dedupeCap} is `null`.
214
+ */
215
+ preloadedStickyShapeKeys: readonly string[];
193
216
  }
194
217
  /**
195
218
  * Inventory-mode runtime configuration. Passed from
@@ -436,6 +459,21 @@ export interface CrawlerEventTypes {
436
459
  /** Epoch ms the recovery probe first succeeded. */
437
460
  endedAt: number;
438
461
  };
462
+ /**
463
+ * Emitted the instant the opt-in same-cluster soft cap
464
+ * ({@link CrawlerOptions.dedupeCap}) confirms a URL shape as a trap (see
465
+ * `DedupeCapTracker`). The orchestrator persists this via
466
+ * `Archive.insertDedupeCapEvent` and must remember the returned row id so
467
+ * `rejected_count` can be finalized once at `crawlEnd` (`Crawler` itself
468
+ * never touches the archive).
469
+ */
470
+ dedupeCap: {
471
+ shapeKey: string;
472
+ sampleUrl: string;
473
+ bodyHash: Buffer;
474
+ effectiveThreshold: number;
475
+ observedCount: number;
476
+ };
439
477
  }
440
478
  /**
441
479
  * Tunables for `NetworkOutageDetector`.
@@ -69,6 +69,18 @@ interface CrawlConfig extends Config {
69
69
  * `options` so an E2E test can inject it via the public API.
70
70
  */
71
71
  networkProbe: NetworkProbe | null;
72
+ /** See {@link CrawlerOptions.dedupeCap}. `null`/omitted disables the feature. */
73
+ dedupeCap: number | null;
74
+ /** See {@link CrawlerOptions.dedupeMapCap}. Omitted falls through to `Crawler`'s own default. */
75
+ dedupeMapCap: number;
76
+ /**
77
+ * See {@link CrawlerOptions.preloadedStickyShapeKeys}. Set internally by
78
+ * the four resuming-session static methods
79
+ * (`append`/`inventory`/`retryFailed`/`resume`), each independently
80
+ * calling `archive.listDedupeCapShapeKeys()`; not part of the public
81
+ * options a caller of those methods passes directly.
82
+ */
83
+ preloadedStickyShapeKeys: readonly string[];
72
84
  }
73
85
  /**
74
86
  * Callback invoked after the CrawlerOrchestrator instance is fully initialized
@@ -216,9 +228,10 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
216
228
  * 1. Open the archive (writer mode, takes the archive lock).
217
229
  * 2. Reject list-mode archives — they hold metadata-only rows that
218
230
  * inventory has no business touching.
219
- * 3. Reject archives with unfinished `pending` URLs — those would inherit
220
- * the inventory `source` label by mistake. Operator must resume /
221
- * retry-failed first.
231
+ * 3. Warn (but proceed) on archives with unfinished `pending` URLs —
232
+ * crawled-wins source priority keeps their labels stable; the
233
+ * operator can `--resume` first if they want the prior work
234
+ * finalized.
222
235
  * 4. If `source` is given, archive its exact bytes under
223
236
  * `inventory/<sha256>.txt` (see {@link Archive.saveInventorySourceList}).
224
237
  * Done before scope classification so even a run that discards every
@@ -231,17 +244,33 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
231
244
  * 6. Subtract URLs that already exist in `pages` or `resources` so the
232
245
  * second (and N-th) inventory pass is a no-op for known rows — keeps
233
246
  * `'inventory-seed'` rows from being silently demoted.
234
- * 7. Make `<archive>.bak`. Anything thrown beyond this point restores
247
+ * 7. Split the remaining novel URLs on the effective `excludes` /
248
+ * `excludeUrls` (archived config overlaid with this run's
249
+ * overrides — the same inputs the crawl's fetch-time
250
+ * `shouldSkipUrl` gate uses). Matching URLs are recorded as
251
+ * terminal skipped pages (`is_skipped=1`,
252
+ * `skip_reason='excluded'`, `source='inventory-seed'`) instead of
253
+ * being imported — the same end state a link-discovered excluded
254
+ * URL reaches in a normal crawl — and counted as
255
+ * `exclude_skipped` (issue #260). Running this after step 6 keeps
256
+ * previously crawled rows that newly match the exclusion config
257
+ * untouched (crawled-wins). `excludeKeywords` does not
258
+ * participate here: it matches rendered page content, which a URL
259
+ * list does not have — HTML seeds still get it at render time via
260
+ * the browser verdict.
261
+ * 8. Make `<archive>.bak`. Anything thrown beyond this point restores
235
262
  * from the backup.
236
- * 8. HEAD-probe each novel URL. Responses classified as HTML are queued
237
- * as Crawler seeds (`'inventory-seed'`); everything else is recorded
238
- * in `resources` directly as `'inventory-seed'` (no browser launch).
239
- * 9. If any HTML seeds exist, start a Crawler with
263
+ * 9. Classify each importable novel URL by URL-extension heuristic
264
+ * (no probe see the in-body rationale). HTML-looking URLs are
265
+ * queued as Crawler seeds (`'inventory-seed'`); everything else is
266
+ * recorded in `resources` directly as `'inventory-seed'` (no
267
+ * browser launch, no HEAD).
268
+ * 10. If any HTML seeds exist, start a Crawler with
240
269
  * `inventoryMode = { seedUrls }` so the rendered page and every newly
241
270
  * discovered downstream link is labelled correctly. `resume` is fed
242
271
  * the existing `scraped` / `resources` sets so links into already-
243
272
  * crawled pages stop at the seen-gate without re-rendering.
244
- * 10. Drop the backup on success; restore it on any throw.
273
+ * 11. Drop the backup on success; restore it on any throw.
245
274
  *
246
275
  * Mutually exclusive with `--append` / `--retry-failed` / `--resume` /
247
276
  * `--diff` / `--list` / `--list-file` / `--single` / `--output` — the
@@ -264,7 +293,7 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
264
293
  * `inventoryUrls` in-memory; the audit row's `source_file_sha256`
265
294
  * column will be `NULL` and no source list is archived.
266
295
  * @returns The orchestrator instance after a successful inventory pass.
267
- * @throws {Error} When `inventoryUrls` is empty, the archive is in list mode, or pending URLs from a previous crawl remain unresolved.
296
+ * @throws {Error} When `inventoryUrls` is empty or the archive is in list mode. Unresolved pending URLs from a previous crawl do NOT throw — see step 3.
268
297
  */
269
298
  static inventory(archivePath: string, inventoryUrls: string[], options?: Partial<CrawlConfig>, initializedCallback?: CrawlInitializedCallback, source?: InventorySource | null): Promise<CrawlerOrchestrator>;
270
299
  /**
@@ -16,6 +16,7 @@ import { isLikelyHtmlUrl } from './crawler/is-likely-html-url.js';
16
16
  import { networkOutageSummaryCounter } from './crawler/network-outage-summary-counter.js';
17
17
  import { PreloadShortCircuitError } from './crawler/preload-short-circuit-error.js';
18
18
  import { protocolAgnosticKey } from './crawler/protocol-agnostic-key.js';
19
+ import { shouldSkipUrl } from './crawler/should-skip-url.js';
19
20
  import { crawlerLog, log } from './debug.js';
20
21
  import { normalizeToArray } from './normalize-to-array.js';
21
22
  import { resolveOutputPath } from './resolve-output-path.js';
@@ -64,6 +65,15 @@ export class CrawlerOrchestrator extends EventEmitter {
64
65
  #archive;
65
66
  /** The crawler engine that discovers and scrapes pages. */
66
67
  #crawler;
68
+ /**
69
+ * `dedupe_cap_events.id` for each shape confirmed capped this session, so
70
+ * `crawlEnd` can look up the right row to finalize with
71
+ * `Crawler#getDedupeCapRejections`'s counts. A `Map` (not a single
72
+ * scalar like {@link #openNetworkOutageId}) because, unlike a network
73
+ * outage, more than one shape can be capped simultaneously within one
74
+ * crawl.
75
+ */
76
+ #dedupeCapEventIds = new Map();
67
77
  /** Whether the crawl was started from a pre-defined URL list (non-recursive mode). */
68
78
  #fromList;
69
79
  /**
@@ -158,6 +168,13 @@ export class CrawlerOrchestrator extends EventEmitter {
158
168
  networkOutageHostThreshold: options?.networkOutageHostThreshold,
159
169
  networkOutageProbeIntervalMs: options?.networkOutageProbeIntervalMs,
160
170
  networkProbe: options?.networkProbe ?? null,
171
+ dedupeCap: options?.dedupeCap ?? null,
172
+ dedupeMapCap: options?.dedupeMapCap,
173
+ // Only the four resuming-session static methods
174
+ // (`append`/`inventory`/`retryFailed`/`resume`) pass this — a
175
+ // fresh `crawling()` has no archive history to seed from (see
176
+ // `CrawlConfig.preloadedStickyShapeKeys`'s JSDoc).
177
+ preloadedStickyShapeKeys: options?.preloadedStickyShapeKeys ?? [],
161
178
  });
162
179
  }
163
180
  /**
@@ -298,6 +315,23 @@ export class CrawlerOrchestrator extends EventEmitter {
298
315
  })
299
316
  .catch((error) => reject(error));
300
317
  });
318
+ this.#crawler.on('dedupeCap', ({ shapeKey, sampleUrl, bodyHash, effectiveThreshold, observedCount }) => {
319
+ crawlerLog('Dedupe cap reached: shapeKey=%s effectiveThreshold=%d observedCount=%d', shapeKey, effectiveThreshold, observedCount);
320
+ console.error(`[dedupe-cap] same-cluster trap confirmed: ${shapeKey} (sample: ${sampleUrl})`);
321
+ writeQueue
322
+ .enqueue(async () => {
323
+ const id = await this.#archive.insertDedupeCapEvent({
324
+ shapeKey,
325
+ sampleUrl,
326
+ bodyHash,
327
+ effectiveThreshold,
328
+ observedCount,
329
+ detectedAt: Date.now(),
330
+ });
331
+ this.#dedupeCapEventIds.set(shapeKey, id);
332
+ })
333
+ .catch((error) => reject(error));
334
+ });
301
335
  this.#crawler.on('response', ({ resource, source }) => {
302
336
  writeQueue
303
337
  .enqueue(() => this.#archive.setResources(resource, source))
@@ -314,6 +348,51 @@ export class CrawlerOrchestrator extends EventEmitter {
314
348
  .catch((error) => reject(error));
315
349
  });
316
350
  this.#crawler.on('crawlEnd', () => {
351
+ // Deferred to INSIDE a queued closure, not read synchronously
352
+ // here, for the same reason `networkOutageRecovered`'s handler
353
+ // defers reading `#openNetworkOutageId`: a `dedupeCap` event's
354
+ // INSERT closure may still be queued (not yet executed) at the
355
+ // instant `crawlEnd` fires. `WriteQueue` runs enqueued
356
+ // operations in submission order, so by the time THIS closure
357
+ // executes, every earlier-queued `dedupeCap` INSERT has
358
+ // already completed and `#dedupeCapEventIds` is reliably
359
+ // populated.
360
+ writeQueue
361
+ .enqueue(async () => {
362
+ const rejections = this.#crawler.getDedupeCapRejections();
363
+ // Finalize every shape capped THIS session (has an id in
364
+ // `#dedupeCapEventIds`), not just the ones with a nonzero
365
+ // rejection count — a shape that capped near the end of the
366
+ // crawl (or whose remaining anchors all happened to be
367
+ // discovered before it capped) never enters `rejections` at
368
+ // all, and would otherwise stay `rejected_count: NULL` forever
369
+ // despite the crawl completing normally, corrupting the "NULL
370
+ // means the crawl never reached crawlEnd" contract
371
+ // `list-dedupe-cap-events.ts` documents.
372
+ const shapeKeysToFinalize = new Set([
373
+ ...this.#dedupeCapEventIds.keys(),
374
+ ...rejections.keys(),
375
+ ]);
376
+ await Promise.all([...shapeKeysToFinalize].map((shapeKey) => {
377
+ const rejectedCount = rejections.get(shapeKey) ?? 0;
378
+ const id = this.#dedupeCapEventIds.get(shapeKey);
379
+ // A shape capped THIS session has an id here (the
380
+ // `dedupeCap` event always enqueues an INSERT before any
381
+ // rejection for that shape can be counted) and is
382
+ // finalized once via its row id. A shape with no id was
383
+ // never observed this session at all — it was preloaded
384
+ // into `DedupeCapTracker`'s sticky set from an EARLIER
385
+ // session's `dedupe_cap_events` row (see
386
+ // `CrawlConfig.preloadedStickyShapeKeys`'s JSDoc), so gate
387
+ // rejections still accumulate for it but no new row (and
388
+ // thus no id) is ever created. That earlier row's count is
389
+ // accumulated onto by shape_key instead of overwritten.
390
+ return id === undefined
391
+ ? this.#archive.accumulateDedupeCapRejectedCount(shapeKey, rejectedCount)
392
+ : this.#archive.finalizeDedupeCapEvent(id, rejectedCount);
393
+ }));
394
+ })
395
+ .catch((error) => reject(error));
317
396
  writeQueue
318
397
  .drain()
319
398
  .then(() => resolve())
@@ -521,9 +600,14 @@ export class CrawlerOrchestrator extends EventEmitter {
521
600
  scopeMap.set(parsed.hostname, [...existing, parsed]);
522
601
  }
523
602
  await archive.repromoteExternalPages(scopeMap, archived);
603
+ // Seed the sticky set from prior sessions' confirmed traps so
604
+ // `--append` does not pay the cost of re-discovering them (see
605
+ // `DedupeCapTracker`'s constructor JSDoc).
606
+ const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys();
524
607
  const orchestrator = new CrawlerOrchestrator(archive, {
525
608
  ...mergedConfig,
526
609
  roots: mergedRoots,
610
+ preloadedStickyShapeKeys,
527
611
  });
528
612
  const { scraped, pending } = await archive.getCrawlingState();
529
613
  const resources = await archive.getResourceUrlList();
@@ -575,9 +659,10 @@ export class CrawlerOrchestrator extends EventEmitter {
575
659
  * 1. Open the archive (writer mode, takes the archive lock).
576
660
  * 2. Reject list-mode archives — they hold metadata-only rows that
577
661
  * inventory has no business touching.
578
- * 3. Reject archives with unfinished `pending` URLs — those would inherit
579
- * the inventory `source` label by mistake. Operator must resume /
580
- * retry-failed first.
662
+ * 3. Warn (but proceed) on archives with unfinished `pending` URLs —
663
+ * crawled-wins source priority keeps their labels stable; the
664
+ * operator can `--resume` first if they want the prior work
665
+ * finalized.
581
666
  * 4. If `source` is given, archive its exact bytes under
582
667
  * `inventory/<sha256>.txt` (see {@link Archive.saveInventorySourceList}).
583
668
  * Done before scope classification so even a run that discards every
@@ -590,17 +675,33 @@ export class CrawlerOrchestrator extends EventEmitter {
590
675
  * 6. Subtract URLs that already exist in `pages` or `resources` so the
591
676
  * second (and N-th) inventory pass is a no-op for known rows — keeps
592
677
  * `'inventory-seed'` rows from being silently demoted.
593
- * 7. Make `<archive>.bak`. Anything thrown beyond this point restores
678
+ * 7. Split the remaining novel URLs on the effective `excludes` /
679
+ * `excludeUrls` (archived config overlaid with this run's
680
+ * overrides — the same inputs the crawl's fetch-time
681
+ * `shouldSkipUrl` gate uses). Matching URLs are recorded as
682
+ * terminal skipped pages (`is_skipped=1`,
683
+ * `skip_reason='excluded'`, `source='inventory-seed'`) instead of
684
+ * being imported — the same end state a link-discovered excluded
685
+ * URL reaches in a normal crawl — and counted as
686
+ * `exclude_skipped` (issue #260). Running this after step 6 keeps
687
+ * previously crawled rows that newly match the exclusion config
688
+ * untouched (crawled-wins). `excludeKeywords` does not
689
+ * participate here: it matches rendered page content, which a URL
690
+ * list does not have — HTML seeds still get it at render time via
691
+ * the browser verdict.
692
+ * 8. Make `<archive>.bak`. Anything thrown beyond this point restores
594
693
  * from the backup.
595
- * 8. HEAD-probe each novel URL. Responses classified as HTML are queued
596
- * as Crawler seeds (`'inventory-seed'`); everything else is recorded
597
- * in `resources` directly as `'inventory-seed'` (no browser launch).
598
- * 9. If any HTML seeds exist, start a Crawler with
694
+ * 9. Classify each importable novel URL by URL-extension heuristic
695
+ * (no probe see the in-body rationale). HTML-looking URLs are
696
+ * queued as Crawler seeds (`'inventory-seed'`); everything else is
697
+ * recorded in `resources` directly as `'inventory-seed'` (no
698
+ * browser launch, no HEAD).
699
+ * 10. If any HTML seeds exist, start a Crawler with
599
700
  * `inventoryMode = { seedUrls }` so the rendered page and every newly
600
701
  * discovered downstream link is labelled correctly. `resume` is fed
601
702
  * the existing `scraped` / `resources` sets so links into already-
602
703
  * crawled pages stop at the seen-gate without re-rendering.
603
- * 10. Drop the backup on success; restore it on any throw.
704
+ * 11. Drop the backup on success; restore it on any throw.
604
705
  *
605
706
  * Mutually exclusive with `--append` / `--retry-failed` / `--resume` /
606
707
  * `--diff` / `--list` / `--list-file` / `--single` / `--output` — the
@@ -623,7 +724,7 @@ export class CrawlerOrchestrator extends EventEmitter {
623
724
  * `inventoryUrls` in-memory; the audit row's `source_file_sha256`
624
725
  * column will be `NULL` and no source list is archived.
625
726
  * @returns The orchestrator instance after a successful inventory pass.
626
- * @throws {Error} When `inventoryUrls` is empty, the archive is in list mode, or pending URLs from a previous crawl remain unresolved.
727
+ * @throws {Error} When `inventoryUrls` is empty or the archive is in list mode. Unresolved pending URLs from a previous crawl do NOT throw — see step 3.
627
728
  */
628
729
  static async inventory(archivePath, inventoryUrls, options, initializedCallback, source = null) {
629
730
  if (inventoryUrls.length === 0) {
@@ -712,17 +813,49 @@ export class CrawlerOrchestrator extends EventEmitter {
712
813
  });
713
814
  const knownCount = existingPageUrls.size + existingResourceUrls.size;
714
815
  log('[inventory] %d in-scope, %d already in archive, %d new', inScope.length, knownCount, novelUrls.length);
816
+ // Split the novel URLs on the exclusion config BEFORE the
817
+ // HTML/non-HTML classification, so an exclude-matched URL is
818
+ // recorded as a terminal skipped page instead of being imported
819
+ // (issue #260). The inputs mirror the scrape phase's fetch-time
820
+ // gate (`shouldSkipUrl` in `crawler.ts` fed by the constructor's
821
+ // merge): archived config overlaid with this run's overrides,
822
+ // and `DEFAULT_EXCLUDED_EXTERNAL_URLS` merged ahead of the
823
+ // user's prefixes — classification and gate must never disagree
824
+ // about the same URL. Running this AFTER the known-URL filter is
825
+ // deliberate: a previously crawled row that newly matches the
826
+ // exclusion config stays untouched (crawled-wins), matching how
827
+ // `getExistingPageUrls` shields known rows from re-labelling.
828
+ // `excludeKeywords` is deliberately absent: it matches rendered
829
+ // page content, which a URL list does not have — HTML seeds
830
+ // still get it at render time via the browser verdict.
831
+ const effectiveConfig = { ...archived, ...cleanObject(options) };
832
+ const excludes = normalizeToArray(effectiveConfig.excludes);
833
+ const excludeUrls = [
834
+ ...DEFAULT_EXCLUDED_EXTERNAL_URLS,
835
+ ...normalizeToArray(effectiveConfig.excludeUrls),
836
+ ];
837
+ const excludedNovelUrls = [];
838
+ const importableNovelUrls = [];
839
+ for (const url of novelUrls) {
840
+ if (shouldSkipUrl({ url, excludes, excludeUrls, options: effectiveConfig })) {
841
+ excludedNovelUrls.push(url);
842
+ }
843
+ else {
844
+ importableNovelUrls.push(url);
845
+ }
846
+ }
847
+ if (excludedNovelUrls.length > 0) {
848
+ log('[inventory] %d URL(s) recorded as skipped (matched excludes / excludeUrls)', excludedNovelUrls.length);
849
+ }
715
850
  if (novelUrls.length === 0) {
716
851
  // Nothing to do — release the archive cleanly without taking a
717
852
  // backup. The orchestrator returned here is empty; the caller
718
- // should only invoke `close` on it.
719
- const noopConfig = {
720
- ...archived,
721
- ...cleanObject(options),
722
- };
723
- const orchestrator = new CrawlerOrchestrator(archive, noopConfig);
853
+ // should only invoke `close` on it. `effectiveConfig` is the
854
+ // same archived-plus-overrides merge every other path in this
855
+ // method sees.
856
+ const orchestrator = new CrawlerOrchestrator(archive, effectiveConfig);
724
857
  if (initializedCallback) {
725
- await initializedCallback(orchestrator, noopConfig);
858
+ await initializedCallback(orchestrator, effectiveConfig);
726
859
  }
727
860
  return orchestrator;
728
861
  }
@@ -738,7 +871,7 @@ export class CrawlerOrchestrator extends EventEmitter {
738
871
  // clause). This flag steers the catch below.
739
872
  let ingestionComplete = false;
740
873
  try {
741
- // Classify novel URLs by URL-extension heuristic (no I/O).
874
+ // Classify importable novel URLs by URL-extension heuristic (no I/O).
742
875
  // Source file lists come from `ls` on the doc-root, so the
743
876
  // extension reflects the real file type — a HEAD pre-flight
744
877
  // here would be pure wasted I/O. Edge cases:
@@ -769,7 +902,7 @@ export class CrawlerOrchestrator extends EventEmitter {
769
902
  // null as "not probed" rather than "failed".
770
903
  const rawHtmlSeeds = [];
771
904
  const nonHtmlSeeds = [];
772
- for (const url of novelUrls) {
905
+ for (const url of importableNovelUrls) {
773
906
  if (isLikelyHtmlUrl(url)) {
774
907
  rawHtmlSeeds.push(url);
775
908
  }
@@ -809,7 +942,15 @@ export class CrawlerOrchestrator extends EventEmitter {
809
942
  // these rows up on the next `--resume` via the
810
943
  // `OR p.source != 'crawled'` clause.
811
944
  await archive.insertInventorySeeds(htmlSeeds);
812
- log('[inventory] %d HTML seed(s), %d non-HTML resource(s) recorded', htmlSeeds.length, nonHtmlSeeds.length);
945
+ // Record exclude-matched novel URLs as terminal skipped pages
946
+ // (`is_skipped=1`, `skip_reason='excluded'`,
947
+ // `source='inventory-seed'`) — the same end state the normal
948
+ // crawl's fetch-time gate produces for link-discovered
949
+ // excluded URLs, so the archive looks identical no matter
950
+ // how the URL was discovered. Inside the `.bak` window for
951
+ // the same all-or-nothing reason as the seed inserts above.
952
+ await archive.insertInventorySkippedPages(excludedNovelUrls);
953
+ log('[inventory] %d HTML seed(s), %d non-HTML resource(s), %d skipped page(s) recorded', htmlSeeds.length, nonHtmlSeeds.length, excludedNovelUrls.length);
813
954
  // Audit row is written *inside* the `.bak` window: a libsql
814
955
  // hiccup or transient lock on the INSERT aborts the ingestion
815
956
  // and the `.bak` restore wipes the pre-inserted seeds too,
@@ -823,6 +964,7 @@ export class CrawlerOrchestrator extends EventEmitter {
823
964
  htmlSeedsCount: htmlSeeds.length,
824
965
  nonHtmlCount: nonHtmlSeeds.length,
825
966
  outOfScope,
967
+ excludeSkipped: excludedNovelUrls.length,
826
968
  sourceFileSha256: source?.sha256 ?? null,
827
969
  invalidSkipped: source?.invalidLineCount ?? null,
828
970
  });
@@ -842,8 +984,7 @@ export class CrawlerOrchestrator extends EventEmitter {
842
984
  // (matches the rest of the orchestrator's public surface —
843
985
  // no inventory bookkeeping leaks out).
844
986
  const baseConfig = {
845
- ...archived,
846
- ...cleanObject(options),
987
+ ...effectiveConfig,
847
988
  recursive: true,
848
989
  fromList: false,
849
990
  };
@@ -856,6 +997,16 @@ export class CrawlerOrchestrator extends EventEmitter {
856
997
  inventoryMode: { seedUrls: seedSet },
857
998
  };
858
999
  if (htmlSeeds.length > 0) {
1000
+ // Seed the sticky set from prior sessions' confirmed traps
1001
+ // so `--inventory` does not pay the cost of
1002
+ // re-discovering them (see `DedupeCapTracker`'s
1003
+ // constructor JSDoc). Scoped to this branch only,
1004
+ // matching `#preloadDnsBurnedHostCache`'s scoping below —
1005
+ // the fallback (non-HTML-only) branch never calls
1006
+ // `orchestrator.crawling(...)`, so the tracker is never
1007
+ // consulted there.
1008
+ orchestratorOptions.preloadedStickyShapeKeys =
1009
+ await archive.listDedupeCapShapeKeys();
859
1010
  const orchestrator = new CrawlerOrchestrator(archive, orchestratorOptions);
860
1011
  // Re-read pending *after* the pre-insert so the strict-
861
1012
  // pending set includes the freshly inserted
@@ -1007,7 +1158,14 @@ export class CrawlerOrchestrator extends EventEmitter {
1007
1158
  log('Start retrying failed pages');
1008
1159
  log('Archive %s', absFilePath);
1009
1160
  log('Reset %d failed page(s)', resetUrls.length);
1010
- const orchestrator = new CrawlerOrchestrator(archive, config);
1161
+ // Seed the sticky set from prior sessions' confirmed traps so
1162
+ // `--retry-failed` does not pay the cost of re-discovering
1163
+ // them (see `DedupeCapTracker`'s constructor JSDoc).
1164
+ const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys();
1165
+ const orchestrator = new CrawlerOrchestrator(archive, {
1166
+ ...config,
1167
+ preloadedStickyShapeKeys,
1168
+ });
1011
1169
  const { scraped, pending } = await archive.getCrawlingState();
1012
1170
  const resources = await archive.getResourceUrlList();
1013
1171
  const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
@@ -1056,9 +1214,14 @@ export class CrawlerOrchestrator extends EventEmitter {
1056
1214
  static async resume(stubPath, options, initializedCallback) {
1057
1215
  const archive = await Archive.resume(stubPath);
1058
1216
  const archivedConfig = await archive.getConfig();
1217
+ // Seed the sticky set from prior sessions' confirmed traps so
1218
+ // `--resume` does not pay the cost of re-discovering them (see
1219
+ // `DedupeCapTracker`'s constructor JSDoc).
1220
+ const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys();
1059
1221
  const config = {
1060
1222
  ...archivedConfig,
1061
1223
  ...cleanObject(options),
1224
+ preloadedStickyShapeKeys,
1062
1225
  };
1063
1226
  const orchestrator = new CrawlerOrchestrator(archive, config);
1064
1227
  const _url = await archive.getUrl();
@@ -1147,6 +1310,7 @@ export class CrawlerOrchestrator extends EventEmitter {
1147
1310
  new_pages: aggregates.htmlSeedsCount,
1148
1311
  new_resources: aggregates.nonHtmlCount,
1149
1312
  scope_skipped: aggregates.outOfScope,
1313
+ exclude_skipped: aggregates.excludeSkipped,
1150
1314
  invalid_skipped: aggregates.invalidSkipped,
1151
1315
  });
1152
1316
  }
package/lib/crawler.d.ts CHANGED
@@ -33,6 +33,7 @@ export { computeBodyHash } from './archive/body-hash/compute-body-hash.js';
33
33
  export { decodeStoredBlob } from './archive/decode-html-blob.js';
34
34
  export { computeTierAAliasKey } from './archive/url-alias/compute-tier-a-alias-key.js';
35
35
  export { computeTierBAliasKey } from './archive/url-alias/compute-tier-b-alias-key.js';
36
+ export { computeShapeKey } from './crawler/dedupe/compute-shape-key.js';
36
37
  export { DEFAULT_EXCLUDED_EXTERNAL_URLS, CrawlerOrchestrator, } from './crawler-orchestrator.js';
37
38
  export * from './types.js';
38
39
  export * from './crawler/types.js';
@@ -46,6 +47,7 @@ export type { NetworkProbe } from './crawler/probe-network.js';
46
47
  export { probeNetwork } from './crawler/probe-network.js';
47
48
  export { computeOutageClampTimestamp } from './archive/db-ops/outages/compute-outage-clamp-timestamp.js';
48
49
  export { chooseProbeHost } from './crawler/choose-probe-host.js';
50
+ export { assertChromeIsInstalled } from './crawler/assert-chrome-installed.js';
49
51
  export { computeFileSha256 } from './utils/compute-file-sha256.js';
50
52
  export { populateEntityTables } from './archive/populate-entity-tables/populate-entities.js';
51
53
  export type { PageDomPathResolver } from './archive/populate-entity-tables/populate-image-items.js';
package/lib/crawler.js CHANGED
@@ -31,6 +31,7 @@ export { computeBodyHash } from './archive/body-hash/compute-body-hash.js';
31
31
  export { decodeStoredBlob } from './archive/decode-html-blob.js';
32
32
  export { computeTierAAliasKey } from './archive/url-alias/compute-tier-a-alias-key.js';
33
33
  export { computeTierBAliasKey } from './archive/url-alias/compute-tier-b-alias-key.js';
34
+ export { computeShapeKey } from './crawler/dedupe/compute-shape-key.js';
34
35
  // Core
35
36
  export { DEFAULT_EXCLUDED_EXTERNAL_URLS, CrawlerOrchestrator, } from './crawler-orchestrator.js';
36
37
  export * from './types.js';
@@ -43,6 +44,7 @@ export { default as NetworkGate } from './crawler/network-gate.js';
43
44
  export { probeNetwork } from './crawler/probe-network.js';
44
45
  export { computeOutageClampTimestamp } from './archive/db-ops/outages/compute-outage-clamp-timestamp.js';
45
46
  export { chooseProbeHost } from './crawler/choose-probe-host.js';
47
+ export { assertChromeIsInstalled } from './crawler/assert-chrome-installed.js';
46
48
  export { computeFileSha256 } from './utils/compute-file-sha256.js';
47
49
  // 0.13 ref-table population (issue #191, epic #103). Exposed as the
48
50
  // public seam that the migration script (`scripts/migrate-to-0.13.mjs`)
@@ -7,11 +7,12 @@ import type { ErrorKind } from './types.js';
7
7
  * Used by `resetFailedPages` to exclude pages whose latest recorded error
8
8
  * falls in this set, so `--retry-failed` actually converges: without the
9
9
  * exclusion, NXDOMAIN / TLS mismatch / `ERR_BLOCKED_BY_CLIENT` /
10
- * `ECONNREFUSED` / HTTP parse-error pages would be reset to pending on every
11
- * iteration, the crawler would re-attempt them, they would fail again the
12
- * same way, and the retry-target count would stay constant forever.
10
+ * `ECONNREFUSED` / HTTP parse-error / redirect-loop pages would be reset to
11
+ * pending on every iteration, the crawler would re-attempt them, they would
12
+ * fail again the same way, and the retry-target count would stay constant
13
+ * forever.
13
14
  *
14
- * Why these five and not others:
15
+ * Why these six and not others:
15
16
  * - **dns** — `ENOTFOUND` / `ERR_NAME_NOT_RESOLVED` are authoritative DNS
16
17
  * answers; the host is gone (or never existed). EAI_AGAIN is split out as
17
18
  * `dns-transient` precisely so it is NOT in this set.
@@ -28,6 +29,10 @@ import type { ErrorKind } from './types.js';
28
29
  * the listener; either no process is listening on the port or its accept
29
30
  * queue rejected the connection. Either way the answer is final until the
30
31
  * server operator intervenes.
32
+ * - **redirect-loop** — `Maximum number of redirects exceeded` /
33
+ * `ERR_TOO_MANY_REDIRECTS` means the site's own redirect chain never
34
+ * terminates; the exact same chain is served on every future fetch until
35
+ * the site operator fixes it.
31
36
  *
32
37
  * Notably absent (intentionally retryable):
33
38
  * - `connection-reset` / `connection-timeout` — could be middlebox or