@nitpicker/crawler 0.14.0 → 0.15.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 (120) hide show
  1. package/lib/archive/archive.d.ts +98 -10
  2. package/lib/archive/archive.js +98 -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 +11 -0
  22. package/lib/archive/create-adjunct-tables.js +127 -0
  23. package/lib/archive/create-entity-tables.js +21 -1
  24. package/lib/archive/database.d.ts +37 -2
  25. package/lib/archive/database.js +50 -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/errors/list-dns-burned-host-candidates.d.ts +11 -0
  47. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.js +21 -4
  48. package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
  49. package/lib/archive/db-ops/lifecycle/init.d.ts +11 -2
  50. package/lib/archive/db-ops/lifecycle/init.js +21 -2
  51. package/lib/archive/db-ops/outages/close-network-outage.d.ts +12 -0
  52. package/lib/archive/db-ops/outages/close-network-outage.js +15 -0
  53. package/lib/archive/db-ops/outages/close-stale-open-network-outages.d.ts +26 -0
  54. package/lib/archive/db-ops/outages/close-stale-open-network-outages.js +42 -0
  55. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.d.ts +26 -0
  56. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.js +31 -0
  57. package/lib/archive/db-ops/outages/insert-network-outage.d.ts +14 -0
  58. package/lib/archive/db-ops/outages/insert-network-outage.js +29 -0
  59. package/lib/archive/db-ops/outages/list-network-outages.d.ts +28 -0
  60. package/lib/archive/db-ops/outages/list-network-outages.js +42 -0
  61. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +13 -0
  62. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +35 -7
  63. package/lib/archive/db-ops/pages/write/update-page.js +12 -0
  64. package/lib/archive/db-ops/pages/write/write-page-html-blob.js +6 -6
  65. package/lib/archive/filesystem/output-binary.d.ts +12 -0
  66. package/lib/archive/filesystem/output-binary.js +17 -0
  67. package/lib/archive/get-failed-page-messages.d.ts +20 -8
  68. package/lib/archive/get-failed-page-messages.js +29 -15
  69. package/lib/archive/migrate-content-items-alias-of-id.d.ts +47 -0
  70. package/lib/archive/migrate-content-items-alias-of-id.js +58 -0
  71. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +11 -0
  72. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +24 -0
  73. package/lib/archive/migrate-page-meta-body-hash.d.ts +39 -0
  74. package/lib/archive/migrate-page-meta-body-hash.js +52 -0
  75. package/lib/archive/migrate-page-meta-console-error-count.d.ts +22 -0
  76. package/lib/archive/migrate-page-meta-console-error-count.js +34 -0
  77. package/lib/archive/page.d.ts +1 -1
  78. package/lib/archive/types.d.ts +47 -2
  79. package/lib/archive/url-alias/compute-tier-a-alias-key.d.ts +31 -0
  80. package/lib/archive/url-alias/compute-tier-a-alias-key.js +36 -0
  81. package/lib/archive/url-alias/compute-tier-b-alias-key.d.ts +19 -0
  82. package/lib/archive/url-alias/compute-tier-b-alias-key.js +30 -0
  83. package/lib/archive/url-alias/format-alias-key.d.ts +13 -0
  84. package/lib/archive/url-alias/format-alias-key.js +14 -0
  85. package/lib/archive/url-alias/parse-alias-key-parts.d.ts +15 -0
  86. package/lib/archive/url-alias/parse-alias-key-parts.js +40 -0
  87. package/lib/archive/url-alias/types.d.ts +7 -0
  88. package/lib/archive/url-alias/types.js +1 -0
  89. package/lib/crawler/choose-probe-host.d.ts +24 -0
  90. package/lib/crawler/choose-probe-host.js +38 -0
  91. package/lib/crawler/clear-dns-burned-host-cache.js +2 -0
  92. package/lib/crawler/crawler.js +257 -0
  93. package/lib/crawler/dns-burned-host-burn-timestamps.d.ts +17 -0
  94. package/lib/crawler/dns-burned-host-burn-timestamps.js +17 -0
  95. package/lib/crawler/evict-network-classified-destination-cache-entries.d.ts +26 -0
  96. package/lib/crawler/evict-network-classified-destination-cache-entries.js +34 -0
  97. package/lib/crawler/evict-outage-tainted-dns-burns.d.ts +23 -0
  98. package/lib/crawler/evict-outage-tainted-dns-burns.js +26 -0
  99. package/lib/crawler/network-gate.d.ts +49 -0
  100. package/lib/crawler/network-gate.js +78 -0
  101. package/lib/crawler/network-outage-detector.d.ts +51 -0
  102. package/lib/crawler/network-outage-detector.js +81 -0
  103. package/lib/crawler/network-outage-summary-counter.d.ts +21 -0
  104. package/lib/crawler/network-outage-summary-counter.js +18 -0
  105. package/lib/crawler/probe-network.d.ts +26 -0
  106. package/lib/crawler/probe-network.js +21 -0
  107. package/lib/crawler/types.d.ts +147 -1
  108. package/lib/crawler-orchestrator.d.ts +62 -18
  109. package/lib/crawler-orchestrator.js +161 -26
  110. package/lib/crawler.d.ts +21 -0
  111. package/lib/crawler.js +17 -0
  112. package/lib/is-within-outage-window.d.ts +49 -0
  113. package/lib/is-within-outage-window.js +33 -0
  114. package/lib/network-related-error-kinds.d.ts +23 -0
  115. package/lib/network-related-error-kinds.js +28 -0
  116. package/lib/types.d.ts +9 -1
  117. package/lib/utils/array/each-splitted.d.ts +1 -1
  118. package/lib/utils/compute-file-sha256.d.ts +17 -17
  119. package/lib/utils/compute-file-sha256.js +18 -47
  120. package/package.json +3 -3
@@ -15,6 +15,7 @@ import { getConfig as getConfigOp } from './db-ops/config/get-config.js';
15
15
  import { getName as getNameOp } from './db-ops/config/get-name.js';
16
16
  import { setConfig as setConfigOp } from './db-ops/config/set-config.js';
17
17
  import { updateConfig as updateConfigOp } from './db-ops/config/update-config.js';
18
+ import { replaceConsoleLogs as replaceConsoleLogsOp } from './db-ops/console-logs/replace-console-logs.js';
18
19
  import { insertCrawlError as insertCrawlErrorOp } from './db-ops/errors/insert-crawl-error.js';
19
20
  import { insertPageError as insertPageErrorOp } from './db-ops/errors/insert-page-error.js';
20
21
  import { listDnsBurnedHostCandidates as listDnsBurnedHostCandidatesOp } from './db-ops/errors/list-dns-burned-host-candidates.js';
@@ -33,6 +34,9 @@ import { getMainContentImagesOfPage as getMainContentImagesOfPageOp } from './db
33
34
  import { getMainContentTablesOfPage as getMainContentTablesOfPageOp } from './db-ops/meta/get-main-content-tables-of-page.js';
34
35
  import { getTagsOfPage as getTagsOfPageOp } from './db-ops/meta/get-tags-of-page.js';
35
36
  import { getVideosOfPage as getVideosOfPageOp } from './db-ops/meta/get-videos-of-page.js';
37
+ import { closeNetworkOutage as closeNetworkOutageOp } from './db-ops/outages/close-network-outage.js';
38
+ import { insertNetworkOutage as insertNetworkOutageOp } from './db-ops/outages/insert-network-outage.js';
39
+ import { listNetworkOutages as listNetworkOutagesOp } from './db-ops/outages/list-network-outages.js';
36
40
  import { setUrlOrder as setUrlOrderOp } from './db-ops/pages/order/set-url-order.js';
37
41
  import { getCrawlingState as getCrawlingStateOp } from './db-ops/pages/read/get-crawling-state.js';
38
42
  import { getExistingPageUrls as getExistingPageUrlsOp } from './db-ops/pages/read/get-existing-page-urls.js';
@@ -121,6 +125,15 @@ export class Database extends EventEmitter {
121
125
  async checkpoint() {
122
126
  await checkpointOp(this.#instance);
123
127
  }
128
+ /**
129
+ * Closes an outage row by stamping `ended_at` — a no-op if the row is
130
+ * already closed. Delegates to {@link closeNetworkOutageOp}.
131
+ * @param id - The `network_outages.id` to close.
132
+ * @param endedAt - Epoch ms the outage is considered to have ended.
133
+ */
134
+ async closeNetworkOutage(id, endedAt) {
135
+ return emitErrorAndRetry(this, 'Database.closeNetworkOutage', async () => await closeNetworkOutageOp(this.#instance, id, endedAt), retrySetting);
136
+ }
124
137
  /**
125
138
  * Destroys the database connection, releasing all pooled resources.
126
139
  * Delegates to {@link destroyOp}.
@@ -427,6 +440,15 @@ export class Database extends EventEmitter {
427
440
  async insertInventorySeeds(urls) {
428
441
  return emitErrorAndRetry(this, 'Database.insertInventorySeeds', async () => await insertInventorySeedsOp(this.#instance, this.#writeRefCaches, urls), retrySetting);
429
442
  }
443
+ /**
444
+ * Appends one open (`ended_at = NULL`) row to the `network_outages`
445
+ * journal. Delegates to {@link insertNetworkOutageOp}.
446
+ * @param params - The confirmed-outage fields to record.
447
+ * @returns The autoincremented `id` of the newly-inserted row.
448
+ */
449
+ async insertNetworkOutage(params) {
450
+ return emitErrorAndRetry(this, 'Database.insertNetworkOutage', async () => await insertNetworkOutageOp(this.#instance, params), retrySetting);
451
+ }
430
452
  /**
431
453
  * Records a partial scrape failure against the page identified by `url`.
432
454
  * Delegates to {@link insertPageErrorOp}.
@@ -465,6 +487,15 @@ export class Database extends EventEmitter {
465
487
  async listDnsBurnedHostCandidates() {
466
488
  return emitErrorAndRetry(this, 'Database.listDnsBurnedHostCandidates', async () => await listDnsBurnedHostCandidatesOp(this.#instance), retrySetting);
467
489
  }
490
+ /**
491
+ * Lists every recorded outage as a resolved {@link OutageWindow}.
492
+ * Delegates to {@link listNetworkOutagesOp}.
493
+ * @returns Resolved outage windows, or `[]` on an archive that predates
494
+ * `network_outages` or has recorded no outages.
495
+ */
496
+ async listNetworkOutages() {
497
+ return emitErrorAndRetry(this, 'Database.listNetworkOutages', async () => await listNetworkOutagesOp(this.#instance), retrySetting);
498
+ }
468
499
  /**
469
500
  * Appends one row to the `inventory_runs` audit log.
470
501
  * Delegates to {@link recordInventoryRunOp}.
@@ -494,13 +525,29 @@ export class Database extends EventEmitter {
494
525
  async replaceAnalysisViolations(violations) {
495
526
  return emitErrorAndRetry(this, 'Database.replaceAnalysisViolations', async () => await replaceAnalysisViolationsOp(this.#instance, violations), retrySetting);
496
527
  }
528
+ /**
529
+ * Replaces one page's `page_console_logs` rows with a freshly captured
530
+ * set of console messages / page errors. Delegates to
531
+ * {@link replaceConsoleLogsOp}.
532
+ * @param pageUrl - The originally-requested URL, normalised (`withoutHashAndAuth` form).
533
+ * @param redirectPaths - The redirect chain hops captured during fetch, in order.
534
+ * @param entries - The console log entries to persist.
535
+ */
536
+ async replaceConsoleLogs(pageUrl, redirectPaths, entries) {
537
+ return emitErrorAndRetry(this, 'Database.replaceConsoleLogs', async () => await replaceConsoleLogsOp(this.#instance, this.#writeRefCaches, pageUrl, redirectPaths, entries), retrySetting);
538
+ }
497
539
  /**
498
540
  * Replaces the stored DOM-structure template classification with a
499
541
  * freshly generated set. Delegates to {@link replacePageTemplatesOp}.
500
542
  * @param templateKeysByUrl - Page URL → template key.
501
- */
502
- async replacePageTemplates(templateKeysByUrl) {
503
- return emitErrorAndRetry(this, 'Database.replacePageTemplates', async () => await replacePageTemplatesOp(this.#instance, templateKeysByUrl), retrySetting);
543
+ * @param clusterReasonsByTemplateKey - Template key → cluster-selection
544
+ * evidence, if the caller captured it.
545
+ */
546
+ async replacePageTemplates(templateKeysByUrl, clusterReasonsByTemplateKey) {
547
+ return emitErrorAndRetry(this, 'Database.replacePageTemplates', async () => await replacePageTemplatesOp(this.#instance, {
548
+ templateKeysByUrl,
549
+ clusterReasonsByTemplateKey,
550
+ }), retrySetting);
504
551
  }
505
552
  /**
506
553
  * Promote previously-external pages whose URL falls under any of the new
@@ -30,5 +30,6 @@ export function clearWriteRefCaches(caches) {
30
30
  caches.contentTypeIds.clear();
31
31
  caches.jsonIds.clear();
32
32
  caches.blobIds.clear();
33
+ caches.consoleLogIds.clear();
33
34
  caches.headers = null;
34
35
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Compresses raw bytes for storage in a `codec`/`size_raw`/`size_stored`
3
+ * BLOB column, matching the read side of
4
+ * {@link import('./decode-json-ref.js').decodeJsonRef} and
5
+ * {@link import('../../decode-html-blob.js').decodeStoredBlob}.
6
+ *
7
+ * The one shared primitive behind every `codec: 'zstd'` writer in the
8
+ * archive (`upsert-json-ref.ts`, `write-page-html-blob.ts`,
9
+ * `replace-page-templates.ts`) — a codec change (e.g. a brotli fallback, a
10
+ * different zstd level) only has to happen here. Callers that also need a
11
+ * content hash (`upsert-json-ref.ts`, `write-page-html-blob.ts`) compute it
12
+ * themselves from the same `rawBytes` they pass in here, rather than this
13
+ * function computing one not every caller needs.
14
+ * @param rawBytes - The raw (uncompressed) payload bytes.
15
+ * @returns The compressed body alongside the codec and size bookkeeping the
16
+ * BLOB column's schema expects.
17
+ * @example
18
+ * const rawBytes = Buffer.from(JSON.stringify(reason), 'utf8');
19
+ * const { body, codec, sizeRaw, sizeStored } = compressPayload(rawBytes);
20
+ */
21
+ export declare function compressPayload(rawBytes: Buffer): {
22
+ readonly body: Buffer;
23
+ readonly codec: 'zstd';
24
+ readonly sizeRaw: number;
25
+ readonly sizeStored: number;
26
+ };
@@ -0,0 +1,30 @@
1
+ import { zstdCompressSync } from 'node:zlib';
2
+ /**
3
+ * Compresses raw bytes for storage in a `codec`/`size_raw`/`size_stored`
4
+ * BLOB column, matching the read side of
5
+ * {@link import('./decode-json-ref.js').decodeJsonRef} and
6
+ * {@link import('../../decode-html-blob.js').decodeStoredBlob}.
7
+ *
8
+ * The one shared primitive behind every `codec: 'zstd'` writer in the
9
+ * archive (`upsert-json-ref.ts`, `write-page-html-blob.ts`,
10
+ * `replace-page-templates.ts`) — a codec change (e.g. a brotli fallback, a
11
+ * different zstd level) only has to happen here. Callers that also need a
12
+ * content hash (`upsert-json-ref.ts`, `write-page-html-blob.ts`) compute it
13
+ * themselves from the same `rawBytes` they pass in here, rather than this
14
+ * function computing one not every caller needs.
15
+ * @param rawBytes - The raw (uncompressed) payload bytes.
16
+ * @returns The compressed body alongside the codec and size bookkeeping the
17
+ * BLOB column's schema expects.
18
+ * @example
19
+ * const rawBytes = Buffer.from(JSON.stringify(reason), 'utf8');
20
+ * const { body, codec, sizeRaw, sizeStored } = compressPayload(rawBytes);
21
+ */
22
+ export function compressPayload(rawBytes) {
23
+ const compressed = zstdCompressSync(rawBytes);
24
+ return {
25
+ body: compressed,
26
+ codec: 'zstd',
27
+ sizeRaw: rawBytes.byteLength,
28
+ sizeStored: compressed.byteLength,
29
+ };
30
+ }
@@ -21,6 +21,7 @@ export function createWriteRefCaches() {
21
21
  contentTypeIds: new Map(),
22
22
  jsonIds: new Map(),
23
23
  blobIds: new Map(),
24
+ consoleLogIds: new Map(),
24
25
  headers: null,
25
26
  };
26
27
  }
@@ -42,6 +42,8 @@ export interface WriteRefCaches {
42
42
  readonly jsonIds: Map<string, number>;
43
43
  /** `hex(content hash) → blob_refs.id`. */
44
44
  readonly blobIds: Map<string, number>;
45
+ /** `hex(content hash) → console_log_items.id`. */
46
+ readonly consoleLogIds: Map<string, number>;
45
47
  /**
46
48
  * Header dictionary caches (`header_name_refs` / `header_value_refs` /
47
49
  * `header_sets` id maps). `null` until the first header-set upsert
@@ -1,5 +1,5 @@
1
- import { zstdCompressSync } from 'node:zlib';
2
1
  import { computeContentHash } from '../../populate-ref-tables/compute-content-hash.js';
2
+ import { compressPayload } from './compress-payload.js';
3
3
  /**
4
4
  * Resolves the `json_refs.id` for one JSON payload string, inserting the
5
5
  * deduplicated (hash-keyed) row when the payload is new.
@@ -27,11 +27,11 @@ export async function upsertJsonRef(qb, caches, jsonText) {
27
27
  if (cached !== undefined) {
28
28
  return cached;
29
29
  }
30
- const compressed = zstdCompressSync(rawBytes);
30
+ const { body, sizeRaw, sizeStored } = compressPayload(rawBytes);
31
31
  const rows = await qb.raw(`INSERT INTO json_refs (hash, json_text, codec, size_raw, size_stored)
32
32
  VALUES (?, ?, 'zstd', ?, ?)
33
33
  ON CONFLICT(hash) DO UPDATE SET hash = hash
34
- RETURNING id`, [hash, compressed, rawBytes.byteLength, compressed.byteLength]);
34
+ RETURNING id`, [hash, body, sizeRaw, sizeStored]);
35
35
  const first = rows[0];
36
36
  if (first === undefined) {
37
37
  throw new Error('upsertJsonRef: RETURNING yielded no row');
@@ -1,3 +1,4 @@
1
+ import type { ReplacePageTemplatesParams } from './types.js';
1
2
  import type { Knex } from 'knex';
2
3
  /**
3
4
  * Replaces the stored DOM-structure template classification (`--templates`)
@@ -13,8 +14,16 @@ import type { Knex } from 'knex';
13
14
  * (e.g. a URL-normalization mismatch between the in-memory `Page.url.href`
14
15
  * and the stored `url_refs.url`) should not discard the rest of a
15
16
  * potentially multi-thousand-page classification run.
17
+ *
18
+ * `page_template_clusters` is always cleared alongside `page_templates`
19
+ * regardless of whether `clusterReasonsByTemplateKey` is passed — "no
20
+ * reason" must mean "not captured for this run", never "carry over the
21
+ * previous run's reason". Reason rows are inserted for every key in
22
+ * `clusterReasonsByTemplateKey` even if some have no surviving member page
23
+ * in `templateKeysByUrl` after URL-resolution skips above — harmless
24
+ * (nothing joins `page_template_clusters` back to `page_templates` by FK;
25
+ * see the table's own JSDoc), and simpler than cross-filtering the two maps.
16
26
  * @param knex - Knex query builder connected to the archive DB.
17
- * @param templateKeysByUrl - Page URL → template key, as produced by
18
- * `classifyPageTemplates`.
27
+ * @param params - See {@link ReplacePageTemplatesParams}.
19
28
  */
20
- export declare function replacePageTemplates(knex: Knex, templateKeysByUrl: ReadonlyMap<string, string>): Promise<void>;
29
+ export declare function replacePageTemplates(knex: Knex, params: ReplacePageTemplatesParams): Promise<void>;
@@ -1,4 +1,5 @@
1
1
  import { eachSplitted } from '../../../utils/array/each-splitted.js';
2
+ import { compressPayload } from '../_shared/compress-payload.js';
2
3
  /**
3
4
  * Replaces the stored DOM-structure template classification (`--templates`)
4
5
  * with a freshly generated set.
@@ -13,13 +14,44 @@ import { eachSplitted } from '../../../utils/array/each-splitted.js';
13
14
  * (e.g. a URL-normalization mismatch between the in-memory `Page.url.href`
14
15
  * and the stored `url_refs.url`) should not discard the rest of a
15
16
  * potentially multi-thousand-page classification run.
17
+ *
18
+ * `page_template_clusters` is always cleared alongside `page_templates`
19
+ * regardless of whether `clusterReasonsByTemplateKey` is passed — "no
20
+ * reason" must mean "not captured for this run", never "carry over the
21
+ * previous run's reason". Reason rows are inserted for every key in
22
+ * `clusterReasonsByTemplateKey` even if some have no surviving member page
23
+ * in `templateKeysByUrl` after URL-resolution skips above — harmless
24
+ * (nothing joins `page_template_clusters` back to `page_templates` by FK;
25
+ * see the table's own JSDoc), and simpler than cross-filtering the two maps.
16
26
  * @param knex - Knex query builder connected to the archive DB.
17
- * @param templateKeysByUrl - Page URL → template key, as produced by
18
- * `classifyPageTemplates`.
27
+ * @param params - See {@link ReplacePageTemplatesParams}.
19
28
  */
20
- export async function replacePageTemplates(knex, templateKeysByUrl) {
29
+ export async function replacePageTemplates(knex, params) {
30
+ const { templateKeysByUrl, clusterReasonsByTemplateKey } = params;
31
+ // Compressing every reason is pure CPU work independent of the DB — done
32
+ // before opening the transaction below so it doesn't extend how long the
33
+ // SQLite write-lock is held for.
34
+ const reasonRows = clusterReasonsByTemplateKey && clusterReasonsByTemplateKey.size > 0
35
+ ? [...clusterReasonsByTemplateKey].map(([templateKey, reason]) => {
36
+ const { body, codec, sizeRaw, sizeStored } = compressPayload(Buffer.from(JSON.stringify(reason), 'utf8'));
37
+ return {
38
+ template_key: templateKey,
39
+ member_count: reason.memberCount,
40
+ reason_json: body,
41
+ codec,
42
+ size_raw: sizeRaw,
43
+ size_stored: sizeStored,
44
+ };
45
+ })
46
+ : [];
21
47
  await knex.transaction(async (trx) => {
22
48
  await trx('page_templates').delete();
49
+ await trx('page_template_clusters').delete();
50
+ if (reasonRows.length > 0) {
51
+ await eachSplitted(reasonRows, 100, async (chunk) => {
52
+ await trx('page_template_clusters').insert(chunk);
53
+ });
54
+ }
23
55
  if (templateKeysByUrl.size === 0) {
24
56
  return;
25
57
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The six landmark types `@d-zero/page-cluster`'s `extractLandmarks` and
3
+ * `ClusterReason.landmarks` recognize.
4
+ */
5
+ export type TemplateClusterLandmarkType = 'header' | 'footer' | 'nav' | 'aside' | 'form' | 'search';
6
+ /**
7
+ * Why a Pass-0 block (one of possibly several that merged into a final
8
+ * cluster) was formed — mirrors `@d-zero/page-cluster`'s `BlockingReason`
9
+ * discriminated union.
10
+ */
11
+ export type TemplateClusterBlockingReason = {
12
+ readonly kind: 'css';
13
+ readonly distinctiveStylesheetHrefs: readonly string[];
14
+ } | {
15
+ readonly kind: 'path';
16
+ readonly pathKey: string;
17
+ } | {
18
+ readonly kind: 'orphanMerge';
19
+ readonly pathKey: string;
20
+ };
21
+ /** One block's blocking key and the reason it was formed. */
22
+ export interface TemplateClusterBlockingEvidence {
23
+ readonly blockKey: string;
24
+ readonly reason: TemplateClusterBlockingReason;
25
+ }
26
+ /** How common one landmark type is across a cluster's member pages. */
27
+ export interface TemplateClusterLandmarkProfile {
28
+ readonly presenceRate: number;
29
+ readonly chromeRate: number;
30
+ readonly shellTokens: readonly string[];
31
+ readonly memberCountWithInstance: number;
32
+ }
33
+ /**
34
+ * nitpicker's own copy of `@d-zero/page-cluster`'s `ClusterReason` shape.
35
+ * Kept independent of the `@d-zero/page-cluster` package (rather than
36
+ * importing its type directly) so `@nitpicker/query` — which does not
37
+ * depend on `@d-zero/page-cluster` and is consumed by the browser-side
38
+ * viewer build — never has to add that dependency just to type a value
39
+ * read back out of the archive. `@d-zero/page-cluster`'s `ClusterReason`
40
+ * is structurally assignable to this type.
41
+ */
42
+ export interface TemplateClusterReason {
43
+ readonly memberCount: number;
44
+ readonly blocking: readonly TemplateClusterBlockingEvidence[];
45
+ readonly structuralCoreTokens: readonly string[];
46
+ readonly landmarks: Partial<Record<TemplateClusterLandmarkType, TemplateClusterLandmarkProfile>>;
47
+ readonly siblingClusterKeys: readonly string[];
48
+ }
49
+ /**
50
+ * Params for {@link import('./replace-page-templates.js').replacePageTemplates}.
51
+ */
52
+ export interface ReplacePageTemplatesParams {
53
+ /** Page URL → template key, as produced by `classifyPageTemplates`. */
54
+ readonly templateKeysByUrl: ReadonlyMap<string, string>;
55
+ /**
56
+ * Template key → `@d-zero/page-cluster`'s cluster-selection evidence for
57
+ * that key, as produced by `classifyPageTemplates`. Omitted entirely (not
58
+ * just empty) when the caller didn't request reasons.
59
+ */
60
+ readonly clusterReasonsByTemplateKey?: ReadonlyMap<string, TemplateClusterReason>;
61
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,19 @@
1
+ import type { ConsoleLogContent } from './types.js';
2
+ /**
3
+ * Computes the `console_log_items.hash` for one console log entry's content.
4
+ *
5
+ * Hashing over the raw content fields (not their resolved ref ids) keeps the
6
+ * dictionary row's identity independent of ref-table state, matching
7
+ * `text_refs` / `json_refs` / `url_refs`, whose hash columns are likewise
8
+ * computed from source content rather than downstream ids. The same
9
+ * message logged by a shared framework on every page therefore hashes to
10
+ * the same value regardless of insertion order or which page's scrape
11
+ * resolves the dictionary row first.
12
+ * @param entry - The content fields to hash. `argsJson` must already be
13
+ * `stringifyConsoleLogArgs`'s output — this function does not
14
+ * re-serialize `args` itself.
15
+ * @returns 32-byte SHA-256 hash, ready for the `hash BLOB UNIQUE` column.
16
+ * @example
17
+ * const hash = computeConsoleLogHash({ type: 'error', text: 'boom', argsJson: null });
18
+ */
19
+ export declare function computeConsoleLogHash(entry: ConsoleLogContent): Buffer;
@@ -0,0 +1,30 @@
1
+ import { computeContentHash } from '../../populate-ref-tables/compute-content-hash.js';
2
+ /**
3
+ * Computes the `console_log_items.hash` for one console log entry's content.
4
+ *
5
+ * Hashing over the raw content fields (not their resolved ref ids) keeps the
6
+ * dictionary row's identity independent of ref-table state, matching
7
+ * `text_refs` / `json_refs` / `url_refs`, whose hash columns are likewise
8
+ * computed from source content rather than downstream ids. The same
9
+ * message logged by a shared framework on every page therefore hashes to
10
+ * the same value regardless of insertion order or which page's scrape
11
+ * resolves the dictionary row first.
12
+ * @param entry - The content fields to hash. `argsJson` must already be
13
+ * `stringifyConsoleLogArgs`'s output — this function does not
14
+ * re-serialize `args` itself.
15
+ * @returns 32-byte SHA-256 hash, ready for the `hash BLOB UNIQUE` column.
16
+ * @example
17
+ * const hash = computeConsoleLogHash({ type: 'error', text: 'boom', argsJson: null });
18
+ */
19
+ export function computeConsoleLogHash(entry) {
20
+ const canonical = JSON.stringify([
21
+ entry.type,
22
+ entry.text,
23
+ entry.argsJson,
24
+ entry.location?.url ?? null,
25
+ entry.location?.lineNumber ?? null,
26
+ entry.location?.columnNumber ?? null,
27
+ entry.stack ?? null,
28
+ ]);
29
+ return computeContentHash(canonical);
30
+ }
@@ -0,0 +1,34 @@
1
+ import type { WriteRefCaches } from '../_shared/types.js';
2
+ import type { ConsoleLogEntry } from '@d-zero/beholder';
3
+ import type { Knex } from 'knex';
4
+ /**
5
+ * Replaces one page's `page_console_logs` rows wholesale with a freshly
6
+ * captured set of console messages / page errors (issue #228).
7
+ *
8
+ * Scoped-Replace, the same pattern as `replaceAnchorEdges` /
9
+ * `replaceImageItems`: the page's existing rows are deleted and the new
10
+ * set is inserted in the same transaction, so a re-scrape (`--append` /
11
+ * `--retry-failed` / re-render) never accumulates duplicate occurrences.
12
+ * Unlike those two, there is no non-empty guard on the DELETE here — the
13
+ * caller (`Crawler#handleConsoleLogs`) already skips emitting the event
14
+ * entirely when `entries` is empty, which is what keeps a degraded
15
+ * re-scrape from wiping out a prior good capture.
16
+ *
17
+ * `pageUrl` is the originally-requested URL (matching `updatePage`'s
18
+ * `page.url.withoutHashAndAuth` contract), not necessarily the page that
19
+ * ends up holding the content: `resolveRedirectChain` derives the same
20
+ * redirect destination `updatePage` writes content under, so console logs
21
+ * attach to the row that actually carries the page's `page_meta`, not to
22
+ * an empty placeholder for the pre-redirect URL.
23
+ * @param knex - Knex query builder connected to the archive DB.
24
+ * @param caches - The connection's write-side id caches.
25
+ * @param pageUrl - The originally-requested URL, normalised
26
+ * (`withoutHashAndAuth` form).
27
+ * @param redirectPaths - The redirect chain hops captured during fetch, in
28
+ * order (empty when the page was not redirected).
29
+ * @param entries - The console log entries to persist. Must be non-empty —
30
+ * callers should skip calling this function entirely for an empty list.
31
+ * @example
32
+ * await replaceConsoleLogs(knex, caches, page.url.withoutHashAndAuth, page.redirectPaths, entries);
33
+ */
34
+ export declare function replaceConsoleLogs(knex: Knex, caches: WriteRefCaches, pageUrl: string, redirectPaths: readonly string[], entries: readonly ConsoleLogEntry[]): Promise<void>;
@@ -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 {};