@nitpicker/crawler 0.11.0 → 0.12.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 (75) hide show
  1. package/lib/archive/archive.d.ts +117 -2
  2. package/lib/archive/archive.js +147 -2
  3. package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
  4. package/lib/archive/cache/compute-archive-cache-key.js +95 -0
  5. package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
  6. package/lib/archive/cache/extract-archive-to-cache.js +309 -0
  7. package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
  8. package/lib/archive/cache/get-archive-cache-root.js +53 -0
  9. package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
  10. package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
  11. package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
  12. package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
  13. package/lib/archive/database.d.ts +216 -15
  14. package/lib/archive/database.js +1459 -938
  15. package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
  16. package/lib/archive/derive-lineage-from-parent.js +42 -0
  17. package/lib/archive/get-failed-page-messages.d.ts +43 -0
  18. package/lib/archive/get-failed-page-messages.js +131 -0
  19. package/lib/archive/init-schema.js +153 -1
  20. package/lib/archive/is-inventory-source.d.ts +21 -0
  21. package/lib/archive/is-inventory-source.js +22 -0
  22. package/lib/archive/migrate-inventory-runs.d.ts +29 -0
  23. package/lib/archive/migrate-inventory-runs.js +52 -0
  24. package/lib/archive/types.d.ts +33 -0
  25. package/lib/classify-error-kind.d.ts +19 -0
  26. package/lib/classify-error-kind.js +122 -0
  27. package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
  28. package/lib/crawler/build-js-redirect-edge.js +57 -0
  29. package/lib/crawler/build-redirect-event.d.ts +24 -0
  30. package/lib/crawler/build-redirect-event.js +28 -0
  31. package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
  32. package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
  33. package/lib/crawler/crawler.d.ts +3 -1
  34. package/lib/crawler/crawler.js +655 -107
  35. package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
  36. package/lib/crawler/derive-js-redirect-target.js +129 -0
  37. package/lib/crawler/derive-resource-source.d.ts +25 -15
  38. package/lib/crawler/derive-resource-source.js +28 -17
  39. package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
  40. package/lib/crawler/dns-burned-host-cache.js +25 -0
  41. package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
  42. package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
  43. package/lib/crawler/fetch-destination.d.ts +12 -4
  44. package/lib/crawler/fetch-destination.js +94 -16
  45. package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
  46. package/lib/crawler/is-js-redirect-error-shape.js +53 -0
  47. package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
  48. package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
  49. package/lib/crawler/link-list.d.ts +21 -1
  50. package/lib/crawler/link-list.js +23 -3
  51. package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
  52. package/lib/crawler/plan-sub-resource-emits.js +44 -0
  53. package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
  54. package/lib/crawler/preload-short-circuit-error.js +25 -0
  55. package/lib/crawler/should-burn-host.d.ts +78 -0
  56. package/lib/crawler/should-burn-host.js +61 -0
  57. package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
  58. package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
  59. package/lib/crawler/types.d.ts +107 -0
  60. package/lib/crawler-orchestrator.d.ts +13 -3
  61. package/lib/crawler-orchestrator.js +292 -69
  62. package/lib/crawler.d.ts +3 -2
  63. package/lib/crawler.js +3 -1
  64. package/lib/permanent-error-kinds.d.ts +43 -0
  65. package/lib/permanent-error-kinds.js +48 -0
  66. package/lib/types.d.ts +84 -0
  67. package/lib/utils/compute-file-sha256.d.ts +23 -0
  68. package/lib/utils/compute-file-sha256.js +55 -0
  69. package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
  70. package/lib/utils/error/emit-error-with-retry.js +44 -0
  71. package/lib/utils/error/emit-error.d.ts +39 -0
  72. package/lib/utils/error/emit-error.js +41 -0
  73. package/package.json +11 -11
  74. package/lib/utils/error/error-emitter.d.ts +0 -18
  75. package/lib/utils/error/error-emitter.js +0 -29
@@ -1,5 +1,5 @@
1
1
  import type { JsonLdRow, TagRow } from './meta/types.js';
2
- import type { Config, DatabaseOption, DB_Anchor, DB_Page, DB_Redirect, DB_Referrer, DB_Resource, DatabaseEvent, PageFilter, PageSource } from './types.js';
2
+ import type { Config, DatabaseOption, DB_Anchor, DB_Page, DB_Redirect, DB_Referrer, DB_Resource, DatabaseEvent, InventoryRunMeta, PageFilter, PageSource } from './types.js';
3
3
  import type { PageData, Resource } from '../utils/types/types.js';
4
4
  import type { ExURL, ParseURLOptions } from '@d-zero/shared/parse-url';
5
5
  import type { Knex } from 'knex';
@@ -7,12 +7,20 @@ import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-awa
7
7
  /**
8
8
  * Low-level database abstraction layer for the archive's SQLite database.
9
9
  *
10
- * Public methods that perform database queries use the `@retryable`
11
- * decorator for automatic retry on transient failures, and `@ErrorEmitter`
12
- * to propagate errors as events. The set of tables this layer manages is
10
+ * Public methods that perform database queries use the `emitErrorAndRetry`
11
+ * HOF for automatic retry on transient failures combined with error-event
12
+ * propagation, or `emitError` when retry is not appropriate. The set of
13
+ * tables this layer manages is
13
14
  * defined by `init-schema.ts` (the source of truth — query that file for
14
15
  * the canonical list).
15
16
  *
17
+ * **Label sync caveat**: each `emitError` / `emitErrorAndRetry` call passes
18
+ * the method name as a string literal (e.g. `'Database.getAnchorsOnPage'`).
19
+ * TypeScript cannot check that the string matches the enclosing method's
20
+ * real name — the two-way sync is manual. Renaming a method here **must**
21
+ * update the literal string too, otherwise debug logs and `RetryTimeoutError`
22
+ * messages will silently report the old name.
23
+ *
16
24
  * Use the static {@link Database.connect} factory method to create instances.
17
25
  * The constructor is private.
18
26
  */
@@ -58,7 +66,67 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
58
66
  getConfig(): Promise<Config>;
59
67
  /**
60
68
  * Retrieves the current crawling state by listing scraped and pending URLs.
61
- * @returns An object with `scraped` (completed URLs) and `pending` (remaining URLs) arrays.
69
+ *
70
+ * `scraped` is straightforward: every page row whose `scraped` flag is `1`
71
+ * — that is, every URL the crawl reached a terminal state on, including
72
+ * setSkippedPage / setExternalPage / outright setPage success or failure.
73
+ *
74
+ * `pending` is intentionally STRICT — not "every `scraped = 0` row".
75
+ * Three filters apply:
76
+ *
77
+ * 1. `scraped = 0` — work still incomplete.
78
+ * 2. `isExternal = 0` — only in-scope work. External URLs go through a
79
+ * HEAD-only path that always lands on `scraped = 1` (either setPage or
80
+ * setExternalPage). A row with `isExternal = 1 AND scraped = 0` is
81
+ * therefore a data anomaly, and resume / inventory / append have no
82
+ * business retrying it on the next session.
83
+ * 3. `EXISTS (anchor with hrefId = pages.id) OR source != 'crawled'` —
84
+ * the row was either discovered as an anchor destination during a
85
+ * previous scrape OR was explicitly tagged with a non-default
86
+ * source label (`'inventory-seed'`, `'inventory-discovered'`, …).
87
+ * Both halves of the OR represent "deliberately enqueued, expected
88
+ * to be processed", which is exactly what `resume` should pick up.
89
+ *
90
+ * The orphan filter targets the **predicted-discard leak** in
91
+ * `crawler.ts` where `shouldDiscardPredicted` returns true but no
92
+ * `emit('skip')` follows. Such placeholders are inserted with the
93
+ * DB DEFAULT `source = 'crawled'` (no caller explicitly labels
94
+ * them) AND have no anchor referrer (predicted URLs are
95
+ * synthesised from pagination patterns, never anchored from a
96
+ * rendered page) — both halves of the OR are therefore false and
97
+ * the leak is excluded.
98
+ *
99
+ * The `source != 'crawled'` clause specifically saves the
100
+ * `--inventory` × `--retry-failed` interaction: an inventory-seed
101
+ * URL came from the operator's URL list (no anchor referrer) and
102
+ * `resetFailedPages` puts it back at `scraped = 0`. Without this
103
+ * clause those legitimate retries would be dropped on resume.
104
+ *
105
+ * The defensive shape is on purpose: the data source can drift into
106
+ * anomalous states under interruption, but the reader must never throw
107
+ * or feed garbage back into the dealer. A real in-scope URL that was
108
+ * truly interrupted mid-crawl will always have at least one anchor
109
+ * referrer (otherwise the dealer would not have queued it), so the
110
+ * strict filter loses no legitimate pending work.
111
+ *
112
+ * Seeds passed directly to `Crawler.start()` are NOT in the strict
113
+ * pending set when they were never picked by the dealer — they have no
114
+ * DB row at all in that case (`linkList.add` is purely in-memory until
115
+ * `setPage` runs). A Ctrl-C between dealer pick and `setPage` likewise
116
+ * leaves no row to recover. Recovery of un-picked seeds is the
117
+ * responsibility of the caller (e.g. re-running `--inventory ./list.txt`
118
+ * with the same URL list).
119
+ *
120
+ * The query uses an explicit `p` alias on the `pages` table so the
121
+ * correlated `EXISTS` subquery can join via `whereRaw('anchors.hrefId =
122
+ * p.id')`. A future refactor that renames the alias must update both
123
+ * sites — the raw string in the subquery cannot be grep-resolved
124
+ * automatically. Read-only / stub viewer connections never call this
125
+ * method (they do not need to know about pending state), so the EXISTS
126
+ * shape is safe to use without the `migrate*` guards that other writer
127
+ * methods carry.
128
+ * @returns An object with `scraped` (completed URLs) and `pending` (the
129
+ * strict set of in-scope, anchor-referenced, unfinished URLs).
62
130
  */
63
131
  getCrawlingState(): Promise<{
64
132
  scraped: string[];
@@ -137,6 +205,27 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
137
205
  * @returns An array of raw {@link DB_Page} rows.
138
206
  */
139
207
  getPages(filter?: PageFilter, offset?: number, limit?: number): Promise<DB_Page[]>;
208
+ /**
209
+ * Look up the `source` column of a single page by its URL key. Used by
210
+ * the orchestrator's `PageSourceLookup` injection so the Crawler can
211
+ * resolve a parent page's lineage on `--resume` / `--retry-failed`
212
+ * sessions, where the in-memory `inventoryMode` is no longer
213
+ * available but the DB still remembers what label was last persisted.
214
+ *
215
+ * Returns `undefined` when the URL has no `pages` row (e.g. a brand-new
216
+ * URL that has not been seen yet) so the caller can fall through to
217
+ * its default behaviour without distinguishing "row absent" from "row
218
+ * present with NULL source" — the schema's `NOT NULL DEFAULT 'crawled'`
219
+ * makes a NULL value impossible in practice.
220
+ *
221
+ * Read-only — no transaction, single PK-equivalent lookup on
222
+ * `pages.url` (a UNIQUE column), so the cost is constant per call. The
223
+ * Crawler calls this at most once per page render, NOT per
224
+ * sub-resource, so the N+1 risk does not apply.
225
+ * @param url - URL key in `url.withoutHashAndAuth` form.
226
+ * @returns The recorded `source`, or `undefined` when no row exists.
227
+ */
228
+ getPageSourceByUrl(url: string): Promise<PageSource | undefined>;
140
229
  /**
141
230
  * Retrieves pages along with their related redirect, anchor, and referrer data.
142
231
  * Results are ordered by the natural URL sort order. Only non-redirected pages are returned.
@@ -183,7 +272,7 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
183
272
  * `href` while callers may only know the hash-stripped form; the first match
184
273
  * wins.
185
274
  *
186
- * Deliberately NOT decorated with `@ErrorEmitter`: the only caller (the
275
+ * Deliberately NOT wrapped with `emitError`/`emitErrorAndRetry`: the only caller (the
187
276
  * crawler's resource-reuse hook) has a full fallback (the HEAD pre-flight),
188
277
  * so a read failure here must not surface as a database `error` event —
189
278
  * the orchestrator aborts the whole crawl on that event, which is the
@@ -240,6 +329,58 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
240
329
  * @param isExternal - Whether the URL is external to the crawl scope.
241
330
  */
242
331
  insertCrawlError(url: string | null, message: string, isExternal?: boolean): Promise<void>;
332
+ /**
333
+ * Pre-insert inventory non-HTML URLs into `resources` as placeholder rows
334
+ * with `source = 'inventory-seed'` and all metadata columns NULL — the
335
+ * non-HTML counterpart of {@link Database.insertInventorySeeds}. Used by
336
+ * `CrawlerOrchestrator.inventory` so the ingestion phase commits all of
337
+ * its non-HTML URLs in one chunked round-trip per 500 instead of N
338
+ * sequential `insertResource` awaits. On a 50k-URL inventory list the
339
+ * old per-URL loop spent minutes inside the `.bak`-protected window;
340
+ * the bulk path finishes in seconds.
341
+ *
342
+ * Idempotent: `onConflict('url').ignore()` leaves existing rows untouched
343
+ * (the orchestrator's `getExistingResourceUrls` filter is what keeps a
344
+ * crawled-lineage `resources` row from being downgraded to the
345
+ * inventory label here).
346
+ *
347
+ * Chunked at 500 to stay well under SQLite's `SQLITE_MAX_VARIABLE_NUMBER`
348
+ * (default 999) — every row binds the URL plus the `responseHeaders`
349
+ * JSON null, so the per-chunk bound budget is well within limits.
350
+ * @param urls - URL strings (already in `withoutHashAndAuth` form).
351
+ */
352
+ insertInventoryResources(urls: readonly string[]): Promise<void>;
353
+ /**
354
+ * Pre-insert inventory HTML seeds into `pages` as `scraped = 0`,
355
+ * `source = 'inventory-seed'` placeholders so the URL's existence in the
356
+ * archive is **durable before the scrape phase starts**.
357
+ *
358
+ * Why this is the linchpin of `--inventory` Ctrl+C tolerance: HTML seeds
359
+ * used to live only in the Crawler's in-memory `LinkList` until the
360
+ * dealer eventually called `setPage`. A Ctrl+C / crash before that point
361
+ * lost the seed without trace, and `--resume` could not recover it
362
+ * because `getCrawlingState`'s strict pending set requires a `pages` row.
363
+ * Pre-inserting fills exactly that gap: the strict pending set picks
364
+ * these rows up via its `OR p.source != 'crawled'` clause, so
365
+ * `--resume` after an interrupted inventory pass picks every seed back
366
+ * up. See {@link Database.getCrawlingState} for the strict-set rationale.
367
+ *
368
+ * Idempotent: `onConflict('url').ignore()` keeps existing rows intact.
369
+ * The {@link Database.#getIdByUrl} crawled-wins downgrade still fires
370
+ * later when a crawled-lineage anchor reaches one of these seeds —
371
+ * that's the right behaviour (a seed that turned out to be reachable
372
+ * is not an orphan and should not retain the inventory label).
373
+ *
374
+ * Chunked into 500-URL batches so SQLite's bound-parameter limit
375
+ * (`SQLITE_MAX_VARIABLE_NUMBER`, default 999) cannot be hit even on a
376
+ * tens-of-thousands inventory list.
377
+ *
378
+ * Called by {@link CrawlerOrchestrator.inventory} during the
379
+ * `.bak`-protected ingestion phase, so any failure here aborts the run
380
+ * and restores from backup — the operator reruns from scratch.
381
+ * @param urls - URL strings already in `withoutHashAndAuth` form.
382
+ */
383
+ insertInventorySeeds(urls: readonly string[]): Promise<void>;
243
384
  /**
244
385
  * Records a partial scrape failure against the page identified by `url`.
245
386
  *
@@ -276,6 +417,44 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
276
417
  * @param pageUrl - The URL of the page that references the resource.
277
418
  */
278
419
  insertResourceReferrers(src: string, pageUrl: string): Promise<void>;
420
+ /**
421
+ * Hostnames whose `crawl_errors` history is consistently DNS failures and
422
+ * for which no recent 2xx-3xx page or resource is recorded — i.e. hosts
423
+ * the previous crawl already proved unreachable. Returned in lower-cased
424
+ * form. Used by `CrawlerOrchestrator.#preloadDnsBurnedHostCache` so the
425
+ * next session short-circuits HEAD pre-flight on these hosts.
426
+ *
427
+ * Implementation: a coarse `LIKE` filter over `crawl_errors.message`
428
+ * narrows the row set, then `classifyErrorKind` confirms `'dns'` in JS
429
+ * (the regex is the single truth source — DB-side filters never narrow
430
+ * it). Exclusion bags are built from a single `pages` and a single
431
+ * `resources` scan: any host with a 2xx-3xx page, a 2xx-3xx resource, or
432
+ * a `pages.lastCrawledAt` newer than its latest DNS error is dropped
433
+ * (the host probably recovered between the failure and the last crawl).
434
+ *
435
+ * Returns `[]` on legacy archives that pre-date the `crawl_errors`
436
+ * table — the `hasTable` guard keeps the call non-destructive.
437
+ * @returns Lower-cased hostnames safe to short-circuit.
438
+ */
439
+ listDnsBurnedHostCandidates(): Promise<string[]>;
440
+ /**
441
+ * Appends one row to the `inventory_runs` audit log.
442
+ *
443
+ * Called by {@link CrawlerOrchestrator.inventory} on every successful
444
+ * `--inventory <list>` invocation so the archive carries a durable
445
+ * record of which deploy list was applied when and at what scale —
446
+ * the operational question "did we apply last month's list" the
447
+ * archive itself can answer without consulting external bookkeeping.
448
+ *
449
+ * Append-only at Phase 1. There is intentionally no UPDATE path and
450
+ * no UNIQUE constraint on `source_file_sha256`; two applies of the
451
+ * same list each get their own row, and `Phase 3 --refresh` is where
452
+ * dedupe / pre-flight against the hash will land. Field-level NULL
453
+ * semantics live on {@link InventoryRunMeta}.
454
+ * @param meta - The run metadata to record. Only `ran_at` is required.
455
+ * @returns The autoincremented `id` of the newly-inserted row.
456
+ */
457
+ recordInventoryRun(meta: InventoryRunMeta): Promise<number>;
279
458
  /**
280
459
  * Records a redirect edge (source → destination) **without** re-storing the
281
460
  * destination's content.
@@ -293,8 +472,17 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
293
472
  * The destination's existing anchors / images are never touched here.
294
473
  * @param page - HEAD-resolved page data carrying the redirect chain. Its
295
474
  * `anchorList` / `imageList` are ignored (a redirect source owns no content).
296
- */
297
- recordRedirect(page: PageData): Promise<void>;
475
+ * @param source - Inventory provenance forwarded by the orchestrator
476
+ * (`Archive.setRedirect` → here) for the redirect-edge fast path. Used
477
+ * as the fallback when the originating URL's row does NOT yet exist in
478
+ * the archive (`#73` convergence on first sight, js-redirect rescue
479
+ * before any prior write). When the originating row already exists
480
+ * (e.g. anchor-lineage INSERT from a prior pass), its stored `source`
481
+ * takes precedence so transitive lineage is preserved across resume /
482
+ * retry-failed sessions. `undefined` keeps the DB DEFAULT `'crawled'`
483
+ * on a brand-new destination row.
484
+ */
485
+ recordRedirect(page: PageData, source?: PageSource): Promise<void>;
298
486
  /**
299
487
  * Promote previously-external pages whose URL falls under any of the new scope
300
488
  * entries back to a "needs scraping" state so that the next crawl picks them up
@@ -330,13 +518,26 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
330
518
  * - `status` is in the `5xx` range — a (frequently transient) server error.
331
519
  *
332
520
  * Definitive `4xx` responses are intentionally excluded: re-fetching a 404
333
- * almost always yields the same answer. Matching rows — internal and
334
- * external alike — are demoted back to pending (`scraped = 0`) and have their
335
- * stale scrape metadata cleared. The page row itself is kept (id preserved)
336
- * so existing `anchors.hrefId` referrers stay valid, and `isExternal` is left
337
- * untouched so the next pass re-classifies each page from the crawl scope.
338
- * Related `anchors`, `images`, `resources-referrers`, and `page_errors` rows
339
- * are deleted so the re-scrape can re-insert fresh data without duplicates.
521
+ * almost always yields the same answer.
522
+ *
523
+ * A second exclusion runs in JS after the SQL candidate scan: any page whose
524
+ * latest recorded `page_errors` / `crawl_errors` message classifies into a
525
+ * permanent {@link PERMANENT_ERROR_KINDS} kind (dns / tls / client-blocked /
526
+ * parse-error / connection-refused) is left as-is rather than reset to
527
+ * pending. Without this filter, `--retry-failed` never converges: NXDOMAIN
528
+ * hosts, expired-cert hosts, and `ERR_BLOCKED_BY_CLIENT` ad pixels would be
529
+ * reset every iteration, re-attempted, fail identically, and rejoin the
530
+ * candidate pool for the next iteration. The exclusion keeps the retry
531
+ * target shrinking across `--retry-failed` passes by leaving deterministic
532
+ * dead-ends alone.
533
+ *
534
+ * Matching rows — internal and external alike — are demoted back to pending
535
+ * (`scraped = 0`) and have their stale scrape metadata cleared. The page row
536
+ * itself is kept (id preserved) so existing `anchors.hrefId` referrers stay
537
+ * valid, and `isExternal` is left untouched so the next pass re-classifies
538
+ * each page from the crawl scope. Related `anchors`, `images`,
539
+ * `resources-referrers`, and `page_errors` rows are deleted so the re-scrape
540
+ * can re-insert fresh data without duplicates.
340
541
  *
341
542
  * SELECT and UPDATE/DELETE statements are chunked to stay below SQLite's
342
543
  * `SQLITE_LIMIT_VARIABLE_NUMBER`.