@nitpicker/crawler 0.20.0 → 0.22.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/append-retry-failed-common-setup-phases.d.ts +1 -1
- package/lib/append-retry-failed-common-setup-phases.js +5 -1
- package/lib/append-setup-phases.d.ts +1 -1
- package/lib/archive/archive.d.ts +57 -8
- package/lib/archive/archive.js +83 -12
- package/lib/archive/create-adjunct-tables.d.ts +3 -2
- package/lib/archive/create-adjunct-tables.js +12 -9
- package/lib/archive/create-entity-tables.js +3 -0
- package/lib/archive/database.d.ts +39 -7
- package/lib/archive/database.js +49 -9
- package/lib/archive/db-ops/_shared/keyset-paginate-by-id.d.ts +49 -0
- package/lib/archive/db-ops/_shared/keyset-paginate-by-id.js +69 -0
- package/lib/archive/db-ops/_shared/resolve-content-item-id.d.ts +13 -12
- package/lib/archive/db-ops/_shared/resolve-content-item-id.js +63 -31
- package/lib/archive/db-ops/_shared/types.d.ts +39 -0
- package/lib/archive/db-ops/analysis/replace-analysis-violations.js +7 -4
- package/lib/archive/db-ops/config/get-created-cwd.d.ts +11 -0
- package/lib/archive/db-ops/config/get-created-cwd.js +13 -0
- package/lib/archive/db-ops/config/info-column-allowlist.js +1 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.d.ts +57 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.js +87 -0
- package/lib/archive/db-ops/errors/insert-page-error.js +3 -1
- package/lib/archive/db-ops/lifecycle/init.d.ts +13 -6
- package/lib/archive/db-ops/lifecycle/init.js +30 -10
- package/lib/archive/db-ops/list-reconcile/record-list-reconcile-run.d.ts +22 -0
- package/lib/archive/db-ops/{inventory/record-inventory-run.js → list-reconcile/record-list-reconcile-run.js} +11 -10
- package/lib/archive/db-ops/pages/read/build-page-query.js +1 -1
- package/lib/archive/db-ops/pages/read/get-crawling-state.d.ts +9 -2
- package/lib/archive/db-ops/pages/read/get-crawling-state.js +11 -3
- package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.d.ts +20 -0
- package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.js +37 -0
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.d.ts +3 -2
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +17 -37
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +22 -1
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +79 -33
- package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.d.ts +78 -0
- package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.js +146 -0
- package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.d.ts +24 -0
- package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.js +24 -0
- package/lib/archive/db-ops/pages/write/insert-inventory-content-items.js +2 -1
- package/lib/archive/db-ops/pages/write/insert-page.js +4 -2
- package/lib/archive/db-ops/pages/write/link-redirect-sources.js +3 -1
- package/lib/archive/db-ops/pages/write/record-redirect.js +1 -1
- package/lib/archive/db-ops/pages/write/set-skipped-page.js +3 -1
- package/lib/archive/db-ops/pages/write/update-page.d.ts +9 -1
- package/lib/archive/db-ops/pages/write/update-page.js +28 -6
- package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +3 -6
- package/lib/archive/db-ops/resources/get-resource-url-list.js +10 -35
- package/lib/archive/init-schema.d.ts +1 -1
- package/lib/archive/init-schema.js +3 -2
- package/lib/archive/meta/compute-main-contents-denormalized.d.ts +11 -3
- package/lib/archive/meta/compute-main-contents-denormalized.js +14 -2
- package/lib/archive/meta/types.d.ts +24 -0
- package/lib/archive/migrate-content-items-is-metadata-only.d.ts +44 -0
- package/lib/archive/migrate-content-items-is-metadata-only.js +60 -0
- package/lib/archive/migrate-info-created-cwd.d.ts +17 -0
- package/lib/archive/migrate-info-created-cwd.js +36 -0
- package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.d.ts +34 -0
- package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.js +51 -0
- package/lib/archive/{migrate-inventory-runs-exclude-skipped.d.ts → migrate-list-reconcile-runs-exclude-skipped.d.ts} +8 -8
- package/lib/archive/{migrate-inventory-runs-exclude-skipped.js → migrate-list-reconcile-runs-exclude-skipped.js} +12 -12
- package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.d.ts +16 -0
- package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.js +35 -0
- package/lib/archive/migrate-page-meta-image-scan.d.ts +27 -0
- package/lib/archive/migrate-page-meta-image-scan.js +52 -0
- package/lib/archive/page.d.ts +12 -0
- package/lib/archive/page.js +16 -0
- package/lib/archive/types.d.ts +97 -10
- package/lib/compute-auto-retry-backoff-delay.d.ts +30 -0
- package/lib/compute-auto-retry-backoff-delay.js +36 -0
- package/lib/crawler/apply-crawl-runtime-options-patch.d.ts +32 -0
- package/lib/crawler/apply-crawl-runtime-options-patch.js +129 -0
- package/lib/crawler/crawler.d.ts +35 -3
- package/lib/crawler/crawler.js +486 -338
- package/lib/crawler/dedupe/build-dedupe-cap-observation.d.ts +74 -0
- package/lib/crawler/dedupe/build-dedupe-cap-observation.js +97 -0
- package/lib/crawler/dedupe/compute-meta-signature.d.ts +7 -5
- package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
- package/lib/crawler/dedupe/compute-shape-key.d.ts +20 -0
- package/lib/crawler/dedupe/compute-shape-key.js +73 -1
- package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +6 -1
- package/lib/crawler/dedupe/dedupe-cap-tracker.js +6 -1
- package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +7 -5
- package/lib/crawler/dedupe/resolve-og-url-mismatch.js +5 -3
- package/lib/crawler/dedupe/types.d.ts +18 -0
- package/lib/crawler/fetch-destination.js +1 -0
- package/lib/crawler/link-list.d.ts +9 -1
- package/lib/crawler/link-list.js +12 -2
- package/lib/crawler/link-to-page-data.js +1 -0
- package/lib/crawler/resource-to-page-data.js +1 -0
- package/lib/crawler/types.d.ts +75 -0
- package/lib/crawler-orchestrator.d.ts +171 -8
- package/lib/crawler-orchestrator.js +1177 -234
- package/lib/crawler.d.ts +3 -0
- package/lib/crawler.js +3 -0
- package/lib/delay-or-abort.d.ts +20 -0
- package/lib/delay-or-abort.js +35 -0
- package/lib/pending-urls-remain-error.d.ts +35 -0
- package/lib/pending-urls-remain-error.js +43 -0
- package/lib/recrawl-setup-phases.d.ts +38 -0
- package/lib/recrawl-setup-phases.js +52 -0
- package/lib/resume-setup-phases.d.ts +1 -1
- package/lib/resume-setup-phases.js +5 -1
- package/lib/retry-failed-setup-phases.d.ts +1 -1
- package/lib/setup-recovery-phase-labels.d.ts +17 -8
- package/lib/setup-recovery-phase-labels.js +17 -8
- package/lib/types.d.ts +62 -12
- package/lib/utils/compute-file-sha256.d.ts +1 -1
- package/lib/utils/compute-file-sha256.js +1 -1
- package/package.json +4 -4
- package/lib/archive/db-ops/inventory/record-inventory-run.d.ts +0 -21
- package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +0 -16
- package/lib/archive/migrate-inventory-runs-invalid-skipped.js +0 -35
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { DedupeCapObservation } from './types.js';
|
|
2
|
+
import type { DedupeCapObservationRow } from '../../archive/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Reconstructs the `DedupeCapObservation` a previously-scraped page would
|
|
5
|
+
* have produced had it been observed live, from the archived
|
|
6
|
+
* `DedupeCapObservationRow` `listDedupeCapObservations` reads back out of
|
|
7
|
+
* `content_items` / `page_meta`. Used to replay a prior session's
|
|
8
|
+
* observations into a fresh `DedupeCapTracker` on `--resume` / `--append` /
|
|
9
|
+
* `--retry-failed` / `--inventory` / `--recrawl`, so the Misra-Gries
|
|
10
|
+
* counters those sessions accumulate are not silently discarded — only the
|
|
11
|
+
* confirmed-capped shapes (`DedupeCapTracker`'s sticky set) survive a
|
|
12
|
+
* process restart otherwise, and a shape that was close to (but short of)
|
|
13
|
+
* its threshold when the previous session ended would restart at count 0.
|
|
14
|
+
*
|
|
15
|
+
* Mirrors the exact exclusions the live crawl-time observation site applies
|
|
16
|
+
* (`Crawler`'s `'page'` handler, gated on `!isExternal && !isMetadataOnly &&
|
|
17
|
+
* html.length > 0`): `listDedupeCapObservations` already narrows to that
|
|
18
|
+
* same population at the SQL layer, so this function only needs to redo
|
|
19
|
+
* the two per-row signal computations that can independently yield "no
|
|
20
|
+
* signal" — `computeShapeKey` (a URL that fails to decompose) and
|
|
21
|
+
* `computeMetaSignature` (no title and no Open Graph tags) — and return
|
|
22
|
+
* `null` exactly where the live site would have skipped the page.
|
|
23
|
+
*
|
|
24
|
+
* **Known limitation — `og:url` absolutisation drift**: `computeMetaSignature`
|
|
25
|
+
* deliberately hashes `og:url` *as written* (no absolutisation — see its own
|
|
26
|
+
* JSDoc), but `page_meta.og_url_id` only ever stores the *absolutised* form
|
|
27
|
+
* (`derive-flat-from-meta.ts`'s `og_url: absolutizeUrl(og?.url, base)`) — the
|
|
28
|
+
* archive never keeps the raw, possibly-relative string. For a page whose
|
|
29
|
+
* template writes `og:url` as an absolute URL (the OGP-recommended, and most
|
|
30
|
+
* common, form) this is a no-op and the reconstructed `metaSig` matches the
|
|
31
|
+
* live one exactly. For a template that writes it relative, the replayed
|
|
32
|
+
* `metaSig` for that shape's already-archived members will not bit-for-bit
|
|
33
|
+
* match the `metaSig` a *newly*-scraped same-shape page computes live this
|
|
34
|
+
* session (e.g. a previously-failed page succeeding under `--retry-failed`),
|
|
35
|
+
* so the Misra-Gries counter may fail to recognise it as a repeat of the
|
|
36
|
+
* dominant signature. This can only cause under-counting (a missed majority
|
|
37
|
+
* match, decrementing instead of incrementing), never over-counting — a
|
|
38
|
+
* `metaSig` mismatch can never falsely trigger a cap — so it degrades this
|
|
39
|
+
* feature's benefit for relative-`og:url` sites without ever making the cap
|
|
40
|
+
* fire incorrectly.
|
|
41
|
+
*
|
|
42
|
+
* **Known limitation — hash-fragment drift in `ogUrlMismatch`**: the live
|
|
43
|
+
* call site (`Crawler`'s `'page'` handler) resolves `og:url` against
|
|
44
|
+
* `result.pageData.url.href` — the page's full URL, hash fragment included
|
|
45
|
+
* — while `listDedupeCapObservations` reads back `url_refs.url`, which is
|
|
46
|
+
* always stored as `withoutHashAndAuth` (see `insert-page.ts`); the archive
|
|
47
|
+
* never keeps the hash. For a page whose own URL carries a `#fragment` and
|
|
48
|
+
* whose `og:url` resolves to that same URL *without* the fragment (a
|
|
49
|
+
* correct, common self-reference), the live computation sees a mismatch
|
|
50
|
+
* (the fragment differs) while the replayed one — comparing two
|
|
51
|
+
* fragment-less strings — does not. Same failure direction as the
|
|
52
|
+
* `metaSig` limitation above: this can only suppress a mismatch replay
|
|
53
|
+
* would otherwise have detected, never fabricate one, so it degrades this
|
|
54
|
+
* signal's confidence-halving benefit for hash-carrying pages without ever
|
|
55
|
+
* making the cap fire incorrectly. `bodyHash` and the reconstructed `url`
|
|
56
|
+
* field are unaffected by this — only the `pageUrl` argument to
|
|
57
|
+
* `resolveOgUrlMismatch` is hash-stripped.
|
|
58
|
+
* @param row - One archived page's fields, from `listDedupeCapObservations`.
|
|
59
|
+
* @returns The reconstructed observation, or `null` if the row carries no
|
|
60
|
+
* usable shape or meta signal (mirroring the live site's skip condition).
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* buildDedupeCapObservation({
|
|
64
|
+
* url: 'https://example.com/news/date/2024/',
|
|
65
|
+
* title: 'お知らせ',
|
|
66
|
+
* description: null,
|
|
67
|
+
* ogTitle: null,
|
|
68
|
+
* ogUrl: null,
|
|
69
|
+
* bodyHash: Buffer.from('...'),
|
|
70
|
+
* });
|
|
71
|
+
* // => { shapeKey: 'example.com/news/date/{n}/', metaSig: '...', bodyHash, ogUrlMismatch: false, url: 'https://example.com/news/date/2024/' }
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export declare function buildDedupeCapObservation(row: DedupeCapObservationRow): DedupeCapObservation | null;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { computeMetaSignature } from './compute-meta-signature.js';
|
|
2
|
+
import { computeShapeKey } from './compute-shape-key.js';
|
|
3
|
+
import { resolveOgUrlMismatch } from './resolve-og-url-mismatch.js';
|
|
4
|
+
/**
|
|
5
|
+
* Reconstructs the `DedupeCapObservation` a previously-scraped page would
|
|
6
|
+
* have produced had it been observed live, from the archived
|
|
7
|
+
* `DedupeCapObservationRow` `listDedupeCapObservations` reads back out of
|
|
8
|
+
* `content_items` / `page_meta`. Used to replay a prior session's
|
|
9
|
+
* observations into a fresh `DedupeCapTracker` on `--resume` / `--append` /
|
|
10
|
+
* `--retry-failed` / `--inventory` / `--recrawl`, so the Misra-Gries
|
|
11
|
+
* counters those sessions accumulate are not silently discarded — only the
|
|
12
|
+
* confirmed-capped shapes (`DedupeCapTracker`'s sticky set) survive a
|
|
13
|
+
* process restart otherwise, and a shape that was close to (but short of)
|
|
14
|
+
* its threshold when the previous session ended would restart at count 0.
|
|
15
|
+
*
|
|
16
|
+
* Mirrors the exact exclusions the live crawl-time observation site applies
|
|
17
|
+
* (`Crawler`'s `'page'` handler, gated on `!isExternal && !isMetadataOnly &&
|
|
18
|
+
* html.length > 0`): `listDedupeCapObservations` already narrows to that
|
|
19
|
+
* same population at the SQL layer, so this function only needs to redo
|
|
20
|
+
* the two per-row signal computations that can independently yield "no
|
|
21
|
+
* signal" — `computeShapeKey` (a URL that fails to decompose) and
|
|
22
|
+
* `computeMetaSignature` (no title and no Open Graph tags) — and return
|
|
23
|
+
* `null` exactly where the live site would have skipped the page.
|
|
24
|
+
*
|
|
25
|
+
* **Known limitation — `og:url` absolutisation drift**: `computeMetaSignature`
|
|
26
|
+
* deliberately hashes `og:url` *as written* (no absolutisation — see its own
|
|
27
|
+
* JSDoc), but `page_meta.og_url_id` only ever stores the *absolutised* form
|
|
28
|
+
* (`derive-flat-from-meta.ts`'s `og_url: absolutizeUrl(og?.url, base)`) — the
|
|
29
|
+
* archive never keeps the raw, possibly-relative string. For a page whose
|
|
30
|
+
* template writes `og:url` as an absolute URL (the OGP-recommended, and most
|
|
31
|
+
* common, form) this is a no-op and the reconstructed `metaSig` matches the
|
|
32
|
+
* live one exactly. For a template that writes it relative, the replayed
|
|
33
|
+
* `metaSig` for that shape's already-archived members will not bit-for-bit
|
|
34
|
+
* match the `metaSig` a *newly*-scraped same-shape page computes live this
|
|
35
|
+
* session (e.g. a previously-failed page succeeding under `--retry-failed`),
|
|
36
|
+
* so the Misra-Gries counter may fail to recognise it as a repeat of the
|
|
37
|
+
* dominant signature. This can only cause under-counting (a missed majority
|
|
38
|
+
* match, decrementing instead of incrementing), never over-counting — a
|
|
39
|
+
* `metaSig` mismatch can never falsely trigger a cap — so it degrades this
|
|
40
|
+
* feature's benefit for relative-`og:url` sites without ever making the cap
|
|
41
|
+
* fire incorrectly.
|
|
42
|
+
*
|
|
43
|
+
* **Known limitation — hash-fragment drift in `ogUrlMismatch`**: the live
|
|
44
|
+
* call site (`Crawler`'s `'page'` handler) resolves `og:url` against
|
|
45
|
+
* `result.pageData.url.href` — the page's full URL, hash fragment included
|
|
46
|
+
* — while `listDedupeCapObservations` reads back `url_refs.url`, which is
|
|
47
|
+
* always stored as `withoutHashAndAuth` (see `insert-page.ts`); the archive
|
|
48
|
+
* never keeps the hash. For a page whose own URL carries a `#fragment` and
|
|
49
|
+
* whose `og:url` resolves to that same URL *without* the fragment (a
|
|
50
|
+
* correct, common self-reference), the live computation sees a mismatch
|
|
51
|
+
* (the fragment differs) while the replayed one — comparing two
|
|
52
|
+
* fragment-less strings — does not. Same failure direction as the
|
|
53
|
+
* `metaSig` limitation above: this can only suppress a mismatch replay
|
|
54
|
+
* would otherwise have detected, never fabricate one, so it degrades this
|
|
55
|
+
* signal's confidence-halving benefit for hash-carrying pages without ever
|
|
56
|
+
* making the cap fire incorrectly. `bodyHash` and the reconstructed `url`
|
|
57
|
+
* field are unaffected by this — only the `pageUrl` argument to
|
|
58
|
+
* `resolveOgUrlMismatch` is hash-stripped.
|
|
59
|
+
* @param row - One archived page's fields, from `listDedupeCapObservations`.
|
|
60
|
+
* @returns The reconstructed observation, or `null` if the row carries no
|
|
61
|
+
* usable shape or meta signal (mirroring the live site's skip condition).
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* buildDedupeCapObservation({
|
|
65
|
+
* url: 'https://example.com/news/date/2024/',
|
|
66
|
+
* title: 'お知らせ',
|
|
67
|
+
* description: null,
|
|
68
|
+
* ogTitle: null,
|
|
69
|
+
* ogUrl: null,
|
|
70
|
+
* bodyHash: Buffer.from('...'),
|
|
71
|
+
* });
|
|
72
|
+
* // => { shapeKey: 'example.com/news/date/{n}/', metaSig: '...', bodyHash, ogUrlMismatch: false, url: 'https://example.com/news/date/2024/' }
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export function buildDedupeCapObservation(row) {
|
|
76
|
+
const shapeKey = computeShapeKey(row.url);
|
|
77
|
+
if (!shapeKey)
|
|
78
|
+
return null;
|
|
79
|
+
const meta = {
|
|
80
|
+
title: row.title ?? '',
|
|
81
|
+
description: row.description ?? undefined,
|
|
82
|
+
og: {
|
|
83
|
+
title: row.ogTitle ?? undefined,
|
|
84
|
+
url: row.ogUrl ?? undefined,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
const metaSig = computeMetaSignature(meta);
|
|
88
|
+
if (!metaSig)
|
|
89
|
+
return null;
|
|
90
|
+
return {
|
|
91
|
+
shapeKey,
|
|
92
|
+
metaSig,
|
|
93
|
+
bodyHash: row.bodyHash,
|
|
94
|
+
ogUrlMismatch: resolveOgUrlMismatch(meta, row.url),
|
|
95
|
+
url: row.url,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { MetaSignatureSource } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Computes a signature of the four meta fields most likely to be identical
|
|
4
4
|
* across a self-generating pager/cluster trap: `<title>`, `<meta
|
|
@@ -17,14 +17,16 @@ import type { Meta } from '@d-zero/beholder';
|
|
|
17
17
|
* for a page-URL argument. Absolutisation only matters when *comparing*
|
|
18
18
|
* `og:url` against the page's own URL (a different signal, computed
|
|
19
19
|
* separately at cap-scoring time).
|
|
20
|
-
* @param meta - Beholder-derived metadata for the page
|
|
20
|
+
* @param meta - Beholder-derived metadata for the page, or the equivalent
|
|
21
|
+
* `MetaSignatureSource` fields reconstructed from archived `page_meta`
|
|
22
|
+
* columns (see `buildDedupeCapObservation`).
|
|
21
23
|
* @returns A hex-encoded SHA-1 signature, or `null` if the page has no title
|
|
22
24
|
* and no Open Graph tags.
|
|
23
25
|
* @example
|
|
24
26
|
* ```ts
|
|
25
|
-
* computeMetaSignature({ title: 'お知らせ', og: { url: '/news' } }
|
|
27
|
+
* computeMetaSignature({ title: 'お知らせ', og: { url: '/news' } });
|
|
26
28
|
* // => a stable hex digest
|
|
27
|
-
* computeMetaSignature({ title: '', og: {} }
|
|
29
|
+
* computeMetaSignature({ title: '', og: {} }); // => null
|
|
28
30
|
* ```
|
|
29
31
|
*/
|
|
30
|
-
export declare function computeMetaSignature(meta:
|
|
32
|
+
export declare function computeMetaSignature(meta: MetaSignatureSource): string | null;
|
|
Binary file
|
|
@@ -21,6 +21,26 @@
|
|
|
21
21
|
* pagination/date/id token", so the whole segment is collapsed. Do not share
|
|
22
22
|
* masking logic between the two — they classify the same kind of text for
|
|
23
23
|
* opposite purposes.
|
|
24
|
+
*
|
|
25
|
+
* Query keys are additionally run through `flattenQueryKeys` before being
|
|
26
|
+
* folded into the key, because `ExURL`'s query normalization (`URLSearchParams`
|
|
27
|
+
* decode-then-re-encode round trip in `@d-zero/shared/parse-url`) means a
|
|
28
|
+
* site that double-encodes `&`/`=` inside a query value produces a URL whose
|
|
29
|
+
* query string still arrives here percent-encoded — `decomposeUrl`'s literal
|
|
30
|
+
* `&`/`=` split then sees ONE key that is itself a percent-encoded
|
|
31
|
+
* `key=value&key=value...` blob, with any variable content buried inside
|
|
32
|
+
* that key name rather than in a value. Without flattening, every such URL
|
|
33
|
+
* gets a distinct key name and therefore a distinct shape key, which
|
|
34
|
+
* silently defeats `--dedupe-cap` (issue #351) no matter the threshold.
|
|
35
|
+
*
|
|
36
|
+
* This changes the shape key computed for URLs that hit the flattening
|
|
37
|
+
* path. `dedupe_cap_events.shape_key` (see `archive/create-adjunct-tables.ts`)
|
|
38
|
+
* is an append-only value frozen at crawl time and never rewritten, so an
|
|
39
|
+
* archive crawled before this fix that happened to already have a
|
|
40
|
+
* `dedupe_cap_events` row for such a URL (rare, since the bug largely
|
|
41
|
+
* prevented capping from firing on it in the first place) will not
|
|
42
|
+
* re-match on the next `backfillDedupeCapEventId` recompute — the same
|
|
43
|
+
* self-healing-backfill trade-off any shape-key algorithm change makes.
|
|
24
44
|
* @param url - A URL string (protocol-agnostic `//host/...` or full
|
|
25
45
|
* `https://host/...`), typically `ExURL.withoutHashAndAuth`.
|
|
26
46
|
* @returns The shape key, or `null` if `url` cannot be decomposed.
|
|
@@ -1,7 +1,58 @@
|
|
|
1
|
+
import { decodeURIComponentSafely } from '@d-zero/shared/decode-uri-safely';
|
|
1
2
|
import { decomposeUrl } from '../decompose-url.js';
|
|
2
3
|
const DIGIT_CONTAINING_SEGMENT_PATTERN = /\d/;
|
|
3
4
|
const SEGMENT_PLACEHOLDER = '{n}';
|
|
4
5
|
const VALUE_PLACEHOLDER = '{v}';
|
|
6
|
+
/**
|
|
7
|
+
* Recursively decodes and re-splits a single query key that may itself be a
|
|
8
|
+
* percent-encoded `key=value&key=value...` blob (see the "query trap" case
|
|
9
|
+
* in `computeShapeKey`'s doc comment), peeling one layer of encoding at a
|
|
10
|
+
* time so a multiply-encoded blob (`%2526` → `%26` → `&`) still converges.
|
|
11
|
+
*
|
|
12
|
+
* Each call first decodes via `decodeURIComponentSafely` (falls back to the
|
|
13
|
+
* input unchanged on malformed `%` sequences). If decoding changed
|
|
14
|
+
* anything, there may be another layer underneath, so it recurses on the
|
|
15
|
+
* decoded string to peel further. Once decoding is a no-op (fully decoded,
|
|
16
|
+
* or undecodable), the value is at a fixed point: split on `&`/`=` if
|
|
17
|
+
* either is now present and recurse on the pieces (each piece may need its
|
|
18
|
+
* own decode chain); otherwise it's a leaf.
|
|
19
|
+
*
|
|
20
|
+
* Every recursive call operates on a string strictly shorter than its
|
|
21
|
+
* input — a successful decode always shrinks length (a `%XX` triplet never
|
|
22
|
+
* expands to more than 1 output character), and splitting on a delimiter
|
|
23
|
+
* that is present at a fixed point always yields a strictly shorter piece
|
|
24
|
+
* — so this terminates for any finite input without needing an explicit
|
|
25
|
+
* depth limit.
|
|
26
|
+
* @param key - A single raw query key, as returned by `decomposeUrl`.
|
|
27
|
+
* @returns The one or more sub-key names this key flattens to.
|
|
28
|
+
*/
|
|
29
|
+
function flattenQueryKey(key) {
|
|
30
|
+
const decoded = decodeURIComponentSafely(key);
|
|
31
|
+
if (decoded !== key)
|
|
32
|
+
return flattenQueryKey(decoded);
|
|
33
|
+
if (!decoded.includes('=') && !decoded.includes('&'))
|
|
34
|
+
return [decoded];
|
|
35
|
+
return decoded.split('&').flatMap((pair) => flattenQueryKey(pair.split('=')[0] ?? ''));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Flattens and normalizes a list of query keys so that keys whose variable
|
|
39
|
+
* content leaked into the key name itself (via nested percent-encoding)
|
|
40
|
+
* still converge to the same set of sub-key names regardless of the
|
|
41
|
+
* variable values or the sub-pairs' original order.
|
|
42
|
+
*
|
|
43
|
+
* Deliberately does NOT deduplicate the flattened keys: a legitimate
|
|
44
|
+
* multi-value query (`?tag=a&tag=b`) must stay distinguishable from a
|
|
45
|
+
* single-value one (`?tag=a`) — collapsing repeated key names would fold
|
|
46
|
+
* pages with a different facet count into the same shape, which is a
|
|
47
|
+
* correctness regression unrelated to the encoding bug this function fixes.
|
|
48
|
+
* @param keys - Raw query keys, as returned by `decomposeUrl`.
|
|
49
|
+
* @returns The flattened, sorted key names.
|
|
50
|
+
*/
|
|
51
|
+
function flattenQueryKeys(keys) {
|
|
52
|
+
return keys
|
|
53
|
+
.flatMap((key) => flattenQueryKey(key))
|
|
54
|
+
.toSorted((a, b) => a.localeCompare(b));
|
|
55
|
+
}
|
|
5
56
|
/**
|
|
6
57
|
* Computes a URL "shape" key: the host plus path/query with every path
|
|
7
58
|
* segment that contains a digit collapsed to a fixed placeholder, and every
|
|
@@ -25,6 +76,26 @@ const VALUE_PLACEHOLDER = '{v}';
|
|
|
25
76
|
* pagination/date/id token", so the whole segment is collapsed. Do not share
|
|
26
77
|
* masking logic between the two — they classify the same kind of text for
|
|
27
78
|
* opposite purposes.
|
|
79
|
+
*
|
|
80
|
+
* Query keys are additionally run through `flattenQueryKeys` before being
|
|
81
|
+
* folded into the key, because `ExURL`'s query normalization (`URLSearchParams`
|
|
82
|
+
* decode-then-re-encode round trip in `@d-zero/shared/parse-url`) means a
|
|
83
|
+
* site that double-encodes `&`/`=` inside a query value produces a URL whose
|
|
84
|
+
* query string still arrives here percent-encoded — `decomposeUrl`'s literal
|
|
85
|
+
* `&`/`=` split then sees ONE key that is itself a percent-encoded
|
|
86
|
+
* `key=value&key=value...` blob, with any variable content buried inside
|
|
87
|
+
* that key name rather than in a value. Without flattening, every such URL
|
|
88
|
+
* gets a distinct key name and therefore a distinct shape key, which
|
|
89
|
+
* silently defeats `--dedupe-cap` (issue #351) no matter the threshold.
|
|
90
|
+
*
|
|
91
|
+
* This changes the shape key computed for URLs that hit the flattening
|
|
92
|
+
* path. `dedupe_cap_events.shape_key` (see `archive/create-adjunct-tables.ts`)
|
|
93
|
+
* is an append-only value frozen at crawl time and never rewritten, so an
|
|
94
|
+
* archive crawled before this fix that happened to already have a
|
|
95
|
+
* `dedupe_cap_events` row for such a URL (rare, since the bug largely
|
|
96
|
+
* prevented capping from firing on it in the first place) will not
|
|
97
|
+
* re-match on the next `backfillDedupeCapEventId` recompute — the same
|
|
98
|
+
* self-healing-backfill trade-off any shape-key algorithm change makes.
|
|
28
99
|
* @param url - A URL string (protocol-agnostic `//host/...` or full
|
|
29
100
|
* `https://host/...`), typically `ExURL.withoutHashAndAuth`.
|
|
30
101
|
* @returns The shape key, or `null` if `url` cannot be decomposed.
|
|
@@ -49,7 +120,8 @@ export function computeShapeKey(url) {
|
|
|
49
120
|
key += `/${shapedSegments.join('/')}`;
|
|
50
121
|
}
|
|
51
122
|
if (queryKeys.length > 0) {
|
|
52
|
-
const
|
|
123
|
+
const flattenedKeys = flattenQueryKeys(queryKeys);
|
|
124
|
+
const pairs = flattenedKeys.map((k) => `${k}=${VALUE_PLACEHOLDER}`);
|
|
53
125
|
key += `?${pairs.join('&')}`;
|
|
54
126
|
}
|
|
55
127
|
return key;
|
|
@@ -61,7 +61,12 @@ export default class DedupeCapTracker {
|
|
|
61
61
|
* @param preloadedSticky - Shape keys already confirmed capped in a prior
|
|
62
62
|
* session (from `dedupe_cap_events.shape_key`), seeded so `--resume` /
|
|
63
63
|
* `--append` / `--retry-failed` / `--inventory` do not re-admit a trap
|
|
64
|
-
* this crawl already paid the cost of discovering once.
|
|
64
|
+
* this crawl already paid the cost of discovering once. Only covers
|
|
65
|
+
* shapes that already crossed their threshold — a shape that fell
|
|
66
|
+
* short still starts this instance's `#state` at nothing; callers
|
|
67
|
+
* restore that half via `#observe` (see `Crawler`'s constructor,
|
|
68
|
+
* which replays `CrawlerOptions.preloadedDedupeObservations` right
|
|
69
|
+
* after constructing this tracker).
|
|
65
70
|
*/
|
|
66
71
|
constructor(options: DedupeCapOptions, preloadedSticky?: Iterable<string>);
|
|
67
72
|
/**
|
|
@@ -67,7 +67,12 @@ export default class DedupeCapTracker {
|
|
|
67
67
|
* @param preloadedSticky - Shape keys already confirmed capped in a prior
|
|
68
68
|
* session (from `dedupe_cap_events.shape_key`), seeded so `--resume` /
|
|
69
69
|
* `--append` / `--retry-failed` / `--inventory` do not re-admit a trap
|
|
70
|
-
* this crawl already paid the cost of discovering once.
|
|
70
|
+
* this crawl already paid the cost of discovering once. Only covers
|
|
71
|
+
* shapes that already crossed their threshold — a shape that fell
|
|
72
|
+
* short still starts this instance's `#state` at nothing; callers
|
|
73
|
+
* restore that half via `#observe` (see `Crawler`'s constructor,
|
|
74
|
+
* which replays `CrawlerOptions.preloadedDedupeObservations` right
|
|
75
|
+
* after constructing this tracker).
|
|
71
76
|
*/
|
|
72
77
|
constructor(options, preloadedSticky = []) {
|
|
73
78
|
this.#options = options;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { MetaSignatureSource } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Determines whether a page's (absolutised) `og:url` points somewhere other
|
|
4
4
|
* than the page itself — one of the two confidence signals that lower the
|
|
@@ -15,17 +15,19 @@ import type { Meta } from '@d-zero/beholder';
|
|
|
15
15
|
* the page's own absolute URL without resolving it first would treat every
|
|
16
16
|
* relative self-reference (e.g. `content="./"`) as a mismatch, inflating
|
|
17
17
|
* this signal on ordinary pages.
|
|
18
|
-
* @param meta - Beholder-derived metadata for the page
|
|
18
|
+
* @param meta - Beholder-derived metadata for the page, or the equivalent
|
|
19
|
+
* `MetaSignatureSource` fields reconstructed from archived `page_meta`
|
|
20
|
+
* columns (see `buildDedupeCapObservation`).
|
|
19
21
|
* @param pageUrl - The page's own absolute URL.
|
|
20
22
|
* @returns `true` if `og:url` is present and resolves to a URL different
|
|
21
23
|
* from `pageUrl`; `false` if absent (no signal) or if it resolves to the
|
|
22
24
|
* same URL.
|
|
23
25
|
* @example
|
|
24
26
|
* ```ts
|
|
25
|
-
* resolveOgUrlMismatch({ title: '', og: { url: '/news' } }
|
|
27
|
+
* resolveOgUrlMismatch({ title: '', og: { url: '/news' } }, 'https://example.com/news/date/2024/');
|
|
26
28
|
* // => true — og:url points at the parent listing, not this page
|
|
27
|
-
* resolveOgUrlMismatch({ title: '', og: { url: './' } }
|
|
29
|
+
* resolveOgUrlMismatch({ title: '', og: { url: './' } }, 'https://example.com/');
|
|
28
30
|
* // => false — relative self-reference resolves to the same URL
|
|
29
31
|
* ```
|
|
30
32
|
*/
|
|
31
|
-
export declare function resolveOgUrlMismatch(meta:
|
|
33
|
+
export declare function resolveOgUrlMismatch(meta: MetaSignatureSource, pageUrl: string): boolean;
|
|
@@ -14,16 +14,18 @@
|
|
|
14
14
|
* the page's own absolute URL without resolving it first would treat every
|
|
15
15
|
* relative self-reference (e.g. `content="./"`) as a mismatch, inflating
|
|
16
16
|
* this signal on ordinary pages.
|
|
17
|
-
* @param meta - Beholder-derived metadata for the page
|
|
17
|
+
* @param meta - Beholder-derived metadata for the page, or the equivalent
|
|
18
|
+
* `MetaSignatureSource` fields reconstructed from archived `page_meta`
|
|
19
|
+
* columns (see `buildDedupeCapObservation`).
|
|
18
20
|
* @param pageUrl - The page's own absolute URL.
|
|
19
21
|
* @returns `true` if `og:url` is present and resolves to a URL different
|
|
20
22
|
* from `pageUrl`; `false` if absent (no signal) or if it resolves to the
|
|
21
23
|
* same URL.
|
|
22
24
|
* @example
|
|
23
25
|
* ```ts
|
|
24
|
-
* resolveOgUrlMismatch({ title: '', og: { url: '/news' } }
|
|
26
|
+
* resolveOgUrlMismatch({ title: '', og: { url: '/news' } }, 'https://example.com/news/date/2024/');
|
|
25
27
|
* // => true — og:url points at the parent listing, not this page
|
|
26
|
-
* resolveOgUrlMismatch({ title: '', og: { url: './' } }
|
|
28
|
+
* resolveOgUrlMismatch({ title: '', og: { url: './' } }, 'https://example.com/');
|
|
27
29
|
* // => false — relative self-reference resolves to the same URL
|
|
28
30
|
* ```
|
|
29
31
|
*/
|
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The structural subset of beholder's `Meta` that `computeMetaSignature` and
|
|
3
|
+
* `resolveOgUrlMismatch` actually read — narrowed so both functions can
|
|
4
|
+
* accept a row reconstructed from archived `page_meta` columns (see
|
|
5
|
+
* `buildDedupeCapObservation`) without a cast. `Meta` itself remains
|
|
6
|
+
* assignable to this type, so no live crawl-time call site needs to change.
|
|
7
|
+
*/
|
|
8
|
+
export interface MetaSignatureSource {
|
|
9
|
+
/** See `Meta.title`. */
|
|
10
|
+
title: string;
|
|
11
|
+
/** See `Meta.description`. */
|
|
12
|
+
description?: string;
|
|
13
|
+
/** See `Meta.og`. Only `title` and `url` are read. */
|
|
14
|
+
og?: {
|
|
15
|
+
title?: string;
|
|
16
|
+
url?: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
1
19
|
/** Options controlling the opt-in same-cluster soft cap (`--dedupe-cap` / `--dedupe-map-cap`). */
|
|
2
20
|
export interface DedupeCapOptions {
|
|
3
21
|
/** Base Misra-Gries majority-vote threshold, before confidence-signal halving. */
|
|
@@ -126,7 +126,15 @@ export default class LinkList {
|
|
|
126
126
|
* @param pending - URLs that were pending in the previous session.
|
|
127
127
|
* @param done - URLs that were already completed in the previous session.
|
|
128
128
|
* @param options - URL parsing options for re-parsing the pending URLs.
|
|
129
|
+
* @param metadataOnlyUrls - The subset of `pending` that was persisted as
|
|
130
|
+
* `content_items.is_metadata_only = 1` (see `getCrawlingState`'s
|
|
131
|
+
* `pendingMetadataOnly`). Without this, every restored URL re-enters
|
|
132
|
+
* the queue as a full-scrape target, silently promoting a URL that was
|
|
133
|
+
* only ever meant for a metadata-only scrape (#369) — the in-memory
|
|
134
|
+
* `add()` call below restores the same `metadataOnly` flag the
|
|
135
|
+
* original discovery set, instead of losing it across the
|
|
136
|
+
* resume boundary.
|
|
129
137
|
* @returns The parsed pending URLs that were successfully added to the queue.
|
|
130
138
|
*/
|
|
131
|
-
resume(pending: string[], done: string[], options: ParseURLOptions): ExURL[];
|
|
139
|
+
resume(pending: string[], done: string[], options: ParseURLOptions, metadataOnlyUrls?: readonly string[]): ExURL[];
|
|
132
140
|
}
|
package/lib/crawler/link-list.js
CHANGED
|
@@ -216,9 +216,18 @@ export default class LinkList {
|
|
|
216
216
|
* @param pending - URLs that were pending in the previous session.
|
|
217
217
|
* @param done - URLs that were already completed in the previous session.
|
|
218
218
|
* @param options - URL parsing options for re-parsing the pending URLs.
|
|
219
|
+
* @param metadataOnlyUrls - The subset of `pending` that was persisted as
|
|
220
|
+
* `content_items.is_metadata_only = 1` (see `getCrawlingState`'s
|
|
221
|
+
* `pendingMetadataOnly`). Without this, every restored URL re-enters
|
|
222
|
+
* the queue as a full-scrape target, silently promoting a URL that was
|
|
223
|
+
* only ever meant for a metadata-only scrape (#369) — the in-memory
|
|
224
|
+
* `add()` call below restores the same `metadataOnly` flag the
|
|
225
|
+
* original discovery set, instead of losing it across the
|
|
226
|
+
* resume boundary.
|
|
219
227
|
* @returns The parsed pending URLs that were successfully added to the queue.
|
|
220
228
|
*/
|
|
221
|
-
resume(pending, done, options) {
|
|
229
|
+
resume(pending, done, options, metadataOnlyUrls = []) {
|
|
230
|
+
const metadataOnlyKeys = new Set(metadataOnlyUrls.map((url) => protocolAgnosticKey(url)));
|
|
222
231
|
const parsedPending = [];
|
|
223
232
|
for (const url of done) {
|
|
224
233
|
this.#done.add(protocolAgnosticKey(url));
|
|
@@ -228,7 +237,8 @@ export default class LinkList {
|
|
|
228
237
|
if (!parsedUrl) {
|
|
229
238
|
continue;
|
|
230
239
|
}
|
|
231
|
-
|
|
240
|
+
const isMetadataOnly = metadataOnlyKeys.has(protocolAgnosticKey(parsedUrl.withoutHashAndAuth));
|
|
241
|
+
this.add(parsedUrl, isMetadataOnly ? { metadataOnly: true } : undefined);
|
|
232
242
|
parsedPending.push(parsedUrl);
|
|
233
243
|
}
|
|
234
244
|
return parsedPending;
|
package/lib/crawler/types.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { DedupeCapObservation } from './dedupe/types.js';
|
|
1
2
|
import type { NetworkProbe } from './probe-network.js';
|
|
2
3
|
import type { PageSource } from '../archive/types.js';
|
|
3
4
|
import type { ErrorKind } from '../types.js';
|
|
4
5
|
import type { PageData, CrawlerError, Resource } from '../utils/types/types.js';
|
|
5
6
|
import type { ChangePhaseEvent, ConsoleLogEntry, ScrapeResult } from '@d-zero/beholder';
|
|
7
|
+
import type { Lanes } from '@d-zero/dealer';
|
|
6
8
|
import type { ParseURLOptions } from '@d-zero/shared/parse-url';
|
|
7
9
|
/**
|
|
8
10
|
* Result of resolving a URL that redirects to a destination already rendered
|
|
@@ -223,6 +225,79 @@ export interface CrawlerOptions extends Required<Pick<ParseURLOptions, 'disableQ
|
|
|
223
225
|
* of discovering once. Ignored when {@link dedupeCap} is `null`.
|
|
224
226
|
*/
|
|
225
227
|
preloadedStickyShapeKeys: readonly string[];
|
|
228
|
+
/**
|
|
229
|
+
* A prior session's per-shape Misra-Gries observations, replayed into
|
|
230
|
+
* the tracker synchronously in the constructor (see `Crawler`'s own
|
|
231
|
+
* constructor JSDoc) so a shape that was close to — but short of — its
|
|
232
|
+
* threshold when the previous session ended does not restart its
|
|
233
|
+
* counter at 0. Unlike {@link preloadedStickyShapeKeys} (which only
|
|
234
|
+
* carries shapes already confirmed capped), this carries every
|
|
235
|
+
* qualifying page's observation regardless of whether it ever
|
|
236
|
+
* contributed to a cap. Seeded from `archive.listDedupeCapObservations()`
|
|
237
|
+
* by the same five resuming-session methods, via
|
|
238
|
+
* `buildDedupeCapObservation`. Ignored when {@link dedupeCap} is `null`.
|
|
239
|
+
*/
|
|
240
|
+
preloadedDedupeObservations: readonly DedupeCapObservation[];
|
|
241
|
+
/**
|
|
242
|
+
* A `Lanes` instance owned by the caller (typically the CLI), reused as
|
|
243
|
+
* `deal()`'s display instead of letting `deal()` create its own. `null`/
|
|
244
|
+
* `undefined` falls back to `deal()`'s own `Lanes` (keyed off `verbose`).
|
|
245
|
+
*
|
|
246
|
+
* Passing this is what lets a caller running crawl-body `deal()`
|
|
247
|
+
* alongside its own runtime input UI (e.g. a CLI reading stdin) draw
|
|
248
|
+
* that UI as a `Lanes` footer without a second `Lanes`/`Display`
|
|
249
|
+
* instance fighting over the same terminal stream (`@d-zero/dealer`'s
|
|
250
|
+
* single-instance-per-stream constraint).
|
|
251
|
+
*/
|
|
252
|
+
lanes?: Lanes;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* A runtime change to apply to an in-progress crawl's tunable options via
|
|
256
|
+
* {@link Crawler.updateRuntimeOptions}. `parallels`/`interval` replace the
|
|
257
|
+
* current value; the exclude arrays are additive (new entries merge into the
|
|
258
|
+
* existing list, duplicates dropped) — there is no way to remove an
|
|
259
|
+
* already-set exclude pattern.
|
|
260
|
+
*/
|
|
261
|
+
export interface CrawlRuntimeOptionsPatch {
|
|
262
|
+
/** New concurrency limit (integer, `>= 1`). */
|
|
263
|
+
parallels?: number;
|
|
264
|
+
/** New per-URL delay in milliseconds (integer, `>= 0`). */
|
|
265
|
+
interval?: number;
|
|
266
|
+
/** Glob patterns to add to {@link CrawlerOptions.excludes}. */
|
|
267
|
+
excludes?: readonly string[];
|
|
268
|
+
/** URL prefixes to add to {@link CrawlerOptions.excludeUrls}. */
|
|
269
|
+
excludeUrls?: readonly string[];
|
|
270
|
+
/** Keywords to add to {@link CrawlerOptions.excludeKeywords}. */
|
|
271
|
+
excludeKeywords?: readonly string[];
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Snapshot of the tunable crawl options after applying a
|
|
275
|
+
* {@link CrawlRuntimeOptionsPatch}, returned by
|
|
276
|
+
* {@link Crawler.updateRuntimeOptions} so the caller can report what changed
|
|
277
|
+
* without re-reading `Crawler`'s private state.
|
|
278
|
+
*/
|
|
279
|
+
export interface CrawlRuntimeOptions {
|
|
280
|
+
readonly parallels: number;
|
|
281
|
+
readonly interval: number;
|
|
282
|
+
readonly excludes: readonly string[];
|
|
283
|
+
readonly excludeUrls: readonly string[];
|
|
284
|
+
readonly excludeKeywords: readonly string[];
|
|
285
|
+
/**
|
|
286
|
+
* The subset of `patch.excludes` that was actually new — i.e. not already
|
|
287
|
+
* present in {@link excludes} before this patch, and not a duplicate of
|
|
288
|
+
* an earlier entry in the same patch. Empty when `patch.excludes` was
|
|
289
|
+
* omitted or every entry it carried was already present. A caller
|
|
290
|
+
* reporting "what changed" (`format-crawl-console-result.ts`) needs this
|
|
291
|
+
* distinct from {@link excludes} itself — the additive-only merge already
|
|
292
|
+
* silently drops duplicates, so echoing back `patch.excludes` verbatim as
|
|
293
|
+
* "added" would claim a no-op resubmission (e.g. the operator typing the
|
|
294
|
+
* same `exclude` pattern twice) actually added something.
|
|
295
|
+
*/
|
|
296
|
+
readonly addedExcludes: readonly string[];
|
|
297
|
+
/** Same as {@link addedExcludes}, for `patch.excludeUrls`/{@link excludeUrls}. */
|
|
298
|
+
readonly addedExcludeUrls: readonly string[];
|
|
299
|
+
/** Same as {@link addedExcludes}, for `patch.excludeKeywords`/{@link excludeKeywords}. */
|
|
300
|
+
readonly addedExcludeKeywords: readonly string[];
|
|
226
301
|
}
|
|
227
302
|
/**
|
|
228
303
|
* Inventory-mode runtime configuration. Passed from
|