@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.
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,78 @@
1
+ import type { ResetPagesByUrlsResult } from '../../../types.js';
2
+ import type { Knex } from 'knex';
3
+ /**
4
+ * Reset pages matching an operator-supplied URL list back to pending, so a
5
+ * follow-up crawl re-fetches them from scratch — the un-scrape half of
6
+ * `crawl --recrawl`.
7
+ *
8
+ * Unlike {@link resetFailedPages}, this function does not filter by prior
9
+ * failure kind: a URL named explicitly by the operator is reset regardless
10
+ * of what its last observation was, including a definitive `4xx`/`5xx` — the
11
+ * whole point of `--recrawl` is to catch a page that used to 404 and is now
12
+ * live. The only requirement on the raw candidate scan is `scraped = 1` (a
13
+ * page never scraped has nothing to reset; it is the caller's "novel URL"
14
+ * path instead).
15
+ *
16
+ * Three conservative guards then apply — matching content follows the same
17
+ * union used to skip `resetFailedPages`'s permanent-failure filter had none:
18
+ * a matched row is excluded (not reset) rather than silently promoted, and
19
+ * the exclusion is reported back to the caller by category:
20
+ *
21
+ * - **Redirect source** (`redirect_dest_id` is set): resetting it would
22
+ * discard a real, previously-observed 3xx. If the operator wants the
23
+ * redirect re-verified, that is `--retry-failed`'s job (which does NOT
24
+ * exclude redirect sources from a permanent-failure reset — a different
25
+ * operation with a different default).
26
+ * - **Intentionally skipped** (`is_skipped = 1`): the row was excluded by
27
+ * `excludes`/`excludeUrls` at ingestion or fetch time. `--recrawl` does not
28
+ * second-guess that configuration; re-running the crawl with a changed
29
+ * exclude list is the supported path to un-skip a page.
30
+ * - **External** (`is_external = 1`): bringing a scope-external URL back
31
+ * into scope is `--append`'s job (`repromoteExternalPages`), which also
32
+ * updates the scope map. `--recrawl` only re-fetches in-scope pages.
33
+ *
34
+ * Deliberately does NOT exclude candidates whose URL shape already has a
35
+ * confirmed same-cluster trap recorded (`dedupe_cap_events.shape_key`) — the
36
+ * exclusion {@link resetFailedPages} applies for exactly that reason (see its
37
+ * own JSDoc). The two functions differ on what "the operator asked for this"
38
+ * means: `resetFailedPages` widens an automatic, unattended SQL scan
39
+ * (`--retry-failed`), so a known trap is safe to silently skip re-admitting.
40
+ * `resetPagesByUrls` resets URLs the operator named one-by-one in a file —
41
+ * the same reasoning that already exempts explicit URLs from the
42
+ * failure-kind filter above applies here too: an operator who names a
43
+ * specific trap-shaped URL is not "re-discovering" it by accident, and
44
+ * silently refusing to reset a row they explicitly listed would be a more
45
+ * surprising failure mode than honouring the request.
46
+ *
47
+ * A URL that matches no `content_items` row at all (not yet known to the
48
+ * archive) is silently absent from every array on the result — the caller
49
+ * treats it as a novel URL, the same "not yet known" path `--inventory`
50
+ * takes. A URL matching a row with `scraped = 0` is likewise absent from
51
+ * every array: it is already pending, so there is nothing to reset.
52
+ *
53
+ * On a match, `content_items` is UPDATEd exactly like
54
+ * {@link resetFailedPages} (`scraped`/`status`/`status_text`/
55
+ * `content_type_id`/`content_length`/`header_set_id` cleared,
56
+ * `first_crawled_at`/`last_crawled_at` preserved), and every derived row is
57
+ * cleared via {@link clearPageDerivedRows} plus `page_errors` and
58
+ * `analysis_violations` — the latter is not part of the shared helper
59
+ * (`repromoteExternalPages` never clears it) but a re-fetched page's old
60
+ * lint findings would otherwise report on HTML that no longer exists until
61
+ * the next `analyze` run overwrites the whole table. `analysis_text_refs` is
62
+ * a content-hash dictionary shared across pages and is not touched; an
63
+ * orphaned entry is a harmless, unreferenced row, the same trade-off already
64
+ * made for `page_html_blobs`.
65
+ *
66
+ * SELECT and UPDATE/DELETE statements are chunked to stay below SQLite's
67
+ * `SQLITE_LIMIT_VARIABLE_NUMBER`.
68
+ * @param knex - Knex query builder connected to the archive DB.
69
+ * @param urls - URL strings to match against `content_items`, already in
70
+ * `withoutHashAndAuth` form (mirrors every other URL-list comparison in
71
+ * this package, e.g. `getExistingPageUrls`).
72
+ * @param onProgress - Called after each chunk's DELETE/UPDATE statements
73
+ * complete, with the pages processed so far and the total to reset. Omit
74
+ * for no reporting (the default; e.g. tests).
75
+ * @returns The reset URLs, plus the excluded URLs grouped by exclusion
76
+ * reason — see {@link ResetPagesByUrlsResult}.
77
+ */
78
+ export declare function resetPagesByUrls(knex: Knex, urls: readonly string[], onProgress?: (processed: number, total: number) => void): Promise<ResetPagesByUrlsResult>;
@@ -0,0 +1,146 @@
1
+ import { eachSplitted } from '../../../../utils/array/each-splitted.js';
2
+ import { dbLog } from '../../../debug.js';
3
+ import { clearPageDerivedRows } from './clear-page-derived-rows.js';
4
+ /**
5
+ * Reset pages matching an operator-supplied URL list back to pending, so a
6
+ * follow-up crawl re-fetches them from scratch — the un-scrape half of
7
+ * `crawl --recrawl`.
8
+ *
9
+ * Unlike {@link resetFailedPages}, this function does not filter by prior
10
+ * failure kind: a URL named explicitly by the operator is reset regardless
11
+ * of what its last observation was, including a definitive `4xx`/`5xx` — the
12
+ * whole point of `--recrawl` is to catch a page that used to 404 and is now
13
+ * live. The only requirement on the raw candidate scan is `scraped = 1` (a
14
+ * page never scraped has nothing to reset; it is the caller's "novel URL"
15
+ * path instead).
16
+ *
17
+ * Three conservative guards then apply — matching content follows the same
18
+ * union used to skip `resetFailedPages`'s permanent-failure filter had none:
19
+ * a matched row is excluded (not reset) rather than silently promoted, and
20
+ * the exclusion is reported back to the caller by category:
21
+ *
22
+ * - **Redirect source** (`redirect_dest_id` is set): resetting it would
23
+ * discard a real, previously-observed 3xx. If the operator wants the
24
+ * redirect re-verified, that is `--retry-failed`'s job (which does NOT
25
+ * exclude redirect sources from a permanent-failure reset — a different
26
+ * operation with a different default).
27
+ * - **Intentionally skipped** (`is_skipped = 1`): the row was excluded by
28
+ * `excludes`/`excludeUrls` at ingestion or fetch time. `--recrawl` does not
29
+ * second-guess that configuration; re-running the crawl with a changed
30
+ * exclude list is the supported path to un-skip a page.
31
+ * - **External** (`is_external = 1`): bringing a scope-external URL back
32
+ * into scope is `--append`'s job (`repromoteExternalPages`), which also
33
+ * updates the scope map. `--recrawl` only re-fetches in-scope pages.
34
+ *
35
+ * Deliberately does NOT exclude candidates whose URL shape already has a
36
+ * confirmed same-cluster trap recorded (`dedupe_cap_events.shape_key`) — the
37
+ * exclusion {@link resetFailedPages} applies for exactly that reason (see its
38
+ * own JSDoc). The two functions differ on what "the operator asked for this"
39
+ * means: `resetFailedPages` widens an automatic, unattended SQL scan
40
+ * (`--retry-failed`), so a known trap is safe to silently skip re-admitting.
41
+ * `resetPagesByUrls` resets URLs the operator named one-by-one in a file —
42
+ * the same reasoning that already exempts explicit URLs from the
43
+ * failure-kind filter above applies here too: an operator who names a
44
+ * specific trap-shaped URL is not "re-discovering" it by accident, and
45
+ * silently refusing to reset a row they explicitly listed would be a more
46
+ * surprising failure mode than honouring the request.
47
+ *
48
+ * A URL that matches no `content_items` row at all (not yet known to the
49
+ * archive) is silently absent from every array on the result — the caller
50
+ * treats it as a novel URL, the same "not yet known" path `--inventory`
51
+ * takes. A URL matching a row with `scraped = 0` is likewise absent from
52
+ * every array: it is already pending, so there is nothing to reset.
53
+ *
54
+ * On a match, `content_items` is UPDATEd exactly like
55
+ * {@link resetFailedPages} (`scraped`/`status`/`status_text`/
56
+ * `content_type_id`/`content_length`/`header_set_id` cleared,
57
+ * `first_crawled_at`/`last_crawled_at` preserved), and every derived row is
58
+ * cleared via {@link clearPageDerivedRows} plus `page_errors` and
59
+ * `analysis_violations` — the latter is not part of the shared helper
60
+ * (`repromoteExternalPages` never clears it) but a re-fetched page's old
61
+ * lint findings would otherwise report on HTML that no longer exists until
62
+ * the next `analyze` run overwrites the whole table. `analysis_text_refs` is
63
+ * a content-hash dictionary shared across pages and is not touched; an
64
+ * orphaned entry is a harmless, unreferenced row, the same trade-off already
65
+ * made for `page_html_blobs`.
66
+ *
67
+ * SELECT and UPDATE/DELETE statements are chunked to stay below SQLite's
68
+ * `SQLITE_LIMIT_VARIABLE_NUMBER`.
69
+ * @param knex - Knex query builder connected to the archive DB.
70
+ * @param urls - URL strings to match against `content_items`, already in
71
+ * `withoutHashAndAuth` form (mirrors every other URL-list comparison in
72
+ * this package, e.g. `getExistingPageUrls`).
73
+ * @param onProgress - Called after each chunk's DELETE/UPDATE statements
74
+ * complete, with the pages processed so far and the total to reset. Omit
75
+ * for no reporting (the default; e.g. tests).
76
+ * @returns The reset URLs, plus the excluded URLs grouped by exclusion
77
+ * reason — see {@link ResetPagesByUrlsResult}.
78
+ */
79
+ export async function resetPagesByUrls(knex, urls, onProgress) {
80
+ const empty = {
81
+ resetUrls: [],
82
+ excludedRedirects: [],
83
+ excludedSkipped: [],
84
+ excludedExternal: [],
85
+ };
86
+ if (urls.length === 0) {
87
+ return empty;
88
+ }
89
+ const candidates = [];
90
+ await eachSplitted([...urls], 500, async (chunk) => {
91
+ const rows = await knex('content_items')
92
+ .join('url_refs', 'url_refs.id', 'content_items.url_id')
93
+ .select('content_items.id as id', 'url_refs.url as url', 'content_items.redirect_dest_id as redirect_dest_id', 'content_items.is_skipped as is_skipped', 'content_items.is_external as is_external')
94
+ .where('content_items.scraped', 1)
95
+ .whereIn('url_refs.url', chunk);
96
+ candidates.push(...rows);
97
+ });
98
+ if (candidates.length === 0) {
99
+ return empty;
100
+ }
101
+ const resettable = [];
102
+ const excludedRedirects = [];
103
+ const excludedSkipped = [];
104
+ const excludedExternal = [];
105
+ for (const row of candidates) {
106
+ if (row.redirect_dest_id != null) {
107
+ excludedRedirects.push(row.url);
108
+ continue;
109
+ }
110
+ if (row.is_skipped) {
111
+ excludedSkipped.push(row.url);
112
+ continue;
113
+ }
114
+ if (row.is_external) {
115
+ excludedExternal.push(row.url);
116
+ continue;
117
+ }
118
+ resettable.push(row);
119
+ }
120
+ if (resettable.length === 0) {
121
+ return { resetUrls: [], excludedRedirects, excludedSkipped, excludedExternal };
122
+ }
123
+ const ids = resettable.map((row) => row.id);
124
+ const resetUrls = resettable.map((row) => row.url);
125
+ const chunkSize = 500;
126
+ for (let i = 0; i < ids.length; i += chunkSize) {
127
+ const chunk = ids.slice(i, i + chunkSize);
128
+ await knex('content_items').whereIn('id', chunk).update({
129
+ scraped: 0,
130
+ status: null,
131
+ status_text: null,
132
+ content_type_id: null,
133
+ content_length: null,
134
+ header_set_id: null,
135
+ // `first_crawled_at` / `last_crawled_at` are deliberately left
136
+ // untouched, matching `resetFailedPages` — the last-success
137
+ // timestamp records survive the demotion.
138
+ });
139
+ await knex('page_errors').whereIn('pageId', chunk).delete();
140
+ await knex('analysis_violations').whereIn('page_id', chunk).delete();
141
+ await clearPageDerivedRows(knex, chunk);
142
+ onProgress?.(Math.min(i + chunkSize, ids.length), ids.length);
143
+ }
144
+ dbLog('Reset %d page(s) matched by URL list back to pending', resetUrls.length);
145
+ return { resetUrls, excludedRedirects, excludedSkipped, excludedExternal };
146
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `page_meta.image_scan_desktop` / `image_scan_mobile` codes that qualify a
3
+ * page for `--retry-failed`, mirroring `@d-zero/beholder`'s `IMAGE_SCAN_CODE`
4
+ * (kept as a plain numeric literal list here rather than importing that
5
+ * constant, since `@nitpicker/crawler`'s `page_meta` columns persist across
6
+ * a `@d-zero/beholder` version bump and must keep meaning the same numbers
7
+ * even if a future crawler build pins an older beholder).
8
+ *
9
+ * - `2` (`nav-unsettled`) and `3` (`frame-lost`) are transient failures the
10
+ * scan itself could not recover from — a retry is likely to succeed.
11
+ * - `255` (`unknown`) is retried too, erring on the side of investigation,
12
+ * consistent with {@link import('../../../../classify-error-kind.js').classifyErrorKind}'s
13
+ * "unknown" kind never being treated as permanent.
14
+ *
15
+ * Deliberately excluded:
16
+ * - `0` (`ok`) and `1` (`degraded`) — the scan produced data.
17
+ * - `4` (`scroll-height-exceeded`) — a deterministic, page-shape-driven
18
+ * outcome that a retry cannot change.
19
+ * @example
20
+ * ```ts
21
+ * qb.whereIn('page_meta.image_scan_desktop', RETRYABLE_IMAGE_SCAN_CODES);
22
+ * ```
23
+ */
24
+ export declare const RETRYABLE_IMAGE_SCAN_CODES: readonly [2, 3, 255];
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `page_meta.image_scan_desktop` / `image_scan_mobile` codes that qualify a
3
+ * page for `--retry-failed`, mirroring `@d-zero/beholder`'s `IMAGE_SCAN_CODE`
4
+ * (kept as a plain numeric literal list here rather than importing that
5
+ * constant, since `@nitpicker/crawler`'s `page_meta` columns persist across
6
+ * a `@d-zero/beholder` version bump and must keep meaning the same numbers
7
+ * even if a future crawler build pins an older beholder).
8
+ *
9
+ * - `2` (`nav-unsettled`) and `3` (`frame-lost`) are transient failures the
10
+ * scan itself could not recover from — a retry is likely to succeed.
11
+ * - `255` (`unknown`) is retried too, erring on the side of investigation,
12
+ * consistent with {@link import('../../../../classify-error-kind.js').classifyErrorKind}'s
13
+ * "unknown" kind never being treated as permanent.
14
+ *
15
+ * Deliberately excluded:
16
+ * - `0` (`ok`) and `1` (`degraded`) — the scan produced data.
17
+ * - `4` (`scroll-height-exceeded`) — a deterministic, page-shape-driven
18
+ * outcome that a retry cannot change.
19
+ * @example
20
+ * ```ts
21
+ * qb.whereIn('page_meta.image_scan_desktop', RETRYABLE_IMAGE_SCAN_CODES);
22
+ * ```
23
+ */
24
+ export const RETRYABLE_IMAGE_SCAN_CODES = [2, 3, 255];
@@ -45,7 +45,7 @@ export async function insertInventoryContentItems(params) {
45
45
  });
46
46
  await knex('content_items').insert(rows).onConflict('url_id').ignore();
47
47
  const inserted = (await knex
48
- .select('ci.id', 'ci.source', 'ur.url')
48
+ .select('ci.id', 'ci.source', 'ci.is_metadata_only', 'ur.url')
49
49
  .from('content_items as ci')
50
50
  .join('url_refs as ur', 'ur.id', 'ci.url_id')
51
51
  .whereIn('ur.url', chunk));
@@ -53,6 +53,7 @@ export async function insertInventoryContentItems(params) {
53
53
  caches.contentItems.set(insertedRow.url, {
54
54
  id: insertedRow.id,
55
55
  source: insertedRow.source,
56
+ isMetadataOnly: insertedRow.is_metadata_only,
56
57
  });
57
58
  }
58
59
  });
@@ -65,10 +65,12 @@ import { upsertUrlRef } from '../../_shared/upsert-url-ref.js';
65
65
  */
66
66
  export async function insertPage(knex, caches, page, isTarget, trx, source) {
67
67
  const qb = trx ?? knex;
68
- const pageId = await resolveContentItemId(qb, caches, page.url.withoutHashAndAuth, undefined, source);
68
+ const pageId = await resolveContentItemId(qb, caches, page.url.withoutHashAndAuth, {
69
+ source,
70
+ });
69
71
  const flat = deriveFlatFromMeta(page.meta, page.url.href);
70
72
  const denorm = computePageDenormalized(page.meta);
71
- const mainContentsDenorm = computeMainContentsDenormalized(page.mainContents, page.scrollHeight, page.mainContentCustomElements?.length);
73
+ const mainContentsDenorm = computeMainContentsDenormalized(page.mainContents, page.scrollHeight, page.mainContentCustomElements?.length, page.imageScan);
72
74
  const extras = deriveMetaExtras(page.meta);
73
75
  const now = Date.now();
74
76
  // Canonicalize so the stored dictionary value matches the exact-string
@@ -53,7 +53,9 @@ export async function linkRedirectSources(trx, caches, sources, destId, destUrlN
53
53
  // argument is `'crawled'`, matching the anchor-lineage propagation
54
54
  // contract — an existing inventory-* intermediate that is later
55
55
  // traversed by a `'crawled'` chain gets downgraded.
56
- const redirectId = await resolveContentItemId(trx, caches, redirect, undefined, chainLineageSource);
56
+ const redirectId = await resolveContentItemId(trx, caches, redirect, {
57
+ source: chainLineageSource,
58
+ });
57
59
  await trx('content_items')
58
60
  .where('id', redirectId)
59
61
  .update({
@@ -62,7 +62,7 @@ export async function recordRedirect(knex, caches, page, source) {
62
62
  // inventory chain — without the pass-through, inventory lineage
63
63
  // would be laundered to `'crawled'` for js-redirect rescue / #73
64
64
  // convergence destinations that have not yet been rendered.
65
- const destId = await resolveContentItemId(trx, caches, destUrlObject.withoutHashAndAuth, undefined, source);
65
+ const destId = await resolveContentItemId(trx, caches, destUrlObject.withoutHashAndAuth, { source });
66
66
  // Chain lineage propagates FROM the originating URL (`page.url`),
67
67
  // NOT from the destination. The originating URL is what initiated
68
68
  // the redirect chain, so its lineage is what every intermediate hop
@@ -10,7 +10,9 @@ import { resolveContentItemId } from '../../_shared/resolve-content-item-id.js';
10
10
  * @param isExternal - Whether the page is on an external domain. Defaults to `false`.
11
11
  */
12
12
  export async function setSkippedPage(knex, caches, url, reason, isExternal = false) {
13
- const pageId = await resolveContentItemId(knex, caches, url, isExternal ? 1 : 0);
13
+ const pageId = await resolveContentItemId(knex, caches, url, {
14
+ isExternal: isExternal ? 1 : 0,
15
+ });
14
16
  await knex('content_items')
15
17
  .where('id', pageId)
16
18
  .update({
@@ -27,6 +27,14 @@ import type { Knex } from 'knex';
27
27
  * @param bodyHash - Precomputed body hash for the page's HTML (see
28
28
  * `CrawlerEventTypes.page.bodyHash`). `undefined`/`null` falls back to
29
29
  * computing it from the HTML instead.
30
+ * @param recursive - The crawl session's `recursive` option, forwarded to
31
+ * `replaceAnchorEdges` so it can compute each discovered anchor's
32
+ * `is_metadata_only` value the same way `processAnchors`
33
+ * (`handle-scrape-end.ts`) decides whether to queue it for a full or
34
+ * metadata-only scrape (#369). Defaults to `true` (the historical,
35
+ * pre-#369 behaviour: never persist an anchor as metadata-only) for
36
+ * callers that do not track the option, such as `setExternalPage`, whose
37
+ * anchor list is always empty anyway.
30
38
  * @returns The database `pageId` (`content_items.id`) of the inserted or updated row.
31
39
  */
32
- export declare function updatePage(knex: Knex, caches: WriteRefCaches, page: PageDataWithDomPaths, writeHtml: boolean, isTarget: boolean, source?: PageSource, bodyHash?: Buffer | null): Promise<number>;
40
+ export declare function updatePage(knex: Knex, caches: WriteRefCaches, page: PageDataWithDomPaths, writeHtml: boolean, isTarget: boolean, source?: PageSource, bodyHash?: Buffer | null, recursive?: boolean): Promise<number>;
@@ -50,9 +50,17 @@ import { writePageHtmlBlob } from './write-page-html-blob.js';
50
50
  * @param bodyHash - Precomputed body hash for the page's HTML (see
51
51
  * `CrawlerEventTypes.page.bodyHash`). `undefined`/`null` falls back to
52
52
  * computing it from the HTML instead.
53
+ * @param recursive - The crawl session's `recursive` option, forwarded to
54
+ * `replaceAnchorEdges` so it can compute each discovered anchor's
55
+ * `is_metadata_only` value the same way `processAnchors`
56
+ * (`handle-scrape-end.ts`) decides whether to queue it for a full or
57
+ * metadata-only scrape (#369). Defaults to `true` (the historical,
58
+ * pre-#369 behaviour: never persist an anchor as metadata-only) for
59
+ * callers that do not track the option, such as `setExternalPage`, whose
60
+ * anchor list is always empty anyway.
53
61
  * @returns The database `pageId` (`content_items.id`) of the inserted or updated row.
54
62
  */
55
- export async function updatePage(knex, caches, page, writeHtml, isTarget, source, bodyHash) {
63
+ export async function updatePage(knex, caches, page, writeHtml, isTarget, source, bodyHash, recursive = true) {
56
64
  const { destUrl, sources } = resolveRedirectChain(page.url.withoutHashAndAuth, page.redirectPaths);
57
65
  const destUrlObject = parseUrl(destUrl);
58
66
  if (!destUrlObject) {
@@ -60,7 +68,7 @@ export async function updatePage(knex, caches, page, writeHtml, isTarget, source
60
68
  }
61
69
  try {
62
70
  return await knex.transaction(async (trx) => {
63
- return await updatePageInTransaction(trx, knex, caches, page, destUrlObject, sources, writeHtml, isTarget, source, bodyHash);
71
+ return await updatePageInTransaction(trx, knex, caches, page, destUrlObject, sources, writeHtml, isTarget, source, bodyHash, recursive);
64
72
  });
65
73
  }
66
74
  catch (error) {
@@ -88,9 +96,10 @@ export async function updatePage(knex, caches, page, writeHtml, isTarget, source
88
96
  * @param isTarget - See {@link updatePage}.
89
97
  * @param source - See {@link updatePage}.
90
98
  * @param bodyHash - See {@link updatePage}.
99
+ * @param recursive - See {@link updatePage}.
91
100
  * @returns The `content_items.id` of the inserted or updated row.
92
101
  */
93
- async function updatePageInTransaction(trx, knex, caches, page, destUrlObject, sources, writeHtml, isTarget, source, bodyHash) {
102
+ async function updatePageInTransaction(trx, knex, caches, page, destUrlObject, sources, writeHtml, isTarget, source, bodyHash, recursive) {
94
103
  const pageId = await insertPage(knex, caches, {
95
104
  ...page,
96
105
  url: destUrlObject,
@@ -218,7 +227,7 @@ async function updatePageInTransaction(trx, knex, caches, page, destUrlObject, s
218
227
  .from('content_items')
219
228
  .where('id', pageId));
220
229
  const anchorLineageSource = deriveLineageFromParent(parentRow?.source, 'crawled');
221
- await replaceAnchorEdges(trx, caches, pageId, page, anchorLineageSource);
230
+ await replaceAnchorEdges(trx, caches, pageId, page, anchorLineageSource, recursive);
222
231
  await replaceImageItems(trx, caches, pageId, page);
223
232
  // Clear this page's resource_ref_edges unconditionally (no non-empty
224
233
  // guard, unlike anchors/images above): the crawler always emits this
@@ -279,14 +288,27 @@ async function readSourceByUrl(trx, caches, url) {
279
288
  * @param page - The scraped page payload.
280
289
  * @param anchorLineageSource - Lineage label for anchor-target
281
290
  * placeholder rows (see the caller's lineage comment).
291
+ * @param recursive - The crawl session's `recursive` option. Combined with
292
+ * each anchor's `isExternal` (already resolved by `processAnchors` before
293
+ * this ever runs) to compute `is_metadata_only` the same way
294
+ * `processAnchors` (`handle-scrape-end.ts`) decides whether to queue the
295
+ * anchor for a full or metadata-only scrape: `!recursive || isExternal`.
296
+ * Both `recursive` and a given URL's `isExternal` are constant for the
297
+ * whole crawl session, so this is a pure recomputation, not a second
298
+ * source of truth (#369).
282
299
  */
283
- async function replaceAnchorEdges(trx, caches, pageId, page, anchorLineageSource) {
300
+ async function replaceAnchorEdges(trx, caches, pageId, page, anchorLineageSource, recursive) {
284
301
  if (page.anchorList.length === 0) {
285
302
  return;
286
303
  }
287
304
  const edges = new Map();
288
305
  for (const anchor of page.anchorList) {
289
- const hrefId = await resolveContentItemId(trx, caches, anchor.href.withoutHashAndAuth, anchor.isExternal ? 1 : 0, anchorLineageSource);
306
+ const isExternal = anchor.isExternal ?? false;
307
+ const hrefId = await resolveContentItemId(trx, caches, anchor.href.withoutHashAndAuth, {
308
+ isExternal: isExternal ? 1 : 0,
309
+ source: anchorLineageSource,
310
+ isMetadataOnly: !recursive || isExternal ? 1 : 0,
311
+ });
290
312
  const existing = edges.get(hrefId);
291
313
  if (existing !== undefined) {
292
314
  existing.count += 1;
@@ -5,12 +5,9 @@ import type { Knex } from 'knex';
5
5
  * two tables and returns the resolved strings.
6
6
  *
7
7
  * Read in `resource_items.id`-keyset chunks rather than a single SELECT
8
- * (issue #294): on a resource-heavy archive (images/JS/CSS in the tens or
9
- * hundreds of thousands) this was one unbounded, multi-second-to-minutes
10
- * query with no way to report progress mid-scan. The accumulated result is
11
- * identical to the previous single-SELECT read — chunking exists purely to
12
- * make the scan observable, not to bound memory (the flat URL list is
13
- * already fully materialised for the caller either way).
8
+ * (issue #294) via `keysetPaginateById` see that function's JSDoc for
9
+ * why (progress observability on a resource-heavy archive, not memory
10
+ * bounding).
14
11
  * @param knex - Knex query builder connected to the archive DB.
15
12
  * @param onProgress - Called after each chunk with the `resource_items.id`
16
13
  * scanned up to so far and the max id. Omit for no reporting (the
@@ -1,3 +1,4 @@
1
+ import { keysetPaginateById } from '../_shared/keyset-paginate-by-id.js';
1
2
  /** Rows read per `resource_items.id` keyset chunk. */
2
3
  const READ_CHUNK_SIZE = 2000;
3
4
  /**
@@ -6,12 +7,9 @@ const READ_CHUNK_SIZE = 2000;
6
7
  * two tables and returns the resolved strings.
7
8
  *
8
9
  * Read in `resource_items.id`-keyset chunks rather than a single SELECT
9
- * (issue #294): on a resource-heavy archive (images/JS/CSS in the tens or
10
- * hundreds of thousands) this was one unbounded, multi-second-to-minutes
11
- * query with no way to report progress mid-scan. The accumulated result is
12
- * identical to the previous single-SELECT read — chunking exists purely to
13
- * make the scan observable, not to bound memory (the flat URL list is
14
- * already fully materialised for the caller either way).
10
+ * (issue #294) via `keysetPaginateById` see that function's JSDoc for
11
+ * why (progress observability on a resource-heavy archive, not memory
12
+ * bounding).
15
13
  * @param knex - Knex query builder connected to the archive DB.
16
14
  * @param onProgress - Called after each chunk with the `resource_items.id`
17
15
  * scanned up to so far and the max id. Omit for no reporting (the
@@ -19,33 +17,10 @@ const READ_CHUNK_SIZE = 2000;
19
17
  * @returns An array of resource URL strings.
20
18
  */
21
19
  export async function getResourceUrlList(knex, onProgress) {
22
- // MAX() over the keyset column is an O(1) index-tail read; only fetched
23
- // when someone is listening.
24
- let maxId = 0;
25
- if (onProgress) {
26
- const [maxRow] = await knex('resource_items').max({
27
- max: 'id',
28
- });
29
- maxId = maxRow?.max ?? 0;
30
- }
31
- const urls = [];
32
- let lastId = 0;
33
- for (;;) {
34
- const rows = (await knex('resource_items')
35
- .join('url_refs', 'url_refs.id', 'resource_items.url_id')
36
- .where('resource_items.id', '>', lastId)
37
- .orderBy('resource_items.id', 'asc')
38
- .limit(READ_CHUNK_SIZE)
39
- .select('resource_items.id as id', 'url_refs.url as url'));
40
- if (rows.length === 0) {
41
- onProgress?.(maxId, maxId);
42
- break;
43
- }
44
- lastId = rows.at(-1).id;
45
- for (const row of rows) {
46
- urls.push(row.url);
47
- }
48
- onProgress?.(Math.min(lastId, maxId), maxId);
49
- }
50
- return urls;
20
+ return keysetPaginateById(knex, 'resource_items', (lastId) => knex('resource_items')
21
+ .join('url_refs', 'url_refs.id', 'resource_items.url_id')
22
+ .where('resource_items.id', '>', lastId)
23
+ .orderBy('resource_items.id', 'asc')
24
+ .limit(READ_CHUNK_SIZE)
25
+ .select('resource_items.id as id', 'url_refs.url as url'), (row) => row.url, onProgress);
51
26
  }
@@ -30,7 +30,7 @@ export declare function applyConnectionPragmas(instance: Knex): Promise<void>;
30
30
  * writes during a crawl and every reader queries. Must run AFTER
31
31
  * `createRefTables` because most entity tables reference ref-table PKs.
32
32
  * - **Adjunct tables** ({@link createAdjunctTables}): `page_errors`,
33
- * `crawl_errors`, `technology_signals`, `page_technologies`, `page_jsonld`, `inventory_runs`,
33
+ * `crawl_errors`, `technology_signals`, `page_technologies`, `page_jsonld`, `list_reconcile_runs`,
34
34
  * `analysis_text_refs` + `analysis_violations`, `page_html_blobs` +
35
35
  * `page_html_ref`. Must run AFTER `createEntityTables` because the
36
36
  * page-scoped tables FK into `content_items(id)`.
@@ -55,7 +55,7 @@ export async function applyConnectionPragmas(instance) {
55
55
  * writes during a crawl and every reader queries. Must run AFTER
56
56
  * `createRefTables` because most entity tables reference ref-table PKs.
57
57
  * - **Adjunct tables** ({@link createAdjunctTables}): `page_errors`,
58
- * `crawl_errors`, `technology_signals`, `page_technologies`, `page_jsonld`, `inventory_runs`,
58
+ * `crawl_errors`, `technology_signals`, `page_technologies`, `page_jsonld`, `list_reconcile_runs`,
59
59
  * `analysis_text_refs` + `analysis_violations`, `page_html_blobs` +
60
60
  * `page_html_ref`. Must run AFTER `createEntityTables` because the
61
61
  * page-scoped tables FK into `content_items(id)`.
@@ -113,6 +113,7 @@ export async function initSchema(instance) {
113
113
  t.string('userAgent');
114
114
  t.boolean('ignoreRobots');
115
115
  t.string('mainContentSelector');
116
+ t.string('createdCwd');
116
117
  });
117
118
  }
118
119
  // 0.13 ref / header dictionary tables. DDL + column-level rationale
@@ -135,7 +136,7 @@ export async function initSchema(instance) {
135
136
  // Adjunct tables that FK into `content_items` (page_errors /
136
137
  // technology_signals / page_technologies / page_jsonld / analysis_* /
137
138
  // page_html_*) plus the standalone log tables
138
- // (crawl_errors / inventory_runs). MUST run after
139
+ // (crawl_errors / list_reconcile_runs). MUST run after
139
140
  // {@link createEntityTables} so the FK targets exist. DDL +
140
141
  // column-level rationale lives in {@link createAdjunctTables}, which is
141
142
  // shared with `scripts/migrate-to-0.13.mjs` — a divergence between the
@@ -1,4 +1,4 @@
1
- import type { MainContentsDenormalizedColumns } from './types.js';
1
+ import type { ImageScanColumns, MainContentsDenormalizedColumns } from './types.js';
2
2
  import type { MainContentsData, ScrollHeightData } from '@d-zero/beholder';
3
3
  /**
4
4
  * Computes the `page_meta.main_content_*` / `scroll_height_*` denormalised
@@ -23,11 +23,19 @@ import type { MainContentsData, ScrollHeightData } from '@d-zero/beholder';
23
23
  * `undefined`/capture-not-attempted and capture-failure both collapse to
24
24
  * `null` (unknown — NOT the same as "captured, zero found"), while any
25
25
  * number (including `0`) means capture succeeded.
26
+ * `imageScan` follows the same independent-of-`mainContents` shape as
27
+ * `scrollHeight` (both are per-viewport beholder scan outcomes, not derived
28
+ * from the main-content region) — it is typed as a plain `number | null`
29
+ * pair here rather than beholder's `ImageScanCode` so this function does not
30
+ * require a `@d-zero/beholder` version that exports it; the caller narrows.
26
31
  * @param mainContents - Beholder's per-page main-content metrics, or `null`/`undefined`.
27
32
  * @param scrollHeight - Beholder's per-page scroll-height measurements, or `null`/`undefined`.
28
33
  * @param customElementCount - Count of Web Components nitpicker captured in
29
34
  * the main-content region, or `null`/`undefined` when capture was not
30
35
  * attempted or failed.
31
- * @returns The eighteen denormalised columns.
36
+ * @param imageScan - Beholder's per-viewport `<img>` scan outcome codes, or
37
+ * `null`/`undefined` when not attempted (page not fully rendered, or the
38
+ * scraper version does not report it yet).
39
+ * @returns The twenty denormalised columns.
32
40
  */
33
- export declare function computeMainContentsDenormalized(mainContents: MainContentsData | null | undefined, scrollHeight: ScrollHeightData | null | undefined, customElementCount?: number | null): MainContentsDenormalizedColumns;
41
+ export declare function computeMainContentsDenormalized(mainContents: MainContentsData | null | undefined, scrollHeight: ScrollHeightData | null | undefined, customElementCount?: number | null, imageScan?: ImageScanColumns | null): MainContentsDenormalizedColumns;
@@ -21,14 +21,22 @@
21
21
  * `undefined`/capture-not-attempted and capture-failure both collapse to
22
22
  * `null` (unknown — NOT the same as "captured, zero found"), while any
23
23
  * number (including `0`) means capture succeeded.
24
+ * `imageScan` follows the same independent-of-`mainContents` shape as
25
+ * `scrollHeight` (both are per-viewport beholder scan outcomes, not derived
26
+ * from the main-content region) — it is typed as a plain `number | null`
27
+ * pair here rather than beholder's `ImageScanCode` so this function does not
28
+ * require a `@d-zero/beholder` version that exports it; the caller narrows.
24
29
  * @param mainContents - Beholder's per-page main-content metrics, or `null`/`undefined`.
25
30
  * @param scrollHeight - Beholder's per-page scroll-height measurements, or `null`/`undefined`.
26
31
  * @param customElementCount - Count of Web Components nitpicker captured in
27
32
  * the main-content region, or `null`/`undefined` when capture was not
28
33
  * attempted or failed.
29
- * @returns The eighteen denormalised columns.
34
+ * @param imageScan - Beholder's per-viewport `<img>` scan outcome codes, or
35
+ * `null`/`undefined` when not attempted (page not fully rendered, or the
36
+ * scraper version does not report it yet).
37
+ * @returns The twenty denormalised columns.
30
38
  */
31
- export function computeMainContentsDenormalized(mainContents, scrollHeight, customElementCount) {
39
+ export function computeMainContentsDenormalized(mainContents, scrollHeight, customElementCount, imageScan) {
32
40
  if (mainContents == null) {
33
41
  return {
34
42
  main_content_node_name: null,
@@ -49,6 +57,8 @@ export function computeMainContentsDenormalized(mainContents, scrollHeight, cust
49
57
  main_content_custom_element_count: null,
50
58
  scroll_height_desktop: null,
51
59
  scroll_height_mobile: null,
60
+ image_scan_desktop: null,
61
+ image_scan_mobile: null,
52
62
  };
53
63
  }
54
64
  return {
@@ -72,5 +82,7 @@ export function computeMainContentsDenormalized(mainContents, scrollHeight, cust
72
82
  main_content_custom_element_count: customElementCount ?? null,
73
83
  scroll_height_desktop: scrollHeight?.desktop ?? null,
74
84
  scroll_height_mobile: scrollHeight?.mobile ?? null,
85
+ image_scan_desktop: imageScan?.desktop ?? null,
86
+ image_scan_mobile: imageScan?.mobile ?? null,
75
87
  };
76
88
  }