@nitpicker/crawler 0.14.0 → 0.16.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 (155) hide show
  1. package/lib/archive/archive.d.ts +136 -10
  2. package/lib/archive/archive.js +147 -9
  3. package/lib/archive/body-hash/compute-body-hash.d.ts +22 -0
  4. package/lib/archive/body-hash/compute-body-hash.js +31 -0
  5. package/lib/archive/body-hash/extract-body.d.ts +18 -0
  6. package/lib/archive/body-hash/extract-body.js +29 -0
  7. package/lib/archive/body-hash/mask-dynamic-ids.d.ts +33 -0
  8. package/lib/archive/body-hash/mask-dynamic-ids.js +44 -0
  9. package/lib/archive/body-hash/normalize-url-like-strings.d.ts +16 -0
  10. package/lib/archive/body-hash/normalize-url-like-strings.js +19 -0
  11. package/lib/archive/cache/clear-archive-cache-entry.d.ts +19 -0
  12. package/lib/archive/cache/clear-archive-cache-entry.js +25 -0
  13. package/lib/archive/cache/clear-archive-cache-root.d.ts +26 -0
  14. package/lib/archive/cache/clear-archive-cache-root.js +32 -0
  15. package/lib/archive/cache/list-archive-cache-entries.d.ts +23 -0
  16. package/lib/archive/cache/list-archive-cache-entries.js +125 -0
  17. package/lib/archive/cache/path-exists.d.ts +9 -0
  18. package/lib/archive/cache/path-exists.js +18 -0
  19. package/lib/archive/cache/types.d.ts +33 -0
  20. package/lib/archive/cache/types.js +1 -0
  21. package/lib/archive/create-adjunct-tables.d.ts +14 -0
  22. package/lib/archive/create-adjunct-tables.js +169 -0
  23. package/lib/archive/create-entity-tables.js +21 -1
  24. package/lib/archive/database.d.ts +67 -2
  25. package/lib/archive/database.js +92 -3
  26. package/lib/archive/db-ops/_shared/clear-write-ref-caches.js +1 -0
  27. package/lib/archive/db-ops/_shared/compress-payload.d.ts +26 -0
  28. package/lib/archive/db-ops/_shared/compress-payload.js +30 -0
  29. package/lib/archive/db-ops/_shared/create-write-ref-caches.js +1 -0
  30. package/lib/archive/db-ops/_shared/types.d.ts +2 -0
  31. package/lib/archive/db-ops/_shared/upsert-json-ref.js +3 -3
  32. package/lib/archive/db-ops/analysis/replace-page-templates.d.ts +12 -3
  33. package/lib/archive/db-ops/analysis/replace-page-templates.js +35 -3
  34. package/lib/archive/db-ops/analysis/types.d.ts +61 -0
  35. package/lib/archive/db-ops/analysis/types.js +1 -0
  36. package/lib/archive/db-ops/console-logs/compute-console-log-hash.d.ts +19 -0
  37. package/lib/archive/db-ops/console-logs/compute-console-log-hash.js +30 -0
  38. package/lib/archive/db-ops/console-logs/replace-console-logs.d.ts +34 -0
  39. package/lib/archive/db-ops/console-logs/replace-console-logs.js +123 -0
  40. package/lib/archive/db-ops/console-logs/stringify-console-log-args.d.ts +16 -0
  41. package/lib/archive/db-ops/console-logs/stringify-console-log-args.js +26 -0
  42. package/lib/archive/db-ops/console-logs/types.d.ts +38 -0
  43. package/lib/archive/db-ops/console-logs/types.js +1 -0
  44. package/lib/archive/db-ops/console-logs/upsert-console-log-item.d.ts +29 -0
  45. package/lib/archive/db-ops/console-logs/upsert-console-log-item.js +52 -0
  46. package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.d.ts +18 -0
  47. package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.js +23 -0
  48. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.d.ts +12 -0
  49. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.js +15 -0
  50. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.d.ts +14 -0
  51. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.js +30 -0
  52. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.d.ts +21 -0
  53. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.js +27 -0
  54. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.d.ts +11 -0
  55. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.js +21 -4
  56. package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
  57. package/lib/archive/db-ops/lifecycle/init.d.ts +11 -2
  58. package/lib/archive/db-ops/lifecycle/init.js +21 -2
  59. package/lib/archive/db-ops/outages/close-network-outage.d.ts +12 -0
  60. package/lib/archive/db-ops/outages/close-network-outage.js +15 -0
  61. package/lib/archive/db-ops/outages/close-stale-open-network-outages.d.ts +26 -0
  62. package/lib/archive/db-ops/outages/close-stale-open-network-outages.js +42 -0
  63. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.d.ts +26 -0
  64. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.js +31 -0
  65. package/lib/archive/db-ops/outages/insert-network-outage.d.ts +14 -0
  66. package/lib/archive/db-ops/outages/insert-network-outage.js +29 -0
  67. package/lib/archive/db-ops/outages/list-network-outages.d.ts +28 -0
  68. package/lib/archive/db-ops/outages/list-network-outages.js +42 -0
  69. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +13 -0
  70. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +35 -7
  71. package/lib/archive/db-ops/pages/write/update-page.js +12 -0
  72. package/lib/archive/db-ops/pages/write/write-page-html-blob.js +6 -6
  73. package/lib/archive/filesystem/output-binary.d.ts +12 -0
  74. package/lib/archive/filesystem/output-binary.js +17 -0
  75. package/lib/archive/get-failed-page-messages.d.ts +20 -8
  76. package/lib/archive/get-failed-page-messages.js +29 -15
  77. package/lib/archive/migrate-content-items-alias-of-id.d.ts +47 -0
  78. package/lib/archive/migrate-content-items-alias-of-id.js +58 -0
  79. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +11 -0
  80. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +24 -0
  81. package/lib/archive/migrate-page-meta-body-hash.d.ts +39 -0
  82. package/lib/archive/migrate-page-meta-body-hash.js +52 -0
  83. package/lib/archive/migrate-page-meta-console-error-count.d.ts +22 -0
  84. package/lib/archive/migrate-page-meta-console-error-count.js +34 -0
  85. package/lib/archive/page.d.ts +1 -1
  86. package/lib/archive/types.d.ts +60 -2
  87. package/lib/archive/url-alias/compute-tier-a-alias-key.d.ts +31 -0
  88. package/lib/archive/url-alias/compute-tier-a-alias-key.js +36 -0
  89. package/lib/archive/url-alias/compute-tier-b-alias-key.d.ts +19 -0
  90. package/lib/archive/url-alias/compute-tier-b-alias-key.js +30 -0
  91. package/lib/archive/url-alias/format-alias-key.d.ts +13 -0
  92. package/lib/archive/url-alias/format-alias-key.js +14 -0
  93. package/lib/archive/url-alias/parse-alias-key-parts.d.ts +15 -0
  94. package/lib/archive/url-alias/parse-alias-key-parts.js +40 -0
  95. package/lib/archive/url-alias/types.d.ts +7 -0
  96. package/lib/archive/url-alias/types.js +1 -0
  97. package/lib/classify-error-kind.d.ts +1 -0
  98. package/lib/classify-error-kind.js +14 -0
  99. package/lib/crawler/assert-chrome-installed.d.ts +24 -0
  100. package/lib/crawler/assert-chrome-installed.js +43 -0
  101. package/lib/crawler/choose-probe-host.d.ts +24 -0
  102. package/lib/crawler/choose-probe-host.js +38 -0
  103. package/lib/crawler/clear-dns-burned-host-cache.js +2 -0
  104. package/lib/crawler/crawler.d.ts +12 -0
  105. package/lib/crawler/crawler.js +496 -29
  106. package/lib/crawler/decode-auth-credential.d.ts +29 -0
  107. package/lib/crawler/decode-auth-credential.js +39 -0
  108. package/lib/crawler/dedupe/compute-meta-signature.d.ts +30 -0
  109. package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
  110. package/lib/crawler/dedupe/compute-shape-key.d.ts +37 -0
  111. package/lib/crawler/dedupe/compute-shape-key.js +56 -0
  112. package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +84 -0
  113. package/lib/crawler/dedupe/dedupe-cap-tracker.js +185 -0
  114. package/lib/crawler/dedupe/is-predicted-content-duplicate.d.ts +24 -0
  115. package/lib/crawler/dedupe/is-predicted-content-duplicate.js +26 -0
  116. package/lib/crawler/dedupe/is-shape-capped.d.ts +10 -0
  117. package/lib/crawler/dedupe/is-shape-capped.js +12 -0
  118. package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +31 -0
  119. package/lib/crawler/dedupe/resolve-og-url-mismatch.js +40 -0
  120. package/lib/crawler/dedupe/types.d.ts +42 -0
  121. package/lib/crawler/dedupe/types.js +1 -0
  122. package/lib/crawler/dns-burned-host-burn-timestamps.d.ts +17 -0
  123. package/lib/crawler/dns-burned-host-burn-timestamps.js +17 -0
  124. package/lib/crawler/evict-network-classified-destination-cache-entries.d.ts +26 -0
  125. package/lib/crawler/evict-network-classified-destination-cache-entries.js +34 -0
  126. package/lib/crawler/evict-outage-tainted-dns-burns.d.ts +23 -0
  127. package/lib/crawler/evict-outage-tainted-dns-burns.js +26 -0
  128. package/lib/crawler/fetch-destination.js +14 -2
  129. package/lib/crawler/generate-predicted-urls.d.ts +12 -0
  130. package/lib/crawler/generate-predicted-urls.js +33 -2
  131. package/lib/crawler/is-puppeteer-fallback-candidate.js +3 -0
  132. package/lib/crawler/network-gate.d.ts +49 -0
  133. package/lib/crawler/network-gate.js +78 -0
  134. package/lib/crawler/network-outage-detector.d.ts +51 -0
  135. package/lib/crawler/network-outage-detector.js +81 -0
  136. package/lib/crawler/network-outage-summary-counter.d.ts +21 -0
  137. package/lib/crawler/network-outage-summary-counter.js +18 -0
  138. package/lib/crawler/probe-network.d.ts +26 -0
  139. package/lib/crawler/probe-network.js +21 -0
  140. package/lib/crawler/types.d.ts +185 -1
  141. package/lib/crawler-orchestrator.d.ts +74 -18
  142. package/lib/crawler-orchestrator.js +267 -27
  143. package/lib/crawler.d.ts +22 -0
  144. package/lib/crawler.js +18 -0
  145. package/lib/is-within-outage-window.d.ts +49 -0
  146. package/lib/is-within-outage-window.js +33 -0
  147. package/lib/network-related-error-kinds.d.ts +23 -0
  148. package/lib/network-related-error-kinds.js +28 -0
  149. package/lib/permanent-error-kinds.d.ts +9 -4
  150. package/lib/permanent-error-kinds.js +10 -4
  151. package/lib/types.d.ts +11 -2
  152. package/lib/utils/array/each-splitted.d.ts +1 -1
  153. package/lib/utils/compute-file-sha256.d.ts +17 -17
  154. package/lib/utils/compute-file-sha256.js +18 -47
  155. package/package.json +3 -3
@@ -0,0 +1,123 @@
1
+ import { upsertTextRefs } from '../../populate-entity-tables/upsert-text-refs.js';
2
+ import { resolveRedirectChain } from '../../resolve-redirect-chain.js';
3
+ import { clearWriteRefCaches } from '../_shared/clear-write-ref-caches.js';
4
+ import { resolveContentItemId } from '../_shared/resolve-content-item-id.js';
5
+ import { upsertJsonRef } from '../_shared/upsert-json-ref.js';
6
+ import { upsertUrlRef } from '../_shared/upsert-url-ref.js';
7
+ import { computeConsoleLogHash } from './compute-console-log-hash.js';
8
+ import { stringifyConsoleLogArgs } from './stringify-console-log-args.js';
9
+ import { upsertConsoleLogItem } from './upsert-console-log-item.js';
10
+ /** Rows sent per `INSERT INTO page_console_logs ... VALUES (...)` statement. */
11
+ const INSERT_CHUNK_SIZE = 500;
12
+ /**
13
+ * Replaces one page's `page_console_logs` rows wholesale with a freshly
14
+ * captured set of console messages / page errors (issue #228).
15
+ *
16
+ * Scoped-Replace, the same pattern as `replaceAnchorEdges` /
17
+ * `replaceImageItems`: the page's existing rows are deleted and the new
18
+ * set is inserted in the same transaction, so a re-scrape (`--append` /
19
+ * `--retry-failed` / re-render) never accumulates duplicate occurrences.
20
+ * Unlike those two, there is no non-empty guard on the DELETE here — the
21
+ * caller (`Crawler#handleConsoleLogs`) already skips emitting the event
22
+ * entirely when `entries` is empty, which is what keeps a degraded
23
+ * re-scrape from wiping out a prior good capture.
24
+ *
25
+ * `pageUrl` is the originally-requested URL (matching `updatePage`'s
26
+ * `page.url.withoutHashAndAuth` contract), not necessarily the page that
27
+ * ends up holding the content: `resolveRedirectChain` derives the same
28
+ * redirect destination `updatePage` writes content under, so console logs
29
+ * attach to the row that actually carries the page's `page_meta`, not to
30
+ * an empty placeholder for the pre-redirect URL.
31
+ * @param knex - Knex query builder connected to the archive DB.
32
+ * @param caches - The connection's write-side id caches.
33
+ * @param pageUrl - The originally-requested URL, normalised
34
+ * (`withoutHashAndAuth` form).
35
+ * @param redirectPaths - The redirect chain hops captured during fetch, in
36
+ * order (empty when the page was not redirected).
37
+ * @param entries - The console log entries to persist. Must be non-empty —
38
+ * callers should skip calling this function entirely for an empty list.
39
+ * @example
40
+ * await replaceConsoleLogs(knex, caches, page.url.withoutHashAndAuth, page.redirectPaths, entries);
41
+ */
42
+ export async function replaceConsoleLogs(knex, caches, pageUrl, redirectPaths, entries) {
43
+ const { destUrl } = resolveRedirectChain(pageUrl, redirectPaths);
44
+ try {
45
+ await knex.transaction(async (trx) => {
46
+ const pageId = await resolveContentItemId(trx, caches, destUrl);
47
+ await trx('page_console_logs').where('pageId', pageId).delete();
48
+ const texts = new Set();
49
+ for (const entry of entries) {
50
+ // `text_refs` never stores the empty string (its upsert
51
+ // treats `''` as "nothing to dedupe" and skips it) — a
52
+ // `console.log()` call with zero arguments reports `text:
53
+ // ''`, so that entry resolves to `textId: null` below
54
+ // instead of looking it up.
55
+ if (entry.text !== '') {
56
+ texts.add(entry.text);
57
+ }
58
+ if (entry.stack) {
59
+ texts.add(entry.stack);
60
+ }
61
+ }
62
+ const textIds = await upsertTextRefs(trx, texts);
63
+ const rows = [];
64
+ for (const entry of entries) {
65
+ let textId = null;
66
+ if (entry.text !== '') {
67
+ const resolved = textIds.get(entry.text);
68
+ if (resolved === undefined) {
69
+ throw new Error(`replaceConsoleLogs: text_refs id not resolved for "${entry.text}"`);
70
+ }
71
+ textId = resolved;
72
+ }
73
+ const stackTextId = entry.stack ? (textIds.get(entry.stack) ?? null) : null;
74
+ const argsJson = stringifyConsoleLogArgs(entry.args);
75
+ const argsJsonId = argsJson === null ? null : await upsertJsonRef(trx, caches, argsJson);
76
+ const locUrlId = entry.location?.url === undefined
77
+ ? null
78
+ : await upsertUrlRef(trx, caches, entry.location.url);
79
+ const consoleLogId = await upsertConsoleLogItem(trx, caches, {
80
+ hash: computeConsoleLogHash({
81
+ type: entry.type,
82
+ text: entry.text,
83
+ argsJson,
84
+ location: entry.location,
85
+ stack: entry.stack,
86
+ }),
87
+ type: entry.type,
88
+ textId,
89
+ argsJsonId,
90
+ locUrlId,
91
+ locLine: entry.location?.lineNumber ?? null,
92
+ locColumn: entry.location?.columnNumber ?? null,
93
+ stackTextId,
94
+ });
95
+ rows.push({ pageId, consoleLogId, ts: entry.ts });
96
+ }
97
+ for (let index = 0; index < rows.length; index += INSERT_CHUNK_SIZE) {
98
+ await trx('page_console_logs').insert(rows.slice(index, index + INSERT_CHUNK_SIZE));
99
+ }
100
+ // Denormalised onto `page_meta` (the same write-once-at-scrape-time
101
+ // pattern as `tag_count` / `jsonld_count`) so the Pages list can
102
+ // display and sort by it without a live JOIN + COUNT per row.
103
+ // Computed from `entries` directly rather than re-querying the rows
104
+ // just inserted above — the data is already in hand. A `page_meta`
105
+ // row may not exist yet for a `'skipped'` / `'error'` scrape; the
106
+ // UPDATE then simply affects zero rows rather than throwing.
107
+ const errorCount = entries.filter((entry) => entry.type === 'error' || entry.type === 'pageerror').length;
108
+ await trx('page_meta').where('page_id', pageId).update({
109
+ console_error_count: errorCount,
110
+ });
111
+ });
112
+ }
113
+ catch (error) {
114
+ // A rolled-back transaction can leave ids cached that no longer
115
+ // correspond to any row (AUTOINCREMENT never rewinds) — same
116
+ // cache-poisoning hazard `updatePage` / `recordRedirect` guard
117
+ // against. A full clear (not a partial one) is required because
118
+ // this function shares `caches` with every other write path on
119
+ // the same connection.
120
+ clearWriteRefCaches(caches);
121
+ throw error;
122
+ }
123
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Serializes a console call's argument array to a JSON string, or `null`
3
+ * when there is nothing worth storing.
4
+ *
5
+ * Two cases collapse to `null` rather than an empty/degenerate string:
6
+ * an empty `args` array (most commonly a `"pageerror"` entry, which
7
+ * beholder always reports with `args: []`) and a `JSON.stringify` failure
8
+ * (e.g. a circular reference in a logged object) — both mean "no
9
+ * additional structured payload beyond `text`", not "a malformed one".
10
+ * @param args - The `ConsoleLogEntry.args` array to serialize.
11
+ * @returns The JSON string, or `null` when empty or unserializable.
12
+ * @example
13
+ * stringifyConsoleLogArgs(['a', 1]); // '["a",1]'
14
+ * stringifyConsoleLogArgs([]); // null
15
+ */
16
+ export declare function stringifyConsoleLogArgs(args: readonly unknown[]): string | null;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Serializes a console call's argument array to a JSON string, or `null`
3
+ * when there is nothing worth storing.
4
+ *
5
+ * Two cases collapse to `null` rather than an empty/degenerate string:
6
+ * an empty `args` array (most commonly a `"pageerror"` entry, which
7
+ * beholder always reports with `args: []`) and a `JSON.stringify` failure
8
+ * (e.g. a circular reference in a logged object) — both mean "no
9
+ * additional structured payload beyond `text`", not "a malformed one".
10
+ * @param args - The `ConsoleLogEntry.args` array to serialize.
11
+ * @returns The JSON string, or `null` when empty or unserializable.
12
+ * @example
13
+ * stringifyConsoleLogArgs(['a', 1]); // '["a",1]'
14
+ * stringifyConsoleLogArgs([]); // null
15
+ */
16
+ export function stringifyConsoleLogArgs(args) {
17
+ if (args.length === 0) {
18
+ return null;
19
+ }
20
+ try {
21
+ return JSON.stringify(args);
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The content fields of a `ConsoleLogEntry` that determine its identity in
3
+ * `console_log_items` — everything except `pageUrl` / `ts`, which are
4
+ * per-occurrence rather than per-content (see `page_console_logs`).
5
+ */
6
+ export interface ConsoleLogContent {
7
+ type: string;
8
+ text: string;
9
+ /**
10
+ * Pre-stringified `args` (via `stringifyConsoleLogArgs`), or `null` for
11
+ * an empty/unserializable args array. Callers compute this once and
12
+ * reuse it both for hashing here and for the `json_refs` storage
13
+ * decision, rather than calling `stringifyConsoleLogArgs` on the same
14
+ * `args` array twice.
15
+ */
16
+ argsJson: string | null;
17
+ location?: {
18
+ url?: string;
19
+ lineNumber?: number;
20
+ columnNumber?: number;
21
+ };
22
+ stack?: string;
23
+ }
24
+ /**
25
+ * The resolved ref ids and scalar fields that make up one
26
+ * `console_log_items` row, computed by the caller before the upsert runs.
27
+ */
28
+ export interface ConsoleLogItemRow {
29
+ hash: Buffer;
30
+ type: string;
31
+ /** `null` when `text` is the empty string — `text_refs` never stores `''`. */
32
+ textId: number | null;
33
+ argsJsonId: number | null;
34
+ locUrlId: number | null;
35
+ locLine: number | null;
36
+ locColumn: number | null;
37
+ stackTextId: number | null;
38
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import type { ConsoleLogItemRow } from './types.js';
2
+ import type { WriteRefCaches } from '../_shared/types.js';
3
+ import type { Knex } from 'knex';
4
+ /**
5
+ * Resolves the `console_log_items.id` for one console log entry's content,
6
+ * inserting the dictionary row when the hash is not yet known.
7
+ *
8
+ * Same no-op-update-for-`RETURNING` idiom as `upsertUrlRef` / `upsertJsonRef`:
9
+ * `INSERT ... ON CONFLICT(hash) DO UPDATE SET hash = hash RETURNING id`
10
+ * yields the existing row's id on a hash collision without a separate
11
+ * SELECT round trip, and (unlike `INSERT OR IGNORE ... RETURNING`) still
12
+ * returns a row when the insert itself is the no-op.
13
+ * @param qb - Knex instance or transaction connected to the archive DB.
14
+ * @param caches - The connection's write-side id caches; mutated in place.
15
+ * @param row - The content hash plus every resolved ref id / scalar column.
16
+ * @returns The `console_log_items.id` of the existing or newly inserted row.
17
+ * @example
18
+ * const id = await upsertConsoleLogItem(trx, caches, {
19
+ * hash: computeConsoleLogHash(entry),
20
+ * type: entry.type,
21
+ * textId,
22
+ * argsJsonId: null,
23
+ * locUrlId: null,
24
+ * locLine: null,
25
+ * locColumn: null,
26
+ * stackTextId: null,
27
+ * });
28
+ */
29
+ export declare function upsertConsoleLogItem(qb: Knex | Knex.Transaction, caches: WriteRefCaches, row: ConsoleLogItemRow): Promise<number>;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Resolves the `console_log_items.id` for one console log entry's content,
3
+ * inserting the dictionary row when the hash is not yet known.
4
+ *
5
+ * Same no-op-update-for-`RETURNING` idiom as `upsertUrlRef` / `upsertJsonRef`:
6
+ * `INSERT ... ON CONFLICT(hash) DO UPDATE SET hash = hash RETURNING id`
7
+ * yields the existing row's id on a hash collision without a separate
8
+ * SELECT round trip, and (unlike `INSERT OR IGNORE ... RETURNING`) still
9
+ * returns a row when the insert itself is the no-op.
10
+ * @param qb - Knex instance or transaction connected to the archive DB.
11
+ * @param caches - The connection's write-side id caches; mutated in place.
12
+ * @param row - The content hash plus every resolved ref id / scalar column.
13
+ * @returns The `console_log_items.id` of the existing or newly inserted row.
14
+ * @example
15
+ * const id = await upsertConsoleLogItem(trx, caches, {
16
+ * hash: computeConsoleLogHash(entry),
17
+ * type: entry.type,
18
+ * textId,
19
+ * argsJsonId: null,
20
+ * locUrlId: null,
21
+ * locLine: null,
22
+ * locColumn: null,
23
+ * stackTextId: null,
24
+ * });
25
+ */
26
+ export async function upsertConsoleLogItem(qb, caches, row) {
27
+ const hex = row.hash.toString('hex');
28
+ const cached = caches.consoleLogIds.get(hex);
29
+ if (cached !== undefined) {
30
+ return cached;
31
+ }
32
+ const rows = await qb.raw(`INSERT INTO console_log_items
33
+ (hash, type, text_id, args_json_id, loc_url_id, loc_line, loc_column, stack_text_id)
34
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
35
+ ON CONFLICT(hash) DO UPDATE SET hash = hash
36
+ RETURNING id`, [
37
+ row.hash,
38
+ row.type,
39
+ row.textId,
40
+ row.argsJsonId,
41
+ row.locUrlId,
42
+ row.locLine,
43
+ row.locColumn,
44
+ row.stackTextId,
45
+ ]);
46
+ const first = rows[0];
47
+ if (first === undefined) {
48
+ throw new Error('upsertConsoleLogItem: RETURNING yielded no row');
49
+ }
50
+ caches.consoleLogIds.set(hex, first.id);
51
+ return first.id;
52
+ }
@@ -0,0 +1,18 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Adds `rejectedCount` onto the `rejected_count` of the `dedupe_cap_events`
4
+ * row for `shapeKey`, treating a still-`NULL` count as `0`. Unlike
5
+ * `finalize-dedupe-cap-event.ts` (which stamps a session's own newly-capped
6
+ * shape exactly once, guarded by `whereNull`), this targets a shape that
7
+ * capped in an EARLIER session and was preloaded into `DedupeCapTracker`'s
8
+ * sticky set (see `DedupeCapTracker`'s constructor JSDoc) — gate rejections
9
+ * for such a shape still occur in the current session, but no `dedupeCap`
10
+ * event (and thus no new row) is ever emitted for it, since the tracker
11
+ * short-circuits on an already-sticky shape before `observe` runs. Matches
12
+ * by `shape_key` rather than `id` because the caller (`CrawlerOrchestrator`)
13
+ * only has the shape key for a preloaded-sticky shape, never its row id.
14
+ * @param knex - Knex query builder connected to the archive DB.
15
+ * @param shapeKey - The capped shape whose rejection count to accumulate.
16
+ * @param rejectedCount - Additional anchors rejected for this shape in the current session.
17
+ */
18
+ export declare function accumulateDedupeCapRejectedCount(knex: Knex, shapeKey: string, rejectedCount: number): Promise<void>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Adds `rejectedCount` onto the `rejected_count` of the `dedupe_cap_events`
3
+ * row for `shapeKey`, treating a still-`NULL` count as `0`. Unlike
4
+ * `finalize-dedupe-cap-event.ts` (which stamps a session's own newly-capped
5
+ * shape exactly once, guarded by `whereNull`), this targets a shape that
6
+ * capped in an EARLIER session and was preloaded into `DedupeCapTracker`'s
7
+ * sticky set (see `DedupeCapTracker`'s constructor JSDoc) — gate rejections
8
+ * for such a shape still occur in the current session, but no `dedupeCap`
9
+ * event (and thus no new row) is ever emitted for it, since the tracker
10
+ * short-circuits on an already-sticky shape before `observe` runs. Matches
11
+ * by `shape_key` rather than `id` because the caller (`CrawlerOrchestrator`)
12
+ * only has the shape key for a preloaded-sticky shape, never its row id.
13
+ * @param knex - Knex query builder connected to the archive DB.
14
+ * @param shapeKey - The capped shape whose rejection count to accumulate.
15
+ * @param rejectedCount - Additional anchors rejected for this shape in the current session.
16
+ */
17
+ export async function accumulateDedupeCapRejectedCount(knex, shapeKey, rejectedCount) {
18
+ await knex('dedupe_cap_events')
19
+ .where({ shape_key: shapeKey })
20
+ .update({
21
+ rejected_count: knex.raw('COALESCE(rejected_count, 0) + ?', [rejectedCount]),
22
+ });
23
+ }
@@ -0,0 +1,12 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Finalizes a `dedupe_cap_events` row by stamping `rejected_count` — but
4
+ * ONLY if it is still unset. The `whereNull('rejected_count')` guard makes
5
+ * this idempotent, mirroring `close-network-outage.ts`'s `ended_at` guard: a
6
+ * second call matches zero rows and is a silent no-op rather than
7
+ * overwriting an already-finalized count.
8
+ * @param knex - Knex query builder connected to the archive DB.
9
+ * @param id - The `dedupe_cap_events.id` to finalize.
10
+ * @param rejectedCount - Number of anchors rejected for this shape after it capped.
11
+ */
12
+ export declare function finalizeDedupeCapEvent(knex: Knex, id: number, rejectedCount: number): Promise<void>;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Finalizes a `dedupe_cap_events` row by stamping `rejected_count` — but
3
+ * ONLY if it is still unset. The `whereNull('rejected_count')` guard makes
4
+ * this idempotent, mirroring `close-network-outage.ts`'s `ended_at` guard: a
5
+ * second call matches zero rows and is a silent no-op rather than
6
+ * overwriting an already-finalized count.
7
+ * @param knex - Knex query builder connected to the archive DB.
8
+ * @param id - The `dedupe_cap_events.id` to finalize.
9
+ * @param rejectedCount - Number of anchors rejected for this shape after it capped.
10
+ */
11
+ export async function finalizeDedupeCapEvent(knex, id, rejectedCount) {
12
+ await knex('dedupe_cap_events').where({ id }).whereNull('rejected_count').update({
13
+ rejected_count: rejectedCount,
14
+ });
15
+ }
@@ -0,0 +1,14 @@
1
+ import type { InsertDedupeCapEventParams } from '../../types.js';
2
+ import type { Knex } from 'knex';
3
+ /**
4
+ * Appends one row to the `dedupe_cap_events` journal, with `rejected_count`
5
+ * left `NULL` — the row starts life without a finalized rejection count.
6
+ *
7
+ * Called the instant `DedupeCapTracker#observe` confirms a URL shape as a
8
+ * same-cluster trap (the `dedupeCap` event). See
9
+ * `finalize-dedupe-cap-event.ts` for how `rejected_count` is later set.
10
+ * @param knex - Knex query builder connected to the archive DB.
11
+ * @param params - The newly-capped shape's fields to record.
12
+ * @returns The autoincremented `id` of the newly-inserted row.
13
+ */
14
+ export declare function insertDedupeCapEvent(knex: Knex, params: InsertDedupeCapEventParams): Promise<number>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Appends one row to the `dedupe_cap_events` journal, with `rejected_count`
3
+ * left `NULL` — the row starts life without a finalized rejection count.
4
+ *
5
+ * Called the instant `DedupeCapTracker#observe` confirms a URL shape as a
6
+ * same-cluster trap (the `dedupeCap` event). See
7
+ * `finalize-dedupe-cap-event.ts` for how `rejected_count` is later set.
8
+ * @param knex - Knex query builder connected to the archive DB.
9
+ * @param params - The newly-capped shape's fields to record.
10
+ * @returns The autoincremented `id` of the newly-inserted row.
11
+ */
12
+ export async function insertDedupeCapEvent(knex, params) {
13
+ const inserted = await knex
14
+ .from('dedupe_cap_events')
15
+ .insert({
16
+ shape_key: params.shapeKey,
17
+ sample_url: params.sampleUrl,
18
+ body_hash: params.bodyHash,
19
+ effective_threshold: params.effectiveThreshold,
20
+ observed_count: params.observedCount,
21
+ detected_at: params.detectedAt,
22
+ rejected_count: null,
23
+ })
24
+ .returning('id');
25
+ const id = inserted[0]?.id;
26
+ if (typeof id !== 'number') {
27
+ throw new TypeError('insertDedupeCapEvent: INSERT returned no row id');
28
+ }
29
+ return id;
30
+ }
@@ -0,0 +1,21 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Every distinct `dedupe_cap_events.shape_key` recorded in this archive —
4
+ * used by `CrawlerOrchestrator` to preload `DedupeCapTracker`'s sticky set
5
+ * on `--resume` / `--append` / `--retry-failed` / `--inventory`, so a trap
6
+ * this crawl already paid the cost of discovering once is not re-admitted
7
+ * in a later session. Fresh (non-resuming) crawls do not call this — there
8
+ * is no archive history to seed from.
9
+ *
10
+ * Unlike `listDnsBurnedHostCandidates`, no additional exclusion logic is
11
+ * needed: once `DedupeCapTracker` confirms a shape as a trap, it stays
12
+ * confirmed — there is no equivalent of "the host might have recovered
13
+ * since".
14
+ *
15
+ * Returns `[]` on legacy archives that pre-date the `dedupe_cap_events`
16
+ * table (self-healed on next writer open, so this is never a permanent
17
+ * state) or that have recorded no capped shapes.
18
+ * @param knex - Knex query builder connected to the archive DB.
19
+ * @returns Distinct shape keys already confirmed capped.
20
+ */
21
+ export declare function listDedupeCapShapeKeys(knex: Knex): Promise<string[]>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Every distinct `dedupe_cap_events.shape_key` recorded in this archive —
3
+ * used by `CrawlerOrchestrator` to preload `DedupeCapTracker`'s sticky set
4
+ * on `--resume` / `--append` / `--retry-failed` / `--inventory`, so a trap
5
+ * this crawl already paid the cost of discovering once is not re-admitted
6
+ * in a later session. Fresh (non-resuming) crawls do not call this — there
7
+ * is no archive history to seed from.
8
+ *
9
+ * Unlike `listDnsBurnedHostCandidates`, no additional exclusion logic is
10
+ * needed: once `DedupeCapTracker` confirms a shape as a trap, it stays
11
+ * confirmed — there is no equivalent of "the host might have recovered
12
+ * since".
13
+ *
14
+ * Returns `[]` on legacy archives that pre-date the `dedupe_cap_events`
15
+ * table (self-healed on next writer open, so this is never a permanent
16
+ * state) or that have recorded no capped shapes.
17
+ * @param knex - Knex query builder connected to the archive DB.
18
+ * @returns Distinct shape keys already confirmed capped.
19
+ */
20
+ export async function listDedupeCapShapeKeys(knex) {
21
+ const hasTable = await knex.schema.hasTable('dedupe_cap_events');
22
+ if (!hasTable) {
23
+ return [];
24
+ }
25
+ const rows = (await knex('dedupe_cap_events').distinct('shape_key'));
26
+ return rows.map((row) => row.shape_key);
27
+ }
@@ -16,6 +16,17 @@ import type { Knex } from 'knex';
16
16
  *
17
17
  * Returns `[]` on legacy archives that pre-date the `crawl_errors`
18
18
  * table — the `hasTable` guard keeps the call non-destructive.
19
+ *
20
+ * **Outage exclusion**: a host whose latest DNS error's `createdAt` falls
21
+ * inside a recorded `network_outages` window is dropped from the result
22
+ * regardless of the other checks. A host with no prior success this
23
+ * archive has ever seen (the case this whole exclusion-bag scheme cannot
24
+ * otherwise catch) would, without this check, get preload-seeded into
25
+ * `dnsBurnedHostCache` and short-circuit on EVERY subsequent session
26
+ * forever — the exact "damage 3" this function's caller
27
+ * (`#preloadDnsBurnedHostCache`) exists to prevent for hosts genuinely
28
+ * proven dead, not to inflict on hosts merely unlucky enough to be first
29
+ * contacted during an operator-side network blip.
19
30
  * @param knex - Knex query builder connected to the archive DB.
20
31
  * @returns Lower-cased hostnames safe to short-circuit.
21
32
  */
@@ -1,4 +1,6 @@
1
1
  import { classifyErrorKind } from '../../../classify-error-kind.js';
2
+ import { isWithinOutageWindow } from '../../../is-within-outage-window.js';
3
+ import { listNetworkOutages } from '../outages/list-network-outages.js';
2
4
  /**
3
5
  * Hostnames whose `crawl_errors` history is consistently DNS failures and
4
6
  * for which no recent 2xx-3xx page or resource is recorded — i.e. hosts
@@ -16,6 +18,17 @@ import { classifyErrorKind } from '../../../classify-error-kind.js';
16
18
  *
17
19
  * Returns `[]` on legacy archives that pre-date the `crawl_errors`
18
20
  * table — the `hasTable` guard keeps the call non-destructive.
21
+ *
22
+ * **Outage exclusion**: a host whose latest DNS error's `createdAt` falls
23
+ * inside a recorded `network_outages` window is dropped from the result
24
+ * regardless of the other checks. A host with no prior success this
25
+ * archive has ever seen (the case this whole exclusion-bag scheme cannot
26
+ * otherwise catch) would, without this check, get preload-seeded into
27
+ * `dnsBurnedHostCache` and short-circuit on EVERY subsequent session
28
+ * forever — the exact "damage 3" this function's caller
29
+ * (`#preloadDnsBurnedHostCache`) exists to prevent for hosts genuinely
30
+ * proven dead, not to inflict on hosts merely unlucky enough to be first
31
+ * contacted during an operator-side network blip.
19
32
  * @param knex - Knex query builder connected to the archive DB.
20
33
  * @returns Lower-cased hostnames safe to short-circuit.
21
34
  */
@@ -118,11 +131,12 @@ export async function listDnsBurnedHostCandidates(knex) {
118
131
  }
119
132
  resourceOkHosts.add(host);
120
133
  }
134
+ const outageWindows = await listNetworkOutages(knex);
121
135
  // A candidate host is burned only if neither pages nor resources hold a
122
- // 2xx-3xx for it, AND its latest 2xx page (if any) is not newer than
123
- // the latest DNS error. The third check guards against re-burning a
124
- // host that recovered between the last DNS failure and the most recent
125
- // crawl.
136
+ // 2xx-3xx for it, its latest 2xx page (if any) is not newer than the
137
+ // latest DNS error (guards against re-burning a host that recovered
138
+ // between the last DNS failure and the most recent crawl), AND that
139
+ // latest DNS error did not happen during a recorded network outage.
126
140
  const burned = [];
127
141
  for (const [host, latestErrorAt] of candidateLatestErrorAt) {
128
142
  if (pageOkHosts.has(host)) {
@@ -135,6 +149,9 @@ export async function listDnsBurnedHostCandidates(knex) {
135
149
  if (typeof latestOkAt === 'number' && latestOkAt > latestErrorAt) {
136
150
  continue;
137
151
  }
152
+ if (isWithinOutageWindow(latestErrorAt, outageWindows)) {
153
+ continue;
154
+ }
138
155
  burned.push(host);
139
156
  }
140
157
  return burned;
@@ -27,6 +27,7 @@ export async function recordInventoryRun(knex, meta) {
27
27
  new_pages: meta.new_pages ?? null,
28
28
  new_resources: meta.new_resources ?? null,
29
29
  scope_skipped: meta.scope_skipped ?? null,
30
+ invalid_skipped: meta.invalid_skipped ?? null,
30
31
  notes: meta.notes ?? null,
31
32
  })
32
33
  .returning('id');
@@ -2,7 +2,7 @@ import type { Knex } from 'knex';
2
2
  /**
3
3
  * Initializes the database schema if tables do not exist, then runs the
4
4
  * remaining lightweight migrations (`info.roots`, `info.mainContentSelector`,
5
- * `page_meta.main_content_*`).
5
+ * `page_meta.main_content_*`, `inventory_runs.invalid_skipped`).
6
6
  *
7
7
  * There is deliberately no per-table *table-creation* migration chain here:
8
8
  * `assertCompatibleVersion` (called below, before any schema work) rejects
@@ -17,7 +17,16 @@ import type { Knex } from 'knex';
17
17
  * `CREATE TABLE IF NOT EXISTS` is a no-op once the table is present. Column
18
18
  * additions to an existing 0.13 table are therefore the one case that still
19
19
  * needs an explicit `hasColumn`-guarded `ALTER TABLE` here (`migrateInfoRoots`,
20
- * `migrateMainContentsColumns`) rather than a DDL-string change alone.
20
+ * `migrateMainContentsColumns`, `migratePageMetaBodyHash`,
21
+ * `migratePageMetaConsoleErrorCount`, `migrateContentItemsAliasOfId`,
22
+ * `migrateInventoryRunsInvalidSkipped`) rather than a DDL-string change alone.
23
+ *
24
+ * `closeStaleOpenNetworkOutages` is not a schema migration (no columns
25
+ * change) but belongs at this same boot phase for the same reason the
26
+ * others do: it must run before ANY reader (`resetFailedPages`,
27
+ * `listDnsBurnedHostCandidates`, …) can observe a `network_outages` row
28
+ * left `ended_at = NULL` by a crawl process that was killed mid-outage in a
29
+ * prior session.
21
30
  *
22
31
  * In read-only mode schema init + migration are SKIPPED so the same DB
23
32
  * can be opened safely by a viewer attached to a live (or interrupted)
@@ -1,12 +1,17 @@
1
1
  import { applyConnectionPragmas, initSchema } from '../../init-schema.js';
2
2
  import { assertCompatibleVersion } from '../../meta/assert-compatible-version.js';
3
+ import { migrateContentItemsAliasOfId } from '../../migrate-content-items-alias-of-id.js';
3
4
  import { migrateInfoMainContentSelector } from '../../migrate-info-main-content-selector.js';
4
5
  import { migrateInfoRoots } from '../../migrate-info-roots.js';
6
+ import { migrateInventoryRunsInvalidSkipped } from '../../migrate-inventory-runs-invalid-skipped.js';
5
7
  import { migrateMainContentsColumns } from '../../migrate-main-contents-columns.js';
8
+ import { migratePageMetaBodyHash } from '../../migrate-page-meta-body-hash.js';
9
+ import { migratePageMetaConsoleErrorCount } from '../../migrate-page-meta-console-error-count.js';
10
+ import { closeStaleOpenNetworkOutages } from '../outages/close-stale-open-network-outages.js';
6
11
  /**
7
12
  * Initializes the database schema if tables do not exist, then runs the
8
13
  * remaining lightweight migrations (`info.roots`, `info.mainContentSelector`,
9
- * `page_meta.main_content_*`).
14
+ * `page_meta.main_content_*`, `inventory_runs.invalid_skipped`).
10
15
  *
11
16
  * There is deliberately no per-table *table-creation* migration chain here:
12
17
  * `assertCompatibleVersion` (called below, before any schema work) rejects
@@ -21,7 +26,16 @@ import { migrateMainContentsColumns } from '../../migrate-main-contents-columns.
21
26
  * `CREATE TABLE IF NOT EXISTS` is a no-op once the table is present. Column
22
27
  * additions to an existing 0.13 table are therefore the one case that still
23
28
  * needs an explicit `hasColumn`-guarded `ALTER TABLE` here (`migrateInfoRoots`,
24
- * `migrateMainContentsColumns`) rather than a DDL-string change alone.
29
+ * `migrateMainContentsColumns`, `migratePageMetaBodyHash`,
30
+ * `migratePageMetaConsoleErrorCount`, `migrateContentItemsAliasOfId`,
31
+ * `migrateInventoryRunsInvalidSkipped`) rather than a DDL-string change alone.
32
+ *
33
+ * `closeStaleOpenNetworkOutages` is not a schema migration (no columns
34
+ * change) but belongs at this same boot phase for the same reason the
35
+ * others do: it must run before ANY reader (`resetFailedPages`,
36
+ * `listDnsBurnedHostCandidates`, …) can observe a `network_outages` row
37
+ * left `ended_at = NULL` by a crawl process that was killed mid-outage in a
38
+ * prior session.
25
39
  *
26
40
  * In read-only mode schema init + migration are SKIPPED so the same DB
27
41
  * can be opened safely by a viewer attached to a live (or interrupted)
@@ -49,4 +63,9 @@ export async function init(knex, readOnly) {
49
63
  await migrateInfoRoots(knex);
50
64
  await migrateInfoMainContentSelector(knex);
51
65
  await migrateMainContentsColumns(knex);
66
+ await migratePageMetaBodyHash(knex);
67
+ await migratePageMetaConsoleErrorCount(knex);
68
+ await migrateContentItemsAliasOfId(knex);
69
+ await migrateInventoryRunsInvalidSkipped(knex);
70
+ await closeStaleOpenNetworkOutages(knex);
52
71
  }
@@ -0,0 +1,12 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Close an outage row by stamping `ended_at` — but ONLY if it is still
4
+ * open. The `whereNull('ended_at')` guard is what makes this idempotent: a
5
+ * second call (e.g. a duplicate recovery-probe success racing the first)
6
+ * matches zero rows and is a silent no-op, rather than overwriting an
7
+ * already-recorded `ended_at` with a later timestamp.
8
+ * @param knex - Knex query builder connected to the archive DB.
9
+ * @param id - The `network_outages.id` to close.
10
+ * @param endedAt - Epoch ms the outage is considered to have ended.
11
+ */
12
+ export declare function closeNetworkOutage(knex: Knex, id: number, endedAt: number): Promise<void>;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Close an outage row by stamping `ended_at` — but ONLY if it is still
3
+ * open. The `whereNull('ended_at')` guard is what makes this idempotent: a
4
+ * second call (e.g. a duplicate recovery-probe success racing the first)
5
+ * matches zero rows and is a silent no-op, rather than overwriting an
6
+ * already-recorded `ended_at` with a later timestamp.
7
+ * @param knex - Knex query builder connected to the archive DB.
8
+ * @param id - The `network_outages.id` to close.
9
+ * @param endedAt - Epoch ms the outage is considered to have ended.
10
+ */
11
+ export async function closeNetworkOutage(knex, id, endedAt) {
12
+ await knex('network_outages').where({ id }).whereNull('ended_at').update({
13
+ ended_at: endedAt,
14
+ });
15
+ }