@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.
- package/lib/archive/archive.d.ts +136 -10
- package/lib/archive/archive.js +147 -9
- package/lib/archive/body-hash/compute-body-hash.d.ts +22 -0
- package/lib/archive/body-hash/compute-body-hash.js +31 -0
- package/lib/archive/body-hash/extract-body.d.ts +18 -0
- package/lib/archive/body-hash/extract-body.js +29 -0
- package/lib/archive/body-hash/mask-dynamic-ids.d.ts +33 -0
- package/lib/archive/body-hash/mask-dynamic-ids.js +44 -0
- package/lib/archive/body-hash/normalize-url-like-strings.d.ts +16 -0
- package/lib/archive/body-hash/normalize-url-like-strings.js +19 -0
- package/lib/archive/cache/clear-archive-cache-entry.d.ts +19 -0
- package/lib/archive/cache/clear-archive-cache-entry.js +25 -0
- package/lib/archive/cache/clear-archive-cache-root.d.ts +26 -0
- package/lib/archive/cache/clear-archive-cache-root.js +32 -0
- package/lib/archive/cache/list-archive-cache-entries.d.ts +23 -0
- package/lib/archive/cache/list-archive-cache-entries.js +125 -0
- package/lib/archive/cache/path-exists.d.ts +9 -0
- package/lib/archive/cache/path-exists.js +18 -0
- package/lib/archive/cache/types.d.ts +33 -0
- package/lib/archive/cache/types.js +1 -0
- package/lib/archive/create-adjunct-tables.d.ts +14 -0
- package/lib/archive/create-adjunct-tables.js +169 -0
- package/lib/archive/create-entity-tables.js +21 -1
- package/lib/archive/database.d.ts +67 -2
- package/lib/archive/database.js +92 -3
- package/lib/archive/db-ops/_shared/clear-write-ref-caches.js +1 -0
- package/lib/archive/db-ops/_shared/compress-payload.d.ts +26 -0
- package/lib/archive/db-ops/_shared/compress-payload.js +30 -0
- package/lib/archive/db-ops/_shared/create-write-ref-caches.js +1 -0
- package/lib/archive/db-ops/_shared/types.d.ts +2 -0
- package/lib/archive/db-ops/_shared/upsert-json-ref.js +3 -3
- package/lib/archive/db-ops/analysis/replace-page-templates.d.ts +12 -3
- package/lib/archive/db-ops/analysis/replace-page-templates.js +35 -3
- package/lib/archive/db-ops/analysis/types.d.ts +61 -0
- package/lib/archive/db-ops/analysis/types.js +1 -0
- package/lib/archive/db-ops/console-logs/compute-console-log-hash.d.ts +19 -0
- package/lib/archive/db-ops/console-logs/compute-console-log-hash.js +30 -0
- package/lib/archive/db-ops/console-logs/replace-console-logs.d.ts +34 -0
- package/lib/archive/db-ops/console-logs/replace-console-logs.js +123 -0
- package/lib/archive/db-ops/console-logs/stringify-console-log-args.d.ts +16 -0
- package/lib/archive/db-ops/console-logs/stringify-console-log-args.js +26 -0
- package/lib/archive/db-ops/console-logs/types.d.ts +38 -0
- package/lib/archive/db-ops/console-logs/types.js +1 -0
- package/lib/archive/db-ops/console-logs/upsert-console-log-item.d.ts +29 -0
- package/lib/archive/db-ops/console-logs/upsert-console-log-item.js +52 -0
- package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.d.ts +18 -0
- package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.js +23 -0
- package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.d.ts +12 -0
- package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.js +15 -0
- package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.d.ts +14 -0
- package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.js +30 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.d.ts +21 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.js +27 -0
- package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.d.ts +11 -0
- package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.js +21 -4
- package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
- package/lib/archive/db-ops/lifecycle/init.d.ts +11 -2
- package/lib/archive/db-ops/lifecycle/init.js +21 -2
- package/lib/archive/db-ops/outages/close-network-outage.d.ts +12 -0
- package/lib/archive/db-ops/outages/close-network-outage.js +15 -0
- package/lib/archive/db-ops/outages/close-stale-open-network-outages.d.ts +26 -0
- package/lib/archive/db-ops/outages/close-stale-open-network-outages.js +42 -0
- package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.d.ts +26 -0
- package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.js +31 -0
- package/lib/archive/db-ops/outages/insert-network-outage.d.ts +14 -0
- package/lib/archive/db-ops/outages/insert-network-outage.js +29 -0
- package/lib/archive/db-ops/outages/list-network-outages.d.ts +28 -0
- package/lib/archive/db-ops/outages/list-network-outages.js +42 -0
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +13 -0
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +35 -7
- package/lib/archive/db-ops/pages/write/update-page.js +12 -0
- package/lib/archive/db-ops/pages/write/write-page-html-blob.js +6 -6
- package/lib/archive/filesystem/output-binary.d.ts +12 -0
- package/lib/archive/filesystem/output-binary.js +17 -0
- package/lib/archive/get-failed-page-messages.d.ts +20 -8
- package/lib/archive/get-failed-page-messages.js +29 -15
- package/lib/archive/migrate-content-items-alias-of-id.d.ts +47 -0
- package/lib/archive/migrate-content-items-alias-of-id.js +58 -0
- package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +11 -0
- package/lib/archive/migrate-inventory-runs-invalid-skipped.js +24 -0
- package/lib/archive/migrate-page-meta-body-hash.d.ts +39 -0
- package/lib/archive/migrate-page-meta-body-hash.js +52 -0
- package/lib/archive/migrate-page-meta-console-error-count.d.ts +22 -0
- package/lib/archive/migrate-page-meta-console-error-count.js +34 -0
- package/lib/archive/page.d.ts +1 -1
- package/lib/archive/types.d.ts +60 -2
- package/lib/archive/url-alias/compute-tier-a-alias-key.d.ts +31 -0
- package/lib/archive/url-alias/compute-tier-a-alias-key.js +36 -0
- package/lib/archive/url-alias/compute-tier-b-alias-key.d.ts +19 -0
- package/lib/archive/url-alias/compute-tier-b-alias-key.js +30 -0
- package/lib/archive/url-alias/format-alias-key.d.ts +13 -0
- package/lib/archive/url-alias/format-alias-key.js +14 -0
- package/lib/archive/url-alias/parse-alias-key-parts.d.ts +15 -0
- package/lib/archive/url-alias/parse-alias-key-parts.js +40 -0
- package/lib/archive/url-alias/types.d.ts +7 -0
- package/lib/archive/url-alias/types.js +1 -0
- package/lib/classify-error-kind.d.ts +1 -0
- package/lib/classify-error-kind.js +14 -0
- package/lib/crawler/assert-chrome-installed.d.ts +24 -0
- package/lib/crawler/assert-chrome-installed.js +43 -0
- package/lib/crawler/choose-probe-host.d.ts +24 -0
- package/lib/crawler/choose-probe-host.js +38 -0
- package/lib/crawler/clear-dns-burned-host-cache.js +2 -0
- package/lib/crawler/crawler.d.ts +12 -0
- package/lib/crawler/crawler.js +496 -29
- package/lib/crawler/decode-auth-credential.d.ts +29 -0
- package/lib/crawler/decode-auth-credential.js +39 -0
- package/lib/crawler/dedupe/compute-meta-signature.d.ts +30 -0
- package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
- package/lib/crawler/dedupe/compute-shape-key.d.ts +37 -0
- package/lib/crawler/dedupe/compute-shape-key.js +56 -0
- package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +84 -0
- package/lib/crawler/dedupe/dedupe-cap-tracker.js +185 -0
- package/lib/crawler/dedupe/is-predicted-content-duplicate.d.ts +24 -0
- package/lib/crawler/dedupe/is-predicted-content-duplicate.js +26 -0
- package/lib/crawler/dedupe/is-shape-capped.d.ts +10 -0
- package/lib/crawler/dedupe/is-shape-capped.js +12 -0
- package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +31 -0
- package/lib/crawler/dedupe/resolve-og-url-mismatch.js +40 -0
- package/lib/crawler/dedupe/types.d.ts +42 -0
- package/lib/crawler/dedupe/types.js +1 -0
- package/lib/crawler/dns-burned-host-burn-timestamps.d.ts +17 -0
- package/lib/crawler/dns-burned-host-burn-timestamps.js +17 -0
- package/lib/crawler/evict-network-classified-destination-cache-entries.d.ts +26 -0
- package/lib/crawler/evict-network-classified-destination-cache-entries.js +34 -0
- package/lib/crawler/evict-outage-tainted-dns-burns.d.ts +23 -0
- package/lib/crawler/evict-outage-tainted-dns-burns.js +26 -0
- package/lib/crawler/fetch-destination.js +14 -2
- package/lib/crawler/generate-predicted-urls.d.ts +12 -0
- package/lib/crawler/generate-predicted-urls.js +33 -2
- package/lib/crawler/is-puppeteer-fallback-candidate.js +3 -0
- package/lib/crawler/network-gate.d.ts +49 -0
- package/lib/crawler/network-gate.js +78 -0
- package/lib/crawler/network-outage-detector.d.ts +51 -0
- package/lib/crawler/network-outage-detector.js +81 -0
- package/lib/crawler/network-outage-summary-counter.d.ts +21 -0
- package/lib/crawler/network-outage-summary-counter.js +18 -0
- package/lib/crawler/probe-network.d.ts +26 -0
- package/lib/crawler/probe-network.js +21 -0
- package/lib/crawler/types.d.ts +185 -1
- package/lib/crawler-orchestrator.d.ts +74 -18
- package/lib/crawler-orchestrator.js +267 -27
- package/lib/crawler.d.ts +22 -0
- package/lib/crawler.js +18 -0
- package/lib/is-within-outage-window.d.ts +49 -0
- package/lib/is-within-outage-window.js +33 -0
- package/lib/network-related-error-kinds.d.ts +23 -0
- package/lib/network-related-error-kinds.js +28 -0
- package/lib/permanent-error-kinds.d.ts +9 -4
- package/lib/permanent-error-kinds.js +10 -4
- package/lib/types.d.ts +11 -2
- package/lib/utils/array/each-splitted.d.ts +1 -1
- package/lib/utils/compute-file-sha256.d.ts +17 -17
- package/lib/utils/compute-file-sha256.js +18 -47
- package/package.json +3 -3
package/lib/archive/archive.d.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { TemplateClusterReason } from './db-ops/analysis/types.js';
|
|
2
|
+
import type { Config, InsertDedupeCapEventParams, 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
|
|
10
|
-
*
|
|
11
|
-
* tar
|
|
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},
|
|
@@ -43,6 +49,17 @@ export default class Archive extends ArchiveAccessor {
|
|
|
43
49
|
* @deprecated This method is no longer functional.
|
|
44
50
|
*/
|
|
45
51
|
abort(): void;
|
|
52
|
+
/**
|
|
53
|
+
* Adds onto the `rejected_count` of a shape's `dedupe_cap_events` row,
|
|
54
|
+
* looked up by `shape_key` rather than `id` — used for a shape that
|
|
55
|
+
* capped in an earlier session (preloaded into `DedupeCapTracker`'s
|
|
56
|
+
* sticky set) and so has no event id from the current session.
|
|
57
|
+
*
|
|
58
|
+
* Thin facade over {@link Database.accumulateDedupeCapRejectedCount}.
|
|
59
|
+
* @param shapeKey - The capped shape whose rejection count to accumulate.
|
|
60
|
+
* @param rejectedCount - Additional anchors rejected for this shape in the current session.
|
|
61
|
+
*/
|
|
62
|
+
accumulateDedupeCapRejectedCount(shapeKey: string, rejectedCount: number): Promise<void>;
|
|
46
63
|
/**
|
|
47
64
|
* Records a crawler-level error to both the human-readable `error.log` (full
|
|
48
65
|
* stack, for debugging) and the structured `crawl_errors` table (queryable,
|
|
@@ -66,6 +83,23 @@ export default class Archive extends ArchiveAccessor {
|
|
|
66
83
|
* @param isExternal - Whether the URL is external. Defaults to `false`.
|
|
67
84
|
*/
|
|
68
85
|
addPageError(url: string, phase: string, message: string, isExternal?: boolean): Promise<void>;
|
|
86
|
+
/**
|
|
87
|
+
* Closes an outage row by stamping `ended_at` — a no-op if already closed.
|
|
88
|
+
*
|
|
89
|
+
* Thin facade over {@link Database.closeNetworkOutage}.
|
|
90
|
+
* @param id - The `network_outages.id` to close.
|
|
91
|
+
* @param endedAt - Epoch ms the outage is considered to have ended.
|
|
92
|
+
*/
|
|
93
|
+
closeNetworkOutage(id: number, endedAt: number): Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* Finalizes a `dedupe_cap_events` row by stamping `rejected_count` — a
|
|
96
|
+
* no-op if already finalized.
|
|
97
|
+
*
|
|
98
|
+
* Thin facade over {@link Database.finalizeDedupeCapEvent}.
|
|
99
|
+
* @param id - The `dedupe_cap_events.id` to finalize.
|
|
100
|
+
* @param rejectedCount - Number of anchors rejected for this shape after it capped.
|
|
101
|
+
*/
|
|
102
|
+
finalizeDedupeCapEvent(id: number, rejectedCount: number): Promise<void>;
|
|
69
103
|
/**
|
|
70
104
|
* Retrieves the current crawling state, including lists of scraped and pending URLs.
|
|
71
105
|
* @returns An object with `scraped` and `pending` URL arrays.
|
|
@@ -122,6 +156,15 @@ export default class Archive extends ArchiveAccessor {
|
|
|
122
156
|
* @returns The base URL string.
|
|
123
157
|
*/
|
|
124
158
|
getUrl(): Promise<string>;
|
|
159
|
+
/**
|
|
160
|
+
* Appends one row (`rejected_count = NULL`) to the `dedupe_cap_events`
|
|
161
|
+
* journal.
|
|
162
|
+
*
|
|
163
|
+
* Thin facade over {@link Database.insertDedupeCapEvent}.
|
|
164
|
+
* @param params - The newly-capped shape's fields to record.
|
|
165
|
+
* @returns The autoincremented `id` of the inserted row.
|
|
166
|
+
*/
|
|
167
|
+
insertDedupeCapEvent(params: InsertDedupeCapEventParams): Promise<number>;
|
|
125
168
|
/**
|
|
126
169
|
* Pre-insert inventory non-HTML URLs as `source='inventory-seed'`
|
|
127
170
|
* placeholders in the `resources` table — the non-HTML counterpart of
|
|
@@ -151,6 +194,24 @@ export default class Archive extends ArchiveAccessor {
|
|
|
151
194
|
* @param urls - HTML seed URLs to pre-insert. No-op when empty.
|
|
152
195
|
*/
|
|
153
196
|
insertInventorySeeds(urls: readonly ExURL[]): Promise<void>;
|
|
197
|
+
/**
|
|
198
|
+
* Appends one open row to the `network_outages` journal.
|
|
199
|
+
*
|
|
200
|
+
* Thin facade over {@link Database.insertNetworkOutage} — see
|
|
201
|
+
* {@link recordInventoryRun}'s docstring for why this indirection exists.
|
|
202
|
+
* @param params - The confirmed-outage fields to record.
|
|
203
|
+
* @returns The autoincremented `id` of the inserted row.
|
|
204
|
+
*/
|
|
205
|
+
insertNetworkOutage(params: InsertNetworkOutageParams): Promise<number>;
|
|
206
|
+
/**
|
|
207
|
+
* Every distinct `dedupe_cap_events.shape_key` recorded in this archive.
|
|
208
|
+
* Consumed by `CrawlerOrchestrator` to preload `DedupeCapTracker`'s
|
|
209
|
+
* sticky set on `--resume` / `--append` / `--retry-failed` /
|
|
210
|
+
* `--inventory`, mirroring {@link listDnsBurnedHostCandidates}'s
|
|
211
|
+
* writer-only exposure.
|
|
212
|
+
* @returns Distinct shape keys already confirmed capped.
|
|
213
|
+
*/
|
|
214
|
+
listDedupeCapShapeKeys(): Promise<string[]>;
|
|
154
215
|
/**
|
|
155
216
|
* Hostnames whose `crawl_errors` history is consistently DNS failures and
|
|
156
217
|
* for which no recent 2xx/3xx page or resource is recorded. Consumed by
|
|
@@ -165,6 +226,13 @@ export default class Archive extends ArchiveAccessor {
|
|
|
165
226
|
* @returns Lower-cased hostnames safe to short-circuit.
|
|
166
227
|
*/
|
|
167
228
|
listDnsBurnedHostCandidates(): Promise<string[]>;
|
|
229
|
+
/**
|
|
230
|
+
* Lists every recorded outage as a resolved {@link OutageWindow}.
|
|
231
|
+
*
|
|
232
|
+
* Thin facade over {@link Database.listNetworkOutages}.
|
|
233
|
+
* @returns Resolved outage windows, or `[]` if none have been recorded.
|
|
234
|
+
*/
|
|
235
|
+
listNetworkOutages(): Promise<OutageWindow[]>;
|
|
168
236
|
/**
|
|
169
237
|
* Appends one row to the `inventory_runs` audit log.
|
|
170
238
|
*
|
|
@@ -215,8 +283,12 @@ export default class Archive extends ArchiveAccessor {
|
|
|
215
283
|
* reaching into the low-level database class directly.
|
|
216
284
|
* @param templateKeysByUrl - Page URL → template key, as produced by
|
|
217
285
|
* `@nitpicker/core`'s `classifyPageTemplates`.
|
|
286
|
+
* @param clusterReasonsByTemplateKey - Template key → cluster-selection
|
|
287
|
+
* evidence, if the caller captured it. Omitting this always clears the
|
|
288
|
+
* previously-stored reasons too — "no reason" means "not captured for
|
|
289
|
+
* this run", never "carry over the previous run's reasons".
|
|
218
290
|
*/
|
|
219
|
-
replacePageTemplates(templateKeysByUrl: ReadonlyMap<string, string>): Promise<void>;
|
|
291
|
+
replacePageTemplates(templateKeysByUrl: ReadonlyMap<string, string>, clusterReasonsByTemplateKey?: ReadonlyMap<string, TemplateClusterReason>): Promise<void>;
|
|
220
292
|
/**
|
|
221
293
|
* Promote previously-external pages that now fall under the (possibly extended)
|
|
222
294
|
* scope back to a pending state so that the crawler re-scrapes them as fully
|
|
@@ -234,11 +306,46 @@ export default class Archive extends ArchiveAccessor {
|
|
|
234
306
|
* @returns The URLs of the pages that were reset to pending.
|
|
235
307
|
*/
|
|
236
308
|
resetFailedPages(): Promise<string[]>;
|
|
309
|
+
/**
|
|
310
|
+
* Persists the raw bytes of an `--inventory` source URL list into the
|
|
311
|
+
* archive's tar payload, at `inventory/<sha256>.txt`.
|
|
312
|
+
*
|
|
313
|
+
* The file name is the content hash rather than the original file name:
|
|
314
|
+
* re-applying the same list is then a no-op write (`fs.writeFile`
|
|
315
|
+
* overwrites identical bytes), and the original name — which may embed a
|
|
316
|
+
* client/project identifier — is never retained (the archive already
|
|
317
|
+
* omits the source file's absolute path for the same reason; see
|
|
318
|
+
* `CrawlerOrchestrator.inventory`'s `source` param).
|
|
319
|
+
*
|
|
320
|
+
* This bypasses the namespace-scoped {@link ArchiveAccessor.setData} API
|
|
321
|
+
* (that one is reserved for analyze plugins and requires a namespace) —
|
|
322
|
+
* this always lands under the fixed `inventory/` prefix regardless of
|
|
323
|
+
* how this accessor was constructed. Callers that need to read the
|
|
324
|
+
* saved list back can use the inherited `getData(`inventory/${sha256}`,
|
|
325
|
+
* 'txt')`, since it resolves to the same path when no namespace is set.
|
|
326
|
+
*
|
|
327
|
+
* No entry is ever removed here — same accepted gap as `page_html_blobs`
|
|
328
|
+
* (a future #23 GC pass will sweep unreachable hashes across both). A
|
|
329
|
+
* source list that differs byte-for-byte on every run (e.g. a
|
|
330
|
+
* regenerated doc-root export with fresh timestamps) adds one entry per
|
|
331
|
+
* run with no pruning of superseded ones.
|
|
332
|
+
* @param sha256 - Lower-case hex SHA-256 digest of `bytes` (used as the file name).
|
|
333
|
+
* @param bytes - The exact bytes of the source list file, written verbatim.
|
|
334
|
+
*/
|
|
335
|
+
saveInventorySourceList(sha256: string, bytes: Buffer): Promise<void>;
|
|
237
336
|
/**
|
|
238
337
|
* Stores the crawl configuration into the archive database.
|
|
239
338
|
* @param config - The configuration object to store.
|
|
240
339
|
*/
|
|
241
340
|
setConfig(config: Config): Promise<number[]>;
|
|
341
|
+
/**
|
|
342
|
+
* Replaces one page's captured console messages / page errors in the
|
|
343
|
+
* archive database.
|
|
344
|
+
* @param pageUrl - The originally-requested URL, normalised (`withoutHashAndAuth` form).
|
|
345
|
+
* @param redirectPaths - The redirect chain hops captured during fetch, in order.
|
|
346
|
+
* @param entries - The console log entries to persist.
|
|
347
|
+
*/
|
|
348
|
+
setConsoleLogs(pageUrl: string, redirectPaths: readonly string[], entries: readonly ConsoleLogEntry[]): Promise<void>;
|
|
242
349
|
/**
|
|
243
350
|
* Stores an external page's data in the archive database without storing
|
|
244
351
|
* an HTML snapshot. External-page rows carry only metadata (status, title,
|
|
@@ -311,10 +418,17 @@ export default class Archive extends ArchiveAccessor {
|
|
|
311
418
|
*
|
|
312
419
|
* Checkpoints the SQLite WAL so the database is self-contained inside
|
|
313
420
|
* `db.sqlite`, renames the temporary working directory to the archive's
|
|
314
|
-
* basename, and tars
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
421
|
+
* basename, and tars the **entire tmpDir**. `db.sqlite` is normally the
|
|
422
|
+
* only entry (HTML lives as BLOBs in the DB, not a `snapshot-html.zip`),
|
|
423
|
+
* but a namespace-scoped `setData` write (analyze output) or
|
|
424
|
+
* `saveInventorySourceList` (a saved `--inventory` source list) adds
|
|
425
|
+
* extra files under tmpDir that get tarred right alongside it.
|
|
426
|
+
*
|
|
427
|
+
* This is why every writer path that reaches `write()` must open with
|
|
428
|
+
* `openPluginData: true` — `Archive.open`'s default extracts only
|
|
429
|
+
* `db.sqlite`, so a re-crawl (`append` / `inventory` / `retryFailed`)
|
|
430
|
+
* opened without it would tar back a tmpDir missing those extra files,
|
|
431
|
+
* silently dropping them from the rewritten archive.
|
|
318
432
|
*/
|
|
319
433
|
write(): Promise<void>;
|
|
320
434
|
/** The file extension for Nitpicker archive files (without the leading dot). */
|
|
@@ -482,7 +596,19 @@ type ArchiveOptions = {
|
|
|
482
596
|
* Additional options for opening an existing archive.
|
|
483
597
|
*/
|
|
484
598
|
type ArchiveOpenOptions = {
|
|
485
|
-
/**
|
|
599
|
+
/**
|
|
600
|
+
* When `false` (the default), only `db.sqlite` is extracted into tmpDir.
|
|
601
|
+
* When `true`, every tar entry is extracted, including non-namespace
|
|
602
|
+
* files written via {@link ArchiveAccessor.setData} (analyze output) or
|
|
603
|
+
* {@link Archive.saveInventorySourceList} (a saved `--inventory` source
|
|
604
|
+
* list).
|
|
605
|
+
*
|
|
606
|
+
* Every writer path that later calls {@link Archive.write} MUST pass
|
|
607
|
+
* `true`: `write()` re-tars whatever is currently in tmpDir, so a
|
|
608
|
+
* re-crawl (`append` / `inventory` / `retryFailed`) opened with the
|
|
609
|
+
* default would tar back a tmpDir missing those extra files, silently
|
|
610
|
+
* dropping them from the rewritten archive.
|
|
611
|
+
*/
|
|
486
612
|
openPluginData?: boolean;
|
|
487
613
|
};
|
|
488
614
|
export {};
|
package/lib/archive/archive.js
CHANGED
|
@@ -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
|
|
23
|
-
*
|
|
24
|
-
* tar
|
|
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},
|
|
@@ -89,6 +94,20 @@ export default class Archive extends ArchiveAccessor {
|
|
|
89
94
|
* @deprecated This method is no longer functional.
|
|
90
95
|
*/
|
|
91
96
|
abort() { }
|
|
97
|
+
/**
|
|
98
|
+
* Adds onto the `rejected_count` of a shape's `dedupe_cap_events` row,
|
|
99
|
+
* looked up by `shape_key` rather than `id` — used for a shape that
|
|
100
|
+
* capped in an earlier session (preloaded into `DedupeCapTracker`'s
|
|
101
|
+
* sticky set) and so has no event id from the current session.
|
|
102
|
+
*
|
|
103
|
+
* Thin facade over {@link Database.accumulateDedupeCapRejectedCount}.
|
|
104
|
+
* @param shapeKey - The capped shape whose rejection count to accumulate.
|
|
105
|
+
* @param rejectedCount - Additional anchors rejected for this shape in the current session.
|
|
106
|
+
*/
|
|
107
|
+
async accumulateDedupeCapRejectedCount(shapeKey, rejectedCount) {
|
|
108
|
+
dbLog('Accumulate dedupe cap rejected count shapeKey=%s rejectedCount=%d', shapeKey, rejectedCount);
|
|
109
|
+
return await this.#db.accumulateDedupeCapRejectedCount(shapeKey, rejectedCount);
|
|
110
|
+
}
|
|
92
111
|
/**
|
|
93
112
|
* Records a crawler-level error to both the human-readable `error.log` (full
|
|
94
113
|
* stack, for debugging) and the structured `crawl_errors` table (queryable,
|
|
@@ -119,6 +138,29 @@ export default class Archive extends ArchiveAccessor {
|
|
|
119
138
|
dbLog('Add page error: %s [%s]', url, phase);
|
|
120
139
|
await this.#db.insertPageError(url, phase, message, isExternal);
|
|
121
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Closes an outage row by stamping `ended_at` — a no-op if already closed.
|
|
143
|
+
*
|
|
144
|
+
* Thin facade over {@link Database.closeNetworkOutage}.
|
|
145
|
+
* @param id - The `network_outages.id` to close.
|
|
146
|
+
* @param endedAt - Epoch ms the outage is considered to have ended.
|
|
147
|
+
*/
|
|
148
|
+
async closeNetworkOutage(id, endedAt) {
|
|
149
|
+
dbLog('Close network outage id=%d endedAt=%d', id, endedAt);
|
|
150
|
+
return await this.#db.closeNetworkOutage(id, endedAt);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Finalizes a `dedupe_cap_events` row by stamping `rejected_count` — a
|
|
154
|
+
* no-op if already finalized.
|
|
155
|
+
*
|
|
156
|
+
* Thin facade over {@link Database.finalizeDedupeCapEvent}.
|
|
157
|
+
* @param id - The `dedupe_cap_events.id` to finalize.
|
|
158
|
+
* @param rejectedCount - Number of anchors rejected for this shape after it capped.
|
|
159
|
+
*/
|
|
160
|
+
async finalizeDedupeCapEvent(id, rejectedCount) {
|
|
161
|
+
dbLog('Finalize dedupe cap event id=%d rejectedCount=%d', id, rejectedCount);
|
|
162
|
+
return await this.#db.finalizeDedupeCapEvent(id, rejectedCount);
|
|
163
|
+
}
|
|
122
164
|
/**
|
|
123
165
|
* Retrieves the current crawling state, including lists of scraped and pending URLs.
|
|
124
166
|
* @returns An object with `scraped` and `pending` URL arrays.
|
|
@@ -186,6 +228,18 @@ export default class Archive extends ArchiveAccessor {
|
|
|
186
228
|
async getUrl() {
|
|
187
229
|
return this.#db.getBaseUrl();
|
|
188
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Appends one row (`rejected_count = NULL`) to the `dedupe_cap_events`
|
|
233
|
+
* journal.
|
|
234
|
+
*
|
|
235
|
+
* Thin facade over {@link Database.insertDedupeCapEvent}.
|
|
236
|
+
* @param params - The newly-capped shape's fields to record.
|
|
237
|
+
* @returns The autoincremented `id` of the inserted row.
|
|
238
|
+
*/
|
|
239
|
+
async insertDedupeCapEvent(params) {
|
|
240
|
+
dbLog('Insert dedupe cap event: shapeKey=%s', params.shapeKey);
|
|
241
|
+
return await this.#db.insertDedupeCapEvent(params);
|
|
242
|
+
}
|
|
189
243
|
/**
|
|
190
244
|
* Pre-insert inventory non-HTML URLs as `source='inventory-seed'`
|
|
191
245
|
* placeholders in the `resources` table — the non-HTML counterpart of
|
|
@@ -227,6 +281,29 @@ export default class Archive extends ArchiveAccessor {
|
|
|
227
281
|
dbLog('Insert inventory seeds: %d URL(s)', urls.length);
|
|
228
282
|
await this.#db.insertInventorySeeds(urls.map((u) => u.withoutHashAndAuth));
|
|
229
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Appends one open row to the `network_outages` journal.
|
|
286
|
+
*
|
|
287
|
+
* Thin facade over {@link Database.insertNetworkOutage} — see
|
|
288
|
+
* {@link recordInventoryRun}'s docstring for why this indirection exists.
|
|
289
|
+
* @param params - The confirmed-outage fields to record.
|
|
290
|
+
* @returns The autoincremented `id` of the inserted row.
|
|
291
|
+
*/
|
|
292
|
+
async insertNetworkOutage(params) {
|
|
293
|
+
dbLog('Insert network outage: startedAt=%d probeHost=%s', params.startedAt, params.probeHost);
|
|
294
|
+
return await this.#db.insertNetworkOutage(params);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Every distinct `dedupe_cap_events.shape_key` recorded in this archive.
|
|
298
|
+
* Consumed by `CrawlerOrchestrator` to preload `DedupeCapTracker`'s
|
|
299
|
+
* sticky set on `--resume` / `--append` / `--retry-failed` /
|
|
300
|
+
* `--inventory`, mirroring {@link listDnsBurnedHostCandidates}'s
|
|
301
|
+
* writer-only exposure.
|
|
302
|
+
* @returns Distinct shape keys already confirmed capped.
|
|
303
|
+
*/
|
|
304
|
+
async listDedupeCapShapeKeys() {
|
|
305
|
+
return this.#db.listDedupeCapShapeKeys();
|
|
306
|
+
}
|
|
230
307
|
/**
|
|
231
308
|
* Hostnames whose `crawl_errors` history is consistently DNS failures and
|
|
232
309
|
* for which no recent 2xx/3xx page or resource is recorded. Consumed by
|
|
@@ -243,6 +320,15 @@ export default class Archive extends ArchiveAccessor {
|
|
|
243
320
|
async listDnsBurnedHostCandidates() {
|
|
244
321
|
return this.#db.listDnsBurnedHostCandidates();
|
|
245
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Lists every recorded outage as a resolved {@link OutageWindow}.
|
|
325
|
+
*
|
|
326
|
+
* Thin facade over {@link Database.listNetworkOutages}.
|
|
327
|
+
* @returns Resolved outage windows, or `[]` if none have been recorded.
|
|
328
|
+
*/
|
|
329
|
+
async listNetworkOutages() {
|
|
330
|
+
return await this.#db.listNetworkOutages();
|
|
331
|
+
}
|
|
246
332
|
/**
|
|
247
333
|
* Appends one row to the `inventory_runs` audit log.
|
|
248
334
|
*
|
|
@@ -295,9 +381,13 @@ export default class Archive extends ArchiveAccessor {
|
|
|
295
381
|
* reaching into the low-level database class directly.
|
|
296
382
|
* @param templateKeysByUrl - Page URL → template key, as produced by
|
|
297
383
|
* `@nitpicker/core`'s `classifyPageTemplates`.
|
|
384
|
+
* @param clusterReasonsByTemplateKey - Template key → cluster-selection
|
|
385
|
+
* evidence, if the caller captured it. Omitting this always clears the
|
|
386
|
+
* previously-stored reasons too — "no reason" means "not captured for
|
|
387
|
+
* this run", never "carry over the previous run's reasons".
|
|
298
388
|
*/
|
|
299
|
-
async replacePageTemplates(templateKeysByUrl) {
|
|
300
|
-
await this.#db.replacePageTemplates(templateKeysByUrl);
|
|
389
|
+
async replacePageTemplates(templateKeysByUrl, clusterReasonsByTemplateKey) {
|
|
390
|
+
await this.#db.replacePageTemplates(templateKeysByUrl, clusterReasonsByTemplateKey);
|
|
301
391
|
}
|
|
302
392
|
/**
|
|
303
393
|
* Promote previously-external pages that now fall under the (possibly extended)
|
|
@@ -322,6 +412,36 @@ export default class Archive extends ArchiveAccessor {
|
|
|
322
412
|
dbLog('Reset failed pages back to pending');
|
|
323
413
|
return this.#db.resetFailedPages();
|
|
324
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* Persists the raw bytes of an `--inventory` source URL list into the
|
|
417
|
+
* archive's tar payload, at `inventory/<sha256>.txt`.
|
|
418
|
+
*
|
|
419
|
+
* The file name is the content hash rather than the original file name:
|
|
420
|
+
* re-applying the same list is then a no-op write (`fs.writeFile`
|
|
421
|
+
* overwrites identical bytes), and the original name — which may embed a
|
|
422
|
+
* client/project identifier — is never retained (the archive already
|
|
423
|
+
* omits the source file's absolute path for the same reason; see
|
|
424
|
+
* `CrawlerOrchestrator.inventory`'s `source` param).
|
|
425
|
+
*
|
|
426
|
+
* This bypasses the namespace-scoped {@link ArchiveAccessor.setData} API
|
|
427
|
+
* (that one is reserved for analyze plugins and requires a namespace) —
|
|
428
|
+
* this always lands under the fixed `inventory/` prefix regardless of
|
|
429
|
+
* how this accessor was constructed. Callers that need to read the
|
|
430
|
+
* saved list back can use the inherited `getData(`inventory/${sha256}`,
|
|
431
|
+
* 'txt')`, since it resolves to the same path when no namespace is set.
|
|
432
|
+
*
|
|
433
|
+
* No entry is ever removed here — same accepted gap as `page_html_blobs`
|
|
434
|
+
* (a future #23 GC pass will sweep unreachable hashes across both). A
|
|
435
|
+
* source list that differs byte-for-byte on every run (e.g. a
|
|
436
|
+
* regenerated doc-root export with fresh timestamps) adds one entry per
|
|
437
|
+
* run with no pruning of superseded ones.
|
|
438
|
+
* @param sha256 - Lower-case hex SHA-256 digest of `bytes` (used as the file name).
|
|
439
|
+
* @param bytes - The exact bytes of the source list file, written verbatim.
|
|
440
|
+
*/
|
|
441
|
+
async saveInventorySourceList(sha256, bytes) {
|
|
442
|
+
const filePath = safePath(this.tmpDir, 'inventory', `${sha256}.txt`);
|
|
443
|
+
await outputBinary(filePath, bytes);
|
|
444
|
+
}
|
|
325
445
|
/**
|
|
326
446
|
* Stores the crawl configuration into the archive database.
|
|
327
447
|
* @param config - The configuration object to store.
|
|
@@ -330,6 +450,17 @@ export default class Archive extends ArchiveAccessor {
|
|
|
330
450
|
dbLog('Set config: %O', config);
|
|
331
451
|
return this.#db.setConfig(config);
|
|
332
452
|
}
|
|
453
|
+
/**
|
|
454
|
+
* Replaces one page's captured console messages / page errors in the
|
|
455
|
+
* archive database.
|
|
456
|
+
* @param pageUrl - The originally-requested URL, normalised (`withoutHashAndAuth` form).
|
|
457
|
+
* @param redirectPaths - The redirect chain hops captured during fetch, in order.
|
|
458
|
+
* @param entries - The console log entries to persist.
|
|
459
|
+
*/
|
|
460
|
+
async setConsoleLogs(pageUrl, redirectPaths, entries) {
|
|
461
|
+
dbLog('Set console logs: %d entries on %s', entries.length, pageUrl);
|
|
462
|
+
await this.#db.replaceConsoleLogs(pageUrl, redirectPaths, entries);
|
|
463
|
+
}
|
|
333
464
|
/**
|
|
334
465
|
* Stores an external page's data in the archive database without storing
|
|
335
466
|
* an HTML snapshot. External-page rows carry only metadata (status, title,
|
|
@@ -423,10 +554,17 @@ export default class Archive extends ArchiveAccessor {
|
|
|
423
554
|
*
|
|
424
555
|
* Checkpoints the SQLite WAL so the database is self-contained inside
|
|
425
556
|
* `db.sqlite`, renames the temporary working directory to the archive's
|
|
426
|
-
* basename, and tars
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
557
|
+
* basename, and tars the **entire tmpDir**. `db.sqlite` is normally the
|
|
558
|
+
* only entry (HTML lives as BLOBs in the DB, not a `snapshot-html.zip`),
|
|
559
|
+
* but a namespace-scoped `setData` write (analyze output) or
|
|
560
|
+
* `saveInventorySourceList` (a saved `--inventory` source list) adds
|
|
561
|
+
* extra files under tmpDir that get tarred right alongside it.
|
|
562
|
+
*
|
|
563
|
+
* This is why every writer path that reaches `write()` must open with
|
|
564
|
+
* `openPluginData: true` — `Archive.open`'s default extracts only
|
|
565
|
+
* `db.sqlite`, so a re-crawl (`append` / `inventory` / `retryFailed`)
|
|
566
|
+
* opened without it would tar back a tmpDir missing those extra files,
|
|
567
|
+
* silently dropping them from the rewritten archive.
|
|
430
568
|
*/
|
|
431
569
|
async write() {
|
|
432
570
|
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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replaces mixed-alphanumeric tokens of 8+ characters with a fixed
|
|
3
|
+
* placeholder, so that two bodies differing only in an embedded dynamic
|
|
4
|
+
* value (a cache-busting hash, a per-build CSS-module suffix, a session or
|
|
5
|
+
* order id) hash the same.
|
|
6
|
+
*
|
|
7
|
+
* A token is masked only when it mixes letters and digits — a pure-digit
|
|
8
|
+
* match (phone numbers, product codes, dates) or a pure-alphabetic match (an
|
|
9
|
+
* ordinary English word) is left untouched, since those are far more likely
|
|
10
|
+
* to be stable content than a dynamic identifier.
|
|
11
|
+
*
|
|
12
|
+
* This is a heuristic, not a semantic classifier, and it is accepted as such:
|
|
13
|
+
* a mixed-alphanumeric SKU/order/product code that is itself the only
|
|
14
|
+
* distinguishing content between two otherwise-identical pages (e.g.
|
|
15
|
+
* `ABC12345` vs `XYZ98765`) also gets masked, collapsing two genuinely
|
|
16
|
+
* different pages into one `computeBodyHash` duplicate-detection group — a
|
|
17
|
+
* false positive. Narrowing the pattern to reduce that risk would also
|
|
18
|
+
* narrow what it catches (the whole point of this mask is to absorb
|
|
19
|
+
* cache-busting hashes and CSS-module suffixes whose shape is
|
|
20
|
+
* indistinguishable from such a code), so this trade-off is deliberate
|
|
21
|
+
* rather than something a stricter regex would cleanly resolve.
|
|
22
|
+
*
|
|
23
|
+
* The placeholder is a fixed constant rather than a per-match incrementing
|
|
24
|
+
* id: nothing needs to reverse the substitution back to the original value —
|
|
25
|
+
* the full original HTML is already preserved verbatim in `page_html_blobs`,
|
|
26
|
+
* so recovering it here would be redundant. All that matters for duplicate
|
|
27
|
+
* detection is that every masked token collapses to the same value.
|
|
28
|
+
* @param text - Text to mask (already URL-normalized by
|
|
29
|
+
* `normalizeUrlLikeStrings`).
|
|
30
|
+
* @returns `text` with every mixed-alphanumeric 8+ character token replaced
|
|
31
|
+
* by a fixed placeholder.
|
|
32
|
+
*/
|
|
33
|
+
export declare function maskDynamicIds(text: string): string;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const DYNAMIC_ID_PATTERN = /[a-z0-9]{8,}/gi;
|
|
2
|
+
const DIGITS_ONLY_PATTERN = /^\d+$/;
|
|
3
|
+
const ALPHA_ONLY_PATTERN = /^[a-z]+$/i;
|
|
4
|
+
const MASK_PLACEHOLDER = '__MASKED_ID__';
|
|
5
|
+
/**
|
|
6
|
+
* Replaces mixed-alphanumeric tokens of 8+ characters with a fixed
|
|
7
|
+
* placeholder, so that two bodies differing only in an embedded dynamic
|
|
8
|
+
* value (a cache-busting hash, a per-build CSS-module suffix, a session or
|
|
9
|
+
* order id) hash the same.
|
|
10
|
+
*
|
|
11
|
+
* A token is masked only when it mixes letters and digits — a pure-digit
|
|
12
|
+
* match (phone numbers, product codes, dates) or a pure-alphabetic match (an
|
|
13
|
+
* ordinary English word) is left untouched, since those are far more likely
|
|
14
|
+
* to be stable content than a dynamic identifier.
|
|
15
|
+
*
|
|
16
|
+
* This is a heuristic, not a semantic classifier, and it is accepted as such:
|
|
17
|
+
* a mixed-alphanumeric SKU/order/product code that is itself the only
|
|
18
|
+
* distinguishing content between two otherwise-identical pages (e.g.
|
|
19
|
+
* `ABC12345` vs `XYZ98765`) also gets masked, collapsing two genuinely
|
|
20
|
+
* different pages into one `computeBodyHash` duplicate-detection group — a
|
|
21
|
+
* false positive. Narrowing the pattern to reduce that risk would also
|
|
22
|
+
* narrow what it catches (the whole point of this mask is to absorb
|
|
23
|
+
* cache-busting hashes and CSS-module suffixes whose shape is
|
|
24
|
+
* indistinguishable from such a code), so this trade-off is deliberate
|
|
25
|
+
* rather than something a stricter regex would cleanly resolve.
|
|
26
|
+
*
|
|
27
|
+
* The placeholder is a fixed constant rather than a per-match incrementing
|
|
28
|
+
* id: nothing needs to reverse the substitution back to the original value —
|
|
29
|
+
* the full original HTML is already preserved verbatim in `page_html_blobs`,
|
|
30
|
+
* so recovering it here would be redundant. All that matters for duplicate
|
|
31
|
+
* detection is that every masked token collapses to the same value.
|
|
32
|
+
* @param text - Text to mask (already URL-normalized by
|
|
33
|
+
* `normalizeUrlLikeStrings`).
|
|
34
|
+
* @returns `text` with every mixed-alphanumeric 8+ character token replaced
|
|
35
|
+
* by a fixed placeholder.
|
|
36
|
+
*/
|
|
37
|
+
export function maskDynamicIds(text) {
|
|
38
|
+
return text.replaceAll(DYNAMIC_ID_PATTERN, (match) => {
|
|
39
|
+
if (DIGITS_ONLY_PATTERN.test(match) || ALPHA_ONLY_PATTERN.test(match)) {
|
|
40
|
+
return match;
|
|
41
|
+
}
|
|
42
|
+
return MASK_PLACEHOLDER;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes `/index.{ext}` path suffixes (`/index.html`, `/index.php`, ...)
|
|
3
|
+
* to a bare trailing `/` throughout the given text, so that two otherwise
|
|
4
|
+
* identical bodies that differ only in which equivalent URL form a template
|
|
5
|
+
* happened to render (`/about/` vs `/about/index.html`) hash the same.
|
|
6
|
+
*
|
|
7
|
+
* Applied as a blanket string sweep over the whole body — not scoped to
|
|
8
|
+
* `href`/`src` attribute values — because the same trailing-suffix variance
|
|
9
|
+
* can appear anywhere a URL-shaped string is rendered as text (breadcrumbs,
|
|
10
|
+
* "print this page" widgets, JSON embedded in an inline `<script>`), and the
|
|
11
|
+
* body is treated as an opaque string for masking purposes (see
|
|
12
|
+
* `computeBodyHash`).
|
|
13
|
+
* @param body - The `<body>` inner HTML (or any text) to normalize.
|
|
14
|
+
* @returns `body` with every `/index.{ext}` suffix collapsed to `/`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function normalizeUrlLikeStrings(body: string): string;
|