@nitpicker/crawler 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/lib/archive/archive-accessor.d.ts +87 -14
  2. package/lib/archive/archive-accessor.js +162 -36
  3. package/lib/archive/archive.d.ts +147 -24
  4. package/lib/archive/archive.js +252 -86
  5. package/lib/archive/database.d.ts +209 -25
  6. package/lib/archive/database.js +928 -108
  7. package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
  8. package/lib/archive/filesystem/peek-tar-top-dir.js +65 -0
  9. package/lib/archive/init-schema.d.ts +53 -2
  10. package/lib/archive/init-schema.js +247 -15
  11. package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
  12. package/lib/archive/meta/assert-compatible-version.js +72 -0
  13. package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
  14. package/lib/archive/meta/classify-jsonld-type.js +43 -0
  15. package/lib/archive/meta/compare-semver.d.ts +23 -0
  16. package/lib/archive/meta/compare-semver.js +51 -0
  17. package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
  18. package/lib/archive/meta/compute-page-denormalized.js +35 -0
  19. package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
  20. package/lib/archive/meta/derive-flat-from-meta.js +158 -0
  21. package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
  22. package/lib/archive/meta/derive-meta-extras.js +23 -0
  23. package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
  24. package/lib/archive/meta/extract-tags-for-archive.js +36 -0
  25. package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
  26. package/lib/archive/meta/summarize-jsonld.js +29 -0
  27. package/lib/archive/meta/summarize-tags.d.ts +16 -0
  28. package/lib/archive/meta/summarize-tags.js +33 -0
  29. package/lib/archive/meta/types.d.ts +207 -0
  30. package/lib/archive/meta/types.js +33 -0
  31. package/lib/archive/migrate-crawl-errors.d.ts +20 -0
  32. package/lib/archive/migrate-crawl-errors.js +38 -0
  33. package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
  34. package/lib/archive/migrate-html-blob-tables.js +53 -0
  35. package/lib/archive/migrate-page-errors.d.ts +16 -0
  36. package/lib/archive/migrate-page-errors.js +35 -0
  37. package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
  38. package/lib/archive/migrate-pages-resources-source.js +46 -0
  39. package/lib/archive/page.d.ts +187 -49
  40. package/lib/archive/page.js +258 -63
  41. package/lib/archive/peek-archive-lock.d.ts +40 -0
  42. package/lib/archive/peek-archive-lock.js +62 -0
  43. package/lib/archive/resolve-redirect-chain.d.ts +33 -0
  44. package/lib/archive/resolve-redirect-chain.js +27 -0
  45. package/lib/archive/types.d.ts +135 -26
  46. package/lib/crawler/close-browser-safely.d.ts +64 -0
  47. package/lib/crawler/close-browser-safely.js +73 -0
  48. package/lib/crawler/crawler.d.ts +4 -1
  49. package/lib/crawler/crawler.js +290 -32
  50. package/lib/crawler/create-change-phase-handler.d.ts +54 -0
  51. package/lib/crawler/create-change-phase-handler.js +44 -0
  52. package/lib/crawler/derive-page-source.d.ts +23 -0
  53. package/lib/crawler/derive-page-source.js +28 -0
  54. package/lib/crawler/derive-resource-source.d.ts +23 -0
  55. package/lib/crawler/derive-resource-source.js +26 -0
  56. package/lib/crawler/drain-phase-errors.d.ts +48 -0
  57. package/lib/crawler/drain-phase-errors.js +35 -0
  58. package/lib/crawler/fetch-destination.js +38 -2
  59. package/lib/crawler/format-crawl-progress.d.ts +12 -3
  60. package/lib/crawler/format-crawl-progress.js +14 -6
  61. package/lib/crawler/handle-browser-close.d.ts +29 -0
  62. package/lib/crawler/handle-browser-close.js +28 -0
  63. package/lib/crawler/is-html-content-type.d.ts +17 -0
  64. package/lib/crawler/is-html-content-type.js +19 -0
  65. package/lib/crawler/is-likely-html-url.d.ts +22 -0
  66. package/lib/crawler/is-likely-html-url.js +65 -0
  67. package/lib/crawler/kill-process-tree.d.ts +94 -0
  68. package/lib/crawler/kill-process-tree.js +178 -0
  69. package/lib/crawler/link-list.js +2 -1
  70. package/lib/crawler/link-to-page-data.d.ts +13 -5
  71. package/lib/crawler/link-to-page-data.js +26 -5
  72. package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
  73. package/lib/crawler/log-undrained-phase-errors.js +34 -0
  74. package/lib/crawler/normalize-content-type.d.ts +14 -0
  75. package/lib/crawler/normalize-content-type.js +20 -0
  76. package/lib/crawler/partition-urls-by-html.d.ts +16 -0
  77. package/lib/crawler/partition-urls-by-html.js +23 -0
  78. package/lib/crawler/redirect-dest-key.d.ts +19 -0
  79. package/lib/crawler/redirect-dest-key.js +27 -0
  80. package/lib/crawler/resource-to-page-data.d.ts +28 -0
  81. package/lib/crawler/resource-to-page-data.js +59 -0
  82. package/lib/crawler/types.d.ts +122 -1
  83. package/lib/crawler-orchestrator.d.ts +93 -1
  84. package/lib/crawler-orchestrator.js +389 -12
  85. package/lib/crawler.d.ts +5 -0
  86. package/lib/crawler.js +3 -0
  87. package/lib/resource-row-to-lookup-result.d.ts +13 -0
  88. package/lib/resource-row-to-lookup-result.js +20 -0
  89. package/lib/types.d.ts +11 -1
  90. package/lib/utils/object/parse-response-headers.d.ts +12 -0
  91. package/lib/utils/object/parse-response-headers.js +26 -0
  92. package/package.json +4 -4
@@ -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
+ }
@@ -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>;
@@ -1,7 +1,69 @@
1
+ /**
2
+ * Applies the connection-level PRAGMAs that govern foreign-key enforcement
3
+ * and BLOB-read performance. These are **per-connection** settings (libsql
4
+ * resets them when a new connection is opened), so they must be reapplied
5
+ * every time `Database.connect` runs — not just on first-time schema
6
+ * initialization. Keeping them separate from `initSchema`'s one-shot path
7
+ * also lets `page_size` (which only takes effect against an empty DB)
8
+ * stay gated behind the existence check.
9
+ * @param instance - The Knex query builder instance connected to the database.
10
+ */
11
+ export async function applyConnectionPragmas(instance) {
12
+ // Foreign-key enforcement defaults to OFF on every new SQLite
13
+ // connection. Required for ON DELETE CASCADE on `page_html_ref`,
14
+ // `page_tags`, and `page_jsonld` to fire.
15
+ await instance.raw('PRAGMA foreign_keys = ON');
16
+ await instance.raw('PRAGMA wal_autocheckpoint = 1000');
17
+ // Negative value = KiB of memory (64 MiB). Helps large BLOB scans.
18
+ await instance.raw('PRAGMA cache_size = -65536');
19
+ // 256 MiB mmap window. SQLite falls back to read() past this so the
20
+ // limit is a soft ceiling, not a hard one.
21
+ await instance.raw('PRAGMA mmap_size = 268435456');
22
+ }
1
23
  /**
2
24
  * Initializes the archive database schema if tables do not exist.
3
- * Enables WAL journal mode and foreign keys, then creates all tables
4
- * (`info`, `pages`, `anchors`, `images`, `resources`, `resources-referrers`).
25
+ *
26
+ * Schema notes:
27
+ *
28
+ * - **Meta columns (v2)**: pages carries ~47 flat columns derived from
29
+ * beholder 3.0.0's nested Meta shape (`canonical`, `og_*`, `twitter_*`,
30
+ * `robots_*`, document basics, editorial fields) plus a `meta_extras`
31
+ * JSON column for everything not flattened. URL-shaped columns are
32
+ * absolutised against the page URL before write (see
33
+ * `archive/meta/derive-flat-from-meta.ts`).
34
+ * - **Denormalised aggregates** (`tag_count`, `jsonld_count`,
35
+ * `tags_providers_csv`): computed at write time from `meta.tags` /
36
+ * `meta.jsonLd` to avoid N+1 GROUP BY at Sheets-render / page-detail time.
37
+ * Plan: "ファイルサイズが多少増えてもいいから取り出しパフォーマンスを優先".
38
+ * - **Per-page timestamps** (`firstCrawledAt`, `lastCrawledAt`): UNIX ms.
39
+ * Written by `#insertPage` on INSERT (`first = last = now`) and UPDATE
40
+ * (`last = now`, `first` preserved). `resetFailedPages` deliberately
41
+ * leaves them alone so failure-reset does not erase the last-success
42
+ * record.
43
+ * - **`page_tags`** (Wappalyzer): per-provider × external-id row shape, plus
44
+ * `categories`/`sources` JSON columns. Compound indexes
45
+ * `(provider, externalId)` / `(provider, pageId)` are pre-built for the
46
+ * Phase 2+ "find duplicate IDs across pages" and "list pages using
47
+ * provider X" hot paths — Phase 1 read perf > storage cost trade-off.
48
+ * - **`page_jsonld`** (JSON-LD / SpeculationRules): one row per
49
+ * `<script type="application/ld+json">` or `<script type="speculationrules">`.
50
+ * `raw` is stored uncompressed (SQLite overflow pages handle large rows);
51
+ * if cross-archive bulk export becomes a use case, add a `codec` column
52
+ * à la `page_html_blobs`. Compound `(type, pageId)` accelerates streaming
53
+ * `list_pages_by_jsonld_type` JOINs.
54
+ * - **HTML snapshots** (`page_html_blobs` + `page_html_ref`): unchanged
55
+ * from v1. zstd-compressed BLOBs keyed by SHA-256 for content-addressable
56
+ * dedup. WITHOUT ROWID via raw SQL because knex's schema builder cannot
57
+ * express it.
58
+ * - **PRAGMA `page_size` and `journal_mode`** are set BEFORE any
59
+ * `CREATE TABLE` because SQLite only honors `page_size` changes against
60
+ * an empty database, and `journal_mode = WAL` is persistent. Other
61
+ * per-connection PRAGMAs live in {@link applyConnectionPragmas}.
62
+ *
63
+ * Pre-0.10 → 0.10 migration is intentionally absent. `assertCompatibleVersion`
64
+ * (called before `initSchema`) rejects pre-0.10 archives with a friendly
65
+ * error pointing the operator at `scripts/migrate-to-0.10.mjs`; `v0.x`
66
+ * policy allows breaking changes.
5
67
  * @param instance - The Knex query builder instance connected to the database.
6
68
  */
7
69
  export async function initSchema(instance) {
@@ -9,9 +71,11 @@ export async function initSchema(instance) {
9
71
  if (isExists) {
10
72
  return;
11
73
  }
12
- // Enable WAL mode and foreign keys for better performance and data integrity
74
+ // Page size must be set on an empty database file; once any data is
75
+ // written, only VACUUM can change it. journal_mode is also one-shot
76
+ // (persistent) and so stays here.
77
+ await instance.raw('PRAGMA page_size = 16384');
13
78
  await instance.raw('PRAGMA journal_mode = WAL');
14
- await instance.raw('PRAGMA foreign_keys = ON');
15
79
  await instance.schema
16
80
  .createTable('info', (t) => {
17
81
  t.increments('id');
@@ -46,31 +110,92 @@ export async function initSchema(instance) {
46
110
  t.string('contentType').nullable();
47
111
  t.integer('contentLength').unsigned().nullable();
48
112
  t.json('responseHeaders').nullable();
113
+ // Document basics
49
114
  t.string('lang');
115
+ t.string('dir');
116
+ t.string('charset');
117
+ t.string('baseHref');
118
+ t.text('viewport_raw');
119
+ t.string('themeColor');
120
+ t.string('applicationName');
121
+ t.string('author');
122
+ t.string('generator');
123
+ t.string('publisher');
124
+ // Title / description / keywords (top-level Meta fields)
50
125
  t.string('title');
51
- t.string('description');
52
- t.string('keywords');
53
- t.boolean('noindex');
54
- t.boolean('nofollow');
55
- t.boolean('noarchive');
56
- t.string('canonical');
57
- t.string('alternate');
126
+ t.text('description');
127
+ t.text('keywords');
128
+ // Robots
129
+ t.text('robots_raw');
130
+ t.integer('robots_noindex');
131
+ t.integer('robots_nofollow');
132
+ t.integer('robots_noarchive');
133
+ t.integer('robots_noimageindex');
134
+ t.string('googlebot');
135
+ // Link (1:1 only — array shapes live in meta_extras)
136
+ t.string('canonical', 8190);
137
+ t.string('amphtml', 8190);
138
+ t.string('manifest', 8190);
139
+ t.string('icon_href', 8190);
140
+ t.string('appleTouchIcon_href', 8190);
141
+ // Open Graph
58
142
  t.string('og_type');
59
143
  t.string('og_title');
144
+ t.string('og_url', 8190);
60
145
  t.string('og_site_name');
61
- t.string('og_description');
62
- t.string('og_url');
63
- t.string('og_image');
146
+ t.text('og_description');
147
+ t.string('og_image', 8190);
148
+ t.string('og_image_alt');
149
+ t.string('og_image_width');
150
+ t.string('og_image_height');
151
+ t.string('og_locale');
152
+ t.string('og_article_published_time');
153
+ t.string('og_article_modified_time');
154
+ // Twitter
64
155
  t.string('twitter_card');
65
- t.string('html');
156
+ t.string('twitter_site');
157
+ t.string('twitter_creator');
158
+ t.string('twitter_title');
159
+ t.text('twitter_description');
160
+ t.string('twitter_image', 8190);
161
+ // One-offs
162
+ t.string('fb_app_id');
163
+ t.string('verification_google');
164
+ t.integer('formatDetection_telephone');
165
+ // Within-archive observation timestamps (UNIX ms)
166
+ t.integer('firstCrawledAt');
167
+ t.integer('lastCrawledAt');
168
+ // Denormalised aggregates (written at scrape time, see
169
+ // archive/meta/compute-page-denormalized.ts)
170
+ t.integer('tag_count');
171
+ t.integer('jsonld_count');
172
+ t.text('tags_providers_csv');
173
+ // Catch-all JSON for nested Meta sub-objects not flattened above
174
+ t.json('meta_extras');
175
+ // Crawl lifecycle
66
176
  t.boolean('isSkipped');
67
177
  t.string('skipReason');
68
178
  t.integer('order').unsigned().nullable();
179
+ // Provenance: which channel inserted this row. Values:
180
+ // 'crawled' — discovered via the recursive crawl from one of `info.roots`
181
+ // 'inventory-seed' — supplied directly by `crawl --inventory` URL list
182
+ // 'inventory-discovered' — found by following links from an `inventory-seed` page
183
+ // Used by `listIsolatedPages` only for badge display; isolation
184
+ // itself is judged by `anchors.hrefId IS NULL`, not by source.
185
+ t.string('source').notNullable().defaultTo('crawled');
69
186
  t.index('isExternal');
70
187
  t.index('contentType');
71
188
  t.index('scraped');
72
189
  t.index('redirectDestId');
73
190
  t.index('order');
191
+ // Phase 1: noindex filter (list_pages) and og:type filter
192
+ // (analytics) are the only new flat-column filters with enough
193
+ // selectivity to benefit from an index. `lang` has cardinality 1
194
+ // on mono-language sites (D-Zero's typical customer) so it is
195
+ // skipped.
196
+ t.index('robots_noindex');
197
+ t.index('og_type');
198
+ t.index('source');
74
199
  })
75
200
  .createTable('anchors', (t) => {
76
201
  t.increments('id');
@@ -107,6 +232,12 @@ export async function initSchema(instance) {
107
232
  t.string('compress').nullable();
108
233
  t.string('cdn').nullable();
109
234
  t.json('responseHeaders').nullable();
235
+ // See `pages.source` for the provenance taxonomy. `inventory-seed`
236
+ // rows here come from non-HTML URLs handed in by
237
+ // `crawl --inventory`; `inventory-discovered` rows are sub-resources
238
+ // pulled in while puppeteer rendered an inventory-seed page.
239
+ t.string('source').notNullable().defaultTo('crawled');
240
+ t.index('source');
110
241
  })
111
242
  .createTable('resources-referrers', (t) => {
112
243
  t.increments('id');
@@ -115,5 +246,106 @@ export async function initSchema(instance) {
115
246
  t.unique(['resourceId', 'pageId']);
116
247
  t.index('resourceId');
117
248
  t.index('pageId');
249
+ })
250
+ .createTable('page_errors', (t) => {
251
+ // Records partial scrape failures (e.g. a viewport switch that
252
+ // detaches the frame and trips beholder's @retryable into the
253
+ // `retryExhausted` phase). A page can have zero or more rows here
254
+ // in addition to its normal `pages` entry — the page itself is
255
+ // considered successfully scraped, but image capture or another
256
+ // secondary step failed for at least one device preset.
257
+ t.increments('id');
258
+ t.integer('pageId').notNullable().unsigned().references('pages.id');
259
+ t.string('phase').notNullable();
260
+ t.text('message').notNullable();
261
+ t.integer('createdAt').notNullable();
262
+ t.index('pageId');
263
+ })
264
+ .createTable('crawl_errors', (t) => {
265
+ // Structured form of the crawler-level `error` channel that otherwise
266
+ // only lands in `error.log`. Unlike `page_errors` these are not tied to
267
+ // a scraped page (the URL may be an external link that failed DNS, or
268
+ // null for a process-level error), so there is no `pageId` FK and `url`
269
+ // is nullable. The cause is NOT stored — it is classified on read from
270
+ // `message` so older archives (which only have `error.log`) classify the
271
+ // same way.
272
+ t.increments('id');
273
+ t.string('url', 8190).nullable();
274
+ t.boolean('isExternal');
275
+ t.text('message').notNullable();
276
+ t.integer('createdAt').notNullable();
277
+ })
278
+ .createTable('page_tags', (t) => {
279
+ // Wappalyzer-derived technology detection. One row per
280
+ // (provider × externalId) tuple per page. `category` is the first
281
+ // element of `categories`; the full list lives in the JSON
282
+ // `categories` column. `sources` records where the provider was
283
+ // detected (script-src / inline / iframe-src / window-global / …).
284
+ t.increments('id');
285
+ t.integer('pageId')
286
+ .notNullable()
287
+ .unsigned()
288
+ .references('pages.id')
289
+ .onDelete('CASCADE');
290
+ t.string('provider').notNullable();
291
+ t.string('category');
292
+ t.string('externalId');
293
+ t.string('version');
294
+ t.integer('confidence');
295
+ t.json('categories');
296
+ t.json('sources');
297
+ t.index('pageId');
298
+ t.index('provider');
299
+ t.index('externalId');
300
+ })
301
+ .createTable('page_jsonld', (t) => {
302
+ // JSON-LD and SpeculationRules entries captured from
303
+ // `<script type="application/ld+json">` and
304
+ // `<script type="speculationrules">`. `kind` discriminates; `type`
305
+ // is the top-level `@type` extracted by classify-jsonld-type for
306
+ // indexable filtering. `raw` is stored uncompressed; SQLite
307
+ // overflow pages handle multi-KB JSON bodies transparently.
308
+ t.increments('id');
309
+ t.integer('pageId')
310
+ .notNullable()
311
+ .unsigned()
312
+ .references('pages.id')
313
+ .onDelete('CASCADE');
314
+ t.string('kind').notNullable();
315
+ t.string('type');
316
+ t.text('raw').notNullable();
317
+ t.json('parsed');
318
+ t.text('parseError');
319
+ t.index('pageId');
320
+ t.index('type');
118
321
  });
322
+ // ON DELETE CASCADE and compound indexes for the new tables. Knex's
323
+ // schema builder can't express CASCADE / compound indexes inline in a
324
+ // way that round-trips through libsql consistently, so we use raw SQL
325
+ // to mirror the `page_html_ref` pattern.
326
+ await instance.raw('CREATE INDEX page_tags_provider_extId ON page_tags(provider, externalId)');
327
+ await instance.raw('CREATE INDEX page_tags_provider_pageId ON page_tags(provider, pageId)');
328
+ await instance.raw('CREATE INDEX page_jsonld_type_pageId ON page_jsonld(type, pageId)');
329
+ // Content-addressable HTML blob storage. Knex's schema builder doesn't
330
+ // expose a WITHOUT ROWID toggle, so the BLOB tables are created via raw
331
+ // SQL. WITHOUT ROWID keeps the rows packed inside the b-tree leaves
332
+ // (no hidden rowid + secondary index pair), which matters for the blob
333
+ // table where a 32-byte hash PK + multi-KB body is the dominant row
334
+ // shape.
335
+ await instance.raw(`
336
+ CREATE TABLE page_html_blobs (
337
+ hash BLOB PRIMARY KEY,
338
+ body BLOB NOT NULL,
339
+ codec TEXT NOT NULL CHECK(codec IN ('zstd', 'none')),
340
+ size_raw INTEGER NOT NULL,
341
+ size_stored INTEGER NOT NULL
342
+ ) WITHOUT ROWID
343
+ `);
344
+ await instance.raw(`
345
+ CREATE TABLE page_html_ref (
346
+ page_id INTEGER PRIMARY KEY REFERENCES pages(id) ON DELETE CASCADE,
347
+ hash BLOB NOT NULL REFERENCES page_html_blobs(hash)
348
+ ) WITHOUT ROWID
349
+ `);
350
+ await instance.raw('CREATE INDEX idx_page_html_ref_hash ON page_html_ref(hash)');
119
351
  }
@@ -0,0 +1,39 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Minimum `info.version` this build accepts. Archives older than this must
4
+ * be upgraded with `scripts/migrate-to-0.10.mjs` before they can be opened.
5
+ *
6
+ * History:
7
+ *
8
+ * - **pre-0.10**: HTML snapshots in `snapshot-html.zip` (#75), then
9
+ * relocated to `page_html_blobs` (#84); pages table has flat `noindex`,
10
+ * `og:type`-style columns derived from beholder 2.x's flat `Meta`.
11
+ * - **0.10.0**: this build. `page_html_blobs` BLOB storage (#75/#84) +
12
+ * nested-`Meta`-derived flat columns, `meta_extras` JSON, `page_tags` /
13
+ * `page_jsonld` tables, denormalised aggregates (#85).
14
+ */
15
+ export declare const REQUIRED_FORMAT_VERSION = "0.10.0";
16
+ /**
17
+ * Verifies that the archive's on-disk format is compatible with this build.
18
+ *
19
+ * Compares the archive's `info.version` (a semver string written by
20
+ * `setConfig` at archive-create time, or by the migration script) against
21
+ * {@link REQUIRED_FORMAT_VERSION}. Older archives throw
22
+ * {@link IncompatibleArchiveError} pointing the operator at the migration
23
+ * script.
24
+ *
25
+ * Called from `Database.#init` for both writer and read-only (stub viewer)
26
+ * connections so old `._nitpicker-*` stubs surface the error too. New
27
+ * archives where the `info` table does not yet exist are tolerated —
28
+ * `initSchema` will fill them in next.
29
+ *
30
+ * The check is intentionally version-string-only, not schema-shape-based:
31
+ * `info.version` is the single declared source of truth, and a v0.10
32
+ * `migrate-to-0.10.mjs` run bumps it explicitly so the assertion passes
33
+ * once migration completes.
34
+ * @param instance - The Knex query builder for the archive's libsql connection.
35
+ * @throws {IncompatibleArchiveError} when `info.version` is older than
36
+ * {@link REQUIRED_FORMAT_VERSION}, or missing entirely on a non-empty
37
+ * archive.
38
+ */
39
+ export declare function assertCompatibleVersion(instance: Knex): Promise<void>;
@@ -0,0 +1,72 @@
1
+ import { compareSemver } from './compare-semver.js';
2
+ import { IncompatibleArchiveError } from './types.js';
3
+ /**
4
+ * Minimum `info.version` this build accepts. Archives older than this must
5
+ * be upgraded with `scripts/migrate-to-0.10.mjs` before they can be opened.
6
+ *
7
+ * History:
8
+ *
9
+ * - **pre-0.10**: HTML snapshots in `snapshot-html.zip` (#75), then
10
+ * relocated to `page_html_blobs` (#84); pages table has flat `noindex`,
11
+ * `og:type`-style columns derived from beholder 2.x's flat `Meta`.
12
+ * - **0.10.0**: this build. `page_html_blobs` BLOB storage (#75/#84) +
13
+ * nested-`Meta`-derived flat columns, `meta_extras` JSON, `page_tags` /
14
+ * `page_jsonld` tables, denormalised aggregates (#85).
15
+ */
16
+ export const REQUIRED_FORMAT_VERSION = '0.10.0';
17
+ /**
18
+ * Verifies that the archive's on-disk format is compatible with this build.
19
+ *
20
+ * Compares the archive's `info.version` (a semver string written by
21
+ * `setConfig` at archive-create time, or by the migration script) against
22
+ * {@link REQUIRED_FORMAT_VERSION}. Older archives throw
23
+ * {@link IncompatibleArchiveError} pointing the operator at the migration
24
+ * script.
25
+ *
26
+ * Called from `Database.#init` for both writer and read-only (stub viewer)
27
+ * connections so old `._nitpicker-*` stubs surface the error too. New
28
+ * archives where the `info` table does not yet exist are tolerated —
29
+ * `initSchema` will fill them in next.
30
+ *
31
+ * The check is intentionally version-string-only, not schema-shape-based:
32
+ * `info.version` is the single declared source of truth, and a v0.10
33
+ * `migrate-to-0.10.mjs` run bumps it explicitly so the assertion passes
34
+ * once migration completes.
35
+ * @param instance - The Knex query builder for the archive's libsql connection.
36
+ * @throws {IncompatibleArchiveError} when `info.version` is older than
37
+ * {@link REQUIRED_FORMAT_VERSION}, or missing entirely on a non-empty
38
+ * archive.
39
+ */
40
+ export async function assertCompatibleVersion(instance) {
41
+ const hasInfo = await instance.schema.hasTable('info');
42
+ if (!hasInfo) {
43
+ // Brand-new archive — `initSchema` will create `info` and fill in
44
+ // the version next.
45
+ return;
46
+ }
47
+ const hasVersionColumn = await instance.schema.hasColumn('info', 'version');
48
+ if (!hasVersionColumn) {
49
+ // Pre-version-tracked archive (very old). The column did not exist
50
+ // before the version was added to the info schema; reject with
51
+ // `'unknown'` so the operator runs the migration script.
52
+ throw new IncompatibleArchiveError('unknown', REQUIRED_FORMAT_VERSION);
53
+ }
54
+ const row = await instance
55
+ .from('info')
56
+ .select('version')
57
+ .first();
58
+ if (row === undefined) {
59
+ // `Archive.create()` calls `Database.connect` (which runs
60
+ // `initSchema` to create the info table) BEFORE `setConfig` writes
61
+ // the initial row. The transient empty-info state is a normal step
62
+ // of archive creation, not a corrupted pre-0.10 archive.
63
+ return;
64
+ }
65
+ const archiveVersion = row.version ?? null;
66
+ if (archiveVersion === null || archiveVersion === '') {
67
+ throw new IncompatibleArchiveError('unknown', REQUIRED_FORMAT_VERSION);
68
+ }
69
+ if (compareSemver(archiveVersion, REQUIRED_FORMAT_VERSION) < 0) {
70
+ throw new IncompatibleArchiveError(archiveVersion, REQUIRED_FORMAT_VERSION);
71
+ }
72
+ }
@@ -0,0 +1,23 @@
1
+ import type { JsonLdEntry } from '@d-zero/beholder';
2
+ /**
3
+ * Extracts the top-level `@type` from a parsed {@link JsonLdEntry}, normalised
4
+ * to a single string suitable for an indexed SQL column.
5
+ *
6
+ * JSON-LD `@type` has four shapes in the wild:
7
+ *
8
+ * - **String** — common single-type case (e.g. `"Product"`).
9
+ * - **Array** — multi-type entries (e.g. `["Product", "Offer"]`). The first
10
+ * element is taken; the rest are recoverable from `parsed`.
11
+ * - **Missing** — top-level object without an `@type` (often a `@graph`
12
+ * wrapper). Returns `null` so the column is filterable but does not over-
13
+ * fit a single type.
14
+ * - **Parse error** — `parsed === undefined` because beholder set
15
+ * `parseError`. Returns `null`.
16
+ *
17
+ * `@graph` entries deliberately return `null` rather than `'@graph'` — a
18
+ * graph is a container, not a type, and consumers grouping by `@type` would
19
+ * see noise.
20
+ * @param entry - One JSON-LD entry as captured by beholder.
21
+ * @returns The normalised `@type` string, or `null` when not extractable.
22
+ */
23
+ export declare function classifyJsonLdType(entry: JsonLdEntry): string | null;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Extracts the top-level `@type` from a parsed {@link JsonLdEntry}, normalised
3
+ * to a single string suitable for an indexed SQL column.
4
+ *
5
+ * JSON-LD `@type` has four shapes in the wild:
6
+ *
7
+ * - **String** — common single-type case (e.g. `"Product"`).
8
+ * - **Array** — multi-type entries (e.g. `["Product", "Offer"]`). The first
9
+ * element is taken; the rest are recoverable from `parsed`.
10
+ * - **Missing** — top-level object without an `@type` (often a `@graph`
11
+ * wrapper). Returns `null` so the column is filterable but does not over-
12
+ * fit a single type.
13
+ * - **Parse error** — `parsed === undefined` because beholder set
14
+ * `parseError`. Returns `null`.
15
+ *
16
+ * `@graph` entries deliberately return `null` rather than `'@graph'` — a
17
+ * graph is a container, not a type, and consumers grouping by `@type` would
18
+ * see noise.
19
+ * @param entry - One JSON-LD entry as captured by beholder.
20
+ * @returns The normalised `@type` string, or `null` when not extractable.
21
+ */
22
+ export function classifyJsonLdType(entry) {
23
+ if (entry.parsed === undefined || entry.parsed === null)
24
+ return null;
25
+ if (typeof entry.parsed !== 'object')
26
+ return null;
27
+ const obj = entry.parsed;
28
+ const rawType = obj['@type'];
29
+ if (typeof rawType === 'string') {
30
+ const trimmed = rawType.trim();
31
+ return trimmed === '' ? null : trimmed;
32
+ }
33
+ if (Array.isArray(rawType)) {
34
+ for (const t of rawType) {
35
+ if (typeof t === 'string') {
36
+ const trimmed = t.trim();
37
+ if (trimmed !== '')
38
+ return trimmed;
39
+ }
40
+ }
41
+ }
42
+ return null;
43
+ }