@nitpicker/crawler 0.9.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 (148) 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 +262 -24
  4. package/lib/archive/archive.js +397 -86
  5. package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
  6. package/lib/archive/cache/compute-archive-cache-key.js +95 -0
  7. package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
  8. package/lib/archive/cache/extract-archive-to-cache.js +309 -0
  9. package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
  10. package/lib/archive/cache/get-archive-cache-root.js +53 -0
  11. package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
  12. package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
  13. package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
  14. package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
  15. package/lib/archive/database.d.ts +411 -26
  16. package/lib/archive/database.js +1881 -540
  17. package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
  18. package/lib/archive/derive-lineage-from-parent.js +42 -0
  19. package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
  20. package/lib/archive/filesystem/peek-tar-top-dir.js +65 -0
  21. package/lib/archive/get-failed-page-messages.d.ts +43 -0
  22. package/lib/archive/get-failed-page-messages.js +131 -0
  23. package/lib/archive/init-schema.d.ts +53 -2
  24. package/lib/archive/init-schema.js +399 -15
  25. package/lib/archive/is-inventory-source.d.ts +21 -0
  26. package/lib/archive/is-inventory-source.js +22 -0
  27. package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
  28. package/lib/archive/meta/assert-compatible-version.js +72 -0
  29. package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
  30. package/lib/archive/meta/classify-jsonld-type.js +43 -0
  31. package/lib/archive/meta/compare-semver.d.ts +23 -0
  32. package/lib/archive/meta/compare-semver.js +51 -0
  33. package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
  34. package/lib/archive/meta/compute-page-denormalized.js +35 -0
  35. package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
  36. package/lib/archive/meta/derive-flat-from-meta.js +158 -0
  37. package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
  38. package/lib/archive/meta/derive-meta-extras.js +23 -0
  39. package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
  40. package/lib/archive/meta/extract-tags-for-archive.js +36 -0
  41. package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
  42. package/lib/archive/meta/summarize-jsonld.js +29 -0
  43. package/lib/archive/meta/summarize-tags.d.ts +16 -0
  44. package/lib/archive/meta/summarize-tags.js +33 -0
  45. package/lib/archive/meta/types.d.ts +207 -0
  46. package/lib/archive/meta/types.js +33 -0
  47. package/lib/archive/migrate-crawl-errors.d.ts +20 -0
  48. package/lib/archive/migrate-crawl-errors.js +38 -0
  49. package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
  50. package/lib/archive/migrate-html-blob-tables.js +53 -0
  51. package/lib/archive/migrate-inventory-runs.d.ts +29 -0
  52. package/lib/archive/migrate-inventory-runs.js +52 -0
  53. package/lib/archive/migrate-page-errors.d.ts +16 -0
  54. package/lib/archive/migrate-page-errors.js +35 -0
  55. package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
  56. package/lib/archive/migrate-pages-resources-source.js +46 -0
  57. package/lib/archive/page.d.ts +187 -49
  58. package/lib/archive/page.js +258 -63
  59. package/lib/archive/peek-archive-lock.d.ts +40 -0
  60. package/lib/archive/peek-archive-lock.js +62 -0
  61. package/lib/archive/resolve-redirect-chain.d.ts +33 -0
  62. package/lib/archive/resolve-redirect-chain.js +27 -0
  63. package/lib/archive/types.d.ts +168 -26
  64. package/lib/classify-error-kind.d.ts +19 -0
  65. package/lib/classify-error-kind.js +122 -0
  66. package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
  67. package/lib/crawler/build-js-redirect-edge.js +57 -0
  68. package/lib/crawler/build-redirect-event.d.ts +24 -0
  69. package/lib/crawler/build-redirect-event.js +28 -0
  70. package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
  71. package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
  72. package/lib/crawler/close-browser-safely.d.ts +64 -0
  73. package/lib/crawler/close-browser-safely.js +73 -0
  74. package/lib/crawler/crawler.d.ts +7 -2
  75. package/lib/crawler/crawler.js +919 -113
  76. package/lib/crawler/create-change-phase-handler.d.ts +54 -0
  77. package/lib/crawler/create-change-phase-handler.js +44 -0
  78. package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
  79. package/lib/crawler/derive-js-redirect-target.js +129 -0
  80. package/lib/crawler/derive-page-source.d.ts +23 -0
  81. package/lib/crawler/derive-page-source.js +28 -0
  82. package/lib/crawler/derive-resource-source.d.ts +33 -0
  83. package/lib/crawler/derive-resource-source.js +37 -0
  84. package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
  85. package/lib/crawler/dns-burned-host-cache.js +25 -0
  86. package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
  87. package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
  88. package/lib/crawler/drain-phase-errors.d.ts +48 -0
  89. package/lib/crawler/drain-phase-errors.js +35 -0
  90. package/lib/crawler/fetch-destination.d.ts +12 -4
  91. package/lib/crawler/fetch-destination.js +132 -18
  92. package/lib/crawler/format-crawl-progress.d.ts +12 -3
  93. package/lib/crawler/format-crawl-progress.js +14 -6
  94. package/lib/crawler/handle-browser-close.d.ts +29 -0
  95. package/lib/crawler/handle-browser-close.js +28 -0
  96. package/lib/crawler/is-html-content-type.d.ts +17 -0
  97. package/lib/crawler/is-html-content-type.js +19 -0
  98. package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
  99. package/lib/crawler/is-js-redirect-error-shape.js +53 -0
  100. package/lib/crawler/is-likely-html-url.d.ts +22 -0
  101. package/lib/crawler/is-likely-html-url.js +65 -0
  102. package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
  103. package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
  104. package/lib/crawler/kill-process-tree.d.ts +94 -0
  105. package/lib/crawler/kill-process-tree.js +178 -0
  106. package/lib/crawler/link-list.d.ts +21 -1
  107. package/lib/crawler/link-list.js +25 -4
  108. package/lib/crawler/link-to-page-data.d.ts +13 -5
  109. package/lib/crawler/link-to-page-data.js +26 -5
  110. package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
  111. package/lib/crawler/log-undrained-phase-errors.js +34 -0
  112. package/lib/crawler/normalize-content-type.d.ts +14 -0
  113. package/lib/crawler/normalize-content-type.js +20 -0
  114. package/lib/crawler/partition-urls-by-html.d.ts +16 -0
  115. package/lib/crawler/partition-urls-by-html.js +23 -0
  116. package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
  117. package/lib/crawler/plan-sub-resource-emits.js +44 -0
  118. package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
  119. package/lib/crawler/preload-short-circuit-error.js +25 -0
  120. package/lib/crawler/redirect-dest-key.d.ts +19 -0
  121. package/lib/crawler/redirect-dest-key.js +27 -0
  122. package/lib/crawler/resource-to-page-data.d.ts +28 -0
  123. package/lib/crawler/resource-to-page-data.js +59 -0
  124. package/lib/crawler/should-burn-host.d.ts +78 -0
  125. package/lib/crawler/should-burn-host.js +61 -0
  126. package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
  127. package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
  128. package/lib/crawler/types.d.ts +229 -1
  129. package/lib/crawler-orchestrator.d.ts +105 -3
  130. package/lib/crawler-orchestrator.js +620 -20
  131. package/lib/crawler.d.ts +8 -2
  132. package/lib/crawler.js +6 -1
  133. package/lib/permanent-error-kinds.d.ts +43 -0
  134. package/lib/permanent-error-kinds.js +48 -0
  135. package/lib/resource-row-to-lookup-result.d.ts +13 -0
  136. package/lib/resource-row-to-lookup-result.js +20 -0
  137. package/lib/types.d.ts +95 -1
  138. package/lib/utils/compute-file-sha256.d.ts +23 -0
  139. package/lib/utils/compute-file-sha256.js +55 -0
  140. package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
  141. package/lib/utils/error/emit-error-with-retry.js +44 -0
  142. package/lib/utils/error/emit-error.d.ts +39 -0
  143. package/lib/utils/error/emit-error.js +41 -0
  144. package/lib/utils/object/parse-response-headers.d.ts +12 -0
  145. package/lib/utils/object/parse-response-headers.js +26 -0
  146. package/package.json +11 -11
  147. package/lib/utils/error/error-emitter.d.ts +0 -18
  148. package/lib/utils/error/error-emitter.js +0 -29
@@ -0,0 +1,37 @@
1
+ import type { PageSource } from './types.js';
2
+ /**
3
+ * Decide which {@link PageSource} label a child row reached through the
4
+ * crawl graph (anchor placeholder, sub-resource, redirect chain
5
+ * intermediate) should inherit from its parent.
6
+ *
7
+ * Two simple rules, expressed once so anchor / redirect / sub-resource
8
+ * call sites stay in lockstep:
9
+ *
10
+ * 1. If the parent is in the inventory chain
11
+ * ({@link isInventorySource}) → propagate
12
+ * `'inventory-discovered'`. The child is itself a transitively
13
+ * reached node in the inventory chain; it is NOT a new seed (the
14
+ * seed label is reserved for URLs the operator listed in
15
+ * `--inventory ./list.txt`).
16
+ *
17
+ * 2. Otherwise → return `fallback`. The two production fallbacks differ
18
+ * by call site:
19
+ *
20
+ * - Anchor lineage passes `'crawled'` explicitly so the crawled-wins
21
+ * downgrade inside `#getIdByUrl` fires when the anchor reaches an
22
+ * existing `'inventory-*'` row.
23
+ * - Sub-resource emit passes `undefined` so the DB DEFAULT
24
+ * `'crawled'` lands on the freshly INSERTed `resources` row (the
25
+ * `setResources` path is INSERT-only with `onConflict.ignore()`, so
26
+ * no downgrade is needed).
27
+ * - Redirect chain intermediate uses `'crawled'` (same reason as
28
+ * anchor): an existing inventory-* intermediate reached by a
29
+ * crawled redirect chain must be downgraded.
30
+ *
31
+ * Pure function — keeps the lineage decision testable in isolation from
32
+ * the database transaction / event-emitter wiring that consumes it.
33
+ * @param parentSource - The parent page's stored `source` column (or `undefined` when no parent row exists).
34
+ * @param fallback - The label to return when the parent is NOT in the inventory chain. Pass `'crawled'` to enable the crawled-wins downgrade, or `undefined` to let the DB DEFAULT apply.
35
+ * @returns The lineage label to attach to the child row.
36
+ */
37
+ export declare function deriveLineageFromParent(parentSource: PageSource | undefined, fallback: PageSource | undefined): PageSource | undefined;
@@ -0,0 +1,42 @@
1
+ import { isInventorySource } from './is-inventory-source.js';
2
+ /**
3
+ * Decide which {@link PageSource} label a child row reached through the
4
+ * crawl graph (anchor placeholder, sub-resource, redirect chain
5
+ * intermediate) should inherit from its parent.
6
+ *
7
+ * Two simple rules, expressed once so anchor / redirect / sub-resource
8
+ * call sites stay in lockstep:
9
+ *
10
+ * 1. If the parent is in the inventory chain
11
+ * ({@link isInventorySource}) → propagate
12
+ * `'inventory-discovered'`. The child is itself a transitively
13
+ * reached node in the inventory chain; it is NOT a new seed (the
14
+ * seed label is reserved for URLs the operator listed in
15
+ * `--inventory ./list.txt`).
16
+ *
17
+ * 2. Otherwise → return `fallback`. The two production fallbacks differ
18
+ * by call site:
19
+ *
20
+ * - Anchor lineage passes `'crawled'` explicitly so the crawled-wins
21
+ * downgrade inside `#getIdByUrl` fires when the anchor reaches an
22
+ * existing `'inventory-*'` row.
23
+ * - Sub-resource emit passes `undefined` so the DB DEFAULT
24
+ * `'crawled'` lands on the freshly INSERTed `resources` row (the
25
+ * `setResources` path is INSERT-only with `onConflict.ignore()`, so
26
+ * no downgrade is needed).
27
+ * - Redirect chain intermediate uses `'crawled'` (same reason as
28
+ * anchor): an existing inventory-* intermediate reached by a
29
+ * crawled redirect chain must be downgraded.
30
+ *
31
+ * Pure function — keeps the lineage decision testable in isolation from
32
+ * the database transaction / event-emitter wiring that consumes it.
33
+ * @param parentSource - The parent page's stored `source` column (or `undefined` when no parent row exists).
34
+ * @param fallback - The label to return when the parent is NOT in the inventory chain. Pass `'crawled'` to enable the crawled-wins downgrade, or `undefined` to let the DB DEFAULT apply.
35
+ * @returns The lineage label to attach to the child row.
36
+ */
37
+ export function deriveLineageFromParent(parentSource, fallback) {
38
+ if (isInventorySource(parentSource)) {
39
+ return 'inventory-discovered';
40
+ }
41
+ return fallback;
42
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Reads the top-level directory name from a `.nitpicker` tar archive.
3
+ *
4
+ * Why: a `.nitpicker` is a tar that wraps `<basename>/db.sqlite` (and
5
+ * historically other files). `Archive.write()` names the inner directory
6
+ * from the file's basename at write time, but users routinely rename
7
+ * `.nitpicker` files after the fact (e.g. `mv X.migrated.nitpicker
8
+ * X.nitpicker`) — a perfectly reasonable filesystem operation that breaks
9
+ * any code that recomputes the inner-dir name from the outer filename.
10
+ *
11
+ * This helper scans the tar's entry list and returns the first top-level
12
+ * directory it sees, so callers (`Archive.open`, the migration script) can
13
+ * use the actual inner name regardless of what the outer file is called.
14
+ *
15
+ * Reads the tar's central directory only; does not extract any data. Runs
16
+ * in O(number of entries) but stops at the first match, so a typical
17
+ * archive (1–2 top-level entries) completes in a single read.
18
+ * @param tarFilePath - Path to the `.nitpicker` tar file.
19
+ * @returns The first top-level directory name found in the archive.
20
+ * @throws {Error} When the tar contains no top-level directory entry
21
+ * (e.g. file is empty, corrupted, or non-tar). The error message
22
+ * identifies the path for diagnostics.
23
+ * @example
24
+ * // The user renamed `original.nitpicker` to `renamed.nitpicker`. The
25
+ * // inner directory was baked in at write time and is unaffected:
26
+ * await peekTarTopDir('renamed.nitpicker'); // → 'original'
27
+ */
28
+ export declare function peekTarTopDir(tarFilePath: string): Promise<string>;
@@ -0,0 +1,65 @@
1
+ import { list } from 'tar';
2
+ /**
3
+ * Reads the top-level directory name from a `.nitpicker` tar archive.
4
+ *
5
+ * Why: a `.nitpicker` is a tar that wraps `<basename>/db.sqlite` (and
6
+ * historically other files). `Archive.write()` names the inner directory
7
+ * from the file's basename at write time, but users routinely rename
8
+ * `.nitpicker` files after the fact (e.g. `mv X.migrated.nitpicker
9
+ * X.nitpicker`) — a perfectly reasonable filesystem operation that breaks
10
+ * any code that recomputes the inner-dir name from the outer filename.
11
+ *
12
+ * This helper scans the tar's entry list and returns the first top-level
13
+ * directory it sees, so callers (`Archive.open`, the migration script) can
14
+ * use the actual inner name regardless of what the outer file is called.
15
+ *
16
+ * Reads the tar's central directory only; does not extract any data. Runs
17
+ * in O(number of entries) but stops at the first match, so a typical
18
+ * archive (1–2 top-level entries) completes in a single read.
19
+ * @param tarFilePath - Path to the `.nitpicker` tar file.
20
+ * @returns The first top-level directory name found in the archive.
21
+ * @throws {Error} When the tar contains no top-level directory entry
22
+ * (e.g. file is empty, corrupted, or non-tar). The error message
23
+ * identifies the path for diagnostics.
24
+ * @example
25
+ * // The user renamed `original.nitpicker` to `renamed.nitpicker`. The
26
+ * // inner directory was baked in at write time and is unaffected:
27
+ * await peekTarTopDir('renamed.nitpicker'); // → 'original'
28
+ */
29
+ export async function peekTarTopDir(tarFilePath) {
30
+ let found = null;
31
+ await list({
32
+ file: tarFilePath,
33
+ onReadEntry: (entry) => {
34
+ if (found !== null)
35
+ return;
36
+ // Only Directory entries count. Files at the tar root would
37
+ // otherwise win — and macOS BSD tar embeds AppleDouble (`._*`)
38
+ // sidecar files at the tar root for resource forks; those are
39
+ // File entries that BSD `tar -tf` hides but Node's `tar`
40
+ // surfaces verbatim, so without this filter the resource-fork
41
+ // of the real top-level dir would win instead of the dir itself.
42
+ if (entry.type !== 'Directory')
43
+ return;
44
+ // `entry.path` is the path inside the tar. Strip any leading
45
+ // `./` (some tar producers prepend it) before splitting.
46
+ const cleaned = entry.path.replace(/^\.\//, '');
47
+ const top = cleaned.split('/')[0];
48
+ if (!top || top === '')
49
+ return;
50
+ // Defense in depth: skip AppleDouble directory entries
51
+ // (`._foo/`) and pax extended header entries
52
+ // (`PaxHeaders.NNN/...` / `@PaxHeader`). Real `.nitpicker`
53
+ // archives never name their top dir with these prefixes.
54
+ if (top.startsWith('._'))
55
+ return;
56
+ if (top.startsWith('PaxHeaders') || top.startsWith('@PaxHeader'))
57
+ return;
58
+ found = top;
59
+ },
60
+ });
61
+ if (found === null) {
62
+ throw new Error(`Tar contains no top-level directory entry: ${tarFilePath}`);
63
+ }
64
+ return found;
65
+ }
@@ -0,0 +1,43 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Bulk-resolve a raw error message for each given page id, using only sources
4
+ * reachable from a {@link Knex} handle. Read order: `page_errors` (keyed by
5
+ * `pageId`, the most direct signal a scrape attempt recorded), then
6
+ * `crawl_errors` (keyed by `url`, the crawler-channel record for failures
7
+ * that happened before a page row was scraped).
8
+ *
9
+ * **Known limitation — pre-`crawl_errors` archives**: This helper does NOT
10
+ * read `error.log`. `migrateCrawlErrors` creates the `crawl_errors` table on
11
+ * writer connect but does NOT back-fill historical lines from `error.log`,
12
+ * so an archive that predates the `crawl_errors` schema and whose failures
13
+ * live only in `error.log` will resolve every id to "no message" here. The
14
+ * downstream `Database.resetFailedPages` treats absence as `unknown` (still
15
+ * retryable), so legacy archives lose the permanent-kind exclusion until a
16
+ * fresh crawl run populates `crawl_errors` / `page_errors`. The trade-off
17
+ * (no error.log parsing in the writer path) keeps the writer dependency
18
+ * surface narrow and avoids re-implementing the parser already living in
19
+ * `@nitpicker/query`'s `resolveFailedPageMessages` — which the crawler
20
+ * package cannot import (reverse-direction dependency). When this matters
21
+ * in practice, run the archive through one fresh `crawl --retry-failed`
22
+ * pass first to populate the structured tables.
23
+ *
24
+ * Pages with no message in any consulted source are simply absent from the
25
+ * returned map; callers treat the absence as "unclassifiable, keep retrying"
26
+ * (i.e. `unknown`).
27
+ * @param instance - The {@link Knex} handle.
28
+ * @param ids - Candidate `pages.id` values.
29
+ * @param urls - The corresponding `pages.url` values, in the same order as
30
+ * `ids`. Length and indexing MUST match `ids` so the page → url join can be
31
+ * reconstructed without a second `pages` round-trip.
32
+ * @returns `Map<pageId, message>` populated only for ids whose message was
33
+ * found in one of the consulted tables.
34
+ * @example
35
+ * ```ts
36
+ * const messages = await getFailedPageMessages(
37
+ * instance,
38
+ * candidates.map(c => c.id),
39
+ * candidates.map(c => c.url),
40
+ * );
41
+ * ```
42
+ */
43
+ export declare function getFailedPageMessages(instance: Knex, ids: readonly number[], urls: readonly string[]): Promise<Map<number, string>>;
@@ -0,0 +1,131 @@
1
+ /** chunk size for `WHERE … IN (?)` SQLite parameter packing. */
2
+ const CHUNK_SIZE = 500;
3
+ /**
4
+ * Bulk-resolve a raw error message for each given page id, using only sources
5
+ * reachable from a {@link Knex} handle. Read order: `page_errors` (keyed by
6
+ * `pageId`, the most direct signal a scrape attempt recorded), then
7
+ * `crawl_errors` (keyed by `url`, the crawler-channel record for failures
8
+ * that happened before a page row was scraped).
9
+ *
10
+ * **Known limitation — pre-`crawl_errors` archives**: This helper does NOT
11
+ * read `error.log`. `migrateCrawlErrors` creates the `crawl_errors` table on
12
+ * writer connect but does NOT back-fill historical lines from `error.log`,
13
+ * so an archive that predates the `crawl_errors` schema and whose failures
14
+ * live only in `error.log` will resolve every id to "no message" here. The
15
+ * downstream `Database.resetFailedPages` treats absence as `unknown` (still
16
+ * retryable), so legacy archives lose the permanent-kind exclusion until a
17
+ * fresh crawl run populates `crawl_errors` / `page_errors`. The trade-off
18
+ * (no error.log parsing in the writer path) keeps the writer dependency
19
+ * surface narrow and avoids re-implementing the parser already living in
20
+ * `@nitpicker/query`'s `resolveFailedPageMessages` — which the crawler
21
+ * package cannot import (reverse-direction dependency). When this matters
22
+ * in practice, run the archive through one fresh `crawl --retry-failed`
23
+ * pass first to populate the structured tables.
24
+ *
25
+ * Pages with no message in any consulted source are simply absent from the
26
+ * returned map; callers treat the absence as "unclassifiable, keep retrying"
27
+ * (i.e. `unknown`).
28
+ * @param instance - The {@link Knex} handle.
29
+ * @param ids - Candidate `pages.id` values.
30
+ * @param urls - The corresponding `pages.url` values, in the same order as
31
+ * `ids`. Length and indexing MUST match `ids` so the page → url join can be
32
+ * reconstructed without a second `pages` round-trip.
33
+ * @returns `Map<pageId, message>` populated only for ids whose message was
34
+ * found in one of the consulted tables.
35
+ * @example
36
+ * ```ts
37
+ * const messages = await getFailedPageMessages(
38
+ * instance,
39
+ * candidates.map(c => c.id),
40
+ * candidates.map(c => c.url),
41
+ * );
42
+ * ```
43
+ */
44
+ export async function getFailedPageMessages(instance, ids, urls) {
45
+ if (ids.length === 0) {
46
+ return new Map();
47
+ }
48
+ if (ids.length !== urls.length) {
49
+ throw new Error(`getFailedPageMessages: ids.length (${ids.length}) !== urls.length (${urls.length}) — must be 1:1`);
50
+ }
51
+ const messageByPageId = new Map();
52
+ if (await instance.schema.hasTable('page_errors')) {
53
+ for (let i = 0; i < ids.length; i += CHUNK_SIZE) {
54
+ const chunk = ids.slice(i, i + CHUNK_SIZE);
55
+ // `orderBy('id', 'asc')` makes the per-pageId "first row seen"
56
+ // behavior deterministic regardless of SQLite's natural ROWID
57
+ // scan order, which is otherwise implementation-defined under
58
+ // concurrent / migrated archives. Without the explicit order, a
59
+ // reset that classified a page as `unknown` once could classify
60
+ // it as `parse-error` on the next run when the rows happen to be
61
+ // returned in a different order.
62
+ const rows = (await instance('page_errors')
63
+ .select('pageId', 'message')
64
+ .whereIn('pageId', chunk)
65
+ .orderBy('id', 'asc'));
66
+ for (const row of rows) {
67
+ // Earliest-id wins. Schema permits multiple rows per pageId
68
+ // (the same scrape can record several phase errors); the
69
+ // first row inserted is usually the trigger cause, later
70
+ // rows are follow-on noise from the same failure cascade.
71
+ //
72
+ // An empty `message` is treated as "no signal" and ignored
73
+ // so the crawl_errors lookup can fill it in. Without this,
74
+ // a page_errors row with `message=''` (recorded by a
75
+ // scraper phase that fired its trigger but had no error
76
+ // text) would short-circuit and we'd lose access to the
77
+ // crawl_errors row that classifies the failure as
78
+ // `dns` / `tls` / `client-blocked` etc. — defeating
79
+ // `--retry-failed`'s permanent-kind exclusion.
80
+ if (row.message !== '' && !messageByPageId.has(row.pageId)) {
81
+ messageByPageId.set(row.pageId, row.message);
82
+ }
83
+ }
84
+ }
85
+ }
86
+ const idsMissing = ids.filter((id) => !messageByPageId.has(id));
87
+ if (idsMissing.length === 0 || !(await instance.schema.hasTable('crawl_errors'))) {
88
+ // Early-exit short-circuits BOTH the idToUrl Map construction and
89
+ // the crawl_errors round-trip. On a 1M-page archive where every
90
+ // failed page already has a `page_errors` row, this avoids walking
91
+ // the candidate list a second time.
92
+ return messageByPageId;
93
+ }
94
+ const idToUrl = new Map();
95
+ for (const [i, id] of ids.entries()) {
96
+ const url = urls[i];
97
+ if (url !== undefined) {
98
+ idToUrl.set(id, url);
99
+ }
100
+ }
101
+ const missingUrls = [];
102
+ for (const id of idsMissing) {
103
+ const url = idToUrl.get(id);
104
+ if (url !== undefined) {
105
+ missingUrls.push(url);
106
+ }
107
+ }
108
+ const urlToMessage = new Map();
109
+ for (let i = 0; i < missingUrls.length; i += CHUNK_SIZE) {
110
+ const chunk = missingUrls.slice(i, i + CHUNK_SIZE);
111
+ const rows = (await instance('crawl_errors')
112
+ .select('url', 'message')
113
+ .whereIn('url', chunk));
114
+ for (const row of rows) {
115
+ if (row.url !== null && !urlToMessage.has(row.url)) {
116
+ urlToMessage.set(row.url, row.message);
117
+ }
118
+ }
119
+ }
120
+ for (const id of idsMissing) {
121
+ const url = idToUrl.get(id);
122
+ if (url === undefined) {
123
+ continue;
124
+ }
125
+ const message = urlToMessage.get(url);
126
+ if (message !== undefined) {
127
+ messageByPageId.set(id, message);
128
+ }
129
+ }
130
+ return messageByPageId;
131
+ }
@@ -1,8 +1,59 @@
1
1
  import type { Knex } from 'knex';
2
+ /**
3
+ * Applies the connection-level PRAGMAs that govern foreign-key enforcement
4
+ * and BLOB-read performance. These are **per-connection** settings (libsql
5
+ * resets them when a new connection is opened), so they must be reapplied
6
+ * every time `Database.connect` runs — not just on first-time schema
7
+ * initialization. Keeping them separate from `initSchema`'s one-shot path
8
+ * also lets `page_size` (which only takes effect against an empty DB)
9
+ * stay gated behind the existence check.
10
+ * @param instance - The Knex query builder instance connected to the database.
11
+ */
12
+ export declare function applyConnectionPragmas(instance: Knex): Promise<void>;
2
13
  /**
3
14
  * Initializes the archive database schema if tables do not exist.
4
- * Enables WAL journal mode and foreign keys, then creates all tables
5
- * (`info`, `pages`, `anchors`, `images`, `resources`, `resources-referrers`).
15
+ *
16
+ * Schema notes:
17
+ *
18
+ * - **Meta columns (v2)**: pages carries ~47 flat columns derived from
19
+ * beholder 3.0.0's nested Meta shape (`canonical`, `og_*`, `twitter_*`,
20
+ * `robots_*`, document basics, editorial fields) plus a `meta_extras`
21
+ * JSON column for everything not flattened. URL-shaped columns are
22
+ * absolutised against the page URL before write (see
23
+ * `archive/meta/derive-flat-from-meta.ts`).
24
+ * - **Denormalised aggregates** (`tag_count`, `jsonld_count`,
25
+ * `tags_providers_csv`): computed at write time from `meta.tags` /
26
+ * `meta.jsonLd` to avoid N+1 GROUP BY at Sheets-render / page-detail time.
27
+ * Plan: "ファイルサイズが多少増えてもいいから取り出しパフォーマンスを優先".
28
+ * - **Per-page timestamps** (`firstCrawledAt`, `lastCrawledAt`): UNIX ms.
29
+ * Written by `#insertPage` on INSERT (`first = last = now`) and UPDATE
30
+ * (`last = now`, `first` preserved). `resetFailedPages` deliberately
31
+ * leaves them alone so failure-reset does not erase the last-success
32
+ * record.
33
+ * - **`page_tags`** (Wappalyzer): per-provider × external-id row shape, plus
34
+ * `categories`/`sources` JSON columns. Compound indexes
35
+ * `(provider, externalId)` / `(provider, pageId)` are pre-built for the
36
+ * Phase 2+ "find duplicate IDs across pages" and "list pages using
37
+ * provider X" hot paths — Phase 1 read perf > storage cost trade-off.
38
+ * - **`page_jsonld`** (JSON-LD / SpeculationRules): one row per
39
+ * `<script type="application/ld+json">` or `<script type="speculationrules">`.
40
+ * `raw` is stored uncompressed (SQLite overflow pages handle large rows);
41
+ * if cross-archive bulk export becomes a use case, add a `codec` column
42
+ * à la `page_html_blobs`. Compound `(type, pageId)` accelerates streaming
43
+ * `list_pages_by_jsonld_type` JOINs.
44
+ * - **HTML snapshots** (`page_html_blobs` + `page_html_ref`): unchanged
45
+ * from v1. zstd-compressed BLOBs keyed by SHA-256 for content-addressable
46
+ * dedup. WITHOUT ROWID via raw SQL because knex's schema builder cannot
47
+ * express it.
48
+ * - **PRAGMA `page_size` and `journal_mode`** are set BEFORE any
49
+ * `CREATE TABLE` because SQLite only honors `page_size` changes against
50
+ * an empty database, and `journal_mode = WAL` is persistent. Other
51
+ * per-connection PRAGMAs live in {@link applyConnectionPragmas}.
52
+ *
53
+ * Pre-0.10 → 0.10 migration is intentionally absent. `assertCompatibleVersion`
54
+ * (called before `initSchema`) rejects pre-0.10 archives with a friendly
55
+ * error pointing the operator at `scripts/migrate-to-0.10.mjs`; `v0.x`
56
+ * policy allows breaking changes.
6
57
  * @param instance - The Knex query builder instance connected to the database.
7
58
  */
8
59
  export declare function initSchema(instance: Knex): Promise<void>;