@nitpicker/crawler 0.21.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.
Files changed (113) hide show
  1. package/lib/append-retry-failed-common-setup-phases.d.ts +1 -1
  2. package/lib/append-retry-failed-common-setup-phases.js +5 -1
  3. package/lib/append-setup-phases.d.ts +1 -1
  4. package/lib/archive/archive.d.ts +57 -8
  5. package/lib/archive/archive.js +83 -12
  6. package/lib/archive/create-adjunct-tables.d.ts +3 -2
  7. package/lib/archive/create-adjunct-tables.js +12 -9
  8. package/lib/archive/create-entity-tables.js +3 -0
  9. package/lib/archive/database.d.ts +39 -7
  10. package/lib/archive/database.js +49 -9
  11. package/lib/archive/db-ops/_shared/keyset-paginate-by-id.d.ts +49 -0
  12. package/lib/archive/db-ops/_shared/keyset-paginate-by-id.js +69 -0
  13. package/lib/archive/db-ops/_shared/resolve-content-item-id.d.ts +13 -12
  14. package/lib/archive/db-ops/_shared/resolve-content-item-id.js +63 -31
  15. package/lib/archive/db-ops/_shared/types.d.ts +39 -0
  16. package/lib/archive/db-ops/analysis/replace-analysis-violations.js +7 -4
  17. package/lib/archive/db-ops/config/get-created-cwd.d.ts +11 -0
  18. package/lib/archive/db-ops/config/get-created-cwd.js +13 -0
  19. package/lib/archive/db-ops/config/info-column-allowlist.js +1 -0
  20. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.d.ts +57 -0
  21. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.js +87 -0
  22. package/lib/archive/db-ops/errors/insert-page-error.js +3 -1
  23. package/lib/archive/db-ops/lifecycle/init.d.ts +13 -6
  24. package/lib/archive/db-ops/lifecycle/init.js +30 -10
  25. package/lib/archive/db-ops/list-reconcile/record-list-reconcile-run.d.ts +22 -0
  26. package/lib/archive/db-ops/{inventory/record-inventory-run.js → list-reconcile/record-list-reconcile-run.js} +11 -10
  27. package/lib/archive/db-ops/pages/read/build-page-query.js +1 -1
  28. package/lib/archive/db-ops/pages/read/get-crawling-state.d.ts +9 -2
  29. package/lib/archive/db-ops/pages/read/get-crawling-state.js +11 -3
  30. package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.d.ts +20 -0
  31. package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.js +37 -0
  32. package/lib/archive/db-ops/pages/reset/repromote-external-pages.d.ts +3 -2
  33. package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +17 -37
  34. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +22 -1
  35. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +79 -33
  36. package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.d.ts +78 -0
  37. package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.js +146 -0
  38. package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.d.ts +24 -0
  39. package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.js +24 -0
  40. package/lib/archive/db-ops/pages/write/insert-inventory-content-items.js +2 -1
  41. package/lib/archive/db-ops/pages/write/insert-page.js +4 -2
  42. package/lib/archive/db-ops/pages/write/link-redirect-sources.js +3 -1
  43. package/lib/archive/db-ops/pages/write/record-redirect.js +1 -1
  44. package/lib/archive/db-ops/pages/write/set-skipped-page.js +3 -1
  45. package/lib/archive/db-ops/pages/write/update-page.d.ts +9 -1
  46. package/lib/archive/db-ops/pages/write/update-page.js +28 -6
  47. package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +3 -6
  48. package/lib/archive/db-ops/resources/get-resource-url-list.js +10 -35
  49. package/lib/archive/init-schema.d.ts +1 -1
  50. package/lib/archive/init-schema.js +3 -2
  51. package/lib/archive/meta/compute-main-contents-denormalized.d.ts +11 -3
  52. package/lib/archive/meta/compute-main-contents-denormalized.js +14 -2
  53. package/lib/archive/meta/types.d.ts +24 -0
  54. package/lib/archive/migrate-content-items-is-metadata-only.d.ts +44 -0
  55. package/lib/archive/migrate-content-items-is-metadata-only.js +60 -0
  56. package/lib/archive/migrate-info-created-cwd.d.ts +17 -0
  57. package/lib/archive/migrate-info-created-cwd.js +36 -0
  58. package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.d.ts +34 -0
  59. package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.js +51 -0
  60. package/lib/archive/{migrate-inventory-runs-exclude-skipped.d.ts → migrate-list-reconcile-runs-exclude-skipped.d.ts} +8 -8
  61. package/lib/archive/{migrate-inventory-runs-exclude-skipped.js → migrate-list-reconcile-runs-exclude-skipped.js} +12 -12
  62. package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.d.ts +16 -0
  63. package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.js +35 -0
  64. package/lib/archive/migrate-page-meta-image-scan.d.ts +27 -0
  65. package/lib/archive/migrate-page-meta-image-scan.js +52 -0
  66. package/lib/archive/page.d.ts +12 -0
  67. package/lib/archive/page.js +16 -0
  68. package/lib/archive/types.d.ts +97 -10
  69. package/lib/compute-auto-retry-backoff-delay.d.ts +30 -0
  70. package/lib/compute-auto-retry-backoff-delay.js +36 -0
  71. package/lib/crawler/apply-crawl-runtime-options-patch.d.ts +32 -0
  72. package/lib/crawler/apply-crawl-runtime-options-patch.js +129 -0
  73. package/lib/crawler/crawler.d.ts +35 -3
  74. package/lib/crawler/crawler.js +486 -338
  75. package/lib/crawler/dedupe/build-dedupe-cap-observation.d.ts +74 -0
  76. package/lib/crawler/dedupe/build-dedupe-cap-observation.js +97 -0
  77. package/lib/crawler/dedupe/compute-meta-signature.d.ts +7 -5
  78. package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
  79. package/lib/crawler/dedupe/compute-shape-key.d.ts +20 -0
  80. package/lib/crawler/dedupe/compute-shape-key.js +73 -1
  81. package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +6 -1
  82. package/lib/crawler/dedupe/dedupe-cap-tracker.js +6 -1
  83. package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +7 -5
  84. package/lib/crawler/dedupe/resolve-og-url-mismatch.js +5 -3
  85. package/lib/crawler/dedupe/types.d.ts +18 -0
  86. package/lib/crawler/fetch-destination.js +1 -0
  87. package/lib/crawler/link-list.d.ts +9 -1
  88. package/lib/crawler/link-list.js +12 -2
  89. package/lib/crawler/link-to-page-data.js +1 -0
  90. package/lib/crawler/resource-to-page-data.js +1 -0
  91. package/lib/crawler/types.d.ts +75 -0
  92. package/lib/crawler-orchestrator.d.ts +171 -8
  93. package/lib/crawler-orchestrator.js +1177 -234
  94. package/lib/crawler.d.ts +3 -0
  95. package/lib/crawler.js +3 -0
  96. package/lib/delay-or-abort.d.ts +20 -0
  97. package/lib/delay-or-abort.js +35 -0
  98. package/lib/pending-urls-remain-error.d.ts +35 -0
  99. package/lib/pending-urls-remain-error.js +43 -0
  100. package/lib/recrawl-setup-phases.d.ts +38 -0
  101. package/lib/recrawl-setup-phases.js +52 -0
  102. package/lib/resume-setup-phases.d.ts +1 -1
  103. package/lib/resume-setup-phases.js +5 -1
  104. package/lib/retry-failed-setup-phases.d.ts +1 -1
  105. package/lib/setup-recovery-phase-labels.d.ts +17 -8
  106. package/lib/setup-recovery-phase-labels.js +17 -8
  107. package/lib/types.d.ts +62 -12
  108. package/lib/utils/compute-file-sha256.d.ts +1 -1
  109. package/lib/utils/compute-file-sha256.js +1 -1
  110. package/package.json +4 -4
  111. package/lib/archive/db-ops/inventory/record-inventory-run.d.ts +0 -21
  112. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +0 -16
  113. 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 { Meta } from '@d-zero/beholder';
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' } } as Meta);
27
+ * computeMetaSignature({ title: 'お知らせ', og: { url: '/news' } });
26
28
  * // => a stable hex digest
27
- * computeMetaSignature({ title: '', og: {} } as Meta); // => null
29
+ * computeMetaSignature({ title: '', og: {} }); // => null
28
30
  * ```
29
31
  */
30
- export declare function computeMetaSignature(meta: Meta): string | null;
32
+ export declare function computeMetaSignature(meta: MetaSignatureSource): string | null;
@@ -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 pairs = queryKeys.map((k) => `${k}=${VALUE_PLACEHOLDER}`);
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 { Meta } from '@d-zero/beholder';
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' } } as Meta, 'https://example.com/news/date/2024/');
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: './' } } as Meta, 'https://example.com/');
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: Meta, pageUrl: string): boolean;
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' } } as Meta, 'https://example.com/news/date/2024/');
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: './' } } as Meta, 'https://example.com/');
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. */
@@ -203,6 +203,7 @@ async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent, t
203
203
  html: '',
204
204
  mainContents: null,
205
205
  scrollHeight: null,
206
+ imageScan: { desktop: null, mobile: null },
206
207
  isSkipped: false,
207
208
  };
208
209
  };
@@ -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
  }
@@ -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
- this.add(parsedUrl);
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;
@@ -50,6 +50,7 @@ export function linkToPageData(link) {
50
50
  html: '',
51
51
  mainContents: null,
52
52
  scrollHeight: null,
53
+ imageScan: { desktop: null, mobile: null },
53
54
  isSkipped: false,
54
55
  };
55
56
  }
@@ -56,6 +56,7 @@ export function resourceToPageData(params) {
56
56
  html: '',
57
57
  mainContents: null,
58
58
  scrollHeight: null,
59
+ imageScan: { desktop: null, mobile: null },
59
60
  isSkipped: false,
60
61
  };
61
62
  }
@@ -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