@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
@@ -2,21 +2,25 @@ import { applyConnectionPragmas, initSchema } from '../../init-schema.js';
2
2
  import { assertCompatibleVersion } from '../../meta/assert-compatible-version.js';
3
3
  import { migrateContentItemsAliasOfId } from '../../migrate-content-items-alias-of-id.js';
4
4
  import { migrateContentItemsDedupeCapEventId } from '../../migrate-content-items-dedupe-cap-event-id.js';
5
+ import { migrateContentItemsIsMetadataOnly } from '../../migrate-content-items-is-metadata-only.js';
6
+ import { migrateInfoCreatedCwd } from '../../migrate-info-created-cwd.js';
5
7
  import { migrateInfoMainContentSelector } from '../../migrate-info-main-content-selector.js';
6
8
  import { migrateInfoRoots } from '../../migrate-info-roots.js';
7
- import { migrateInventoryRunsExcludeSkipped } from '../../migrate-inventory-runs-exclude-skipped.js';
8
- import { migrateInventoryRunsInvalidSkipped } from '../../migrate-inventory-runs-invalid-skipped.js';
9
+ import { migrateInventoryRunsToListReconcileRuns } from '../../migrate-inventory-runs-to-list-reconcile-runs.js';
10
+ import { migrateListReconcileRunsExcludeSkipped } from '../../migrate-list-reconcile-runs-exclude-skipped.js';
11
+ import { migrateListReconcileRunsInvalidSkipped } from '../../migrate-list-reconcile-runs-invalid-skipped.js';
9
12
  import { migrateMainContentsColumns } from '../../migrate-main-contents-columns.js';
10
13
  import { migratePageMetaBodyHash } from '../../migrate-page-meta-body-hash.js';
11
14
  import { migratePageMetaConsoleErrorCount } from '../../migrate-page-meta-console-error-count.js';
12
15
  import { migratePageMetaCustomElementCount } from '../../migrate-page-meta-custom-element-count.js';
16
+ import { migratePageMetaImageScan } from '../../migrate-page-meta-image-scan.js';
13
17
  import { migratePageTagsToPageTechnologies } from '../../migrate-page-tags-to-page-technologies.js';
14
18
  import { closeStaleOpenNetworkOutages } from '../outages/close-stale-open-network-outages.js';
15
19
  /**
16
20
  * Initializes the database schema if tables do not exist, then runs the
17
21
  * remaining lightweight migrations (`info.roots`, `info.mainContentSelector`,
18
- * `page_meta.main_content_*`, `inventory_runs.invalid_skipped`,
19
- * `inventory_runs.exclude_skipped`).
22
+ * `page_meta.main_content_*`, `list_reconcile_runs.invalid_skipped`,
23
+ * `list_reconcile_runs.exclude_skipped`).
20
24
  *
21
25
  * There is deliberately no per-table *table-creation* migration chain here:
22
26
  * `assertCompatibleVersion` (called below, before any schema work) rejects
@@ -31,9 +35,10 @@ import { closeStaleOpenNetworkOutages } from '../outages/close-stale-open-networ
31
35
  * `CREATE TABLE IF NOT EXISTS` is a no-op once the table is present. Column
32
36
  * additions to an existing 0.13 table are therefore the one case that still
33
37
  * needs an explicit `hasColumn`-guarded `ALTER TABLE` here (`migrateInfoRoots`,
34
- * `migrateMainContentsColumns`, `migratePageMetaBodyHash`,
38
+ * `migrateInfoCreatedCwd`, `migrateMainContentsColumns`, `migratePageMetaBodyHash`,
35
39
  * `migratePageMetaConsoleErrorCount`, `migratePageMetaCustomElementCount`,
36
- * `migrateContentItemsAliasOfId`,
40
+ * `migratePageMetaImageScan`, `migrateContentItemsAliasOfId`,
41
+ * `migrateContentItemsIsMetadataOnly`,
37
42
  *
38
43
  * `migratePageTagsToPageTechnologies` is the one exception to "column adds
39
44
  * only": it converts `page_tags` (removed) rows into `technology_signals`/
@@ -41,8 +46,14 @@ import { closeStaleOpenNetworkOutages } from '../outages/close-stale-open-networ
41
46
  * not a column add, but it belongs in this same boot phase for the same
42
47
  * reason (self-healing an old archive's schema before any reader runs).
43
48
  *
44
- * `migrateContentItemsDedupeCapEventId`, `migrateInventoryRunsInvalidSkipped`,
45
- * `migrateInventoryRunsExcludeSkipped`) rather than a DDL-string change alone.
49
+ * `migrateContentItemsDedupeCapEventId`, `migrateListReconcileRunsInvalidSkipped`,
50
+ * `migrateListReconcileRunsExcludeSkipped`) rather than a DDL-string change alone.
51
+ *
52
+ * `migrateInventoryRunsToListReconcileRuns` runs BEFORE `initSchema`
53
+ * (unlike every other migration here) — see its own JSDoc for why: a
54
+ * plain rename must land before `createAdjunctTables`'s
55
+ * `IF NOT EXISTS` guard would otherwise create an empty `list_reconcile_runs`
56
+ * and strand the old table's rows.
46
57
  *
47
58
  * `closeStaleOpenNetworkOutages` is not a schema migration (no columns
48
59
  * change) but belongs at this same boot phase for the same reason the
@@ -77,13 +88,19 @@ export async function init(knex, readOnly, onLog) {
77
88
  if (readOnly) {
78
89
  return;
79
90
  }
91
+ // Must run before `initSchema`: see the function's own JSDoc for why a
92
+ // plain table rename has to land before `createAdjunctTables`'s
93
+ // `IF NOT EXISTS` guard would otherwise create an empty replacement.
94
+ await migrateInventoryRunsToListReconcileRuns(knex, onLog);
80
95
  await initSchema(knex);
81
96
  await migrateInfoRoots(knex, onLog);
82
97
  await migrateInfoMainContentSelector(knex, onLog);
98
+ await migrateInfoCreatedCwd(knex, onLog);
83
99
  await migrateMainContentsColumns(knex, onLog);
84
100
  await migratePageMetaBodyHash(knex, onLog);
85
101
  await migratePageMetaConsoleErrorCount(knex, onLog);
86
102
  await migratePageMetaCustomElementCount(knex, onLog);
103
+ await migratePageMetaImageScan(knex, onLog);
87
104
  // Table-level migration (converts + drops page_tags), not a column
88
105
  // add — see its own JSDoc for why it still belongs in this boot phase.
89
106
  await migratePageTagsToPageTechnologies(knex, onLog);
@@ -93,7 +110,10 @@ export async function init(knex, readOnly, onLog) {
93
110
  // column's `REFERENCES dedupe_cap_events(id)` target always exists by
94
111
  // this point, for both fresh and legacy archives.
95
112
  await migrateContentItemsDedupeCapEventId(knex, onLog);
96
- await migrateInventoryRunsInvalidSkipped(knex, onLog);
97
- await migrateInventoryRunsExcludeSkipped(knex, onLog);
113
+ // A plain `hasColumn`-guarded ADD COLUMN, same as `migrateContentItemsAliasOfId`
114
+ // above — no adjunct-table dependency, so ordering relative to it doesn't matter.
115
+ await migrateContentItemsIsMetadataOnly(knex, onLog);
116
+ await migrateListReconcileRunsInvalidSkipped(knex, onLog);
117
+ await migrateListReconcileRunsExcludeSkipped(knex, onLog);
98
118
  await closeStaleOpenNetworkOutages(knex);
99
119
  }
@@ -0,0 +1,22 @@
1
+ import type { ListReconcileRunMeta } from '../../types.js';
2
+ import type { Knex } from 'knex';
3
+ /**
4
+ * Appends one row to the `list_reconcile_runs` audit log.
5
+ *
6
+ * Called by `CrawlerOrchestrator.inventory` and `CrawlerOrchestrator.recrawl`
7
+ * on every successful `--inventory <list>` / `--recrawl <list>` invocation
8
+ * so the archive carries a durable record of which list was applied when
9
+ * and at what scale — the operational question "did we apply last month's
10
+ * list" the archive itself can answer without consulting external
11
+ * bookkeeping.
12
+ *
13
+ * Append-only. There is intentionally no UPDATE path and no UNIQUE
14
+ * constraint on `source_file_sha256`; two applies of the same list
15
+ * each get their own row. Duplicate detection is left to readers —
16
+ * the hash is recorded as the content-identity key they would use.
17
+ * Field-level NULL semantics live on {@link ListReconcileRunMeta}.
18
+ * @param knex - Knex query builder connected to the archive DB.
19
+ * @param meta - The run metadata to record. Only `ran_at` is required.
20
+ * @returns The autoincremented `id` of the newly-inserted row.
21
+ */
22
+ export declare function recordListReconcileRun(knex: Knex, meta: ListReconcileRunMeta): Promise<number>;
@@ -1,24 +1,25 @@
1
1
  /**
2
- * Appends one row to the `inventory_runs` audit log.
2
+ * Appends one row to the `list_reconcile_runs` audit log.
3
3
  *
4
- * Called by `CrawlerOrchestrator.inventory` on every successful
5
- * `--inventory <list>` invocation so the archive carries a durable
6
- * record of which deploy list was applied when and at what scale —
7
- * the operational question "did we apply last month's list" the
8
- * archive itself can answer without consulting external bookkeeping.
4
+ * Called by `CrawlerOrchestrator.inventory` and `CrawlerOrchestrator.recrawl`
5
+ * on every successful `--inventory <list>` / `--recrawl <list>` invocation
6
+ * so the archive carries a durable record of which list was applied when
7
+ * and at what scale — the operational question "did we apply last month's
8
+ * list" the archive itself can answer without consulting external
9
+ * bookkeeping.
9
10
  *
10
11
  * Append-only. There is intentionally no UPDATE path and no UNIQUE
11
12
  * constraint on `source_file_sha256`; two applies of the same list
12
13
  * each get their own row. Duplicate detection is left to readers —
13
14
  * the hash is recorded as the content-identity key they would use.
14
- * Field-level NULL semantics live on {@link InventoryRunMeta}.
15
+ * Field-level NULL semantics live on {@link ListReconcileRunMeta}.
15
16
  * @param knex - Knex query builder connected to the archive DB.
16
17
  * @param meta - The run metadata to record. Only `ran_at` is required.
17
18
  * @returns The autoincremented `id` of the newly-inserted row.
18
19
  */
19
- export async function recordInventoryRun(knex, meta) {
20
+ export async function recordListReconcileRun(knex, meta) {
20
21
  const inserted = await knex
21
- .from('inventory_runs')
22
+ .from('list_reconcile_runs')
22
23
  .insert({
23
24
  ran_at: meta.ran_at,
24
25
  list_label: meta.list_label ?? null,
@@ -34,7 +35,7 @@ export async function recordInventoryRun(knex, meta) {
34
35
  .returning('id');
35
36
  const id = inserted[0]?.id;
36
37
  if (typeof id !== 'number') {
37
- throw new TypeError('recordInventoryRun: INSERT returned no row id');
38
+ throw new TypeError('recordListReconcileRun: INSERT returned no row id');
38
39
  }
39
40
  return id;
40
41
  }
@@ -36,5 +36,5 @@ export function buildPageQuery(knex) {
36
36
  .leftJoin('url_refs as og_image_ur', 'og_image_ur.id', 'pm.og_image_url_id')
37
37
  .leftJoin('url_refs as twitter_image_ur', 'twitter_image_ur.id', 'pm.twitter_image_url_id')
38
38
  .leftJoin('json_refs as extras_ref', 'extras_ref.id', 'pm.meta_extras_json_id')
39
- .select('ci.id as id', 'ur.url as url', 'ci.redirect_dest_id as redirectDestId', 'ci.scraped as scraped', 'ci.is_target as isTarget', 'ci.is_external as isExternal', 'ci.status as status', 'ci.status_text as statusText', 'ctr.raw as contentType', 'ci.content_length as contentLength', 'ci.header_set_id as headerSetId', 'pm.lang as lang', 'pm.dir as dir', 'pm.charset as charset', 'pm.base_href as baseHref', 'pm.viewport_raw as viewport_raw', 'pm.theme_color as themeColor', 'pm.application_name as applicationName', 'pm.author as author', 'pm.generator as generator', 'pm.publisher as publisher', 'title_ref.text as title', 'description_ref.text as description', 'keywords_ref.text as keywords', 'robots_raw_ref.text as robots_raw', 'pm.robots_noindex as robots_noindex', 'pm.robots_nofollow as robots_nofollow', 'pm.robots_noarchive as robots_noarchive', 'pm.robots_noimageindex as robots_noimageindex', 'pm.googlebot as googlebot', 'canonical_ur.url as canonical', 'amphtml_ur.url as amphtml', 'manifest_ur.url as manifest', 'icon_ur.url as icon_href', 'apple_ur.url as appleTouchIcon_href', 'pm.og_type as og_type', 'og_title_ref.text as og_title', 'og_url_ur.url as og_url', 'pm.og_site_name as og_site_name', 'og_description_ref.text as og_description', 'og_image_ur.url as og_image', 'pm.og_image_alt as og_image_alt', 'pm.og_image_width as og_image_width', 'pm.og_image_height as og_image_height', 'pm.og_locale as og_locale', 'pm.og_article_published_time as og_article_published_time', 'pm.og_article_modified_time as og_article_modified_time', 'pm.twitter_card as twitter_card', 'pm.twitter_site as twitter_site', 'pm.twitter_creator as twitter_creator', 'twitter_title_ref.text as twitter_title', 'twitter_description_ref.text as twitter_description', 'twitter_image_ur.url as twitter_image', 'pm.fb_app_id as fb_app_id', 'pm.verification_google as verification_google', 'pm.format_detection_telephone as formatDetection_telephone', 'ci.first_crawled_at as firstCrawledAt', 'ci.last_crawled_at as lastCrawledAt', 'pm.tag_count as tag_count', 'pm.jsonld_count as jsonld_count', 'pm.tags_providers_csv as tags_providers_csv', 'pm.main_content_node_name as main_content_node_name', 'pm.main_content_id as main_content_id', 'pm.main_content_role as main_content_role', 'pm.main_content_selector as main_content_selector', 'pm.main_content_class_list as main_content_class_list', 'pm.main_content_word_count as main_content_word_count', 'pm.main_content_body_word_count as main_content_body_word_count', 'pm.main_content_heading_count as main_content_heading_count', 'pm.main_content_image_count as main_content_image_count', 'pm.main_content_table_count as main_content_table_count', 'pm.main_content_button_count as main_content_button_count', 'pm.main_content_iframe_count as main_content_iframe_count', 'pm.main_content_video_count as main_content_video_count', 'pm.main_content_audio_count as main_content_audio_count', 'pm.main_content_canvas_count as main_content_canvas_count', 'pm.main_content_custom_element_count as main_content_custom_element_count', 'pm.scroll_height_desktop as scroll_height_desktop', 'pm.scroll_height_mobile as scroll_height_mobile', 'extras_ref.json_text as extras_body', 'extras_ref.codec as extras_codec', 'ci.is_skipped as isSkipped', 'ci.skip_reason as skipReason', 'ci.crawl_order as order', 'ci.source as source');
39
+ .select('ci.id as id', 'ur.url as url', 'ci.redirect_dest_id as redirectDestId', 'ci.scraped as scraped', 'ci.is_target as isTarget', 'ci.is_external as isExternal', 'ci.status as status', 'ci.status_text as statusText', 'ctr.raw as contentType', 'ci.content_length as contentLength', 'ci.header_set_id as headerSetId', 'pm.lang as lang', 'pm.dir as dir', 'pm.charset as charset', 'pm.base_href as baseHref', 'pm.viewport_raw as viewport_raw', 'pm.theme_color as themeColor', 'pm.application_name as applicationName', 'pm.author as author', 'pm.generator as generator', 'pm.publisher as publisher', 'title_ref.text as title', 'description_ref.text as description', 'keywords_ref.text as keywords', 'robots_raw_ref.text as robots_raw', 'pm.robots_noindex as robots_noindex', 'pm.robots_nofollow as robots_nofollow', 'pm.robots_noarchive as robots_noarchive', 'pm.robots_noimageindex as robots_noimageindex', 'pm.googlebot as googlebot', 'canonical_ur.url as canonical', 'amphtml_ur.url as amphtml', 'manifest_ur.url as manifest', 'icon_ur.url as icon_href', 'apple_ur.url as appleTouchIcon_href', 'pm.og_type as og_type', 'og_title_ref.text as og_title', 'og_url_ur.url as og_url', 'pm.og_site_name as og_site_name', 'og_description_ref.text as og_description', 'og_image_ur.url as og_image', 'pm.og_image_alt as og_image_alt', 'pm.og_image_width as og_image_width', 'pm.og_image_height as og_image_height', 'pm.og_locale as og_locale', 'pm.og_article_published_time as og_article_published_time', 'pm.og_article_modified_time as og_article_modified_time', 'pm.twitter_card as twitter_card', 'pm.twitter_site as twitter_site', 'pm.twitter_creator as twitter_creator', 'twitter_title_ref.text as twitter_title', 'twitter_description_ref.text as twitter_description', 'twitter_image_ur.url as twitter_image', 'pm.fb_app_id as fb_app_id', 'pm.verification_google as verification_google', 'pm.format_detection_telephone as formatDetection_telephone', 'ci.first_crawled_at as firstCrawledAt', 'ci.last_crawled_at as lastCrawledAt', 'pm.tag_count as tag_count', 'pm.jsonld_count as jsonld_count', 'pm.tags_providers_csv as tags_providers_csv', 'pm.main_content_node_name as main_content_node_name', 'pm.main_content_id as main_content_id', 'pm.main_content_role as main_content_role', 'pm.main_content_selector as main_content_selector', 'pm.main_content_class_list as main_content_class_list', 'pm.main_content_word_count as main_content_word_count', 'pm.main_content_body_word_count as main_content_body_word_count', 'pm.main_content_heading_count as main_content_heading_count', 'pm.main_content_image_count as main_content_image_count', 'pm.main_content_table_count as main_content_table_count', 'pm.main_content_button_count as main_content_button_count', 'pm.main_content_iframe_count as main_content_iframe_count', 'pm.main_content_video_count as main_content_video_count', 'pm.main_content_audio_count as main_content_audio_count', 'pm.main_content_canvas_count as main_content_canvas_count', 'pm.main_content_custom_element_count as main_content_custom_element_count', 'pm.scroll_height_desktop as scroll_height_desktop', 'pm.scroll_height_mobile as scroll_height_mobile', 'pm.image_scan_desktop as image_scan_desktop', 'pm.image_scan_mobile as image_scan_mobile', 'extras_ref.json_text as extras_body', 'extras_ref.codec as extras_codec', 'ci.is_skipped as isSkipped', 'ci.skip_reason as skipReason', 'ci.crawl_order as order', 'ci.source as source');
40
40
  }
@@ -61,10 +61,17 @@ import type { Knex } from 'knex';
61
61
  * shape is safe to use without the `migrate*` guards that other writer
62
62
  * methods carry.
63
63
  * @param knex - Knex query builder connected to the archive DB.
64
- * @returns An object with `scraped` (completed URLs) and `pending` (the
65
- * strict set of in-scope, anchor-referenced, unfinished URLs).
64
+ * @returns An object with `scraped` (completed URLs), `pending` (the
65
+ * strict set of in-scope, anchor-referenced, unfinished URLs), and
66
+ * `pendingMetadataOnly` (the subset of `pending` whose
67
+ * `content_items.is_metadata_only` was persisted as `1` — see
68
+ * `replaceAnchorEdges`/`resolveContentItemId`). Callers that resume a
69
+ * crawl (`Crawler#resume` → `LinkList#resume`) pass this subset through
70
+ * so a metadata-only anchor discovered before an interruption is not
71
+ * silently promoted to a full-scrape target on resume (#369).
66
72
  */
67
73
  export declare function getCrawlingState(knex: Knex): Promise<{
68
74
  scraped: string[];
69
75
  pending: string[];
76
+ pendingMetadataOnly: string[];
70
77
  }>;
@@ -60,8 +60,14 @@
60
60
  * shape is safe to use without the `migrate*` guards that other writer
61
61
  * methods carry.
62
62
  * @param knex - Knex query builder connected to the archive DB.
63
- * @returns An object with `scraped` (completed URLs) and `pending` (the
64
- * strict set of in-scope, anchor-referenced, unfinished URLs).
63
+ * @returns An object with `scraped` (completed URLs), `pending` (the
64
+ * strict set of in-scope, anchor-referenced, unfinished URLs), and
65
+ * `pendingMetadataOnly` (the subset of `pending` whose
66
+ * `content_items.is_metadata_only` was persisted as `1` — see
67
+ * `replaceAnchorEdges`/`resolveContentItemId`). Callers that resume a
68
+ * crawl (`Crawler#resume` → `LinkList#resume`) pass this subset through
69
+ * so a metadata-only anchor discovered before an interruption is not
70
+ * silently promoted to a full-scrape target on resume (#369).
65
71
  */
66
72
  export async function getCrawlingState(knex) {
67
73
  const ex = (r) => r.url;
@@ -71,7 +77,7 @@ export async function getCrawlingState(knex) {
71
77
  .where('content_items.scraped', 1);
72
78
  const scraped = $scraped.map(ex);
73
79
  const $pending = await knex
74
- .select('ur.url as url')
80
+ .select('ur.url as url', 'ci.is_metadata_only as isMetadataOnly')
75
81
  .from({ ci: 'content_items' })
76
82
  .join({ ur: 'url_refs' }, 'ur.id', 'ci.url_id')
77
83
  .where('ci.scraped', 0)
@@ -91,8 +97,10 @@ export async function getCrawlingState(knex) {
91
97
  }).orWhereNot('ci.source', 'crawled');
92
98
  });
93
99
  const pending = $pending.map(ex);
100
+ const pendingMetadataOnly = $pending.filter((r) => r.isMetadataOnly === 1).map(ex);
94
101
  return {
95
102
  scraped,
96
103
  pending,
104
+ pendingMetadataOnly,
97
105
  };
98
106
  }
@@ -0,0 +1,20 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Deletes every Scoped-Replace derived row for the given page ids, across the
4
+ * 17 tables a page's re-scrape needs to start clean.
5
+ *
6
+ * Shared by {@link resetFailedPages} and {@link repromoteExternalPages} —
7
+ * both un-scrape a page back to `scraped = 0` and need the same "delete this
8
+ * page's derived rows so the re-scrape can re-insert fresh data without
9
+ * duplicates" sweep. `page_errors` is deliberately NOT included here:
10
+ * `repromoteExternalPages` never cleared it (an external page's prior error
11
+ * history stays visible after being promoted back into scope), and folding
12
+ * it into this shared helper would silently change that existing behaviour.
13
+ * Callers that do want `page_errors` cleared (`resetFailedPages`) delete it
14
+ * themselves alongside this call.
15
+ * @param knex - Knex query builder connected to the archive DB.
16
+ * @param pageIds - `content_items.id` values whose derived rows should be
17
+ * deleted. Expected to already be chunked below
18
+ * `SQLITE_LIMIT_VARIABLE_NUMBER` by the caller.
19
+ */
20
+ export declare function clearPageDerivedRows(knex: Knex, pageIds: readonly number[]): Promise<void>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Deletes every Scoped-Replace derived row for the given page ids, across the
3
+ * 17 tables a page's re-scrape needs to start clean.
4
+ *
5
+ * Shared by {@link resetFailedPages} and {@link repromoteExternalPages} —
6
+ * both un-scrape a page back to `scraped = 0` and need the same "delete this
7
+ * page's derived rows so the re-scrape can re-insert fresh data without
8
+ * duplicates" sweep. `page_errors` is deliberately NOT included here:
9
+ * `repromoteExternalPages` never cleared it (an external page's prior error
10
+ * history stays visible after being promoted back into scope), and folding
11
+ * it into this shared helper would silently change that existing behaviour.
12
+ * Callers that do want `page_errors` cleared (`resetFailedPages`) delete it
13
+ * themselves alongside this call.
14
+ * @param knex - Knex query builder connected to the archive DB.
15
+ * @param pageIds - `content_items.id` values whose derived rows should be
16
+ * deleted. Expected to already be chunked below
17
+ * `SQLITE_LIMIT_VARIABLE_NUMBER` by the caller.
18
+ */
19
+ export async function clearPageDerivedRows(knex, pageIds) {
20
+ await knex('page_meta').whereIn('page_id', pageIds).delete();
21
+ await knex('anchor_edges').whereIn('page_id', pageIds).delete();
22
+ await knex('image_items').whereIn('page_id', pageIds).delete();
23
+ await knex('resource_ref_edges').whereIn('page_id', pageIds).delete();
24
+ await knex('page_html_ref').whereIn('page_id', pageIds).delete();
25
+ await knex('technology_signals').whereIn('pageId', pageIds).delete();
26
+ await knex('page_technologies').whereIn('pageId', pageIds).delete();
27
+ await knex('page_jsonld').whereIn('pageId', pageIds).delete();
28
+ await knex('page_main_content_headings').whereIn('pageId', pageIds).delete();
29
+ await knex('page_main_content_images').whereIn('pageId', pageIds).delete();
30
+ await knex('page_main_content_tables').whereIn('pageId', pageIds).delete();
31
+ await knex('page_main_content_buttons').whereIn('pageId', pageIds).delete();
32
+ await knex('page_main_content_iframes').whereIn('pageId', pageIds).delete();
33
+ await knex('page_main_content_videos').whereIn('pageId', pageIds).delete();
34
+ await knex('page_main_content_audios').whereIn('pageId', pageIds).delete();
35
+ await knex('page_main_content_canvases').whereIn('pageId', pageIds).delete();
36
+ await knex('page_main_content_custom_elements').whereIn('pageId', pageIds).delete();
37
+ }
@@ -22,8 +22,9 @@ import type { Knex } from 'knex';
22
22
  * @param onProgress - Called after each chunk's DELETE/UPDATE statements
23
23
  * complete, with the pages processed so far and the total to promote
24
24
  * (issue #294: a large `--append` can promote thousands of pages across
25
- * 14 tables, running for seconds to minutes with no other signal it
26
- * hasn't hung). Omit for no reporting (the default; e.g. tests).
25
+ * `content_items` plus the 17 tables {@link clearPageDerivedRows} clears,
26
+ * running for seconds to minutes with no other signal it hasn't hung).
27
+ * Omit for no reporting (the default; e.g. tests).
27
28
  * @returns The URLs of the pages that were promoted.
28
29
  */
29
30
  export declare function repromoteExternalPages(knex: Knex, scopes: ReadonlyMap<string, readonly ExURL[]>, options?: ParseURLOptions, onProgress?: (processed: number, total: number) => void): Promise<string[]>;
@@ -1,6 +1,7 @@
1
1
  import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
2
2
  import { findScopeEntry } from '../../../../crawler/find-scope-entry.js';
3
3
  import { dbLog } from '../../../debug.js';
4
+ import { clearPageDerivedRows } from './clear-page-derived-rows.js';
4
5
  /**
5
6
  * Promote previously-external pages whose URL falls under any of the new scope
6
7
  * entries back to a "needs scraping" state so that the next crawl picks them up
@@ -23,8 +24,9 @@ import { dbLog } from '../../../debug.js';
23
24
  * @param onProgress - Called after each chunk's DELETE/UPDATE statements
24
25
  * complete, with the pages processed so far and the total to promote
25
26
  * (issue #294: a large `--append` can promote thousands of pages across
26
- * 14 tables, running for seconds to minutes with no other signal it
27
- * hasn't hung). Omit for no reporting (the default; e.g. tests).
27
+ * `content_items` plus the 17 tables {@link clearPageDerivedRows} clears,
28
+ * running for seconds to minutes with no other signal it hasn't hung).
29
+ * Omit for no reporting (the default; e.g. tests).
28
30
  * @returns The URLs of the pages that were promoted.
29
31
  */
30
32
  export async function repromoteExternalPages(knex, scopes, options, onProgress) {
@@ -69,41 +71,19 @@ export async function repromoteExternalPages(knex, scopes, options, onProgress)
69
71
  // left untouched — the last-success timestamp survives the
70
72
  // demotion.
71
73
  });
72
- // Clear the prior crawl's data for the repromoted pages. `updatePage`
73
- // also replaces anchor_edges/image_items/tags/jsonld/page_main_content_*
74
- // when it re-scrapes them, but only when the new scrape is non-empty —
75
- // so this pre-clear is still load-bearing for pages that get
76
- // repromoted but then re-scrape to nothing (or are never reached
77
- // again), and it is the only place `resource_ref_edges` is cleared.
78
- // Deleting the `page_meta` row (rather than nulling every column)
79
- // clears title / description / og:* / twitter:* / meta_extras /
80
- // main_content_* in one statement; a re-scrape re-inserts it via
81
- // `ON CONFLICT(page_id) DO UPDATE`. `technology_signals` /
82
- // `page_technologies` / `page_jsonld` /
83
- // `page_main_content_*` are cleared explicitly even though all of
84
- // them also carry ON DELETE CASCADE — we keep the existing pattern
85
- // of explicit chunked DELETEs rather than relying on CASCADE
86
- // indirectly (and would not cascade anyway: the parent
87
- // `content_items` row is updated, not
88
- // deleted). Orphan blobs in `page_html_blobs` are left behind; #23
89
- // will add GC.
90
- await knex('page_meta').whereIn('page_id', chunk).delete();
91
- await knex('anchor_edges').whereIn('page_id', chunk).delete();
92
- await knex('image_items').whereIn('page_id', chunk).delete();
93
- await knex('resource_ref_edges').whereIn('page_id', chunk).delete();
94
- await knex('page_html_ref').whereIn('page_id', chunk).delete();
95
- await knex('technology_signals').whereIn('pageId', chunk).delete();
96
- await knex('page_technologies').whereIn('pageId', chunk).delete();
97
- await knex('page_jsonld').whereIn('pageId', chunk).delete();
98
- await knex('page_main_content_headings').whereIn('pageId', chunk).delete();
99
- await knex('page_main_content_images').whereIn('pageId', chunk).delete();
100
- await knex('page_main_content_tables').whereIn('pageId', chunk).delete();
101
- await knex('page_main_content_buttons').whereIn('pageId', chunk).delete();
102
- await knex('page_main_content_iframes').whereIn('pageId', chunk).delete();
103
- await knex('page_main_content_videos').whereIn('pageId', chunk).delete();
104
- await knex('page_main_content_audios').whereIn('pageId', chunk).delete();
105
- await knex('page_main_content_canvases').whereIn('pageId', chunk).delete();
106
- await knex('page_main_content_custom_elements').whereIn('pageId', chunk).delete();
74
+ // Clear the prior crawl's data for the repromoted pages via the
75
+ // shared sweep (also used by `resetFailedPages` / `resetPagesByUrls`).
76
+ // `updatePage` also replaces anchor_edges/image_items/tags/jsonld/
77
+ // page_main_content_* when it re-scrapes them, but only when the new
78
+ // scrape is non-empty so this pre-clear is still load-bearing for
79
+ // pages that get repromoted but then re-scrape to nothing (or are
80
+ // never reached again), and it is the only place `resource_ref_edges`
81
+ // is cleared for this operation. Deleting the `page_meta` row (rather
82
+ // than nulling every column) clears title / description / og:* /
83
+ // twitter:* / meta_extras / main_content_* in one statement; a
84
+ // re-scrape re-inserts it via `ON CONFLICT(page_id) DO UPDATE`.
85
+ // Orphan blobs in `page_html_blobs` are left behind; #23 will add GC.
86
+ await clearPageDerivedRows(knex, chunk);
107
87
  onProgress?.(Math.min(i + chunkSize, promotedIds.length), promotedIds.length);
108
88
  }
109
89
  dbLog('Repromoted %d external pages back to pending', promotedUrls.length);
@@ -13,7 +13,13 @@ import type { Knex } from 'knex';
13
13
  * - `status IS NULL` — no status was ever stored for the row;
14
14
  * - the row has no `content_type_refs` link — the content type could not be
15
15
  * determined;
16
- * - `status` is in the `5xx` range — a (frequently transient) server error.
16
+ * - `status` is in the `5xx` range — a (frequently transient) server error;
17
+ * - `page_meta.image_scan_desktop` or `image_scan_mobile` is one of
18
+ * {@link RETRYABLE_IMAGE_SCAN_CODES} — `@d-zero/beholder` abandoned that
19
+ * viewport's `<img>` scan for a transient reason (navigation never
20
+ * settled, or the frame/session was lost mid-scan), independent of the
21
+ * page's own HTTP `status` (a page can score `status = 200` and still
22
+ * have failed its mobile image scan).
17
23
  *
18
24
  * Definitive `4xx` responses are intentionally excluded: re-fetching a 404
19
25
  * almost always yields the same answer.
@@ -42,6 +48,21 @@ import type { Knex } from 'knex';
42
48
  * with no recorded outages (`listNetworkOutages` returns `[]`) behaves
43
49
  * exactly as before this override existed.
44
50
  *
51
+ * **Confirmed same-cluster trap exclusion**: a third pass drops any
52
+ * remaining candidate whose URL shape (`computeShapeKey`) matches a
53
+ * `dedupe_cap_events.shape_key` already recorded in this archive (see
54
+ * `DedupeCapTracker`). A trap page frequently fails outright (timeout / 5xx)
55
+ * rather than rendering a comparable body, so it never reaches
56
+ * `DedupeCapTracker#observe` during the crawl that hit it — without this
57
+ * exclusion, `--retry-failed` would keep re-queueing the very pages the cap
58
+ * already exists to suppress, re-inflating the pending count on every pass
59
+ * for a shape this archive has already confirmed is not worth the cost of
60
+ * re-discovering. Left as a failed row rather than rewritten to a skip —
61
+ * post-hoc marking (`content_items.dedupe_cap_event_id`, computed at
62
+ * `viewer-build`) is what surfaces these pages as capped, not this reset
63
+ * path. An archive with no recorded cap events (`listDedupeCapShapeKeys`
64
+ * returns `[]`) behaves exactly as before this exclusion existed.
65
+ *
45
66
  * Matching rows — internal and external alike — are demoted back to pending
46
67
  * (`scraped = 0`) and have their stale scrape metadata cleared (the
47
68
  * `page_meta` row is deleted outright rather than nulled column-by-column).
@@ -1,9 +1,13 @@
1
1
  import { classifyErrorKind } from '../../../../classify-error-kind.js';
2
+ import { computeShapeKey } from '../../../../crawler/dedupe/compute-shape-key.js';
2
3
  import { isWithinOutageWindow } from '../../../../is-within-outage-window.js';
3
4
  import { PERMANENT_ERROR_KINDS } from '../../../../permanent-error-kinds.js';
4
5
  import { dbLog } from '../../../debug.js';
5
6
  import { getFailedPageMessages } from '../../../get-failed-page-messages.js';
7
+ import { listDedupeCapShapeKeys } from '../../dedupe-cap/list-dedupe-cap-shape-keys.js';
6
8
  import { listNetworkOutages } from '../../outages/list-network-outages.js';
9
+ import { clearPageDerivedRows } from './clear-page-derived-rows.js';
10
+ import { RETRYABLE_IMAGE_SCAN_CODES } from './retryable-image-scan-codes.js';
7
11
  /**
8
12
  * Reset previously-attempted pages that ended in a recoverable failure so a
9
13
  * follow-up crawl can re-fetch them from scratch.
@@ -18,7 +22,13 @@ import { listNetworkOutages } from '../../outages/list-network-outages.js';
18
22
  * - `status IS NULL` — no status was ever stored for the row;
19
23
  * - the row has no `content_type_refs` link — the content type could not be
20
24
  * determined;
21
- * - `status` is in the `5xx` range — a (frequently transient) server error.
25
+ * - `status` is in the `5xx` range — a (frequently transient) server error;
26
+ * - `page_meta.image_scan_desktop` or `image_scan_mobile` is one of
27
+ * {@link RETRYABLE_IMAGE_SCAN_CODES} — `@d-zero/beholder` abandoned that
28
+ * viewport's `<img>` scan for a transient reason (navigation never
29
+ * settled, or the frame/session was lost mid-scan), independent of the
30
+ * page's own HTTP `status` (a page can score `status = 200` and still
31
+ * have failed its mobile image scan).
22
32
  *
23
33
  * Definitive `4xx` responses are intentionally excluded: re-fetching a 404
24
34
  * almost always yields the same answer.
@@ -47,6 +57,21 @@ import { listNetworkOutages } from '../../outages/list-network-outages.js';
47
57
  * with no recorded outages (`listNetworkOutages` returns `[]`) behaves
48
58
  * exactly as before this override existed.
49
59
  *
60
+ * **Confirmed same-cluster trap exclusion**: a third pass drops any
61
+ * remaining candidate whose URL shape (`computeShapeKey`) matches a
62
+ * `dedupe_cap_events.shape_key` already recorded in this archive (see
63
+ * `DedupeCapTracker`). A trap page frequently fails outright (timeout / 5xx)
64
+ * rather than rendering a comparable body, so it never reaches
65
+ * `DedupeCapTracker#observe` during the crawl that hit it — without this
66
+ * exclusion, `--retry-failed` would keep re-queueing the very pages the cap
67
+ * already exists to suppress, re-inflating the pending count on every pass
68
+ * for a shape this archive has already confirmed is not worth the cost of
69
+ * re-discovering. Left as a failed row rather than rewritten to a skip —
70
+ * post-hoc marking (`content_items.dedupe_cap_event_id`, computed at
71
+ * `viewer-build`) is what surfaces these pages as capped, not this reset
72
+ * path. An archive with no recorded cap events (`listDedupeCapShapeKeys`
73
+ * returns `[]`) behaves exactly as before this exclusion existed.
74
+ *
50
75
  * Matching rows — internal and external alike — are demoted back to pending
51
76
  * (`scraped = 0`) and have their stale scrape metadata cleared (the
52
77
  * `page_meta` row is deleted outright rather than nulled column-by-column).
@@ -72,7 +97,8 @@ import { listNetworkOutages } from '../../outages/list-network-outages.js';
72
97
  export async function resetFailedPages(knex, onProgress) {
73
98
  const candidates = await knex('content_items')
74
99
  .join('url_refs', 'content_items.url_id', 'url_refs.id')
75
- .select('content_items.id as id', 'url_refs.url as url')
100
+ .leftJoin('page_meta', 'content_items.id', 'page_meta.page_id')
101
+ .select('content_items.id as id', 'url_refs.url as url', 'content_items.status as status', 'content_items.content_type_id as contentTypeId')
76
102
  .where('content_items.scraped', 1)
77
103
  .whereNull('content_items.redirect_dest_id')
78
104
  .where((qb) => {
@@ -82,19 +108,37 @@ export async function resetFailedPages(knex, onProgress) {
82
108
  qb.whereNull('content_items.status')
83
109
  .orWhere('content_items.status', -1)
84
110
  .orWhereNull('content_items.content_type_id')
85
- .orWhereBetween('content_items.status', [500, 599]);
111
+ .orWhereBetween('content_items.status', [500, 599])
112
+ .orWhereIn('page_meta.image_scan_desktop', RETRYABLE_IMAGE_SCAN_CODES)
113
+ .orWhereIn('page_meta.image_scan_mobile', RETRYABLE_IMAGE_SCAN_CODES);
86
114
  });
87
115
  if (candidates.length === 0) {
88
116
  return [];
89
117
  }
118
+ // Diagnostic only: how many candidates matched solely because of an
119
+ // image-scan outcome, not the pre-existing status-based conditions —
120
+ // i.e. a page whose HTTP status looks fine but whose mobile/desktop
121
+ // `<img>` scan was abandoned for a transient reason.
122
+ const imageScanOnlyCount = candidates.filter((row) => {
123
+ const statusQualifies = row.status === null ||
124
+ row.status === -1 ||
125
+ row.contentTypeId === null ||
126
+ (row.status >= 500 && row.status <= 599);
127
+ return !statusQualifies;
128
+ }).length;
129
+ if (imageScanOnlyCount > 0) {
130
+ dbLog('%d of %d retry candidate(s) matched via image-scan degradation only', imageScanOnlyCount, candidates.length);
131
+ }
90
132
  const candidateIds = candidates.map((row) => row.id);
91
133
  const candidateUrls = candidates.map((row) => row.url);
92
- // Unrelated tables (page_errors/crawl_errors vs network_outages), no data
93
- // dependency between them — run concurrently instead of paying two
94
- // sequential round-trips on every `--retry-failed` pass.
95
- const [messages, outageWindows] = await Promise.all([
134
+ // Three unrelated reads (page_errors/crawl_errors, network_outages,
135
+ // dedupe_cap_events) with no data dependency between them — run
136
+ // concurrently instead of paying three sequential round-trips on every
137
+ // `--retry-failed` pass.
138
+ const [messages, outageWindows, cappedShapeKeys] = await Promise.all([
96
139
  getFailedPageMessages(knex, candidateIds, candidateUrls),
97
140
  listNetworkOutages(knex),
141
+ listDedupeCapShapeKeys(knex).then((shapeKeys) => new Set(shapeKeys)),
98
142
  ]);
99
143
  // Drop candidates whose latest recorded message classifies as permanent —
100
144
  // UNLESS that message's timestamp falls inside a recorded network outage,
@@ -117,11 +161,26 @@ export async function resetFailedPages(knex, onProgress) {
117
161
  if (excludedCount > 0) {
118
162
  dbLog('Excluded %d page(s) from retry — permanent failure kinds (dns/tls/client-blocked/parse-error/connection-refused)', excludedCount);
119
163
  }
120
- if (retryable.length === 0) {
164
+ // Drop candidates whose URL shape already has a confirmed same-cluster
165
+ // trap recorded (see the "Confirmed same-cluster trap exclusion" section
166
+ // of this function's docstring). A row whose shape cannot be computed
167
+ // (`computeShapeKey` returns `null`) stays in the retry pool — no signal
168
+ // either way, so err on the side of retrying it.
169
+ const notCapped = cappedShapeKeys.size === 0
170
+ ? retryable
171
+ : retryable.filter((row) => {
172
+ const shapeKey = computeShapeKey(row.url);
173
+ return shapeKey === null || !cappedShapeKeys.has(shapeKey);
174
+ });
175
+ const excludedCappedCount = retryable.length - notCapped.length;
176
+ if (excludedCappedCount > 0) {
177
+ dbLog('Excluded %d page(s) from retry — confirmed same-cluster trap shape', excludedCappedCount);
178
+ }
179
+ if (notCapped.length === 0) {
121
180
  return [];
122
181
  }
123
- const ids = retryable.map((row) => row.id);
124
- const urls = retryable.map((row) => row.url);
182
+ const ids = notCapped.map((row) => row.id);
183
+ const urls = notCapped.map((row) => row.url);
125
184
  const chunkSize = 500;
126
185
  for (let i = 0; i < ids.length; i += chunkSize) {
127
186
  const chunk = ids.slice(i, i + chunkSize);
@@ -136,33 +195,20 @@ export async function resetFailedPages(knex, onProgress) {
136
195
  // untouched so the last-success timestamp records survive the
137
196
  // demotion (the within-archive observation axis for #11/#17/#19).
138
197
  });
139
- // Clear the prior crawl's per-page data so the re-scrape starts clean.
140
- // `updatePage` only replaces anchor_edges/image_items/tags/jsonld when
141
- // the new scrape is non-empty, so this pre-clear is load-bearing for
142
- // pages that reset but then fail again (or are never reached), and it
143
- // is the only place `resource_ref_edges` and `page_errors` are
144
- // cleared. Deleting the `page_meta` row (rather than nulling every
198
+ // Clear the prior crawl's per-page data so the re-scrape starts clean,
199
+ // via the shared sweep (also used by `repromoteExternalPages` /
200
+ // `resetPagesByUrls`) plus `page_errors`, which that sweep
201
+ // deliberately excludes (see its JSDoc). `updatePage` only replaces
202
+ // anchor_edges/image_items/tags/jsonld when the new scrape is
203
+ // non-empty, so this pre-clear is load-bearing for pages that reset
204
+ // but then fail again (or are never reached), and it is the only
205
+ // place `resource_ref_edges` and `page_errors` are cleared for this
206
+ // operation. Deleting the `page_meta` row (rather than nulling every
145
207
  // column) clears title / description / og:* / twitter:* /
146
208
  // meta_extras in one statement; a re-scrape re-inserts it via
147
209
  // `ON CONFLICT(page_id) DO UPDATE`.
148
- await knex('page_meta').whereIn('page_id', chunk).delete();
149
- await knex('anchor_edges').whereIn('page_id', chunk).delete();
150
- await knex('image_items').whereIn('page_id', chunk).delete();
151
- await knex('resource_ref_edges').whereIn('page_id', chunk).delete();
152
210
  await knex('page_errors').whereIn('pageId', chunk).delete();
153
- await knex('page_html_ref').whereIn('page_id', chunk).delete();
154
- await knex('technology_signals').whereIn('pageId', chunk).delete();
155
- await knex('page_technologies').whereIn('pageId', chunk).delete();
156
- await knex('page_jsonld').whereIn('pageId', chunk).delete();
157
- await knex('page_main_content_headings').whereIn('pageId', chunk).delete();
158
- await knex('page_main_content_images').whereIn('pageId', chunk).delete();
159
- await knex('page_main_content_tables').whereIn('pageId', chunk).delete();
160
- await knex('page_main_content_buttons').whereIn('pageId', chunk).delete();
161
- await knex('page_main_content_iframes').whereIn('pageId', chunk).delete();
162
- await knex('page_main_content_videos').whereIn('pageId', chunk).delete();
163
- await knex('page_main_content_audios').whereIn('pageId', chunk).delete();
164
- await knex('page_main_content_canvases').whereIn('pageId', chunk).delete();
165
- await knex('page_main_content_custom_elements').whereIn('pageId', chunk).delete();
211
+ await clearPageDerivedRows(knex, chunk);
166
212
  onProgress?.(Math.min(i + chunkSize, ids.length), ids.length);
167
213
  }
168
214
  dbLog('Reset %d failed pages back to pending', urls.length);