@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.
- package/lib/archive/archive.d.ts +117 -2
- package/lib/archive/archive.js +147 -2
- package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
- package/lib/archive/cache/compute-archive-cache-key.js +95 -0
- package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
- package/lib/archive/cache/extract-archive-to-cache.js +309 -0
- package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
- package/lib/archive/cache/get-archive-cache-root.js +53 -0
- package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
- package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
- package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
- package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
- package/lib/archive/database.d.ts +216 -15
- package/lib/archive/database.js +1459 -938
- package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
- package/lib/archive/derive-lineage-from-parent.js +42 -0
- package/lib/archive/get-failed-page-messages.d.ts +43 -0
- package/lib/archive/get-failed-page-messages.js +131 -0
- package/lib/archive/init-schema.js +153 -1
- package/lib/archive/is-inventory-source.d.ts +21 -0
- package/lib/archive/is-inventory-source.js +22 -0
- package/lib/archive/migrate-inventory-runs.d.ts +29 -0
- package/lib/archive/migrate-inventory-runs.js +52 -0
- package/lib/archive/types.d.ts +33 -0
- package/lib/classify-error-kind.d.ts +19 -0
- package/lib/classify-error-kind.js +122 -0
- package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
- package/lib/crawler/build-js-redirect-edge.js +57 -0
- package/lib/crawler/build-redirect-event.d.ts +24 -0
- package/lib/crawler/build-redirect-event.js +28 -0
- package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
- package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
- package/lib/crawler/crawler.d.ts +3 -1
- package/lib/crawler/crawler.js +655 -107
- package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
- package/lib/crawler/derive-js-redirect-target.js +129 -0
- package/lib/crawler/derive-resource-source.d.ts +25 -15
- package/lib/crawler/derive-resource-source.js +28 -17
- package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
- package/lib/crawler/dns-burned-host-cache.js +25 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
- package/lib/crawler/fetch-destination.d.ts +12 -4
- package/lib/crawler/fetch-destination.js +94 -16
- package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
- package/lib/crawler/is-js-redirect-error-shape.js +53 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
- package/lib/crawler/link-list.d.ts +21 -1
- package/lib/crawler/link-list.js +23 -3
- package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
- package/lib/crawler/plan-sub-resource-emits.js +44 -0
- package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
- package/lib/crawler/preload-short-circuit-error.js +25 -0
- package/lib/crawler/should-burn-host.d.ts +78 -0
- package/lib/crawler/should-burn-host.js +61 -0
- package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
- package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
- package/lib/crawler/types.d.ts +107 -0
- package/lib/crawler-orchestrator.d.ts +13 -3
- package/lib/crawler-orchestrator.js +292 -69
- package/lib/crawler.d.ts +3 -2
- package/lib/crawler.js +3 -1
- package/lib/permanent-error-kinds.d.ts +43 -0
- package/lib/permanent-error-kinds.js +48 -0
- package/lib/types.d.ts +84 -0
- package/lib/utils/compute-file-sha256.d.ts +23 -0
- package/lib/utils/compute-file-sha256.js +55 -0
- package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
- package/lib/utils/error/emit-error-with-retry.js +44 -0
- package/lib/utils/error/emit-error.d.ts +39 -0
- package/lib/utils/error/emit-error.js +41 -0
- package/package.json +11 -11
- package/lib/utils/error/error-emitter.d.ts +0 -18
- 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,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
|
+
}
|
|
@@ -15,9 +15,20 @@ export async function applyConnectionPragmas(instance) {
|
|
|
15
15
|
await instance.raw('PRAGMA foreign_keys = ON');
|
|
16
16
|
await instance.raw('PRAGMA wal_autocheckpoint = 1000');
|
|
17
17
|
// Negative value = KiB of memory (64 MiB). Helps large BLOB scans.
|
|
18
|
+
//
|
|
19
|
+
// Empirically validated against larger values on a 10 GB archive:
|
|
20
|
+
// bumping to 512 MiB regressed `getSummary` (1.9s → 5.7s), `pages`
|
|
21
|
+
// (2.3s → 21s), and `images` (3.7s → 12s) — libsql's page eviction
|
|
22
|
+
// policy interacts poorly with a cache sized comparable to the
|
|
23
|
+
// host's page-cache window when the DB itself far exceeds RAM.
|
|
24
|
+
// 64 MiB stays the sweet spot.
|
|
18
25
|
await instance.raw('PRAGMA cache_size = -65536');
|
|
19
26
|
// 256 MiB mmap window. SQLite falls back to read() past this so the
|
|
20
|
-
// limit is a soft ceiling, not a hard one.
|
|
27
|
+
// limit is a soft ceiling, not a hard one. A 4 GiB window was
|
|
28
|
+
// catastrophic on a 10 GB archive on macOS (summary 1.9s → 43s,
|
|
29
|
+
// pages 2.3s → 21s) — the kernel's read-ahead policy and libsql's
|
|
30
|
+
// mmap path interact badly when the window can cover most of the
|
|
31
|
+
// DB. Keep this conservative.
|
|
21
32
|
await instance.raw('PRAGMA mmap_size = 268435456');
|
|
22
33
|
}
|
|
23
34
|
/**
|
|
@@ -318,6 +329,23 @@ export async function initSchema(instance) {
|
|
|
318
329
|
t.text('parseError');
|
|
319
330
|
t.index('pageId');
|
|
320
331
|
t.index('type');
|
|
332
|
+
})
|
|
333
|
+
.createTable('inventory_runs', (t) => {
|
|
334
|
+
// One row per successful `--inventory <list>` invocation. The
|
|
335
|
+
// archive's audit log of "when did we apply which deploy list
|
|
336
|
+
// at what scale". `.bak` is removed on success so this table
|
|
337
|
+
// is the only durable provenance record. Schema rationale +
|
|
338
|
+
// non-goals live in {@link migrateInventoryRuns}.
|
|
339
|
+
t.increments('id');
|
|
340
|
+
t.string('ran_at').notNullable();
|
|
341
|
+
t.string('list_label').nullable();
|
|
342
|
+
t.string('source_file_sha256', 64).nullable();
|
|
343
|
+
t.integer('total_lines').nullable();
|
|
344
|
+
t.integer('new_pages').nullable();
|
|
345
|
+
t.integer('new_resources').nullable();
|
|
346
|
+
t.integer('scope_skipped').nullable();
|
|
347
|
+
t.text('notes').nullable();
|
|
348
|
+
t.index('ran_at');
|
|
321
349
|
});
|
|
322
350
|
// ON DELETE CASCADE and compound indexes for the new tables. Knex's
|
|
323
351
|
// schema builder can't express CASCADE / compound indexes inline in a
|
|
@@ -348,4 +376,128 @@ export async function initSchema(instance) {
|
|
|
348
376
|
) WITHOUT ROWID
|
|
349
377
|
`);
|
|
350
378
|
await instance.raw('CREATE INDEX idx_page_html_ref_hash ON page_html_ref(hash)');
|
|
379
|
+
// Composite covering index for the default Pages-view filter + url-ordered
|
|
380
|
+
// scan. Without it, `listPages` on a 400k-row archive runs ~15s per page
|
|
381
|
+
// click (SCAN pages USING pages_scraped_index + TEMP B-TREE FOR ORDER BY);
|
|
382
|
+
// with it, the same query runs ~45ms (368x speedup, confirmed via
|
|
383
|
+
// `scripts/bench-partial-listfilter.mjs` against a real customer archive).
|
|
384
|
+
// The same index also serves `listIsolatedPages`, `listIsolatedClusters`,
|
|
385
|
+
// and `getSummary`'s HTML-page counts.
|
|
386
|
+
//
|
|
387
|
+
// **Column order: `(isExternal, scraped, redirectDestId, url, contentType)`.**
|
|
388
|
+
// The leading `isExternal` is critical: the Pages view's default
|
|
389
|
+
// "external excluded" filter adds `WHERE isExternal = 0` to both the
|
|
390
|
+
// SELECT and the paginate-query COUNT. A previous version of this index
|
|
391
|
+
// (`(scraped, redirectDestId, url, contentType)`) shipped without
|
|
392
|
+
// `isExternal`, and the SELECT picked it up (`ORDER BY url` forced the
|
|
393
|
+
// match) while the COUNT — having no `ORDER BY` — fell back to the
|
|
394
|
+
// single-column `pages_isexternal_index` + scan + per-row WHERE filter,
|
|
395
|
+
// costing ~8.7s for the COUNT alone on a 165k-internal-page archive.
|
|
396
|
+
// Putting `isExternal` first makes both shapes pick this index as a
|
|
397
|
+
// covering scan (~33ms COUNT, ~1ms SELECT warm).
|
|
398
|
+
//
|
|
399
|
+
// **DO NOT RUN `ANALYZE` ON .nitpicker ARCHIVES.** With ANALYZE statistics
|
|
400
|
+
// available, the planner switches the JOIN paths in `listLinks`,
|
|
401
|
+
// `getLinkGraph`, and `listPageLinks` to use this index for source/dest
|
|
402
|
+
// seeks (SCAN dest → SEARCH anchors → SEARCH source) instead of the
|
|
403
|
+
// existing `SCAN anchors → rowid seek` plan. That regression takes those
|
|
404
|
+
// queries from ~15s to ~500s (33x worse). The unanalyzed-table heuristic
|
|
405
|
+
// happens to pick the right plan for the joins while still picking the new
|
|
406
|
+
// index for `listPages` because the column order exactly matches the
|
|
407
|
+
// WHERE+ORDER predicates. If a future change adds `ANALYZE` anywhere in
|
|
408
|
+
// the crawler / viewer / MCP / migration paths, this index must be
|
|
409
|
+
// re-evaluated first.
|
|
410
|
+
await instance.raw('CREATE INDEX idx_pages_listfilter ON pages(isExternal, scraped, redirectDestId, url, contentType)');
|
|
411
|
+
// Covering index for `listUnusedResources`. Without it the query SCAN s
|
|
412
|
+
// `resources_url_unique` (every resource, including externals) then
|
|
413
|
+
// filters `isExternal = 0` row-by-row — ~66s on the bench archive. With
|
|
414
|
+
// the `(isExternal, url)` leading prefix, the planner serves the WHERE
|
|
415
|
+
// + ORDER BY url from one covering scan — ~7.5s (8.8x). Same
|
|
416
|
+
// no-ANALYZE invariant applies (see `idx_pages_listfilter` above);
|
|
417
|
+
// validated against the 4 regression sentinels in
|
|
418
|
+
// `scripts/bench-unused-images.mjs`.
|
|
419
|
+
await instance.raw('CREATE INDEX idx_resources_internal_url ON resources(isExternal, url)');
|
|
420
|
+
// Covering index for `listImages`. The default query joins `images` to
|
|
421
|
+
// `pages` and orders by `pages.url`. Without this index the planner
|
|
422
|
+
// scans `images` first, seeks `pages` by rowid, and pays a TEMP B-TREE
|
|
423
|
+
// FOR ORDER BY (~32s on the bench archive). With the index the plan
|
|
424
|
+
// flips to SCAN pages (via `pages_url_unique`, url-ordered already)
|
|
425
|
+
// → SEARCH images via the covering pageId index — no temp sort, ~16s
|
|
426
|
+
// (2.0x). The included columns (src, alt, dimensions, isLazy) make
|
|
427
|
+
// `idx_images_covering` covering for every `select` `listImages` does,
|
|
428
|
+
// so the SEARCH does not need to materialise the underlying row.
|
|
429
|
+
// Validated by `scripts/bench-unused-images.mjs`.
|
|
430
|
+
await instance.raw('CREATE INDEX idx_images_covering ON images(pageId, src, alt, width, height, naturalWidth, naturalHeight, isLazy)');
|
|
431
|
+
// Targets `getSummary` Q2 (metadata fulfilment) + Q3 (content-type
|
|
432
|
+
// histogram). With this index Q2 and Q3 both become covering
|
|
433
|
+
// (`SEARCH ... USING COVERING INDEX`) — the SELECT columns are
|
|
434
|
+
// contained inside the index entry, so no rowid lookup is needed.
|
|
435
|
+
// Q1 (status histogram) also picks this index for its seek but
|
|
436
|
+
// keeps `USE TEMP B-TREE FOR GROUP BY` because the index column
|
|
437
|
+
// order leads with `contentType, isExternal` while Q1's GROUP BY
|
|
438
|
+
// is `(isExternal, status)` — the residual ordering inside the
|
|
439
|
+
// `scraped=1 AND redirectDestId IS NULL` slice does not match.
|
|
440
|
+
// Empirically this still gives the largest net win because Q2 +
|
|
441
|
+
// Q3 dominate `getSummary` on archives whose `pages` table dwarfs
|
|
442
|
+
// the SQLite page cache (10 GB bench: 1157 ms → 717 ms, 38 %).
|
|
443
|
+
//
|
|
444
|
+
// **An additional candidate index `(scraped, redirectDestId,
|
|
445
|
+
// isExternal, status)` was empirically rejected**: in isolation it
|
|
446
|
+
// matches Q1's GROUP BY column order and would eliminate the temp
|
|
447
|
+
// B-tree there, but in combination with this one or with the
|
|
448
|
+
// `pages_scraped_index` fallback the planner shifted to plans that
|
|
449
|
+
// regressed `getSummary` to 4.6-10 s (PR #96 教訓 — bulk index
|
|
450
|
+
// addition without ANALYZE confuses the heuristic). The
|
|
451
|
+
// `idx_pages_summary_contenttype` form below is the only summary
|
|
452
|
+
// index that survived the matrix test in
|
|
453
|
+
// `scripts/bench-summary-configs.mjs`.
|
|
454
|
+
//
|
|
455
|
+
// Column order rationale:
|
|
456
|
+
//
|
|
457
|
+
// 1. `scraped` — leading seek key. All summary queries constrain
|
|
458
|
+
// it to `=1`.
|
|
459
|
+
// 2. `redirectDestId` — post-seek filter, IS NULL folded into the
|
|
460
|
+
// seek key by SQLite's index walk without needing ANALYZE
|
|
461
|
+
// (per operator forum; the IS NULL leading column rule only
|
|
462
|
+
// bites when the column is the LEADING one and there is no
|
|
463
|
+
// other equality constraint).
|
|
464
|
+
// 3. `contentType` — the column Q3 groups by.
|
|
465
|
+
// 4. `isExternal` — Q3's second GROUP BY column AND Q2's WHERE
|
|
466
|
+
// constraint (`isExternal=0`).
|
|
467
|
+
// 5. `isSkipped` — Q1/Q3's residual `(isSkipped=0 OR IS NULL)`
|
|
468
|
+
// filter (`excludeSkippedPages`). Having it in the index lets
|
|
469
|
+
// the residual filter use the index entry instead of a per-row
|
|
470
|
+
// rowid lookup.
|
|
471
|
+
//
|
|
472
|
+
// `id` is implicitly included (every SQLite index entry carries
|
|
473
|
+
// the rowid), so the `count(id)` aggregates cover off-index.
|
|
474
|
+
//
|
|
475
|
+
// **No-ANALYZE invariant** identical to `idx_pages_listfilter`
|
|
476
|
+
// (PR #96): the column order matches the WHERE+GROUP BY predicates
|
|
477
|
+
// exactly, so SQLite's heuristic-only planner picks it without
|
|
478
|
+
// needing `sqlite_stat1`. Adding `ANALYZE` would risk planner
|
|
479
|
+
// shifts in this and other queries.
|
|
480
|
+
//
|
|
481
|
+
// **Regression check**: `listPages` / `listPages COUNT` /
|
|
482
|
+
// `listLinks broken` / `listPageLinks` plans were re-verified
|
|
483
|
+
// against this index — `idx_pages_listfilter` continues to win
|
|
484
|
+
// for all of them. See `scripts/bench-summary-configs.mjs`.
|
|
485
|
+
await instance.raw('CREATE INDEX idx_pages_summary_contenttype ON pages(scraped, redirectDestId, contentType, isExternal, isSkipped)');
|
|
486
|
+
// Targets `getSummary` Q4 (`failedPageIdRows`) — selects pages with
|
|
487
|
+
// `scraped=1 AND status=-1 AND redirectDestId IS NULL`. `status=-1`
|
|
488
|
+
// is highly selective (a few hundred rows on archives with
|
|
489
|
+
// ~400 k `scraped=1` pages), but without this index the planner
|
|
490
|
+
// seeks all `scraped=1` rows via `pages_scraped_index` and then
|
|
491
|
+
// row-by-row filters status, costing ~5 s. The 3-column form
|
|
492
|
+
// `(scraped, status, redirectDestId)` is fully covering for
|
|
493
|
+
// `SELECT id` and gives a 5113 ms → 14 ms (~365x) reduction
|
|
494
|
+
// verified by `scripts/prototype-summary-indexes.mjs`.
|
|
495
|
+
//
|
|
496
|
+
// Note the column order: `status` comes second so the `(scraped=1
|
|
497
|
+
// AND status=-1)` 2-column equality seek lands directly in the
|
|
498
|
+
// failed-page slice without scanning the 400 k+ healthy rows.
|
|
499
|
+
// Putting `redirectDestId` last keeps it as a 3rd-level seek
|
|
500
|
+
// constraint that the planner folds into the slice once the
|
|
501
|
+
// (scraped, status) pair is fixed.
|
|
502
|
+
await instance.raw('CREATE INDEX idx_pages_summary_failed ON pages(scraped, status, redirectDestId)');
|
|
351
503
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PageSource } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Predicate that returns `true` when the given page source value belongs to
|
|
4
|
+
* the inventory chain — i.e. it is one of the `'inventory-*'` variants of
|
|
5
|
+
* {@link PageSource}.
|
|
6
|
+
*
|
|
7
|
+
* Centralises the membership check that decides whether lineage
|
|
8
|
+
* propagation should fire. Three call sites used to inline
|
|
9
|
+
* `s === 'inventory-seed' || s === 'inventory-discovered'`, which is both
|
|
10
|
+
* a DRY violation AND a future-proofing trap: when a new inventory-family
|
|
11
|
+
* label gets added (e.g. `'inventory-promoted'`), every inlined check has
|
|
12
|
+
* to be located and updated by hand. Routing through this predicate
|
|
13
|
+
* keeps the membership rule in one place.
|
|
14
|
+
*
|
|
15
|
+
* Returns `false` for `undefined` so callers can pass the raw `source`
|
|
16
|
+
* column value (which is non-NULL in the DB schema but reads as
|
|
17
|
+
* `undefined` from a missing row in JS) without a separate null check.
|
|
18
|
+
* @param source - The source value to test, or `undefined` when no row matched.
|
|
19
|
+
* @returns `true` if the source is in the inventory chain, `false` otherwise.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isInventorySource(source: PageSource | undefined): boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predicate that returns `true` when the given page source value belongs to
|
|
3
|
+
* the inventory chain — i.e. it is one of the `'inventory-*'` variants of
|
|
4
|
+
* {@link PageSource}.
|
|
5
|
+
*
|
|
6
|
+
* Centralises the membership check that decides whether lineage
|
|
7
|
+
* propagation should fire. Three call sites used to inline
|
|
8
|
+
* `s === 'inventory-seed' || s === 'inventory-discovered'`, which is both
|
|
9
|
+
* a DRY violation AND a future-proofing trap: when a new inventory-family
|
|
10
|
+
* label gets added (e.g. `'inventory-promoted'`), every inlined check has
|
|
11
|
+
* to be located and updated by hand. Routing through this predicate
|
|
12
|
+
* keeps the membership rule in one place.
|
|
13
|
+
*
|
|
14
|
+
* Returns `false` for `undefined` so callers can pass the raw `source`
|
|
15
|
+
* column value (which is non-NULL in the DB schema but reads as
|
|
16
|
+
* `undefined` from a missing row in JS) without a separate null check.
|
|
17
|
+
* @param source - The source value to test, or `undefined` when no row matched.
|
|
18
|
+
* @returns `true` if the source is in the inventory chain, `false` otherwise.
|
|
19
|
+
*/
|
|
20
|
+
export function isInventorySource(source) {
|
|
21
|
+
return source === 'inventory-seed' || source === 'inventory-discovered';
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
/**
|
|
3
|
+
* Adds the `inventory_runs` audit-log table to archives created before
|
|
4
|
+
* Phase 1 of inventory run tracking shipped.
|
|
5
|
+
*
|
|
6
|
+
* `inventory_runs` records one row per successful `--inventory <list>`
|
|
7
|
+
* invocation, capturing which deploy list was applied when and what
|
|
8
|
+
* scale it operated at. The motivation is operational: client /
|
|
9
|
+
* director conversations repeatedly ask "did you apply last month's
|
|
10
|
+
* list" / "we didn't double-apply, right" — the archive itself had no
|
|
11
|
+
* trace of inventory passes (`.bak` is unlinked on success), so this
|
|
12
|
+
* table is the durable provenance record.
|
|
13
|
+
*
|
|
14
|
+
* Schema details (column semantics, NULL policy, index) live in
|
|
15
|
+
* {@link initSchema} — this migration only re-creates the table shape
|
|
16
|
+
* on legacy archives so the rest of the codebase can treat the table
|
|
17
|
+
* as always-present once a writer connection has opened the file.
|
|
18
|
+
*
|
|
19
|
+
* Idempotent: when the table already exists, the function exits
|
|
20
|
+
* silently — the `[migrate] inventory_runs table created` stderr line
|
|
21
|
+
* fires **only** on the first run against a legacy archive, matching
|
|
22
|
+
* the established pattern of `migrate-page-errors.ts` /
|
|
23
|
+
* `migrate-crawl-errors.ts`. Operators can rely on the log line as a
|
|
24
|
+
* stable "first time this archive saw Phase 1 schema" event marker.
|
|
25
|
+
* Empty archives (no `pages` table) are skipped entirely; the regular
|
|
26
|
+
* `initSchema` path provisions them at first crawl.
|
|
27
|
+
* @param instance - The Knex query builder instance connected to the database.
|
|
28
|
+
*/
|
|
29
|
+
export declare function migrateInventoryRuns(instance: Knex): Promise<void>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adds the `inventory_runs` audit-log table to archives created before
|
|
3
|
+
* Phase 1 of inventory run tracking shipped.
|
|
4
|
+
*
|
|
5
|
+
* `inventory_runs` records one row per successful `--inventory <list>`
|
|
6
|
+
* invocation, capturing which deploy list was applied when and what
|
|
7
|
+
* scale it operated at. The motivation is operational: client /
|
|
8
|
+
* director conversations repeatedly ask "did you apply last month's
|
|
9
|
+
* list" / "we didn't double-apply, right" — the archive itself had no
|
|
10
|
+
* trace of inventory passes (`.bak` is unlinked on success), so this
|
|
11
|
+
* table is the durable provenance record.
|
|
12
|
+
*
|
|
13
|
+
* Schema details (column semantics, NULL policy, index) live in
|
|
14
|
+
* {@link initSchema} — this migration only re-creates the table shape
|
|
15
|
+
* on legacy archives so the rest of the codebase can treat the table
|
|
16
|
+
* as always-present once a writer connection has opened the file.
|
|
17
|
+
*
|
|
18
|
+
* Idempotent: when the table already exists, the function exits
|
|
19
|
+
* silently — the `[migrate] inventory_runs table created` stderr line
|
|
20
|
+
* fires **only** on the first run against a legacy archive, matching
|
|
21
|
+
* the established pattern of `migrate-page-errors.ts` /
|
|
22
|
+
* `migrate-crawl-errors.ts`. Operators can rely on the log line as a
|
|
23
|
+
* stable "first time this archive saw Phase 1 schema" event marker.
|
|
24
|
+
* Empty archives (no `pages` table) are skipped entirely; the regular
|
|
25
|
+
* `initSchema` path provisions them at first crawl.
|
|
26
|
+
* @param instance - The Knex query builder instance connected to the database.
|
|
27
|
+
*/
|
|
28
|
+
export async function migrateInventoryRuns(instance) {
|
|
29
|
+
const hasTable = await instance.schema.hasTable('inventory_runs');
|
|
30
|
+
if (hasTable) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const hasPages = await instance.schema.hasTable('pages');
|
|
34
|
+
if (!hasPages) {
|
|
35
|
+
// Empty archive; the regular initSchema path will create the table.
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
await instance.schema.createTable('inventory_runs', (t) => {
|
|
39
|
+
t.increments('id');
|
|
40
|
+
t.string('ran_at').notNullable();
|
|
41
|
+
t.string('list_label').nullable();
|
|
42
|
+
t.string('source_file_sha256', 64).nullable();
|
|
43
|
+
t.integer('total_lines').nullable();
|
|
44
|
+
t.integer('new_pages').nullable();
|
|
45
|
+
t.integer('new_resources').nullable();
|
|
46
|
+
t.integer('scope_skipped').nullable();
|
|
47
|
+
t.text('notes').nullable();
|
|
48
|
+
t.index('ran_at');
|
|
49
|
+
});
|
|
50
|
+
// eslint-disable-next-line no-console
|
|
51
|
+
console.error('[migrate] inventory_runs table created');
|
|
52
|
+
}
|
package/lib/archive/types.d.ts
CHANGED
|
@@ -68,6 +68,39 @@ export interface Config extends Required<Pick<ParseURLOptions, 'disableQueries'>
|
|
|
68
68
|
* the row.
|
|
69
69
|
*/
|
|
70
70
|
export type PageSource = 'crawled' | 'inventory-seed' | 'inventory-discovered';
|
|
71
|
+
/**
|
|
72
|
+
* One row written to the `inventory_runs` audit table on each successful
|
|
73
|
+
* `--inventory <list>` invocation.
|
|
74
|
+
*
|
|
75
|
+
* Schema-mirror interface: every column on `inventory_runs` is represented
|
|
76
|
+
* here. Only `ran_at` is required — every other field is nullable so the
|
|
77
|
+
* post-merge raw-SQL backfill path (a one-off `sqlite3 INSERT` for the
|
|
78
|
+
* initial inventory pass that predated this table) can omit summary
|
|
79
|
+
* stats it cannot reconstruct.
|
|
80
|
+
*
|
|
81
|
+
* The audit log is append-only at Phase 1: there is no UPDATE path, no
|
|
82
|
+
* UNIQUE constraint on `source_file_sha256`, and no FK to pages /
|
|
83
|
+
* resources. Phase 2 (`inventory_memberships`) introduces the M:N link
|
|
84
|
+
* to URLs; Phase 3 (`--refresh`) uses `source_file_sha256` for dedupe.
|
|
85
|
+
*/
|
|
86
|
+
export interface InventoryRunMeta {
|
|
87
|
+
/** ISO 8601 timestamp at which the run completed (e.g. `'2026-06-21T11:30:00+09:00'`). */
|
|
88
|
+
ran_at: string;
|
|
89
|
+
/** Human-readable identifier (e.g. `'prod-2026-06-21'`). `null` when the caller did not supply one. */
|
|
90
|
+
list_label?: string | null;
|
|
91
|
+
/** SHA-256 hex digest of the source file. `null` if hashing failed (e.g. file vanished mid-run). */
|
|
92
|
+
source_file_sha256?: string | null;
|
|
93
|
+
/** Number of non-empty lines in the input list (= URL count before scope filtering). */
|
|
94
|
+
total_lines?: number | null;
|
|
95
|
+
/** Number of new HTML seeds inserted as `pages` rows by this run. */
|
|
96
|
+
new_pages?: number | null;
|
|
97
|
+
/** Number of new non-HTML URLs inserted as `resources` rows by this run. */
|
|
98
|
+
new_resources?: number | null;
|
|
99
|
+
/** Number of input URLs dropped because they fell outside the archived scope. */
|
|
100
|
+
scope_skipped?: number | null;
|
|
101
|
+
/** Free-form text for backfill annotations or operator notes. */
|
|
102
|
+
notes?: string | null;
|
|
103
|
+
}
|
|
71
104
|
/**
|
|
72
105
|
* Filter type for querying pages from the database.
|
|
73
106
|
*
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ErrorKind } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Classify a raw crawler/scraper error message into a coarse {@link ErrorKind}.
|
|
4
|
+
*
|
|
5
|
+
* Pure and deterministic: the same message always yields the same kind, which
|
|
6
|
+
* is why the kind is derived on read rather than persisted — it can be applied
|
|
7
|
+
* uniformly to freshly captured `crawl_errors`, legacy `error.log` lines, and
|
|
8
|
+
* `page_errors` alike.
|
|
9
|
+
* @param message - The raw error message (a single line is sufficient; the
|
|
10
|
+
* cause token such as `ENOTFOUND` or `Navigation timeout` lives there).
|
|
11
|
+
* @returns The matched kind, or `unknown` when no matcher applies.
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* classifyErrorKind('getaddrinfo ENOTFOUND www.example.com'); // 'dns'
|
|
15
|
+
* classifyErrorKind('gave up after 3 retries — Race 180,000ms'); // 'timeout'
|
|
16
|
+
* classifyErrorKind('Protocol error (Page.reload): Target closed'); // 'protocol'
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export declare function classifyErrorKind(message: string): ErrorKind;
|