@nitpicker/crawler 0.13.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 (171) hide show
  1. package/lib/archive/archive-accessor.d.ts +56 -0
  2. package/lib/archive/archive-accessor.js +72 -0
  3. package/lib/archive/archive.d.ts +108 -9
  4. package/lib/archive/archive.js +109 -7
  5. package/lib/archive/body-hash/compute-body-hash.d.ts +22 -0
  6. package/lib/archive/body-hash/compute-body-hash.js +31 -0
  7. package/lib/archive/body-hash/extract-body.d.ts +18 -0
  8. package/lib/archive/body-hash/extract-body.js +29 -0
  9. package/lib/archive/body-hash/mask-dynamic-ids.d.ts +33 -0
  10. package/lib/archive/body-hash/mask-dynamic-ids.js +44 -0
  11. package/lib/archive/body-hash/normalize-url-like-strings.d.ts +16 -0
  12. package/lib/archive/body-hash/normalize-url-like-strings.js +19 -0
  13. package/lib/archive/cache/clear-archive-cache-entry.d.ts +19 -0
  14. package/lib/archive/cache/clear-archive-cache-entry.js +25 -0
  15. package/lib/archive/cache/clear-archive-cache-root.d.ts +26 -0
  16. package/lib/archive/cache/clear-archive-cache-root.js +32 -0
  17. package/lib/archive/cache/list-archive-cache-entries.d.ts +23 -0
  18. package/lib/archive/cache/list-archive-cache-entries.js +125 -0
  19. package/lib/archive/cache/path-exists.d.ts +9 -0
  20. package/lib/archive/cache/path-exists.js +18 -0
  21. package/lib/archive/cache/types.d.ts +33 -0
  22. package/lib/archive/cache/types.js +1 -0
  23. package/lib/archive/create-adjunct-tables.d.ts +18 -0
  24. package/lib/archive/create-adjunct-tables.js +277 -0
  25. package/lib/archive/create-entity-tables.d.ts +14 -0
  26. package/lib/archive/create-entity-tables.js +52 -1
  27. package/lib/archive/database.d.ts +91 -2
  28. package/lib/archive/database.js +128 -0
  29. package/lib/archive/db-ops/_shared/clear-write-ref-caches.js +1 -0
  30. package/lib/archive/db-ops/_shared/compress-payload.d.ts +26 -0
  31. package/lib/archive/db-ops/_shared/compress-payload.js +30 -0
  32. package/lib/archive/db-ops/_shared/create-write-ref-caches.js +1 -0
  33. package/lib/archive/db-ops/_shared/types.d.ts +2 -0
  34. package/lib/archive/db-ops/_shared/upsert-json-ref.js +3 -3
  35. package/lib/archive/db-ops/analysis/replace-page-templates.d.ts +29 -0
  36. package/lib/archive/db-ops/analysis/replace-page-templates.js +84 -0
  37. package/lib/archive/db-ops/analysis/types.d.ts +61 -0
  38. package/lib/archive/db-ops/analysis/types.js +1 -0
  39. package/lib/archive/db-ops/config/info-column-allowlist.js +1 -0
  40. package/lib/archive/db-ops/console-logs/compute-console-log-hash.d.ts +19 -0
  41. package/lib/archive/db-ops/console-logs/compute-console-log-hash.js +30 -0
  42. package/lib/archive/db-ops/console-logs/replace-console-logs.d.ts +34 -0
  43. package/lib/archive/db-ops/console-logs/replace-console-logs.js +123 -0
  44. package/lib/archive/db-ops/console-logs/stringify-console-log-args.d.ts +16 -0
  45. package/lib/archive/db-ops/console-logs/stringify-console-log-args.js +26 -0
  46. package/lib/archive/db-ops/console-logs/types.d.ts +38 -0
  47. package/lib/archive/db-ops/console-logs/types.js +1 -0
  48. package/lib/archive/db-ops/console-logs/upsert-console-log-item.d.ts +29 -0
  49. package/lib/archive/db-ops/console-logs/upsert-console-log-item.js +52 -0
  50. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.d.ts +11 -0
  51. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.js +21 -4
  52. package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
  53. package/lib/archive/db-ops/lifecycle/init.d.ts +25 -10
  54. package/lib/archive/db-ops/lifecycle/init.js +39 -10
  55. package/lib/archive/db-ops/meta/get-audios-of-page.d.ts +11 -0
  56. package/lib/archive/db-ops/meta/get-audios-of-page.js +15 -0
  57. package/lib/archive/db-ops/meta/get-buttons-of-page.d.ts +11 -0
  58. package/lib/archive/db-ops/meta/get-buttons-of-page.js +15 -0
  59. package/lib/archive/db-ops/meta/get-canvases-of-page.d.ts +11 -0
  60. package/lib/archive/db-ops/meta/get-canvases-of-page.js +15 -0
  61. package/lib/archive/db-ops/meta/get-headings-of-page.d.ts +11 -0
  62. package/lib/archive/db-ops/meta/get-headings-of-page.js +15 -0
  63. package/lib/archive/db-ops/meta/get-iframes-of-page.d.ts +11 -0
  64. package/lib/archive/db-ops/meta/get-iframes-of-page.js +15 -0
  65. package/lib/archive/db-ops/meta/get-main-content-images-of-page.d.ts +11 -0
  66. package/lib/archive/db-ops/meta/get-main-content-images-of-page.js +15 -0
  67. package/lib/archive/db-ops/meta/get-main-content-tables-of-page.d.ts +11 -0
  68. package/lib/archive/db-ops/meta/get-main-content-tables-of-page.js +15 -0
  69. package/lib/archive/db-ops/meta/get-videos-of-page.d.ts +11 -0
  70. package/lib/archive/db-ops/meta/get-videos-of-page.js +15 -0
  71. package/lib/archive/db-ops/outages/close-network-outage.d.ts +12 -0
  72. package/lib/archive/db-ops/outages/close-network-outage.js +15 -0
  73. package/lib/archive/db-ops/outages/close-stale-open-network-outages.d.ts +26 -0
  74. package/lib/archive/db-ops/outages/close-stale-open-network-outages.js +42 -0
  75. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.d.ts +26 -0
  76. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.js +31 -0
  77. package/lib/archive/db-ops/outages/insert-network-outage.d.ts +14 -0
  78. package/lib/archive/db-ops/outages/insert-network-outage.js +29 -0
  79. package/lib/archive/db-ops/outages/list-network-outages.d.ts +28 -0
  80. package/lib/archive/db-ops/outages/list-network-outages.js +42 -0
  81. package/lib/archive/db-ops/pages/read/build-page-query.js +1 -1
  82. package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +19 -10
  83. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +18 -3
  84. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +48 -10
  85. package/lib/archive/db-ops/pages/write/insert-audios.d.ts +14 -0
  86. package/lib/archive/db-ops/pages/write/insert-audios.js +25 -0
  87. package/lib/archive/db-ops/pages/write/insert-buttons.d.ts +14 -0
  88. package/lib/archive/db-ops/pages/write/insert-buttons.js +29 -0
  89. package/lib/archive/db-ops/pages/write/insert-canvases.d.ts +14 -0
  90. package/lib/archive/db-ops/pages/write/insert-canvases.js +26 -0
  91. package/lib/archive/db-ops/pages/write/insert-headings.d.ts +15 -0
  92. package/lib/archive/db-ops/pages/write/insert-headings.js +27 -0
  93. package/lib/archive/db-ops/pages/write/insert-iframes.d.ts +14 -0
  94. package/lib/archive/db-ops/pages/write/insert-iframes.js +28 -0
  95. package/lib/archive/db-ops/pages/write/insert-main-content-images.d.ts +20 -0
  96. package/lib/archive/db-ops/pages/write/insert-main-content-images.js +32 -0
  97. package/lib/archive/db-ops/pages/write/insert-main-content-tables.d.ts +14 -0
  98. package/lib/archive/db-ops/pages/write/insert-main-content-tables.js +29 -0
  99. package/lib/archive/db-ops/pages/write/insert-page.js +15 -2
  100. package/lib/archive/db-ops/pages/write/insert-videos.d.ts +14 -0
  101. package/lib/archive/db-ops/pages/write/insert-videos.js +28 -0
  102. package/lib/archive/db-ops/pages/write/update-page.js +33 -0
  103. package/lib/archive/db-ops/pages/write/write-page-html-blob.js +6 -6
  104. package/lib/archive/filesystem/output-binary.d.ts +12 -0
  105. package/lib/archive/filesystem/output-binary.js +17 -0
  106. package/lib/archive/get-failed-page-messages.d.ts +20 -8
  107. package/lib/archive/get-failed-page-messages.js +29 -15
  108. package/lib/archive/init-schema.js +2 -1
  109. package/lib/archive/meta/compute-main-contents-denormalized.d.ts +22 -0
  110. package/lib/archive/meta/compute-main-contents-denormalized.js +63 -0
  111. package/lib/archive/meta/types.d.ts +263 -0
  112. package/lib/archive/migrate-content-items-alias-of-id.d.ts +47 -0
  113. package/lib/archive/migrate-content-items-alias-of-id.js +58 -0
  114. package/lib/archive/migrate-info-main-content-selector.d.ts +11 -0
  115. package/lib/archive/migrate-info-main-content-selector.js +24 -0
  116. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +11 -0
  117. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +24 -0
  118. package/lib/archive/migrate-main-contents-columns.d.ts +21 -0
  119. package/lib/archive/migrate-main-contents-columns.js +50 -0
  120. package/lib/archive/migrate-page-meta-body-hash.d.ts +39 -0
  121. package/lib/archive/migrate-page-meta-body-hash.js +52 -0
  122. package/lib/archive/migrate-page-meta-console-error-count.d.ts +22 -0
  123. package/lib/archive/migrate-page-meta-console-error-count.js +34 -0
  124. package/lib/archive/page.d.ts +145 -3
  125. package/lib/archive/page.js +195 -0
  126. package/lib/archive/types.d.ts +86 -2
  127. package/lib/archive/url-alias/compute-tier-a-alias-key.d.ts +31 -0
  128. package/lib/archive/url-alias/compute-tier-a-alias-key.js +36 -0
  129. package/lib/archive/url-alias/compute-tier-b-alias-key.d.ts +19 -0
  130. package/lib/archive/url-alias/compute-tier-b-alias-key.js +30 -0
  131. package/lib/archive/url-alias/format-alias-key.d.ts +13 -0
  132. package/lib/archive/url-alias/format-alias-key.js +14 -0
  133. package/lib/archive/url-alias/parse-alias-key-parts.d.ts +15 -0
  134. package/lib/archive/url-alias/parse-alias-key-parts.js +40 -0
  135. package/lib/archive/url-alias/types.d.ts +7 -0
  136. package/lib/archive/url-alias/types.js +1 -0
  137. package/lib/crawler/choose-probe-host.d.ts +24 -0
  138. package/lib/crawler/choose-probe-host.js +38 -0
  139. package/lib/crawler/clear-dns-burned-host-cache.js +2 -0
  140. package/lib/crawler/crawler.js +261 -2
  141. package/lib/crawler/dns-burned-host-burn-timestamps.d.ts +17 -0
  142. package/lib/crawler/dns-burned-host-burn-timestamps.js +17 -0
  143. package/lib/crawler/evict-network-classified-destination-cache-entries.d.ts +26 -0
  144. package/lib/crawler/evict-network-classified-destination-cache-entries.js +34 -0
  145. package/lib/crawler/evict-outage-tainted-dns-burns.d.ts +23 -0
  146. package/lib/crawler/evict-outage-tainted-dns-burns.js +26 -0
  147. package/lib/crawler/fetch-destination.js +2 -0
  148. package/lib/crawler/link-to-page-data.js +2 -0
  149. package/lib/crawler/network-gate.d.ts +49 -0
  150. package/lib/crawler/network-gate.js +78 -0
  151. package/lib/crawler/network-outage-detector.d.ts +51 -0
  152. package/lib/crawler/network-outage-detector.js +81 -0
  153. package/lib/crawler/network-outage-summary-counter.d.ts +21 -0
  154. package/lib/crawler/network-outage-summary-counter.js +18 -0
  155. package/lib/crawler/probe-network.d.ts +26 -0
  156. package/lib/crawler/probe-network.js +21 -0
  157. package/lib/crawler/resource-to-page-data.js +2 -0
  158. package/lib/crawler/types.d.ts +152 -1
  159. package/lib/crawler-orchestrator.d.ts +62 -18
  160. package/lib/crawler-orchestrator.js +163 -26
  161. package/lib/crawler.d.ts +21 -0
  162. package/lib/crawler.js +17 -0
  163. package/lib/is-within-outage-window.d.ts +49 -0
  164. package/lib/is-within-outage-window.js +33 -0
  165. package/lib/network-related-error-kinds.d.ts +23 -0
  166. package/lib/network-related-error-kinds.js +28 -0
  167. package/lib/types.d.ts +9 -1
  168. package/lib/utils/array/each-splitted.d.ts +1 -1
  169. package/lib/utils/compute-file-sha256.d.ts +17 -17
  170. package/lib/utils/compute-file-sha256.js +18 -47
  171. package/package.json +3 -3
@@ -68,6 +68,27 @@ export declare class ArchiveAccessor extends EventEmitter<DatabaseEvent> {
68
68
  * @returns An array of anchor records found on the page.
69
69
  */
70
70
  getAnchorsOnPage(pageId: number): Promise<any[]>;
71
+ /**
72
+ * Retrieves the audios within the given page's detected main content
73
+ * region, from `page_main_content_audios`.
74
+ * @param pageId - The database id of the page.
75
+ * @returns Ordered audio rows.
76
+ */
77
+ getAudiosOfPage(pageId: number): Promise<import("./meta/types.js").MainContentAudioRow[]>;
78
+ /**
79
+ * Retrieves the button-like elements within the given page's detected
80
+ * main content region, from `page_main_content_buttons`.
81
+ * @param pageId - The database id of the page.
82
+ * @returns Ordered button rows.
83
+ */
84
+ getButtonsOfPage(pageId: number): Promise<import("./meta/types.js").MainContentButtonRow[]>;
85
+ /**
86
+ * Retrieves the canvases within the given page's detected main content
87
+ * region, from `page_main_content_canvases`.
88
+ * @param pageId - The database id of the page.
89
+ * @returns Ordered canvas rows.
90
+ */
91
+ getCanvasesOfPage(pageId: number): Promise<import("./meta/types.js").MainContentCanvasRow[]>;
71
92
  /**
72
93
  * Retrieves the crawl configuration stored in the archive database.
73
94
  * @returns The parsed {@link Config} object.
@@ -87,6 +108,13 @@ export declare class ArchiveAccessor extends EventEmitter<DatabaseEvent> {
87
108
  * @returns The file contents as a string.
88
109
  */
89
110
  getData(name: string, format?: 'txt' | 'html'): Promise<string>;
111
+ /**
112
+ * Retrieves the headings within the given page's detected main content
113
+ * region, from `page_main_content_headings`.
114
+ * @param pageId - The database id of the page.
115
+ * @returns Ordered heading rows.
116
+ */
117
+ getHeadingsOfPage(pageId: number): Promise<import("./meta/types.js").MainContentHeadingRow[]>;
90
118
  /**
91
119
  * Reads the HTML snapshot of a page from the archive.
92
120
  *
@@ -117,6 +145,13 @@ export declare class ArchiveAccessor extends EventEmitter<DatabaseEvent> {
117
145
  * }
118
146
  */
119
147
  getHtmlOfPage(pageId: number): Promise<string | null>;
148
+ /**
149
+ * Retrieves the iframes within the given page's detected main content
150
+ * region, from `page_main_content_iframes`.
151
+ * @param pageId - The database id of the page.
152
+ * @returns Ordered iframe rows.
153
+ */
154
+ getIframesOfPage(pageId: number): Promise<import("./meta/types.js").MainContentIframeRow[]>;
120
155
  /**
121
156
  * Retrieves the JSON-LD / SpeculationRules entries for the given page,
122
157
  * parsed back from the `page_jsonld` table.
@@ -131,6 +166,20 @@ export declare class ArchiveAccessor extends EventEmitter<DatabaseEvent> {
131
166
  * @returns The Knex instance connected to the SQLite database.
132
167
  */
133
168
  getKnex(): import("knex").Knex<any, any[]>;
169
+ /**
170
+ * Retrieves the images within the given page's detected main content
171
+ * region, from `page_main_content_images`.
172
+ * @param pageId - The database id of the page.
173
+ * @returns Ordered image rows.
174
+ */
175
+ getMainContentImagesOfPage(pageId: number): Promise<import("./meta/types.js").MainContentImageRow[]>;
176
+ /**
177
+ * Retrieves the tables within the given page's detected main content
178
+ * region, from `page_main_content_tables`.
179
+ * @param pageId - The database id of the page.
180
+ * @returns Ordered table rows.
181
+ */
182
+ getMainContentTablesOfPage(pageId: number): Promise<import("./meta/types.js").MainContentTableRow[]>;
134
183
  /**
135
184
  * Retrieves all pages from the archive, optionally filtered by type.
136
185
  * Eagerly loads redirect relationships (`redirectFrom`) but does NOT load
@@ -179,6 +228,13 @@ export declare class ArchiveAccessor extends EventEmitter<DatabaseEvent> {
179
228
  * @returns Ordered entries with provider, category, externalId, etc.
180
229
  */
181
230
  getTagsOfPage(pageId: number): Promise<import("./meta/types.js").TagRow[]>;
231
+ /**
232
+ * Retrieves the videos within the given page's detected main content
233
+ * region, from `page_main_content_videos`.
234
+ * @param pageId - The database id of the page.
235
+ * @returns Ordered video rows.
236
+ */
237
+ getVideosOfPage(pageId: number): Promise<import("./meta/types.js").MainContentVideoRow[]>;
182
238
  /**
183
239
  * Stores custom data in the archive under the configured namespace.
184
240
  * Requires a namespace to be set on this accessor; throws if namespace is null.
@@ -128,6 +128,33 @@ export class ArchiveAccessor extends EventEmitter {
128
128
  const refs = await this.#db.getAnchorsOnPage(pageId);
129
129
  return refs;
130
130
  }
131
+ /**
132
+ * Retrieves the audios within the given page's detected main content
133
+ * region, from `page_main_content_audios`.
134
+ * @param pageId - The database id of the page.
135
+ * @returns Ordered audio rows.
136
+ */
137
+ async getAudiosOfPage(pageId) {
138
+ return this.#db.getAudiosOfPage(pageId);
139
+ }
140
+ /**
141
+ * Retrieves the button-like elements within the given page's detected
142
+ * main content region, from `page_main_content_buttons`.
143
+ * @param pageId - The database id of the page.
144
+ * @returns Ordered button rows.
145
+ */
146
+ async getButtonsOfPage(pageId) {
147
+ return this.#db.getButtonsOfPage(pageId);
148
+ }
149
+ /**
150
+ * Retrieves the canvases within the given page's detected main content
151
+ * region, from `page_main_content_canvases`.
152
+ * @param pageId - The database id of the page.
153
+ * @returns Ordered canvas rows.
154
+ */
155
+ async getCanvasesOfPage(pageId) {
156
+ return this.#db.getCanvasesOfPage(pageId);
157
+ }
131
158
  /**
132
159
  * Retrieves the crawl configuration stored in the archive database.
133
160
  * @returns The parsed {@link Config} object.
@@ -143,6 +170,15 @@ export class ArchiveAccessor extends EventEmitter {
143
170
  }
144
171
  return await readText(filePath);
145
172
  }
173
+ /**
174
+ * Retrieves the headings within the given page's detected main content
175
+ * region, from `page_main_content_headings`.
176
+ * @param pageId - The database id of the page.
177
+ * @returns Ordered heading rows.
178
+ */
179
+ async getHeadingsOfPage(pageId) {
180
+ return this.#db.getHeadingsOfPage(pageId);
181
+ }
146
182
  /**
147
183
  * Reads the HTML snapshot of a page from the archive.
148
184
  *
@@ -179,6 +215,15 @@ export class ArchiveAccessor extends EventEmitter {
179
215
  }
180
216
  return html;
181
217
  }
218
+ /**
219
+ * Retrieves the iframes within the given page's detected main content
220
+ * region, from `page_main_content_iframes`.
221
+ * @param pageId - The database id of the page.
222
+ * @returns Ordered iframe rows.
223
+ */
224
+ async getIframesOfPage(pageId) {
225
+ return this.#db.getIframesOfPage(pageId);
226
+ }
182
227
  /**
183
228
  * Retrieves the JSON-LD / SpeculationRules entries for the given page,
184
229
  * parsed back from the `page_jsonld` table.
@@ -197,6 +242,24 @@ export class ArchiveAccessor extends EventEmitter {
197
242
  getKnex() {
198
243
  return this.#db.getKnex();
199
244
  }
245
+ /**
246
+ * Retrieves the images within the given page's detected main content
247
+ * region, from `page_main_content_images`.
248
+ * @param pageId - The database id of the page.
249
+ * @returns Ordered image rows.
250
+ */
251
+ async getMainContentImagesOfPage(pageId) {
252
+ return this.#db.getMainContentImagesOfPage(pageId);
253
+ }
254
+ /**
255
+ * Retrieves the tables within the given page's detected main content
256
+ * region, from `page_main_content_tables`.
257
+ * @param pageId - The database id of the page.
258
+ * @returns Ordered table rows.
259
+ */
260
+ async getMainContentTablesOfPage(pageId) {
261
+ return this.#db.getMainContentTablesOfPage(pageId);
262
+ }
200
263
  /**
201
264
  * Retrieves all pages from the archive, optionally filtered by type.
202
265
  * Eagerly loads redirect relationships (`redirectFrom`) but does NOT load
@@ -289,6 +352,15 @@ export class ArchiveAccessor extends EventEmitter {
289
352
  async getTagsOfPage(pageId) {
290
353
  return this.#db.getTagsOfPage(pageId);
291
354
  }
355
+ /**
356
+ * Retrieves the videos within the given page's detected main content
357
+ * region, from `page_main_content_videos`.
358
+ * @param pageId - The database id of the page.
359
+ * @returns Ordered video rows.
360
+ */
361
+ async getVideosOfPage(pageId) {
362
+ return this.#db.getVideosOfPage(pageId);
363
+ }
292
364
  /**
293
365
  * Stores custom data in the archive under the configured namespace.
294
366
  * Requires a namespace to be set on this accessor; throws if namespace is null.
@@ -1,14 +1,20 @@
1
- import type { Config, InventoryRunMeta, PageSource } from './types.js';
1
+ import type { TemplateClusterReason } from './db-ops/analysis/types.js';
2
+ import type { Config, InsertNetworkOutageParams, InventoryRunMeta, PageSource } from './types.js';
3
+ import type { OutageWindow } from '../is-within-outage-window.js';
2
4
  import type { PageData, CrawlerError, Resource } from '../utils/types/types.js';
5
+ import type { ConsoleLogEntry } from '@d-zero/beholder';
3
6
  import type { ExURL, ParseURLOptions } from '@d-zero/shared/parse-url';
4
7
  import { ArchiveAccessor } from './archive-accessor.js';
5
8
  /**
6
9
  * Main archive class for creating, opening, resuming, and writing Nitpicker
7
10
  * archive files (`.nitpicker`).
8
11
  *
9
- * An Archive wraps a single SQLite database into a tar archive. HTML
10
- * bodies live inside the same DB as zstd-compressed BLOBs (see #75) the
11
- * tar payload is effectively just `db.sqlite`. It extends
12
+ * An Archive wraps a SQLite database into a tar archive. HTML bodies live
13
+ * inside the same DB as zstd-compressed BLOBs (see #75), so `db.sqlite` is
14
+ * normally the tar's only entry — but it is not the only entry the format
15
+ * allows: {@link ArchiveAccessor.setData} (namespace-scoped analyze output)
16
+ * and {@link Archive.saveInventorySourceList} (a saved `--inventory`
17
+ * source list) add plain files alongside it. It extends
12
18
  * {@link ArchiveAccessor} to provide read access to stored data.
13
19
  *
14
20
  * Use the static factory methods ({@link Archive.create}, {@link Archive.open},
@@ -66,6 +72,14 @@ export default class Archive extends ArchiveAccessor {
66
72
  * @param isExternal - Whether the URL is external. Defaults to `false`.
67
73
  */
68
74
  addPageError(url: string, phase: string, message: string, isExternal?: boolean): Promise<void>;
75
+ /**
76
+ * Closes an outage row by stamping `ended_at` — a no-op if already closed.
77
+ *
78
+ * Thin facade over {@link Database.closeNetworkOutage}.
79
+ * @param id - The `network_outages.id` to close.
80
+ * @param endedAt - Epoch ms the outage is considered to have ended.
81
+ */
82
+ closeNetworkOutage(id: number, endedAt: number): Promise<void>;
69
83
  /**
70
84
  * Retrieves the current crawling state, including lists of scraped and pending URLs.
71
85
  * @returns An object with `scraped` and `pending` URL arrays.
@@ -151,6 +165,15 @@ export default class Archive extends ArchiveAccessor {
151
165
  * @param urls - HTML seed URLs to pre-insert. No-op when empty.
152
166
  */
153
167
  insertInventorySeeds(urls: readonly ExURL[]): Promise<void>;
168
+ /**
169
+ * Appends one open row to the `network_outages` journal.
170
+ *
171
+ * Thin facade over {@link Database.insertNetworkOutage} — see
172
+ * {@link recordInventoryRun}'s docstring for why this indirection exists.
173
+ * @param params - The confirmed-outage fields to record.
174
+ * @returns The autoincremented `id` of the inserted row.
175
+ */
176
+ insertNetworkOutage(params: InsertNetworkOutageParams): Promise<number>;
154
177
  /**
155
178
  * Hostnames whose `crawl_errors` history is consistently DNS failures and
156
179
  * for which no recent 2xx/3xx page or resource is recorded. Consumed by
@@ -165,6 +188,13 @@ export default class Archive extends ArchiveAccessor {
165
188
  * @returns Lower-cased hostnames safe to short-circuit.
166
189
  */
167
190
  listDnsBurnedHostCandidates(): Promise<string[]>;
191
+ /**
192
+ * Lists every recorded outage as a resolved {@link OutageWindow}.
193
+ *
194
+ * Thin facade over {@link Database.listNetworkOutages}.
195
+ * @returns Resolved outage windows, or `[]` if none have been recorded.
196
+ */
197
+ listNetworkOutages(): Promise<OutageWindow[]>;
168
198
  /**
169
199
  * Appends one row to the `inventory_runs` audit log.
170
200
  *
@@ -206,6 +236,21 @@ export default class Archive extends ArchiveAccessor {
206
236
  line?: number | null;
207
237
  col?: number | null;
208
238
  }[]): Promise<void>;
239
+ /**
240
+ * Replaces the archive's DOM-structure template classification
241
+ * (`--templates`) with a fresh SQL-backed set.
242
+ *
243
+ * Thin facade over {@link Database.replacePageTemplates}; kept on
244
+ * `Archive` so the analyze pipeline can persist template keys without
245
+ * reaching into the low-level database class directly.
246
+ * @param templateKeysByUrl - Page URL → template key, as produced by
247
+ * `@nitpicker/core`'s `classifyPageTemplates`.
248
+ * @param clusterReasonsByTemplateKey - Template key → cluster-selection
249
+ * evidence, if the caller captured it. Omitting this always clears the
250
+ * previously-stored reasons too — "no reason" means "not captured for
251
+ * this run", never "carry over the previous run's reasons".
252
+ */
253
+ replacePageTemplates(templateKeysByUrl: ReadonlyMap<string, string>, clusterReasonsByTemplateKey?: ReadonlyMap<string, TemplateClusterReason>): Promise<void>;
209
254
  /**
210
255
  * Promote previously-external pages that now fall under the (possibly extended)
211
256
  * scope back to a pending state so that the crawler re-scrapes them as fully
@@ -223,11 +268,46 @@ export default class Archive extends ArchiveAccessor {
223
268
  * @returns The URLs of the pages that were reset to pending.
224
269
  */
225
270
  resetFailedPages(): Promise<string[]>;
271
+ /**
272
+ * Persists the raw bytes of an `--inventory` source URL list into the
273
+ * archive's tar payload, at `inventory/<sha256>.txt`.
274
+ *
275
+ * The file name is the content hash rather than the original file name:
276
+ * re-applying the same list is then a no-op write (`fs.writeFile`
277
+ * overwrites identical bytes), and the original name — which may embed a
278
+ * client/project identifier — is never retained (the archive already
279
+ * omits the source file's absolute path for the same reason; see
280
+ * `CrawlerOrchestrator.inventory`'s `source` param).
281
+ *
282
+ * This bypasses the namespace-scoped {@link ArchiveAccessor.setData} API
283
+ * (that one is reserved for analyze plugins and requires a namespace) —
284
+ * this always lands under the fixed `inventory/` prefix regardless of
285
+ * how this accessor was constructed. Callers that need to read the
286
+ * saved list back can use the inherited `getData(`inventory/${sha256}`,
287
+ * 'txt')`, since it resolves to the same path when no namespace is set.
288
+ *
289
+ * No entry is ever removed here — same accepted gap as `page_html_blobs`
290
+ * (a future #23 GC pass will sweep unreachable hashes across both). A
291
+ * source list that differs byte-for-byte on every run (e.g. a
292
+ * regenerated doc-root export with fresh timestamps) adds one entry per
293
+ * run with no pruning of superseded ones.
294
+ * @param sha256 - Lower-case hex SHA-256 digest of `bytes` (used as the file name).
295
+ * @param bytes - The exact bytes of the source list file, written verbatim.
296
+ */
297
+ saveInventorySourceList(sha256: string, bytes: Buffer): Promise<void>;
226
298
  /**
227
299
  * Stores the crawl configuration into the archive database.
228
300
  * @param config - The configuration object to store.
229
301
  */
230
302
  setConfig(config: Config): Promise<number[]>;
303
+ /**
304
+ * Replaces one page's captured console messages / page errors in the
305
+ * archive database.
306
+ * @param pageUrl - The originally-requested URL, normalised (`withoutHashAndAuth` form).
307
+ * @param redirectPaths - The redirect chain hops captured during fetch, in order.
308
+ * @param entries - The console log entries to persist.
309
+ */
310
+ setConsoleLogs(pageUrl: string, redirectPaths: readonly string[], entries: readonly ConsoleLogEntry[]): Promise<void>;
231
311
  /**
232
312
  * Stores an external page's data in the archive database without storing
233
313
  * an HTML snapshot. External-page rows carry only metadata (status, title,
@@ -300,10 +380,17 @@ export default class Archive extends ArchiveAccessor {
300
380
  *
301
381
  * Checkpoints the SQLite WAL so the database is self-contained inside
302
382
  * `db.sqlite`, renames the temporary working directory to the archive's
303
- * basename, and tars it into the final `.nitpicker`. The tar container
304
- * holds a single `db.sqlite` file (the legacy `snapshot-html.zip` is gone
305
- * HTML lives as BLOBs in the DB), so finalisation is effectively a
306
- * single-file copy with no per-snapshot syscalls.
383
+ * basename, and tars the **entire tmpDir**. `db.sqlite` is normally the
384
+ * only entry (HTML lives as BLOBs in the DB, not a `snapshot-html.zip`),
385
+ * but a namespace-scoped `setData` write (analyze output) or
386
+ * `saveInventorySourceList` (a saved `--inventory` source list) adds
387
+ * extra files under tmpDir that get tarred right alongside it.
388
+ *
389
+ * This is why every writer path that reaches `write()` must open with
390
+ * `openPluginData: true` — `Archive.open`'s default extracts only
391
+ * `db.sqlite`, so a re-crawl (`append` / `inventory` / `retryFailed`)
392
+ * opened without it would tar back a tmpDir missing those extra files,
393
+ * silently dropping them from the rewritten archive.
307
394
  */
308
395
  write(): Promise<void>;
309
396
  /** The file extension for Nitpicker archive files (without the leading dot). */
@@ -471,7 +558,19 @@ type ArchiveOptions = {
471
558
  * Additional options for opening an existing archive.
472
559
  */
473
560
  type ArchiveOpenOptions = {
474
- /** When true, extracts all files including plugin data. When false, only extracts the database and snapshots. */
561
+ /**
562
+ * When `false` (the default), only `db.sqlite` is extracted into tmpDir.
563
+ * When `true`, every tar entry is extracted, including non-namespace
564
+ * files written via {@link ArchiveAccessor.setData} (analyze output) or
565
+ * {@link Archive.saveInventorySourceList} (a saved `--inventory` source
566
+ * list).
567
+ *
568
+ * Every writer path that later calls {@link Archive.write} MUST pass
569
+ * `true`: `write()` re-tars whatever is currently in tmpDir, so a
570
+ * re-crawl (`append` / `inventory` / `retryFailed`) opened with the
571
+ * default would tar back a tmpDir missing those extra files, silently
572
+ * dropping them from the rewritten archive.
573
+ */
475
574
  openPluginData?: boolean;
476
575
  };
477
576
  export {};
@@ -10,18 +10,23 @@ import { dbLog, log, saveLog } from './debug.js';
10
10
  import { appendText } from './filesystem/append-text.js';
11
11
  import { exists } from './filesystem/exists.js';
12
12
  import { isDir } from './filesystem/is-dir.js';
13
+ import { outputBinary } from './filesystem/output-binary.js';
13
14
  import { peekTarTopDir } from './filesystem/peek-tar-top-dir.js';
14
15
  import { remove } from './filesystem/remove.js';
15
16
  import { rename } from './filesystem/rename.js';
16
17
  import { tar } from './filesystem/tar.js';
17
18
  import { untar } from './filesystem/untar.js';
19
+ import { safePath } from './safe-path.js';
18
20
  /**
19
21
  * Main archive class for creating, opening, resuming, and writing Nitpicker
20
22
  * archive files (`.nitpicker`).
21
23
  *
22
- * An Archive wraps a single SQLite database into a tar archive. HTML
23
- * bodies live inside the same DB as zstd-compressed BLOBs (see #75) the
24
- * tar payload is effectively just `db.sqlite`. It extends
24
+ * An Archive wraps a SQLite database into a tar archive. HTML bodies live
25
+ * inside the same DB as zstd-compressed BLOBs (see #75), so `db.sqlite` is
26
+ * normally the tar's only entry — but it is not the only entry the format
27
+ * allows: {@link ArchiveAccessor.setData} (namespace-scoped analyze output)
28
+ * and {@link Archive.saveInventorySourceList} (a saved `--inventory`
29
+ * source list) add plain files alongside it. It extends
25
30
  * {@link ArchiveAccessor} to provide read access to stored data.
26
31
  *
27
32
  * Use the static factory methods ({@link Archive.create}, {@link Archive.open},
@@ -119,6 +124,17 @@ export default class Archive extends ArchiveAccessor {
119
124
  dbLog('Add page error: %s [%s]', url, phase);
120
125
  await this.#db.insertPageError(url, phase, message, isExternal);
121
126
  }
127
+ /**
128
+ * Closes an outage row by stamping `ended_at` — a no-op if already closed.
129
+ *
130
+ * Thin facade over {@link Database.closeNetworkOutage}.
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
+ dbLog('Close network outage id=%d endedAt=%d', id, endedAt);
136
+ return await this.#db.closeNetworkOutage(id, endedAt);
137
+ }
122
138
  /**
123
139
  * Retrieves the current crawling state, including lists of scraped and pending URLs.
124
140
  * @returns An object with `scraped` and `pending` URL arrays.
@@ -227,6 +243,18 @@ export default class Archive extends ArchiveAccessor {
227
243
  dbLog('Insert inventory seeds: %d URL(s)', urls.length);
228
244
  await this.#db.insertInventorySeeds(urls.map((u) => u.withoutHashAndAuth));
229
245
  }
246
+ /**
247
+ * Appends one open row to the `network_outages` journal.
248
+ *
249
+ * Thin facade over {@link Database.insertNetworkOutage} — see
250
+ * {@link recordInventoryRun}'s docstring for why this indirection exists.
251
+ * @param params - The confirmed-outage fields to record.
252
+ * @returns The autoincremented `id` of the inserted row.
253
+ */
254
+ async insertNetworkOutage(params) {
255
+ dbLog('Insert network outage: startedAt=%d probeHost=%s', params.startedAt, params.probeHost);
256
+ return await this.#db.insertNetworkOutage(params);
257
+ }
230
258
  /**
231
259
  * Hostnames whose `crawl_errors` history is consistently DNS failures and
232
260
  * for which no recent 2xx/3xx page or resource is recorded. Consumed by
@@ -243,6 +271,15 @@ export default class Archive extends ArchiveAccessor {
243
271
  async listDnsBurnedHostCandidates() {
244
272
  return this.#db.listDnsBurnedHostCandidates();
245
273
  }
274
+ /**
275
+ * Lists every recorded outage as a resolved {@link OutageWindow}.
276
+ *
277
+ * Thin facade over {@link Database.listNetworkOutages}.
278
+ * @returns Resolved outage windows, or `[]` if none have been recorded.
279
+ */
280
+ async listNetworkOutages() {
281
+ return await this.#db.listNetworkOutages();
282
+ }
246
283
  /**
247
284
  * Appends one row to the `inventory_runs` audit log.
248
285
  *
@@ -286,6 +323,23 @@ export default class Archive extends ArchiveAccessor {
286
323
  async replaceAnalysisViolations(violations) {
287
324
  await this.#db.replaceAnalysisViolations(violations);
288
325
  }
326
+ /**
327
+ * Replaces the archive's DOM-structure template classification
328
+ * (`--templates`) with a fresh SQL-backed set.
329
+ *
330
+ * Thin facade over {@link Database.replacePageTemplates}; kept on
331
+ * `Archive` so the analyze pipeline can persist template keys without
332
+ * reaching into the low-level database class directly.
333
+ * @param templateKeysByUrl - Page URL → template key, as produced by
334
+ * `@nitpicker/core`'s `classifyPageTemplates`.
335
+ * @param clusterReasonsByTemplateKey - Template key → cluster-selection
336
+ * evidence, if the caller captured it. Omitting this always clears the
337
+ * previously-stored reasons too — "no reason" means "not captured for
338
+ * this run", never "carry over the previous run's reasons".
339
+ */
340
+ async replacePageTemplates(templateKeysByUrl, clusterReasonsByTemplateKey) {
341
+ await this.#db.replacePageTemplates(templateKeysByUrl, clusterReasonsByTemplateKey);
342
+ }
289
343
  /**
290
344
  * Promote previously-external pages that now fall under the (possibly extended)
291
345
  * scope back to a pending state so that the crawler re-scrapes them as fully
@@ -309,6 +363,36 @@ export default class Archive extends ArchiveAccessor {
309
363
  dbLog('Reset failed pages back to pending');
310
364
  return this.#db.resetFailedPages();
311
365
  }
366
+ /**
367
+ * Persists the raw bytes of an `--inventory` source URL list into the
368
+ * archive's tar payload, at `inventory/<sha256>.txt`.
369
+ *
370
+ * The file name is the content hash rather than the original file name:
371
+ * re-applying the same list is then a no-op write (`fs.writeFile`
372
+ * overwrites identical bytes), and the original name — which may embed a
373
+ * client/project identifier — is never retained (the archive already
374
+ * omits the source file's absolute path for the same reason; see
375
+ * `CrawlerOrchestrator.inventory`'s `source` param).
376
+ *
377
+ * This bypasses the namespace-scoped {@link ArchiveAccessor.setData} API
378
+ * (that one is reserved for analyze plugins and requires a namespace) —
379
+ * this always lands under the fixed `inventory/` prefix regardless of
380
+ * how this accessor was constructed. Callers that need to read the
381
+ * saved list back can use the inherited `getData(`inventory/${sha256}`,
382
+ * 'txt')`, since it resolves to the same path when no namespace is set.
383
+ *
384
+ * No entry is ever removed here — same accepted gap as `page_html_blobs`
385
+ * (a future #23 GC pass will sweep unreachable hashes across both). A
386
+ * source list that differs byte-for-byte on every run (e.g. a
387
+ * regenerated doc-root export with fresh timestamps) adds one entry per
388
+ * run with no pruning of superseded ones.
389
+ * @param sha256 - Lower-case hex SHA-256 digest of `bytes` (used as the file name).
390
+ * @param bytes - The exact bytes of the source list file, written verbatim.
391
+ */
392
+ async saveInventorySourceList(sha256, bytes) {
393
+ const filePath = safePath(this.tmpDir, 'inventory', `${sha256}.txt`);
394
+ await outputBinary(filePath, bytes);
395
+ }
312
396
  /**
313
397
  * Stores the crawl configuration into the archive database.
314
398
  * @param config - The configuration object to store.
@@ -317,6 +401,17 @@ export default class Archive extends ArchiveAccessor {
317
401
  dbLog('Set config: %O', config);
318
402
  return this.#db.setConfig(config);
319
403
  }
404
+ /**
405
+ * Replaces one page's captured console messages / page errors in the
406
+ * archive database.
407
+ * @param pageUrl - The originally-requested URL, normalised (`withoutHashAndAuth` form).
408
+ * @param redirectPaths - The redirect chain hops captured during fetch, in order.
409
+ * @param entries - The console log entries to persist.
410
+ */
411
+ async setConsoleLogs(pageUrl, redirectPaths, entries) {
412
+ dbLog('Set console logs: %d entries on %s', entries.length, pageUrl);
413
+ await this.#db.replaceConsoleLogs(pageUrl, redirectPaths, entries);
414
+ }
320
415
  /**
321
416
  * Stores an external page's data in the archive database without storing
322
417
  * an HTML snapshot. External-page rows carry only metadata (status, title,
@@ -410,10 +505,17 @@ export default class Archive extends ArchiveAccessor {
410
505
  *
411
506
  * Checkpoints the SQLite WAL so the database is self-contained inside
412
507
  * `db.sqlite`, renames the temporary working directory to the archive's
413
- * basename, and tars it into the final `.nitpicker`. The tar container
414
- * holds a single `db.sqlite` file (the legacy `snapshot-html.zip` is gone
415
- * HTML lives as BLOBs in the DB), so finalisation is effectively a
416
- * single-file copy with no per-snapshot syscalls.
508
+ * basename, and tars the **entire tmpDir**. `db.sqlite` is normally the
509
+ * only entry (HTML lives as BLOBs in the DB, not a `snapshot-html.zip`),
510
+ * but a namespace-scoped `setData` write (analyze output) or
511
+ * `saveInventorySourceList` (a saved `--inventory` source list) adds
512
+ * extra files under tmpDir that get tarred right alongside it.
513
+ *
514
+ * This is why every writer path that reaches `write()` must open with
515
+ * `openPluginData: true` — `Archive.open`'s default extracts only
516
+ * `db.sqlite`, so a re-crawl (`append` / `inventory` / `retryFailed`)
517
+ * opened without it would tar back a tmpDir missing those extra files,
518
+ * silently dropping them from the rewritten archive.
417
519
  */
418
520
  async write() {
419
521
  saveLog('Starts: %s', this.#filePath);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Computes a content hash of a page's `<body>`, after normalizing away the
3
+ * kinds of incidental variance that would otherwise make two structurally
4
+ * identical pages hash differently: `/index.{ext}` URL-suffix forms and
5
+ * embedded dynamic tokens (cache-busting hashes, session/order ids, per-build
6
+ * CSS-module suffixes).
7
+ *
8
+ * Only the resulting hash is persisted (`page_meta.body_hash`) — the masked
9
+ * intermediate string is never stored. The unmasked original HTML remains
10
+ * fully recoverable from `page_html_blobs`, so nothing is lost by discarding
11
+ * it here.
12
+ * @param html - A full HTML document string (or fragment).
13
+ * @returns 32-byte SHA-256 hash of the masked `<body>` content, ready to
14
+ * insert into a `BLOB` column.
15
+ * @example
16
+ * ```ts
17
+ * const hashA = computeBodyHash('<body><a href="/p/a1b2c3d4">x</a></body>');
18
+ * const hashB = computeBodyHash('<body><a href="/p/z9y8x7w6">x</a></body>');
19
+ * hashA.equals(hashB); // true — the differing token is masked before hashing
20
+ * ```
21
+ */
22
+ export declare function computeBodyHash(html: string): Buffer;
@@ -0,0 +1,31 @@
1
+ import { computeContentHash } from '../populate-ref-tables/compute-content-hash.js';
2
+ import { extractBody } from './extract-body.js';
3
+ import { maskDynamicIds } from './mask-dynamic-ids.js';
4
+ import { normalizeUrlLikeStrings } from './normalize-url-like-strings.js';
5
+ /**
6
+ * Computes a content hash of a page's `<body>`, after normalizing away the
7
+ * kinds of incidental variance that would otherwise make two structurally
8
+ * identical pages hash differently: `/index.{ext}` URL-suffix forms and
9
+ * embedded dynamic tokens (cache-busting hashes, session/order ids, per-build
10
+ * CSS-module suffixes).
11
+ *
12
+ * Only the resulting hash is persisted (`page_meta.body_hash`) — the masked
13
+ * intermediate string is never stored. The unmasked original HTML remains
14
+ * fully recoverable from `page_html_blobs`, so nothing is lost by discarding
15
+ * it here.
16
+ * @param html - A full HTML document string (or fragment).
17
+ * @returns 32-byte SHA-256 hash of the masked `<body>` content, ready to
18
+ * insert into a `BLOB` column.
19
+ * @example
20
+ * ```ts
21
+ * const hashA = computeBodyHash('<body><a href="/p/a1b2c3d4">x</a></body>');
22
+ * const hashB = computeBodyHash('<body><a href="/p/z9y8x7w6">x</a></body>');
23
+ * hashA.equals(hashB); // true — the differing token is masked before hashing
24
+ * ```
25
+ */
26
+ export function computeBodyHash(html) {
27
+ const body = extractBody(html);
28
+ const normalized = normalizeUrlLikeStrings(body);
29
+ const masked = maskDynamicIds(normalized);
30
+ return computeContentHash(masked);
31
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Extracts the inner HTML of the first `<body>` element from a full HTML
3
+ * document string.
4
+ *
5
+ * Uses a greedy match (`[\s\S]*`, not `[\s\S]*?`) so a literal `<body>`
6
+ * substring appearing inside the real body (e.g. an inline code sample) does
7
+ * not truncate the extracted content at that inner occurrence — the match
8
+ * always extends to the last `</body>` in the document.
9
+ *
10
+ * Falls back to returning the full input unchanged when no `<body>` tag is
11
+ * found (fragment HTML, a page that failed to render, or a snapshot cut off
12
+ * mid-render) rather than throwing, so callers never need a separate
13
+ * not-found branch.
14
+ * @param html - A full HTML document string, or a fragment.
15
+ * @returns The content between `<body...>` and `</body>`, or `html` unchanged
16
+ * if no `<body>` tag is present.
17
+ */
18
+ export declare function extractBody(html: string): string;
@@ -0,0 +1,29 @@
1
+ // The opening tag's attribute span is matched attribute-aware
2
+ // (`(?:"[^"]*"|'[^']*'|[^"'>])*`, not a plain `[^>]*`) so a literal `>`
3
+ // inside a quoted attribute value (e.g. `<body data-x="a>b">`) — legal HTML,
4
+ // since only the delimiting quote character itself must not appear
5
+ // unescaped inside the value — does not end the match early. A plain
6
+ // `[^>]*` would stop at that inner `>`, so the captured body would start
7
+ // mid-attribute instead of at the real content.
8
+ const BODY_PATTERN = /<body(?:"[^"]*"|'[^']*'|[^"'>])*>([\s\S]*)<\/body>/i;
9
+ /**
10
+ * Extracts the inner HTML of the first `<body>` element from a full HTML
11
+ * document string.
12
+ *
13
+ * Uses a greedy match (`[\s\S]*`, not `[\s\S]*?`) so a literal `<body>`
14
+ * substring appearing inside the real body (e.g. an inline code sample) does
15
+ * not truncate the extracted content at that inner occurrence — the match
16
+ * always extends to the last `</body>` in the document.
17
+ *
18
+ * Falls back to returning the full input unchanged when no `<body>` tag is
19
+ * found (fragment HTML, a page that failed to render, or a snapshot cut off
20
+ * mid-render) rather than throwing, so callers never need a separate
21
+ * not-found branch.
22
+ * @param html - A full HTML document string, or a fragment.
23
+ * @returns The content between `<body...>` and `</body>`, or `html` unchanged
24
+ * if no `<body>` tag is present.
25
+ */
26
+ export function extractBody(html) {
27
+ const match = BODY_PATTERN.exec(html);
28
+ return match?.[1] ?? html;
29
+ }