@nitpicker/crawler 0.11.0 → 0.13.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 (387) hide show
  1. package/README.md +6 -4
  2. package/lib/archive/archive-accessor.d.ts +2 -2
  3. package/lib/archive/archive-accessor.js +2 -2
  4. package/lib/archive/archive-lock.d.ts +7 -0
  5. package/lib/archive/archive-lock.js +7 -0
  6. package/lib/archive/archive.d.ts +175 -13
  7. package/lib/archive/archive.js +198 -14
  8. package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
  9. package/lib/archive/cache/compute-archive-cache-key.js +95 -0
  10. package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
  11. package/lib/archive/cache/extract-archive-to-cache.js +309 -0
  12. package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
  13. package/lib/archive/cache/get-archive-cache-root.js +53 -0
  14. package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
  15. package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
  16. package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
  17. package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
  18. package/lib/archive/create-adjunct-tables.d.ts +43 -0
  19. package/lib/archive/create-adjunct-tables.js +213 -0
  20. package/lib/archive/create-entity-tables.d.ts +173 -0
  21. package/lib/archive/create-entity-tables.js +318 -0
  22. package/lib/archive/create-progress-reporter.d.ts +30 -0
  23. package/lib/archive/create-progress-reporter.js +38 -0
  24. package/lib/archive/create-ref-tables.d.ts +35 -0
  25. package/lib/archive/create-ref-tables.js +188 -0
  26. package/lib/archive/database.d.ts +130 -182
  27. package/lib/archive/database.js +524 -1777
  28. package/lib/archive/db-ops/_shared/clear-write-ref-caches.d.ts +27 -0
  29. package/lib/archive/db-ops/_shared/clear-write-ref-caches.js +34 -0
  30. package/lib/archive/db-ops/_shared/create-write-ref-caches.d.ts +17 -0
  31. package/lib/archive/db-ops/_shared/create-write-ref-caches.js +26 -0
  32. package/lib/archive/db-ops/_shared/decode-json-ref.d.ts +17 -0
  33. package/lib/archive/db-ops/_shared/decode-json-ref.js +31 -0
  34. package/lib/archive/db-ops/_shared/load-response-headers-by-set-ids.d.ts +20 -0
  35. package/lib/archive/db-ops/_shared/load-response-headers-by-set-ids.js +53 -0
  36. package/lib/archive/db-ops/_shared/resolve-content-item-id.d.ts +61 -0
  37. package/lib/archive/db-ops/_shared/resolve-content-item-id.js +111 -0
  38. package/lib/archive/db-ops/_shared/resolve-url-or-blob.d.ts +23 -0
  39. package/lib/archive/db-ops/_shared/resolve-url-or-blob.js +29 -0
  40. package/lib/archive/db-ops/_shared/retry-setting.d.ts +16 -0
  41. package/lib/archive/db-ops/_shared/retry-setting.js +18 -0
  42. package/lib/archive/db-ops/_shared/safe-parse-json.d.ts +11 -0
  43. package/lib/archive/db-ops/_shared/safe-parse-json.js +18 -0
  44. package/lib/archive/db-ops/_shared/types.d.ts +53 -0
  45. package/lib/archive/db-ops/_shared/types.js +1 -0
  46. package/lib/archive/db-ops/_shared/upsert-blob-ref.d.ts +25 -0
  47. package/lib/archive/db-ops/_shared/upsert-blob-ref.js +48 -0
  48. package/lib/archive/db-ops/_shared/upsert-content-type-ref.d.ts +30 -0
  49. package/lib/archive/db-ops/_shared/upsert-content-type-ref.js +45 -0
  50. package/lib/archive/db-ops/_shared/upsert-json-ref.d.ts +22 -0
  51. package/lib/archive/db-ops/_shared/upsert-json-ref.js +41 -0
  52. package/lib/archive/db-ops/_shared/upsert-response-headers.d.ts +35 -0
  53. package/lib/archive/db-ops/_shared/upsert-response-headers.js +49 -0
  54. package/lib/archive/db-ops/_shared/upsert-url-ref.d.ts +39 -0
  55. package/lib/archive/db-ops/_shared/upsert-url-ref.js +62 -0
  56. package/lib/archive/db-ops/analysis/replace-analysis-violations.d.ts +28 -0
  57. package/lib/archive/db-ops/analysis/replace-analysis-violations.js +152 -0
  58. package/lib/archive/db-ops/anchors/get-anchors-on-page.d.ts +10 -0
  59. package/lib/archive/db-ops/anchors/get-anchors-on-page.js +21 -0
  60. package/lib/archive/db-ops/config/get-base-url.d.ts +8 -0
  61. package/lib/archive/db-ops/config/get-base-url.js +14 -0
  62. package/lib/archive/db-ops/config/get-config.d.ts +10 -0
  63. package/lib/archive/db-ops/config/get-config.js +27 -0
  64. package/lib/archive/db-ops/config/get-name.d.ts +8 -0
  65. package/lib/archive/db-ops/config/get-name.js +14 -0
  66. package/lib/archive/db-ops/config/info-column-allowlist.d.ts +7 -0
  67. package/lib/archive/db-ops/config/info-column-allowlist.js +26 -0
  68. package/lib/archive/db-ops/config/info-json-columns.d.ts +5 -0
  69. package/lib/archive/db-ops/config/info-json-columns.js +10 -0
  70. package/lib/archive/db-ops/config/set-config.d.ts +12 -0
  71. package/lib/archive/db-ops/config/set-config.js +21 -0
  72. package/lib/archive/db-ops/config/update-config.d.ts +17 -0
  73. package/lib/archive/db-ops/config/update-config.js +36 -0
  74. package/lib/archive/db-ops/errors/insert-crawl-error.d.ts +15 -0
  75. package/lib/archive/db-ops/errors/insert-crawl-error.js +21 -0
  76. package/lib/archive/db-ops/errors/insert-page-error.d.ts +21 -0
  77. package/lib/archive/db-ops/errors/insert-page-error.js +28 -0
  78. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.d.ts +22 -0
  79. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.js +141 -0
  80. package/lib/archive/db-ops/html/get-html-of-page-by-id.d.ts +18 -0
  81. package/lib/archive/db-ops/html/get-html-of-page-by-id.js +29 -0
  82. package/lib/archive/db-ops/inventory/record-inventory-run.d.ts +21 -0
  83. package/lib/archive/db-ops/inventory/record-inventory-run.js +38 -0
  84. package/lib/archive/db-ops/lifecycle/checkpoint.d.ts +8 -0
  85. package/lib/archive/db-ops/lifecycle/checkpoint.js +9 -0
  86. package/lib/archive/db-ops/lifecycle/destroy.d.ts +6 -0
  87. package/lib/archive/db-ops/lifecycle/destroy.js +7 -0
  88. package/lib/archive/db-ops/lifecycle/init.d.ts +22 -0
  89. package/lib/archive/db-ops/lifecycle/init.js +42 -0
  90. package/lib/archive/db-ops/meta/get-jsonld-of-page.d.ts +13 -0
  91. package/lib/archive/db-ops/meta/get-jsonld-of-page.js +27 -0
  92. package/lib/archive/db-ops/meta/get-tags-of-page.d.ts +12 -0
  93. package/lib/archive/db-ops/meta/get-tags-of-page.js +28 -0
  94. package/lib/archive/db-ops/pages/order/set-url-order.d.ts +8 -0
  95. package/lib/archive/db-ops/pages/order/set-url-order.js +32 -0
  96. package/lib/archive/db-ops/pages/read/build-page-query.d.ts +18 -0
  97. package/lib/archive/db-ops/pages/read/build-page-query.js +40 -0
  98. package/lib/archive/db-ops/pages/read/get-crawling-state.d.ts +70 -0
  99. package/lib/archive/db-ops/pages/read/get-crawling-state.js +98 -0
  100. package/lib/archive/db-ops/pages/read/get-existing-page-urls.d.ts +15 -0
  101. package/lib/archive/db-ops/pages/read/get-existing-page-urls.js +30 -0
  102. package/lib/archive/db-ops/pages/read/get-page-count.d.ts +12 -0
  103. package/lib/archive/db-ops/pages/read/get-page-count.js +21 -0
  104. package/lib/archive/db-ops/pages/read/get-page-source-by-url.d.ts +24 -0
  105. package/lib/archive/db-ops/pages/read/get-page-source-by-url.js +28 -0
  106. package/lib/archive/db-ops/pages/read/get-pages-with-rels.d.ts +38 -0
  107. package/lib/archive/db-ops/pages/read/get-pages-with-rels.js +107 -0
  108. package/lib/archive/db-ops/pages/read/get-pages.d.ts +11 -0
  109. package/lib/archive/db-ops/pages/read/get-pages.js +51 -0
  110. package/lib/archive/db-ops/pages/read/get-scraped-html-page-count.d.ts +18 -0
  111. package/lib/archive/db-ops/pages/read/get-scraped-html-page-count.js +25 -0
  112. package/lib/archive/db-ops/pages/read/reconstruct-page-rows.d.ts +31 -0
  113. package/lib/archive/db-ops/pages/read/reconstruct-page-rows.js +32 -0
  114. package/lib/archive/db-ops/pages/reset/repromote-external-pages.d.ts +24 -0
  115. package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +93 -0
  116. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +47 -0
  117. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +124 -0
  118. package/lib/archive/db-ops/pages/write/insert-inventory-seeds.d.ts +37 -0
  119. package/lib/archive/db-ops/pages/write/insert-inventory-seeds.js +72 -0
  120. package/lib/archive/db-ops/pages/write/insert-jsonld.d.ts +17 -0
  121. package/lib/archive/db-ops/pages/write/insert-jsonld.js +49 -0
  122. package/lib/archive/db-ops/pages/write/insert-page.d.ts +36 -0
  123. package/lib/archive/db-ops/pages/write/insert-page.js +208 -0
  124. package/lib/archive/db-ops/pages/write/insert-tags.d.ts +16 -0
  125. package/lib/archive/db-ops/pages/write/insert-tags.js +34 -0
  126. package/lib/archive/db-ops/pages/write/link-redirect-sources.d.ts +36 -0
  127. package/lib/archive/db-ops/pages/write/link-redirect-sources.js +93 -0
  128. package/lib/archive/db-ops/pages/write/record-redirect.d.ts +35 -0
  129. package/lib/archive/db-ops/pages/write/record-redirect.js +100 -0
  130. package/lib/archive/db-ops/pages/write/set-skipped-page.d.ts +13 -0
  131. package/lib/archive/db-ops/pages/write/set-skipped-page.js +22 -0
  132. package/lib/archive/db-ops/pages/write/update-page.d.ts +29 -0
  133. package/lib/archive/db-ops/pages/write/update-page.js +334 -0
  134. package/lib/archive/db-ops/pages/write/write-page-html-blob.d.ts +19 -0
  135. package/lib/archive/db-ops/pages/write/write-page-html-blob.js +41 -0
  136. package/lib/archive/db-ops/referrers/get-redirects-for-pages.d.ts +9 -0
  137. package/lib/archive/db-ops/referrers/get-redirects-for-pages.js +15 -0
  138. package/lib/archive/db-ops/referrers/get-referrers-of-page.d.ts +17 -0
  139. package/lib/archive/db-ops/referrers/get-referrers-of-page.js +32 -0
  140. package/lib/archive/db-ops/referrers/get-referrers-of-resource.d.ts +8 -0
  141. package/lib/archive/db-ops/referrers/get-referrers-of-resource.js +15 -0
  142. package/lib/archive/db-ops/resources/build-resource-query.d.ts +25 -0
  143. package/lib/archive/db-ops/resources/build-resource-query.js +29 -0
  144. package/lib/archive/db-ops/resources/get-existing-resource-urls.d.ts +9 -0
  145. package/lib/archive/db-ops/resources/get-existing-resource-urls.js +24 -0
  146. package/lib/archive/db-ops/resources/get-resource-by-url.d.ts +13 -0
  147. package/lib/archive/db-ops/resources/get-resource-by-url.js +22 -0
  148. package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +9 -0
  149. package/lib/archive/db-ops/resources/get-resource-url-list.js +13 -0
  150. package/lib/archive/db-ops/resources/get-resources.d.ts +8 -0
  151. package/lib/archive/db-ops/resources/get-resources.js +11 -0
  152. package/lib/archive/db-ops/resources/insert-inventory-resources.d.ts +24 -0
  153. package/lib/archive/db-ops/resources/insert-inventory-resources.js +64 -0
  154. package/lib/archive/db-ops/resources/insert-resource-referrers.d.ts +15 -0
  155. package/lib/archive/db-ops/resources/insert-resource-referrers.js +54 -0
  156. package/lib/archive/db-ops/resources/insert-resource.d.ts +34 -0
  157. package/lib/archive/db-ops/resources/insert-resource.js +73 -0
  158. package/lib/archive/db-ops/resources/reconstruct-resource-rows.d.ts +26 -0
  159. package/lib/archive/db-ops/resources/reconstruct-resource-rows.js +30 -0
  160. package/lib/archive/decode-html-blob.d.ts +18 -0
  161. package/lib/archive/decode-html-blob.js +31 -0
  162. package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
  163. package/lib/archive/derive-lineage-from-parent.js +42 -0
  164. package/lib/archive/drop-legacy-tables.d.ts +45 -0
  165. package/lib/archive/drop-legacy-tables.js +56 -0
  166. package/lib/archive/filesystem/rename.js +1 -1
  167. package/lib/archive/get-failed-page-messages.d.ts +44 -0
  168. package/lib/archive/get-failed-page-messages.js +132 -0
  169. package/lib/archive/init-schema.d.ts +35 -39
  170. package/lib/archive/init-schema.js +111 -320
  171. package/lib/archive/is-inventory-source.d.ts +21 -0
  172. package/lib/archive/is-inventory-source.js +22 -0
  173. package/lib/archive/limited-page-ids.d.ts +2 -1
  174. package/lib/archive/limited-page-ids.js +5 -4
  175. package/lib/archive/meta/assert-compatible-version.d.ts +24 -3
  176. package/lib/archive/meta/assert-compatible-version.js +24 -3
  177. package/lib/archive/meta/types.d.ts +87 -1
  178. package/lib/archive/meta/types.js +34 -2
  179. package/lib/archive/migrate-entity-tables.d.ts +45 -0
  180. package/lib/archive/migrate-entity-tables.js +56 -0
  181. package/lib/archive/migrate-ref-tables.d.ts +25 -0
  182. package/lib/archive/migrate-ref-tables.js +38 -0
  183. package/lib/archive/page-meta-column-maps.d.ts +32 -0
  184. package/lib/archive/page-meta-column-maps.js +43 -0
  185. package/lib/archive/page.d.ts +6 -6
  186. package/lib/archive/page.js +5 -5
  187. package/lib/archive/peek-archive-lock.d.ts +2 -2
  188. package/lib/archive/peek-archive-lock.js +2 -2
  189. package/lib/archive/populate-entity-tables/collapse-anchor-rows.d.ts +41 -0
  190. package/lib/archive/populate-entity-tables/collapse-anchor-rows.js +87 -0
  191. package/lib/archive/populate-entity-tables/derive-dom-path.d.ts +35 -0
  192. package/lib/archive/populate-entity-tables/derive-dom-path.js +72 -0
  193. package/lib/archive/populate-entity-tables/is-blob-ref-value.d.ts +16 -0
  194. package/lib/archive/populate-entity-tables/is-blob-ref-value.js +19 -0
  195. package/lib/archive/populate-entity-tables/match-images-to-dom-paths.d.ts +66 -0
  196. package/lib/archive/populate-entity-tables/match-images-to-dom-paths.js +96 -0
  197. package/lib/archive/populate-entity-tables/populate-anchor-edges.d.ts +33 -0
  198. package/lib/archive/populate-entity-tables/populate-anchor-edges.js +153 -0
  199. package/lib/archive/populate-entity-tables/populate-content-items.d.ts +40 -0
  200. package/lib/archive/populate-entity-tables/populate-content-items.js +141 -0
  201. package/lib/archive/populate-entity-tables/populate-entities.d.ts +81 -0
  202. package/lib/archive/populate-entity-tables/populate-entities.js +111 -0
  203. package/lib/archive/populate-entity-tables/populate-image-items.d.ts +91 -0
  204. package/lib/archive/populate-entity-tables/populate-image-items.js +223 -0
  205. package/lib/archive/populate-entity-tables/populate-page-meta.d.ts +33 -0
  206. package/lib/archive/populate-entity-tables/populate-page-meta.js +267 -0
  207. package/lib/archive/populate-entity-tables/populate-resource-items.d.ts +22 -0
  208. package/lib/archive/populate-entity-tables/populate-resource-items.js +114 -0
  209. package/lib/archive/populate-entity-tables/populate-resource-ref-edges.d.ts +31 -0
  210. package/lib/archive/populate-entity-tables/populate-resource-ref-edges.js +33 -0
  211. package/lib/archive/populate-entity-tables/resolve-blob-refs.d.ts +31 -0
  212. package/lib/archive/populate-entity-tables/resolve-blob-refs.js +100 -0
  213. package/lib/archive/populate-entity-tables/resolve-content-type-refs.d.ts +22 -0
  214. package/lib/archive/populate-entity-tables/resolve-content-type-refs.js +27 -0
  215. package/lib/archive/populate-entity-tables/resolve-header-sets.d.ts +49 -0
  216. package/lib/archive/populate-entity-tables/resolve-header-sets.js +122 -0
  217. package/lib/archive/populate-entity-tables/resolve-json-refs.d.ts +25 -0
  218. package/lib/archive/populate-entity-tables/resolve-json-refs.js +67 -0
  219. package/lib/archive/populate-entity-tables/resolve-text-refs.d.ts +30 -0
  220. package/lib/archive/populate-entity-tables/resolve-text-refs.js +61 -0
  221. package/lib/archive/populate-entity-tables/resolve-url-or-blob-from-maps.d.ts +21 -0
  222. package/lib/archive/populate-entity-tables/resolve-url-or-blob-from-maps.js +27 -0
  223. package/lib/archive/populate-entity-tables/resolve-url-refs.d.ts +33 -0
  224. package/lib/archive/populate-entity-tables/resolve-url-refs.js +60 -0
  225. package/lib/archive/populate-entity-tables/test-utils/count-rows.d.ts +17 -0
  226. package/lib/archive/populate-entity-tables/test-utils/count-rows.js +20 -0
  227. package/lib/archive/populate-entity-tables/test-utils/seed-content-items.d.ts +25 -0
  228. package/lib/archive/populate-entity-tables/test-utils/seed-content-items.js +42 -0
  229. package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.d.ts +23 -0
  230. package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.js +178 -0
  231. package/lib/archive/populate-entity-tables/types.d.ts +157 -0
  232. package/lib/archive/populate-entity-tables/types.js +12 -0
  233. package/lib/archive/populate-entity-tables/upsert-text-refs.d.ts +38 -0
  234. package/lib/archive/populate-entity-tables/upsert-text-refs.js +78 -0
  235. package/lib/archive/populate-ref-tables/classify-content-type.d.ts +16 -0
  236. package/lib/archive/populate-ref-tables/classify-content-type.js +52 -0
  237. package/lib/archive/populate-ref-tables/compute-content-hash.d.ts +22 -0
  238. package/lib/archive/populate-ref-tables/compute-content-hash.js +26 -0
  239. package/lib/archive/populate-ref-tables/compute-header-flags.d.ts +16 -0
  240. package/lib/archive/populate-ref-tables/compute-header-flags.js +70 -0
  241. package/lib/archive/populate-ref-tables/content-type-rules.d.ts +38 -0
  242. package/lib/archive/populate-ref-tables/content-type-rules.js +133 -0
  243. package/lib/archive/populate-ref-tables/create-header-table-caches.d.ts +25 -0
  244. package/lib/archive/populate-ref-tables/create-header-table-caches.js +49 -0
  245. package/lib/archive/populate-ref-tables/data-uri-url-refs-limit.d.ts +15 -0
  246. package/lib/archive/populate-ref-tables/data-uri-url-refs-limit.js +15 -0
  247. package/lib/archive/populate-ref-tables/decode-data-uri.d.ts +21 -0
  248. package/lib/archive/populate-ref-tables/decode-data-uri.js +126 -0
  249. package/lib/archive/populate-ref-tables/decompose-header-set.d.ts +29 -0
  250. package/lib/archive/populate-ref-tables/decompose-header-set.js +157 -0
  251. package/lib/archive/populate-ref-tables/decompose-url.d.ts +25 -0
  252. package/lib/archive/populate-ref-tables/decompose-url.js +70 -0
  253. package/lib/archive/populate-ref-tables/header-stability.d.ts +19 -0
  254. package/lib/archive/populate-ref-tables/header-stability.js +22 -0
  255. package/lib/archive/populate-ref-tables/header-value-cache-key.d.ts +17 -0
  256. package/lib/archive/populate-ref-tables/header-value-cache-key.js +19 -0
  257. package/lib/archive/populate-ref-tables/normalize-mime.d.ts +24 -0
  258. package/lib/archive/populate-ref-tables/normalize-mime.js +36 -0
  259. package/lib/archive/populate-ref-tables/populate-blob-refs.d.ts +38 -0
  260. package/lib/archive/populate-ref-tables/populate-blob-refs.js +134 -0
  261. package/lib/archive/populate-ref-tables/populate-content-type-refs.d.ts +27 -0
  262. package/lib/archive/populate-ref-tables/populate-content-type-refs.js +70 -0
  263. package/lib/archive/populate-ref-tables/populate-header-tables.d.ts +35 -0
  264. package/lib/archive/populate-ref-tables/populate-header-tables.js +80 -0
  265. package/lib/archive/populate-ref-tables/populate-json-refs.d.ts +29 -0
  266. package/lib/archive/populate-ref-tables/populate-json-refs.js +101 -0
  267. package/lib/archive/populate-ref-tables/populate-refs.d.ts +51 -0
  268. package/lib/archive/populate-ref-tables/populate-refs.js +62 -0
  269. package/lib/archive/populate-ref-tables/populate-text-refs.d.ts +32 -0
  270. package/lib/archive/populate-ref-tables/populate-text-refs.js +133 -0
  271. package/lib/archive/populate-ref-tables/populate-url-refs.d.ts +28 -0
  272. package/lib/archive/populate-ref-tables/populate-url-refs.js +148 -0
  273. package/lib/archive/populate-ref-tables/test-utils/count-rows.d.ts +15 -0
  274. package/lib/archive/populate-ref-tables/test-utils/count-rows.js +17 -0
  275. package/lib/archive/populate-ref-tables/types.d.ts +197 -0
  276. package/lib/archive/populate-ref-tables/types.js +7 -0
  277. package/lib/archive/populate-ref-tables/upsert-one-header-set.d.ts +34 -0
  278. package/lib/archive/populate-ref-tables/upsert-one-header-set.js +208 -0
  279. package/lib/archive/populate-ref-tables/volatile-header-names.d.ts +20 -0
  280. package/lib/archive/populate-ref-tables/volatile-header-names.js +33 -0
  281. package/lib/archive/redirect-table.d.ts +4 -2
  282. package/lib/archive/redirect-table.js +15 -10
  283. package/lib/archive/resolve-redirect-chain.d.ts +3 -3
  284. package/lib/archive/resolve-redirect-chain.js +2 -2
  285. package/lib/archive/resource.d.ts +1 -1
  286. package/lib/archive/retarget-legacy-fk-tables.d.ts +47 -0
  287. package/lib/archive/retarget-legacy-fk-tables.js +107 -0
  288. package/lib/archive/test-utils/fk-parent-tables.d.ts +15 -0
  289. package/lib/archive/test-utils/fk-parent-tables.js +19 -0
  290. package/lib/archive/test-utils/seed-content-item.d.ts +35 -0
  291. package/lib/archive/test-utils/seed-content-item.js +42 -0
  292. package/lib/archive/test-utils/setup-legacy-fk-db.d.ts +33 -0
  293. package/lib/archive/test-utils/setup-legacy-fk-db.js +270 -0
  294. package/lib/archive/types.d.ts +151 -15
  295. package/lib/archive/verify-migration/capture-rejection.d.ts +24 -0
  296. package/lib/archive/verify-migration/capture-rejection.js +31 -0
  297. package/lib/archive/verify-migration/check-anchor-edges-count.d.ts +34 -0
  298. package/lib/archive/verify-migration/check-anchor-edges-count.js +72 -0
  299. package/lib/archive/verify-migration/check-anchor-edges-sum.d.ts +13 -0
  300. package/lib/archive/verify-migration/check-anchor-edges-sum.js +27 -0
  301. package/lib/archive/verify-migration/check-content-items-count.d.ts +16 -0
  302. package/lib/archive/verify-migration/check-content-items-count.js +30 -0
  303. package/lib/archive/verify-migration/check-content-type-preservation.d.ts +22 -0
  304. package/lib/archive/verify-migration/check-content-type-preservation.js +40 -0
  305. package/lib/archive/verify-migration/check-foreign-key-integrity.d.ts +31 -0
  306. package/lib/archive/verify-migration/check-foreign-key-integrity.js +47 -0
  307. package/lib/archive/verify-migration/check-image-items-count.d.ts +12 -0
  308. package/lib/archive/verify-migration/check-image-items-count.js +26 -0
  309. package/lib/archive/verify-migration/check-page-meta-count.d.ts +15 -0
  310. package/lib/archive/verify-migration/check-page-meta-count.js +31 -0
  311. package/lib/archive/verify-migration/check-reader-parity.d.ts +23 -0
  312. package/lib/archive/verify-migration/check-reader-parity.js +211 -0
  313. package/lib/archive/verify-migration/check-resource-items-count.d.ts +17 -0
  314. package/lib/archive/verify-migration/check-resource-items-count.js +33 -0
  315. package/lib/archive/verify-migration/check-url-round-trip.d.ts +43 -0
  316. package/lib/archive/verify-migration/check-url-round-trip.js +112 -0
  317. package/lib/archive/verify-migration/types.d.ts +70 -0
  318. package/lib/archive/verify-migration/types.js +63 -0
  319. package/lib/archive/verify-migration/verify-migration.d.ts +41 -0
  320. package/lib/archive/verify-migration/verify-migration.js +120 -0
  321. package/lib/classify-error-kind.d.ts +19 -0
  322. package/lib/classify-error-kind.js +122 -0
  323. package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
  324. package/lib/crawler/build-js-redirect-edge.js +57 -0
  325. package/lib/crawler/build-redirect-event.d.ts +24 -0
  326. package/lib/crawler/build-redirect-event.js +28 -0
  327. package/lib/crawler/capture-image-dom-paths.d.ts +33 -0
  328. package/lib/crawler/capture-image-dom-paths.js +39 -0
  329. package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
  330. package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
  331. package/lib/crawler/collect-image-dom-paths.d.ts +23 -0
  332. package/lib/crawler/collect-image-dom-paths.js +64 -0
  333. package/lib/crawler/crawler.d.ts +22 -1
  334. package/lib/crawler/crawler.js +679 -117
  335. package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
  336. package/lib/crawler/derive-js-redirect-target.js +129 -0
  337. package/lib/crawler/derive-resource-source.d.ts +25 -15
  338. package/lib/crawler/derive-resource-source.js +28 -17
  339. package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
  340. package/lib/crawler/dns-burned-host-cache.js +25 -0
  341. package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
  342. package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
  343. package/lib/crawler/fetch-destination.d.ts +12 -4
  344. package/lib/crawler/fetch-destination.js +94 -16
  345. package/lib/crawler/inject-scope-auth.d.ts +1 -1
  346. package/lib/crawler/inject-scope-auth.js +1 -1
  347. package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
  348. package/lib/crawler/is-js-redirect-error-shape.js +53 -0
  349. package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
  350. package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
  351. package/lib/crawler/link-list.d.ts +21 -1
  352. package/lib/crawler/link-list.js +23 -3
  353. package/lib/crawler/normalize-content-type.d.ts +1 -1
  354. package/lib/crawler/normalize-content-type.js +1 -1
  355. package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
  356. package/lib/crawler/plan-sub-resource-emits.js +44 -0
  357. package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
  358. package/lib/crawler/preload-short-circuit-error.js +25 -0
  359. package/lib/crawler/should-burn-host.d.ts +78 -0
  360. package/lib/crawler/should-burn-host.js +61 -0
  361. package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
  362. package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
  363. package/lib/crawler/types.d.ts +108 -1
  364. package/lib/crawler-orchestrator.d.ts +22 -3
  365. package/lib/crawler-orchestrator.js +312 -73
  366. package/lib/crawler.d.ts +15 -2
  367. package/lib/crawler.js +24 -1
  368. package/lib/permanent-error-kinds.d.ts +43 -0
  369. package/lib/permanent-error-kinds.js +48 -0
  370. package/lib/types.d.ts +84 -0
  371. package/lib/utils/compute-file-sha256.d.ts +24 -0
  372. package/lib/utils/compute-file-sha256.js +56 -0
  373. package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
  374. package/lib/utils/error/emit-error-with-retry.js +44 -0
  375. package/lib/utils/error/emit-error.d.ts +39 -0
  376. package/lib/utils/error/emit-error.js +41 -0
  377. package/package.json +12 -12
  378. package/lib/archive/migrate-crawl-errors.d.ts +0 -20
  379. package/lib/archive/migrate-crawl-errors.js +0 -38
  380. package/lib/archive/migrate-html-blob-tables.d.ts +0 -24
  381. package/lib/archive/migrate-html-blob-tables.js +0 -53
  382. package/lib/archive/migrate-page-errors.d.ts +0 -16
  383. package/lib/archive/migrate-page-errors.js +0 -35
  384. package/lib/archive/migrate-pages-resources-source.d.ts +0 -16
  385. package/lib/archive/migrate-pages-resources-source.js +0 -46
  386. package/lib/utils/error/error-emitter.d.ts +0 -18
  387. package/lib/utils/error/error-emitter.js +0 -29
@@ -3,16 +3,22 @@ import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import Scraper from '@d-zero/beholder';
5
5
  import { deal } from '@d-zero/dealer';
6
+ import { delay } from '@d-zero/shared/delay';
6
7
  import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
7
8
  import { retryCall } from '@d-zero/shared/retry';
8
9
  import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
9
10
  import c from 'ansi-colors';
10
11
  import pkg from '../../package.json' with { type: 'json' };
12
+ import { classifyErrorKind } from '../classify-error-kind.js';
11
13
  import { crawlerLog } from '../debug.js';
14
+ import { buildJsRedirectEdge } from './build-js-redirect-edge.js';
15
+ import { buildRedirectEvent } from './build-redirect-event.js';
16
+ import { captureImageDomPaths } from './capture-image-dom-paths.js';
12
17
  import { createChangePhaseHandler } from './create-change-phase-handler.js';
13
18
  import { derivePageSource } from './derive-page-source.js';
14
- import { deriveResourceSource } from './derive-resource-source.js';
15
19
  import { detectPaginationPattern } from './detect-pagination-pattern.js';
20
+ import { dnsBurnedHostCache } from './dns-burned-host-cache.js';
21
+ import { dnsBurnedHostShortCircuitCounter } from './dns-burned-host-short-circuit-counter.js';
16
22
  import { drainPhaseErrors } from './drain-phase-errors.js';
17
23
  import { fetchDestination } from './fetch-destination.js';
18
24
  import { findScopeEntry } from './find-scope-entry.js';
@@ -20,21 +26,36 @@ import { formatCrawlProgress } from './format-crawl-progress.js';
20
26
  import { generatePredictedUrls } from './generate-predicted-urls.js';
21
27
  import { handleBrowserClose } from './handle-browser-close.js';
22
28
  import { handleIgnoreAndSkip } from './handle-ignore-and-skip.js';
23
- import { handleResourceResponse } from './handle-resource-response.js';
24
29
  import { handleScrapeEnd } from './handle-scrape-end.js';
25
30
  import { handleScrapeError } from './handle-scrape-error.js';
26
31
  import { injectScopeAuth } from './inject-scope-auth.js';
27
32
  import { isHtmlContentType } from './is-html-content-type.js';
33
+ import { isLikelyHtmlUrl } from './is-likely-html-url.js';
34
+ import { isPuppeteerFallbackCandidate } from './is-puppeteer-fallback-candidate.js';
28
35
  import LinkList from './link-list.js';
29
36
  import { linkToPageData } from './link-to-page-data.js';
30
37
  import { logUndrainedPhaseErrors } from './log-undrained-phase-errors.js';
31
38
  import { partitionUrlsByHtml } from './partition-urls-by-html.js';
39
+ import { planSubResourceEmits } from './plan-sub-resource-emits.js';
40
+ import { PreloadShortCircuitError } from './preload-short-circuit-error.js';
32
41
  import { protocolAgnosticKey } from './protocol-agnostic-key.js';
33
42
  import { redirectDestKey } from './redirect-dest-key.js';
34
43
  import { resourceToPageData } from './resource-to-page-data.js';
35
44
  import { RobotsChecker } from './robots-checker.js';
45
+ import { shouldBurnHost } from './should-burn-host.js';
36
46
  import { shouldDiscardPredicted } from './should-discard-predicted.js';
37
47
  import { shouldSkipUrl } from './should-skip-url.js';
48
+ /**
49
+ * Per-attempt HEAD pre-flight timeouts in milliseconds.
50
+ *
51
+ * `retryCall` re-invokes the work function up to `retry + 1` times; we keep
52
+ * the first attempt short so a fast healthy site never pays the slow-server
53
+ * tax, then escalate so that a slow-but-eventually-responsive host gets a
54
+ * larger budget on retry. The attempt index is clamped to the last element
55
+ * of the array, so configurations with `retry > escalation.length - 1` just
56
+ * stay on the final (longest) timeout for any additional attempts.
57
+ */
58
+ const HEAD_TIMEOUT_ESCALATION_MS = [10_000, 30_000, 60_000];
38
59
  /**
39
60
  * The core crawler engine that discovers and scrapes web pages.
40
61
  *
@@ -54,7 +75,7 @@ class Crawler extends EventEmitter {
54
75
  /** Merged crawler configuration (user overrides + defaults). */
55
76
  #options;
56
77
  /**
57
- * Phase errors observed during {@link Crawler.#launchBrowserAndScrape},
78
+ * Phase errors observed during {@link Crawler._launchBrowserAndScrape},
58
79
  * buffered per URL href so they can be emitted as `pageError` events
59
80
  * AFTER the corresponding `page` / `externalPage` event. This ordering
60
81
  * lets the orchestrator's WriteQueue serialise `setPage` before
@@ -80,6 +101,20 @@ class Crawler extends EventEmitter {
80
101
  * Keyed by {@link redirectDestKey}. Reset at the start of {@link #runDeal}.
81
102
  */
82
103
  #scrapedDestinations = new Set();
104
+ /**
105
+ * Lower-cased hostnames for which at least one URL has returned an
106
+ * HTTP response (any status) via `fetchDestination` in this session.
107
+ * Consulted by {@link shouldBurnHost} as the cascade guard against
108
+ * "transient local DNS hiccup wipes out a healthy host": a host that
109
+ * responded earlier is treated as still alive even when the next URL on
110
+ * it exhausts retries with a `getaddrinfo ENOTFOUND`, since the most
111
+ * likely cause is the operator's resolver flipping mid-crawl rather than
112
+ * the host suddenly disappearing. Populated by {@link #sendHeadRequest}
113
+ * on the success path; reset at the start of {@link #runDeal} alongside
114
+ * {@link #scrapedDestinations} so a fresh session does not inherit
115
+ * stale liveness assumptions.
116
+ */
117
+ #successfulHosts = new Set();
83
118
  /**
84
119
  * The AbortSignal associated with this crawler's AbortController.
85
120
  *
@@ -115,6 +150,7 @@ class Crawler extends EventEmitter {
115
150
  userAgent: options?.userAgent || `Nitpicker/${pkg.version}`,
116
151
  ignoreRobots: options?.ignoreRobots ?? false,
117
152
  lookupResource: options?.lookupResource ?? null,
153
+ lookupPageSource: options?.lookupPageSource ?? null,
118
154
  inventoryMode: options?.inventoryMode ?? null,
119
155
  };
120
156
  this.#robotsChecker = new RobotsChecker(this.#options.userAgent, !this.#options.ignoreRobots);
@@ -180,21 +216,27 @@ class Crawler extends EventEmitter {
180
216
  * before reaching the dealer so a URL that exists in both sources — which
181
217
  * is common in append-mode when a new root coincides with a repromoted
182
218
  * previously-external page — does not race on two parallel slots.
183
- * @param urls - The list of root URLs to begin crawling from. Must be non-empty.
219
+ * @param urls - The list of root URLs to begin crawling from. May be empty
220
+ * when resumed pending URLs already exist (for example `--retry-failed`).
184
221
  * @param opts - Optional overrides; currently only `recursive` is honoured.
185
222
  * @param opts.recursive - When `false`, disables recursive discovery and forces list-mode.
186
223
  * Defaults to the constructor option's `recursive` value.
187
224
  * @throws {Error} If the URL list is empty.
188
225
  */
189
226
  start(urls, opts) {
190
- const root = urls[0];
191
- if (!root) {
192
- throw new Error('urls is empty');
193
- }
227
+ // Inventory mode pre-loads tens of thousands of seed URLs that all
228
+ // fall under archived `roots` (already populated into `#scope` by
229
+ // the constructor). Adding each seed as its own scope entry was
230
+ // O(N²) on build (per-host `existing.some` + array spread) AND
231
+ // turned every later `findScopeEntry` into a 70k linear scan. Skip
232
+ // the scope add — seeds remain entry points via `#linkList`.
233
+ const skipScopeAdd = this.#options.inventoryMode != null;
194
234
  for (const url of urls) {
195
- const existing = this.#scope.get(url.hostname) || [];
196
- if (!existing.some((u) => u.href === url.href)) {
197
- this.#scope.set(url.hostname, [...existing, url]);
235
+ if (!skipScopeAdd) {
236
+ const existing = this.#scope.get(url.hostname) || [];
237
+ if (!existing.some((u) => u.href === url.href)) {
238
+ this.#scope.set(url.hostname, [...existing, url]);
239
+ }
198
240
  }
199
241
  this.#linkList.add(url);
200
242
  }
@@ -223,13 +265,17 @@ class Crawler extends EventEmitter {
223
265
  seenInitial.add(key);
224
266
  initialUrls.push(url);
225
267
  }
268
+ const root = initialUrls[0];
269
+ if (!root) {
270
+ if (isResuming) {
271
+ crawlerLog('Crawl End (nothing to resume)');
272
+ void this.emit('crawlEnd', {});
273
+ return;
274
+ }
275
+ throw new Error('urls is empty');
276
+ }
226
277
  const resumeOffset = this.#resumedScraped.length;
227
278
  const pagesScrapedOffset = this.#resumedPagesScraped;
228
- if (initialUrls.length === 0) {
229
- crawlerLog('Crawl End (nothing to resume)');
230
- void this.emit('crawlEnd', {});
231
- return;
232
- }
233
279
  void this.#runDeal(initialUrls, resumeOffset, pagesScrapedOffset).catch((error) => {
234
280
  crawlerLog('runDeal error: %O', error);
235
281
  this.#emitDealErrors(error, root.href);
@@ -281,25 +327,22 @@ class Crawler extends EventEmitter {
281
327
  * Processes captured sub-resources from a page scrape, deduplicates them,
282
328
  * and emits `response` / `responseReferrers` events for new resources.
283
329
  * @param resources - Sub-resource entries captured during the page load
330
+ * @param parentSource
284
331
  */
285
- #handleResources(resources) {
286
- // `deriveResourceSource` encodes the "sub-resources are never seeds"
287
- // rule and stays in lockstep with `derivePageSource` if PageSource
288
- // gains new variants. Computed once outside the loop because the
289
- // inventoryMode reference does not change mid-batch.
290
- const subResourceSource = deriveResourceSource(this.#options.inventoryMode);
291
- for (const { resource, pageUrl } of resources) {
292
- const { isNew } = handleResourceResponse(resource, this.#resources);
293
- if (isNew) {
294
- void this.emit('response', {
295
- resource: resource,
296
- source: subResourceSource,
297
- });
298
- }
299
- void this.emit('responseReferrers', {
300
- url: pageUrl,
301
- src: resource.url.withoutHash,
302
- });
332
+ #handleResources(resources, parentSource) {
333
+ // Decide the full emit plan first via the pure planner that lets
334
+ // the lineage propagation contract (parent source sub-resource
335
+ // `source`) be unit-tested in `plan-sub-resource-emits.spec.ts`
336
+ // without spinning up the puppeteer stack here. The previous
337
+ // inline shape made the `source` value invisible to tests because
338
+ // emit() side effects were only observable via a full scrape run
339
+ // that requires a mocked Chromium instance.
340
+ const { responseEmits, referrerEmits } = planSubResourceEmits(resources, parentSource, this.#resources);
341
+ for (const payload of responseEmits) {
342
+ void this.emit('response', payload);
343
+ }
344
+ for (const payload of referrerEmits) {
345
+ void this.emit('responseReferrers', payload);
303
346
  }
304
347
  }
305
348
  /**
@@ -428,84 +471,47 @@ class Crawler extends EventEmitter {
428
471
  }
429
472
  }
430
473
  /**
431
- * Launches a fresh Puppeteer browser, runs the beholder scraper, and cleans up.
474
+ * Resolve the source label of the page being scraped so sub-resources
475
+ * captured during its render can inherit the correct lineage label
476
+ * (`'inventory-discovered'` when the parent is in the inventory chain,
477
+ * `undefined` otherwise so the DB DEFAULT `'crawled'` lands).
432
478
  *
433
- * WHY per-URL browser: Each URL gets its own browser instance to ensure
434
- * complete isolation (cookies, cache, service workers). The browser is always
435
- * closed in the `finally` block, even on error.
436
- * @param url - Target URL to scrape
437
- * @param update - Callback for progress messages
438
- * @param isExternal - Whether the URL is external to the crawl scope
439
- * @param metadataOnly - When true, only extract title metadata
440
- * @param headCheckResult - Optional HEAD result to pass to the scraper, avoiding a redundant request
441
- * @returns The scrape result from beholder
479
+ * Two-stage resolution:
480
+ *
481
+ * 1. If `inventoryMode` is active (live `--inventory` session), use
482
+ * `derivePageSource` directly the in-memory seed set is the
483
+ * authoritative answer and no DB round-trip is needed.
484
+ *
485
+ * 2. Otherwise (`--resume`, `--retry-failed`, `--append`, or a normal
486
+ * `crawl` of a previously-inventoried archive), ask the injected
487
+ * `lookupPageSource` callback. The orchestrator wires that callback
488
+ * to `Archive.getPageSourceByUrl` so the parent's lineage from
489
+ * earlier sessions survives across sessions.
490
+ *
491
+ * One round-trip per page render at most — the result is not memoised
492
+ * because each worker scrapes a single page per `#scrapePage` call
493
+ * and the cost is amortised across every sub-resource of that page.
494
+ * @param url - The URL of the page being scraped.
495
+ * @returns The parent page's source, or `undefined` when none applies.
442
496
  */
443
- async #launchBrowserAndScrape(url, update, isExternal, metadataOnly, headCheckResult) {
444
- update('Launching browser%dots%');
445
- if (this.#options.executablePath) {
446
- const execPath = path.resolve(this.#options.executablePath);
447
- if (!existsSync(execPath)) {
448
- throw new Error(`Executable path does not exist: ${execPath}`);
449
- }
497
+ async #resolveParentSource(url) {
498
+ const fromInventoryMode = derivePageSource(this.#options.inventoryMode, url.withoutHashAndAuth);
499
+ if (fromInventoryMode !== undefined) {
500
+ return fromInventoryMode;
501
+ }
502
+ const lookupPageSource = this.#options.lookupPageSource;
503
+ if (!lookupPageSource) {
504
+ return undefined;
450
505
  }
451
- const puppeteer = await import('puppeteer');
452
- const browser = await puppeteer.launch({
453
- headless: true,
454
- ...(this.#options.executablePath
455
- ? { executablePath: this.#options.executablePath }
456
- : {}),
457
- });
458
506
  try {
459
- update('Creating page%dots%');
460
- const page = await browser.newPage();
461
- await page.setUserAgent(this.#options.userAgent);
462
- // Defence-in-depth: beholder sets Authorization via setExtraHTTPHeaders,
463
- // but page.authenticate() handles Chromium-level HTTP auth challenges
464
- // (401 + WWW-Authenticate) that setExtraHTTPHeaders cannot cover.
465
- if (url.username && url.password) {
466
- await page.authenticate({
467
- username: url.username,
468
- password: url.password,
469
- });
470
- }
471
- const scraper = new Scraper();
472
- scraper.on('changePhase', createChangePhaseHandler({
473
- emit: (event) => void this.emit('changePhase', event),
474
- update,
475
- formatLog: formatPhaseLog,
476
- buffer: this.#pendingPhaseErrors,
477
- urlHref: url.href,
478
- }));
479
- const result = await scraper.scrapeStart(page, url, {
480
- isExternal,
481
- captureImages: !isExternal && this.#options.captureImages,
482
- excludeKeywords: this.#options.excludeKeywords,
483
- disableQueries: this.#options.disableQueries,
484
- metadataOnly,
485
- retries: this.#options.retry,
486
- headCheckResult,
487
- });
488
- update('Closing browser%dots%');
489
- return result;
507
+ return await lookupPageSource(url.withoutHashAndAuth);
490
508
  }
491
509
  catch (error) {
492
- return {
493
- type: 'error',
494
- resources: [],
495
- error: {
496
- name: error instanceof Error ? error.name : 'Error',
497
- message: error instanceof Error ? error.message : String(error),
498
- stack: error instanceof Error ? error.stack : undefined,
499
- shutdown: true,
500
- },
501
- };
502
- }
503
- finally {
504
- // handleBrowserClose force-kills the underlying Chromium when a
505
- // graceful close() hangs (e.g. the session died mid-scrape) and
506
- // guarantees the finally never throws, so the try-block's return
507
- // value or caught error is never masked.
508
- await handleBrowserClose(browser, url.href, crawlerLog);
510
+ // A lookup failure must never be worse than not having lineage
511
+ // — fall back to undefined so the sub-resources land at the DB
512
+ // DEFAULT `'crawled'` rather than crashing the whole worker.
513
+ crawlerLog('Parent source lookup failed for %s: %O', url.href, error);
514
+ return undefined;
509
515
  }
510
516
  }
511
517
  /**
@@ -530,6 +536,10 @@ class Crawler extends EventEmitter {
530
536
  }
531
537
  // Redirect-destination dedup is per-crawl; clear any state from a prior run.
532
538
  this.#scrapedDestinations.clear();
539
+ // Session-liveness signal is per-crawl too; clear so a fresh session
540
+ // does not inherit "host alive" claims from a prior run that may have
541
+ // happened on an entirely different network.
542
+ this.#successfulHosts.clear();
533
543
  // external URL の追跡(target は deal の total/done から導出)
534
544
  const externalUrls = new Set();
535
545
  const externalDoneUrls = new Set();
@@ -577,6 +587,20 @@ class Crawler extends EventEmitter {
577
587
  return Promise.all(ops).then(() => { });
578
588
  };
579
589
  return async () => {
590
+ // Interval delay is handled here instead of by dealer because
591
+ // DNS-burned hosts must skip the wait entirely. Spending the
592
+ // per-URL interval on a host the cache already knows is dead
593
+ // just slows the crawl down for zero benefit — the HEAD won't
594
+ // be fired and `Crawler.#sendHeadRequest` will throw the
595
+ // preload short-circuit immediately. For all other URLs, run
596
+ // the same `delay()` + `%countdown(...)` log that dealer would
597
+ // have emitted, so the dealer display reads identically.
598
+ const burned = dnsBurnedHostCache.has(url.hostname.toLowerCase());
599
+ if (!burned && this.#options.interval && this.#options.interval > 0) {
600
+ await delay(this.#options.interval, (determinedInterval) => {
601
+ update(`Waiting interval: %countdown(${determinedInterval},${_index}_interval)%ms`);
602
+ });
603
+ }
580
604
  const log = createTimedUpdate(update, this.#options.verbose);
581
605
  // `#scrapePage` 内のブラウザ HTML レンダーが成功したかをマークするフラグ。
582
606
  // 成功時のみ #scrapePage 側で true に設定される。
@@ -640,8 +664,63 @@ class Crawler extends EventEmitter {
640
664
  // path, where the first predicted source to a destination renders
641
665
  // it and is recorded as a redirect source the same way; only 404 /
642
666
  // error predicted URLs are dropped (by `shouldDiscardPredicted`).
643
- this.#linkList.done(url, this.#scope, { page: result.pageData }, this.#options);
644
- void this.emit('redirect', { result: result.pageData });
667
+ //
668
+ // The `source` discriminator divides this branch in two:
669
+ //
670
+ // - `'http-chain'` — the HEAD pre-flight resolved a real 3xx chain
671
+ // and the destination is already rendered (`#scrapedDestinations`
672
+ // claim). Every URL in `redirectPaths` is intermediate / known,
673
+ // so the existing behaviour applies: `linkList.done` folds the
674
+ // whole chain into the done-set so later references skip cleanly.
675
+ //
676
+ // - `'js-redirect'` — `scraper.scrapeStart` threw because
677
+ // `page.goto()` returned null (`window.location.replace()` /
678
+ // meta-refresh fired mid-navigation), and `redirectPaths`
679
+ // carries the single JS target Chromium ended up on. That target
680
+ // has NOT been rendered yet — it must enter the crawl queue, and
681
+ // `linkList.done` MUST NOT fold it into the done-set (otherwise
682
+ // the dealer's `seen` rejects the push and the destination is
683
+ // silently lost from the archive).
684
+ if (result.source === 'js-redirect') {
685
+ const destination = result.pageData.redirectPaths.at(-1);
686
+ if (destination) {
687
+ const destinationUrl = parseUrl(destination, this.#options);
688
+ if (destinationUrl) {
689
+ this.#linkList.add(destinationUrl);
690
+ void enqueue(destinationUrl);
691
+ }
692
+ else {
693
+ // `deriveJsRedirectTarget` already canonicalises
694
+ // via WHATWG URL parsing, so reaching the
695
+ // `parseUrl === null` branch here would mean
696
+ // `@d-zero/shared/parse-url` rejected what
697
+ // WHATWG accepted — unexpected, and silently
698
+ // dropping the destination would be a silent
699
+ // archive loss. Log it so DEBUG=Nitpicker:Crawler
700
+ // catches the case.
701
+ crawlerLog('JS-redirect destination %s failed to parse — dropping enqueue', destination);
702
+ }
703
+ }
704
+ else {
705
+ crawlerLog('JS-redirect result for %s had no redirectPaths destination — dropping enqueue', url.href);
706
+ }
707
+ this.#linkList.done(url, this.#scope, { page: result.pageData }, this.#options, { includeRedirectPaths: false });
708
+ }
709
+ else {
710
+ this.#linkList.done(url, this.#scope, { page: result.pageData }, this.#options);
711
+ }
712
+ // The redirect-edge call path may INSERT a brand-new
713
+ // destination row (js-redirect rescue, #73
714
+ // convergence on first sight). Forward the
715
+ // originating page's inventory provenance so the
716
+ // destination + intermediate hops inherit the
717
+ // chain's lineage instead of laundering to DB
718
+ // DEFAULT `'crawled'`. `inventoryMode === null`
719
+ // (resume / retry-failed) yields `undefined`,
720
+ // which is correct: the DB-side lookup in
721
+ // `#linkRedirectSources` reads the destination's
722
+ // stored source for those sessions.
723
+ void this.emit('redirect', buildRedirectEvent(result.pageData, this.#options.inventoryMode, url.withoutHashAndAuth));
645
724
  log(c.dim('Redirect (dest already scraped)'));
646
725
  return;
647
726
  }
@@ -660,7 +739,8 @@ class Crawler extends EventEmitter {
660
739
  }
661
740
  log('Saving results%dots%');
662
741
  this.#handleResult(result, url, enqueue, paginationState, concurrency);
663
- this.#handleResources(result.resources);
742
+ const parentSource = await this.#resolveParentSource(url);
743
+ this.#handleResources(result.resources, parentSource);
664
744
  log(formatResultSummary(result));
665
745
  // Phase errors must be emitted AFTER 'page' / 'externalPage'
666
746
  // so the orchestrator's WriteQueue sees `setPage` before
@@ -703,7 +783,10 @@ class Crawler extends EventEmitter {
703
783
  };
704
784
  }, {
705
785
  limit: concurrency,
706
- interval: this.#options.interval,
786
+ // Interval is applied per-URL inside the worker callback above so
787
+ // DNS-burned hosts can skip it. Letting dealer handle interval
788
+ // would run the wait before our short-circuit check fires.
789
+ interval: 0,
707
790
  verbose: this.#options.verbose || !process.stdout.isTTY,
708
791
  signal: this.#abortController.signal,
709
792
  header: (_progress, done, total, limit) => {
@@ -745,7 +828,7 @@ class Crawler extends EventEmitter {
745
828
  * @param metadataOnly - When true, only extract title metadata without full browser scraping
746
829
  * @param laneIndex - The dealer lane index, used to create unique countdown IDs
747
830
  * @param markBrowserScrape - Called once **after** the browser successfully
748
- * renders an HTML page (i.e. `#launchBrowserAndScrape` resolved with
831
+ * renders an HTML page (i.e. `_launchBrowserAndScrape` resolved with
749
832
  * `type: 'success'`). Not called for HEAD-only, title-only, captured-resource
750
833
  * reuse, non-HTML responses, non-HTTP protocols (mailto:, tel:), browser
751
834
  * launch throws (e.g. invalid executablePath), or scraper-returned
@@ -757,7 +840,7 @@ class Crawler extends EventEmitter {
757
840
  const isExternal = findScopeEntry(url, this.#scope, this.#options) === null;
758
841
  // Non-HTTP protocols (mailto:, tel:, etc.) — let the scraper handle early return
759
842
  if (!url.isHTTP) {
760
- return this.#launchBrowserAndScrape(url, update, isExternal, metadataOnly);
843
+ return this._launchBrowserAndScrape(url, update, isExternal, metadataOnly);
761
844
  }
762
845
  // Reuse captured resource data — when this URL was already observed as a
763
846
  // sub-resource during page rendering, its response data is recorded and
@@ -801,6 +884,131 @@ class Crawler extends EventEmitter {
801
884
  headCheckResult = await this.#sendHeadRequest(url, isExternal, update, laneIndex);
802
885
  }
803
886
  catch (error) {
887
+ const errorMessage = error instanceof Error ? error.message : String(error);
888
+ // Puppeteer-only fallback: when the HEAD pre-flight (and its GET
889
+ // companion inside `fetchDestination`) exhaust retries on what
890
+ // looks like an HTML URL, give the browser exactly one chance
891
+ // before recording the page as `status = -1`. Some middleboxes /
892
+ // WAF configurations drop bare HEAD/GET probes (parse-error,
893
+ // reset, silent timeout) while still answering a real puppeteer
894
+ // navigation; those URLs would otherwise be permanently lost.
895
+ //
896
+ // Restricted to non-metadataOnly scrapes because metadata-only
897
+ // mode is a bandwidth-saving path for external pages — there is
898
+ // no payoff in spinning up puppeteer when the row was never
899
+ // going to be fully rendered. `isPuppeteerFallbackCandidate`
900
+ // filters PreloadShortCircuitError automatically via its
901
+ // classifier check (its synthesised message classifies as `dns`).
902
+ if (!metadataOnly &&
903
+ isLikelyHtmlUrl(url) &&
904
+ isPuppeteerFallbackCandidate(errorMessage)) {
905
+ update(c.yellow('HEAD/GET unreachable — trying puppeteer once'));
906
+ try {
907
+ const fallback = await this._launchBrowserAndScrape(url, update, isExternal, metadataOnly);
908
+ if (fallback.type === 'success') {
909
+ if (fallback.pageData) {
910
+ const renderedKey = redirectDestKey(url, fallback.pageData.redirectPaths);
911
+ this.#scrapedDestinations.add(renderedKey);
912
+ }
913
+ // Puppeteer fallback proved the host is reachable
914
+ // (HEAD/GET probes died at a middlebox / WAF but the
915
+ // real browser navigation got a response). Mark the
916
+ // host alive for the cascade guard — without this, a
917
+ // host whose first URL only succeeded via the
918
+ // browser-rescue path would still be vulnerable to
919
+ // the next URL's HEAD failure burning it.
920
+ this.#successfulHosts.add(url.hostname.toLowerCase());
921
+ markBrowserScrape();
922
+ return fallback;
923
+ }
924
+ if (fallback.type === 'skipped') {
925
+ // Puppeteer rendered the page far enough for the scraper
926
+ // to match an `excludeKeywords` rule. That is a definitive
927
+ // "skip" verdict from the browser, NOT an unreachable
928
+ // host — surface the skip so downstream handling (skip
929
+ // counter, anchor-extraction suppression, `setSkippedPage`
930
+ // in the archive) behaves identically to the case where
931
+ // HEAD had succeeded. Without this branch, the page would
932
+ // be recorded as `status = -1` with the HEAD timeout
933
+ // message — a misleading entry that conflates
934
+ // "operator-intended skip" with "network failure".
935
+ //
936
+ // Skipped also counts as proof-of-life: the browser
937
+ // reached the page far enough to match exclude rules,
938
+ // so the host was clearly responding.
939
+ this.#successfulHosts.add(url.hostname.toLowerCase());
940
+ return fallback;
941
+ }
942
+ // `fallback.type === 'error'`. `_launchBrowserAndScrape`
943
+ // catches its own exceptions and returns
944
+ // `{type:'error', shutdown:...}` rather than throwing, so
945
+ // the `catch` arm below would NOT see this branch. Log
946
+ // the puppeteer-side cause (and any `shutdown` flag the
947
+ // scraper attached) so operators have a breadcrumb that
948
+ // the safety net actually fired and lost — otherwise
949
+ // only the HEAD error reaches `crawl_errors` and the
950
+ // browser failure mode is invisible.
951
+ crawlerLog('Puppeteer fallback returned error for %s: %s (shutdown=%s)', url.href, fallback.error?.message ?? '(no message)', fallback.error?.shutdown ?? false);
952
+ // JS-redirect rescue on the puppeteer-fallback branch:
953
+ // the HEAD/GET probes died (the kind set in
954
+ // `isPuppeteerFallbackCandidate` — middlebox / WAF
955
+ // shapes), the one-shot puppeteer attempt also threw,
956
+ // but `page.url()` reported a different post-navigation
957
+ // URL. This is the same WAF-+-JS-redirect shape the
958
+ // HEAD-success rescue handles one branch below, applied
959
+ // to the prior failure layer. Without this, a URL whose
960
+ // only sin is "HEAD blocked + JS-redirected body" falls
961
+ // to `status = -1` and joins the retry-forever loop the
962
+ // rescue is supposed to break. The trigger is the same
963
+ // narrow `Page.goto returned null` shape — anything
964
+ // else (TLS failure inside puppeteer, target crash, …)
965
+ // must fall through to the unreachable path so the real
966
+ // failure surfaces. We synthesise the redirect-edge
967
+ // PageData from the HEAD error (status = -1) instead of
968
+ // from a HEAD success, so `#linkRedirectSources` still
969
+ // stamps the source as 301 and the edge wires the dest
970
+ // in.
971
+ const fallbackRescue = buildJsRedirectEdge({
972
+ url,
973
+ isExternal,
974
+ errorMessage: fallback.error?.message,
975
+ postNavigationUrl: fallback.postNavigationUrl,
976
+ // No `headCheckResult`: HEAD itself died on this
977
+ // path, so the synthesised PageData starts from a
978
+ // `linkToPageData` placeholder with `status = -1`
979
+ // carrying the original HEAD error message.
980
+ // `#linkRedirectSources` still flips the source row
981
+ // to 301 because NULL/-1 satisfies its conditional
982
+ // stamp predicate.
983
+ });
984
+ if (fallbackRescue !== null) {
985
+ return fallbackRescue;
986
+ }
987
+ }
988
+ catch (browserError) {
989
+ // Browser launch / runtime crash — fall through to the
990
+ // unreachable path below. The original HEAD error is more
991
+ // informative about WHY the URL wasn't reachable, so it
992
+ // (not the puppeteer noise) is what we surface in
993
+ // `crawl_errors`. The lane display flag below (
994
+ // "Unreachable (fallback failed)") preserves the fact
995
+ // that puppeteer also tried, so operators reading the
996
+ // progress log can tell this URL got the safety-net
997
+ // attempt versus the cheap-probe-only path.
998
+ crawlerLog('Puppeteer fallback also failed for %s: %O', url.href, browserError);
999
+ }
1000
+ update(c.red('Unreachable (fallback failed)'));
1001
+ return {
1002
+ type: 'error',
1003
+ resources: [],
1004
+ error: {
1005
+ name: error instanceof Error ? error.name : 'Error',
1006
+ message: errorMessage,
1007
+ stack: error instanceof Error ? error.stack : undefined,
1008
+ shutdown: false,
1009
+ },
1010
+ };
1011
+ }
804
1012
  // Server unreachable — skip browser launch entirely
805
1013
  update(c.red('Unreachable'));
806
1014
  return {
@@ -808,7 +1016,7 @@ class Crawler extends EventEmitter {
808
1016
  resources: [],
809
1017
  error: {
810
1018
  name: error instanceof Error ? error.name : 'Error',
811
- message: error instanceof Error ? error.message : String(error),
1019
+ message: errorMessage,
812
1020
  stack: error instanceof Error ? error.stack : undefined,
813
1021
  shutdown: false,
814
1022
  },
@@ -834,7 +1042,7 @@ class Crawler extends EventEmitter {
834
1042
  const finalKey = redirectDestKey(url, headCheckResult.redirectPaths);
835
1043
  if (this.#scrapedDestinations.has(finalKey)) {
836
1044
  crawlerLog('Redirect dest already rendered, edge only: %s', url.href);
837
- return { type: 'redirect-edge', pageData: headCheckResult };
1045
+ return { type: 'redirect-edge', source: 'http-chain', pageData: headCheckResult };
838
1046
  }
839
1047
  // Title-only mode — extract <title> via partial GET for HTML, skip browser
840
1048
  if (metadataOnly) {
@@ -876,12 +1084,12 @@ class Crawler extends EventEmitter {
876
1084
  }
877
1085
  // HTML or unknown content type — launch browser with preflight result.
878
1086
  // markBrowserScrape() fires only when the result is `success`.
879
- // `#launchBrowserAndScrape` catches internal errors and returns
1087
+ // `_launchBrowserAndScrape` catches internal errors and returns
880
1088
  // `{ type: 'error', ... }` instead of throwing (see its catch block),
881
1089
  // so awaiting alone does NOT prove the page was rendered. The explicit
882
1090
  // success check excludes navigation failures, scraper exceptions, and
883
1091
  // shutdown-class errors from the pages-rendered count.
884
- const browserResult = await this.#launchBrowserAndScrape(url, update, isExternal, metadataOnly, headCheckResult);
1092
+ const browserResult = await this._launchBrowserAndScrape(url, update, isExternal, metadataOnly, headCheckResult);
885
1093
  if (browserResult.type === 'success') {
886
1094
  markBrowserScrape();
887
1095
  // Claim the destination that was ACTUALLY rendered, keyed off the
@@ -905,6 +1113,95 @@ class Crawler extends EventEmitter {
905
1113
  ? redirectDestKey(url, browserResult.pageData.redirectPaths)
906
1114
  : finalKey;
907
1115
  this.#scrapedDestinations.add(renderedKey);
1116
+ return browserResult;
1117
+ }
1118
+ // Browser scrape failed but the HEAD pre-flight already resolved a
1119
+ // redirect chain — fall back to the redirect-edge path so the chain
1120
+ // is not lost. Without this, a URL whose final destination is on
1121
+ // HTTPS→HTTP downgrade (or any other navigation Chromium refuses
1122
+ // to complete while the underlying redirect was a normal 301/302)
1123
+ // would be persisted as `status = -1` with NULL `redirectDestId`,
1124
+ // then re-picked up by every `--retry-failed` pass forever — the
1125
+ // HEAD answer is the authoritative truth and the browser cannot
1126
+ // invalidate it.
1127
+ //
1128
+ // Restricted to `type === 'error'` because:
1129
+ // - `'skipped'` is an `excludeKeywords` verdict from the browser
1130
+ // on the rendered URL and is its own definitive outcome —
1131
+ // surfacing it as a redirect-edge would lose the skip signal.
1132
+ // - `'success'` is handled above.
1133
+ //
1134
+ // The destination is claimed even though no row was rendered for
1135
+ // it: subsequent siblings on the same chain should also fold into
1136
+ // the same edge instead of re-firing the same failing browser
1137
+ // attempt. If the destination URL itself reaches the queue later,
1138
+ // it goes through the normal `#scrapePage` path (the claim only
1139
+ // short-circuits sibling redirect SOURCES, not the destination
1140
+ // itself).
1141
+ if (browserResult.type === 'error' && headCheckResult.redirectPaths.length > 0) {
1142
+ this.#scrapedDestinations.add(finalKey);
1143
+ crawlerLog('Browser scrape failed for %s but HEAD resolved a redirect chain — recording as edge', url.href);
1144
+ return { type: 'redirect-edge', source: 'http-chain', pageData: headCheckResult };
1145
+ }
1146
+ // JS-redirect rescue: HEAD returned a definitive response (no chain),
1147
+ // the browser scrape threw with the specific `Page.goto returned null`
1148
+ // shape (gated by `isJsRedirectErrorShape` below), and puppeteer
1149
+ // reports a different post-navigation URL via `page.url()`. The
1150
+ // motivating case is a server returning `200 OK` whose body contains
1151
+ // `window.location.replace(...)` or `<meta http-equiv="refresh">` —
1152
+ // `page.goto()` resolves to `null` once the JS-driven navigation
1153
+ // supersedes the original, and the scraper throws
1154
+ // `The method Page.goto returned null`. Recording the edge preserves
1155
+ // the link from the source to the JS-redirect target, removes the
1156
+ // page from `--retry-failed`'s candidate pool (the SQL filter
1157
+ // excludes rows with a non-null `redirectDestId`), and matches what
1158
+ // a real browser shows the user.
1159
+ //
1160
+ // What the source row reads as:
1161
+ // - the source is not committed via `setPage`/`updatePage` on this
1162
+ // path (the redirect-edge handler in `#runDeal` only calls
1163
+ // `linkList.done` + `emit('redirect', ...)` → `Archive.setRedirect`),
1164
+ // so `recordRedirect` → `resolveContentItemId` creates a NULL-status
1165
+ // placeholder row for the source if it did not already exist;
1166
+ // - `#linkRedirectSources` then stamps `status = 301
1167
+ // statusText='Moved Permanently'` because NULL satisfies its
1168
+ // conditional-update predicate.
1169
+ // That is the same shape an HTTP 301 source ends up with — the
1170
+ // truthful HTTP layer (the upstream's 200) is lost on this path, but
1171
+ // the alternative (status=-1 retry-forever) is strictly worse. A
1172
+ // future refinement could keep the HEAD-derived status by routing
1173
+ // the source through `setPage` before `setRedirect`; intentionally
1174
+ // deferred to keep this rescue minimal.
1175
+ //
1176
+ // Pre-claiming the destination in `#scrapedDestinations` would
1177
+ // short-circuit the freshly-enqueued destination at the top of
1178
+ // `#scrapePage` (the `if (#scrapedDestinations.has(finalKey))` guard
1179
+ // at line 1213), leaving the dest row as a content-less HEAD edge
1180
+ // instead of a fully rendered page. So we *do not* claim here — the
1181
+ // destination renders normally via the queue, and `#scrapedDestinations`
1182
+ // is populated at line ~1322 of the render-success path the way every
1183
+ // other URL is. Sibling JS-redirect sources to the same destination
1184
+ // still converge: the second sibling enters this branch, observes its
1185
+ // own `page.url()` landing on the same target, records its own
1186
+ // redirect-edge, and re-enqueues — the dealer's `seen` dedup absorbs
1187
+ // the duplicate push, so the destination renders exactly once.
1188
+ if (browserResult.type === 'error') {
1189
+ const headSuccessRescue = buildJsRedirectEdge({
1190
+ url,
1191
+ isExternal,
1192
+ errorMessage: browserResult.error?.message,
1193
+ postNavigationUrl: browserResult.postNavigationUrl,
1194
+ // `headCheckResult` is supplied here so the synthesised
1195
+ // PageData carries the real HTTP-level status / content
1196
+ // type from the HEAD pre-flight. `#linkRedirectSources`
1197
+ // only stamps 301 onto NULL/-1 status rows, so the
1198
+ // HEAD-derived status DOES survive on this path — the
1199
+ // truthful HTTP 200 is preserved.
1200
+ headCheckResult,
1201
+ });
1202
+ if (headSuccessRescue !== null) {
1203
+ return headSuccessRescue;
1204
+ }
908
1205
  }
909
1206
  return browserResult;
910
1207
  }
@@ -921,17 +1218,282 @@ class Crawler extends EventEmitter {
921
1218
  * @returns Lightweight page data from the HEAD response
922
1219
  */
923
1220
  async #sendHeadRequest(url, isExternal, update, laneIndex) {
924
- return retryCall(() => fetchDestination({ url, isExternal, userAgent: this.#options.userAgent }), {
1221
+ const host = url.hostname.toLowerCase();
1222
+ if (dnsBurnedHostCache.has(host)) {
1223
+ // Either session-learned earlier in this crawl (one URL on this host
1224
+ // already exhausted retries with a DNS error) or preload-seeded from
1225
+ // `crawl_errors` on archive open. Either way: skip the HEAD entirely.
1226
+ // The orchestrator's error-channel listener detects
1227
+ // PreloadShortCircuitError via instanceof and refuses to write it to
1228
+ // `crawl_errors`, preventing self-amplification across crawls.
1229
+ dnsBurnedHostShortCircuitCounter.count++;
1230
+ update(c.red(`HEAD request: host ${host} DNS-burned — skipping`));
1231
+ throw new PreloadShortCircuitError(host);
1232
+ }
1233
+ // Escalating per-attempt timeout: a slow-but-reachable server (e.g. some
1234
+ // government sites under load) often answers in 20-40 s but is missed by
1235
+ // a flat 10 s race on every retry. Start short to keep crawl throughput
1236
+ // up on healthy URLs, then back off so the last attempt is generous
1237
+ // enough that "really slow" gets a fair shot before we give up.
1238
+ let attempt = 0;
1239
+ return retryCall(async () => {
1240
+ // Clamp the attempt index to the last entry of the escalation array
1241
+ // so retry counts past the array length keep using the longest
1242
+ // budget instead of falling off into `undefined`. `as number`
1243
+ // only because TS can't see that a positive-length readonly array
1244
+ // always has a defined last element.
1245
+ const escalationIndex = Math.min(attempt, HEAD_TIMEOUT_ESCALATION_MS.length - 1);
1246
+ const timeoutMs = HEAD_TIMEOUT_ESCALATION_MS[escalationIndex];
1247
+ attempt += 1;
1248
+ const headResult = await fetchDestination({
1249
+ url,
1250
+ isExternal,
1251
+ userAgent: this.#options.userAgent,
1252
+ timeout: timeoutMs,
1253
+ });
1254
+ // Mark host alive the MOMENT an HTTP response is observed,
1255
+ // before retryCall's outer resolution settles. A later attempt
1256
+ // (or a sibling worker's onGiveUp) racing this success would
1257
+ // otherwise see an empty `#successfulHosts` and burn the host
1258
+ // — exactly the cascade the guard is here to prevent. Any HTTP
1259
+ // status counts: the guard cares about DNS-and-TCP reachability,
1260
+ // not application-level success, and `fetchDestination` only
1261
+ // resolves when an HTTP response was actually received.
1262
+ this.#successfulHosts.add(host);
1263
+ return headResult;
1264
+ }, {
925
1265
  retries: this.#options.retry,
926
1266
  label: 'HEAD request',
927
1267
  onWait: (determinedInterval, retryCount, label, error) => {
928
1268
  update(`${label}: ${error.message} — %countdown(${determinedInterval},fetchHead_${laneIndex}_${retryCount},s)%s (retry #${retryCount + 1})`);
929
1269
  },
930
1270
  onGiveUp: (retryCount, error, label) => {
1271
+ // Burn the host so subsequent URLs short-circuit — but ONLY
1272
+ // when this is the first time we've ever seen the host fail
1273
+ // in this session. A host that responded earlier is treated
1274
+ // as transiently unreachable (operator's resolver flipped
1275
+ // mid-crawl etc.), not a dead domain. `shouldBurnHost`
1276
+ // encapsulates this decision so the cascade guard is
1277
+ // independently testable. Also gated to `onGiveUp` rather
1278
+ // than `onWait` so an `EAI_AGAIN` that recovers on retry
1279
+ // doesn't trip the guard prematurely.
1280
+ if (shouldBurnHost({
1281
+ errorKind: classifyErrorKind(error.message),
1282
+ host,
1283
+ successfulHosts: this.#successfulHosts,
1284
+ })) {
1285
+ dnsBurnedHostCache.set(host, 'dns');
1286
+ }
931
1287
  update(c.red(`${label}: gave up after ${retryCount} retries — ${error.message}`));
932
1288
  },
933
1289
  });
934
1290
  }
1291
+ /**
1292
+ * Launches a fresh Puppeteer browser, runs the beholder scraper, and cleans up.
1293
+ *
1294
+ * WHY per-URL browser: Each URL gets its own browser instance to ensure
1295
+ * complete isolation (cookies, cache, service workers). The browser is always
1296
+ * closed in the `finally` block, even on error.
1297
+ *
1298
+ * The cascade-guard contract for the puppeteer-fallback success / skipped
1299
+ * branches can be exercised via `vi.spyOn(Crawler.prototype,
1300
+ * '_launchBrowserAndScrape')` in unit tests. There is no production
1301
+ * consumer outside this class.
1302
+ * @internal
1303
+ * @param url - Target URL to scrape
1304
+ * @param update - Callback for progress messages
1305
+ * @param isExternal - Whether the URL is external to the crawl scope
1306
+ * @param metadataOnly - When true, only extract title metadata
1307
+ * @param headCheckResult - Optional HEAD result to pass to the scraper, avoiding a redundant request
1308
+ * @returns The scrape result from beholder
1309
+ */
1310
+ // eslint-disable-next-line no-restricted-syntax -- intentional `private` (vs `#`) so tests can spyOn the prototype to drive the puppeteer-fallback cascade-guard branches without a full browser mock; see JSDoc above.
1311
+ async _launchBrowserAndScrape(url, update, isExternal, metadataOnly, headCheckResult) {
1312
+ update('Launching browser%dots%');
1313
+ if (this.#options.executablePath) {
1314
+ const execPath = path.resolve(this.#options.executablePath);
1315
+ if (!existsSync(execPath)) {
1316
+ throw new Error(`Executable path does not exist: ${execPath}`);
1317
+ }
1318
+ }
1319
+ const puppeteer = await import('puppeteer');
1320
+ const browser = await puppeteer.launch({
1321
+ headless: true,
1322
+ ...(this.#options.executablePath
1323
+ ? { executablePath: this.#options.executablePath }
1324
+ : {}),
1325
+ });
1326
+ // `page` is hoisted out of the try-block so the catch arm can read
1327
+ // `page.url()` for JS-redirect detection. See `BrowserScrapeResult`
1328
+ // JSDoc for the full why; in short, when `scrapeStart` throws because
1329
+ // `page.goto()` returned `null`, the puppeteer page object still
1330
+ // holds the URL Chromium actually navigated to via the offending
1331
+ // `window.location.replace()` / meta-refresh, and that is the only
1332
+ // authoritative source for the JS-redirect destination.
1333
+ let page = null;
1334
+ try {
1335
+ update('Creating page%dots%');
1336
+ page = await browser.newPage();
1337
+ await page.setUserAgent(this.#options.userAgent);
1338
+ // HTTP-auth handling — two cooperating pieces, BOTH required:
1339
+ //
1340
+ // 1. `page.authenticate({user, pass})` (always, even with empty
1341
+ // strings) registers a Fetch-domain auth handler with
1342
+ // Chromium. With empty credentials it ALSO drains Chromium's
1343
+ // native HTTP-auth dialog without sending anything
1344
+ // privileged — the dialog cannot be captured by
1345
+ // `page.on('dialog')` (HTTP-auth is not a JS dialog) and
1346
+ // would otherwise hang the navigation until puppeteer's
1347
+ // timeout fires. With non-empty credentials it provides the
1348
+ // scope's auth so the in-scope navigation succeeds.
1349
+ //
1350
+ // 2. Stripping URL-embedded credentials from the navigation
1351
+ // target. **This is the credential-leak guard.** When the
1352
+ // URL we hand puppeteer carries `user:pass@host`, Chromium
1353
+ // promotes those credentials into its HTTP-auth cache
1354
+ // keyed by (scheme, host, port, realm). Subsequent
1355
+ // sub-resource requests issued from the same page —
1356
+ // including cross-origin requests to a different hostname
1357
+ // sharing the same IP / port (e.g. an embedded
1358
+ // `<img src="http://127.0.0.1:8010/…">` loaded from a
1359
+ // `localhost:8010` page) — get the cached `Authorization`
1360
+ // header re-attached by the network stack. The
1361
+ // `Fetch.authRequired` event never fires for these
1362
+ // pre-emptive attachments, so neither `page.authenticate`
1363
+ // nor any custom Fetch listener can filter them. The only
1364
+ // way to keep the cred out of the cross-origin request is
1365
+ // to make sure it never enters the cache in the first
1366
+ // place — hence stripping the URL before navigation.
1367
+ //
1368
+ // Verified by `scope-auth-leak.e2e.ts`: removing either piece
1369
+ // causes that test to fail (without auth → main 401 hangs;
1370
+ // without strip → scope cred leaks to off-scope sub-resource).
1371
+ await page.authenticate({
1372
+ username: url.username ?? '',
1373
+ password: url.password ?? '',
1374
+ });
1375
+ // Re-parse from `withoutHashAndAuth` rather than mutating the
1376
+ // re-parsed `url.href` object: ExURL pre-computes `href`,
1377
+ // `withoutHash` and other derived strings at parse time, and
1378
+ // post-hoc field assignment (`navigateUrl.username = ''`)
1379
+ // leaves those derived strings stale. Anything downstream that
1380
+ // reads `navigateUrl.href` (e.g. a future beholder bump that
1381
+ // switches `page.goto` from `withoutHashAndAuth` to `href`)
1382
+ // would silently get back the credentialed string — defeating
1383
+ // the leak guard. Building the navigation URL from a known
1384
+ // credential-free string guarantees every field is consistent.
1385
+ const navigateUrl = parseUrl(url.withoutHashAndAuth) ?? url;
1386
+ const scraper = new Scraper();
1387
+ scraper.on('changePhase', createChangePhaseHandler({
1388
+ emit: (event) => void this.emit('changePhase', event),
1389
+ update,
1390
+ formatLog: formatPhaseLog,
1391
+ buffer: this.#pendingPhaseErrors,
1392
+ urlHref: url.href,
1393
+ }));
1394
+ const result = await scraper.scrapeStart(page, navigateUrl, {
1395
+ isExternal,
1396
+ captureImages: !isExternal && this.#options.captureImages,
1397
+ excludeKeywords: this.#options.excludeKeywords,
1398
+ disableQueries: this.#options.disableQueries,
1399
+ metadataOnly,
1400
+ retries: this.#options.retry,
1401
+ headCheckResult,
1402
+ });
1403
+ // Image dom-path capture runs here — after the scrape completed but
1404
+ // while `page` is still alive — because beholder's image metadata
1405
+ // carries each element's `outerHTML` with no positional
1406
+ // information. The captured candidates ride on the page data into
1407
+ // `image_items.dom_path_text_id` resolution at write time; a
1408
+ // capture failure (or a page with no images) falls back to the
1409
+ // synthetic `unknown/<n>` markers, so this stays best-effort.
1410
+ if (result.type === 'success' &&
1411
+ result.pageData &&
1412
+ result.pageData.imageList.length > 0) {
1413
+ const imageDomPaths = await captureImageDomPaths(page);
1414
+ if (imageDomPaths !== undefined) {
1415
+ const withDomPaths = {
1416
+ ...result.pageData,
1417
+ imageDomPaths,
1418
+ };
1419
+ result.pageData = withDomPaths;
1420
+ }
1421
+ }
1422
+ update('Closing browser%dots%');
1423
+ // JS-redirect rescue capture: when `scrapeStart` catches a
1424
+ // `#fetchData` throw internally (e.g. `Page.goto returned null`
1425
+ // because a client-side `window.location.replace()` /
1426
+ // meta-refresh fired), it returns `{ type: 'error', ... }`
1427
+ // instead of re-throwing — so the `catch` arm below never
1428
+ // sees those cases. Read `page.url()` here while `page` is
1429
+ // still alive (finally still hasn't called `handleBrowserClose`)
1430
+ // and attach it to the result so `#scrapePage` can fold the
1431
+ // source into a redirect edge. Without this capture, the
1432
+ // rescue path is dead for the most common failure shape it
1433
+ // was designed to handle.
1434
+ //
1435
+ // `page.url()` itself can throw when the browser context died
1436
+ // mid-scrape (target crashed, session killed). On failure we
1437
+ // fall through with `postNavigationUrl` unset so the existing
1438
+ // HEAD-chain rescue / normal error path takes over.
1439
+ if (result.type === 'error') {
1440
+ try {
1441
+ const postNavigationUrl = page.url();
1442
+ return { ...result, postNavigationUrl };
1443
+ }
1444
+ catch (urlReadError) {
1445
+ crawlerLog('Reading page.url() for JS-redirect detection failed on %s: %O', url.href, urlReadError);
1446
+ }
1447
+ }
1448
+ return result;
1449
+ }
1450
+ catch (error) {
1451
+ // JS-redirect rescue: when `scrapeStart` throws because
1452
+ // `page.goto()` returned `null` (the symptom of a client-side
1453
+ // `window.location.replace()` / meta-refresh navigating away
1454
+ // before the original response materialised), `page.url()` still
1455
+ // reports the destination Chromium ended up on. Capturing it
1456
+ // here lets `#scrapePage` fold the source into a redirect edge
1457
+ // instead of recording a hard `status = -1` — `Page.goto returned
1458
+ // null` classifies as `protocol`, which is neither permanent nor
1459
+ // a puppeteer-fallback kind, so without this rescue the page
1460
+ // loops through `--retry-failed` forever with the same failure.
1461
+ //
1462
+ // `page.url()` itself can throw when the browser context is
1463
+ // already torn down (target closed, session killed). Treat any
1464
+ // such failure as "no extra information" and fall back to the
1465
+ // normal error path — the existing redirect-edge fallback that
1466
+ // keys off `headCheckResult.redirectPaths` may still rescue the
1467
+ // page when the HEAD pre-flight resolved a chain.
1468
+ let postNavigationUrl;
1469
+ if (page) {
1470
+ try {
1471
+ postNavigationUrl = page.url();
1472
+ }
1473
+ catch (urlReadError) {
1474
+ crawlerLog('Reading page.url() for JS-redirect detection failed on %s: %O', url.href, urlReadError);
1475
+ }
1476
+ }
1477
+ return {
1478
+ type: 'error',
1479
+ resources: [],
1480
+ error: {
1481
+ name: error instanceof Error ? error.name : 'Error',
1482
+ message: error instanceof Error ? error.message : String(error),
1483
+ stack: error instanceof Error ? error.stack : undefined,
1484
+ shutdown: true,
1485
+ },
1486
+ ...(postNavigationUrl === undefined ? {} : { postNavigationUrl }),
1487
+ };
1488
+ }
1489
+ finally {
1490
+ // handleBrowserClose force-kills the underlying Chromium when a
1491
+ // graceful close() hangs (e.g. the session died mid-scrape) and
1492
+ // guarantees the finally never throws, so the try-block's return
1493
+ // value or caught error is never masked.
1494
+ await handleBrowserClose(browser, url.href, crawlerLog);
1495
+ }
1496
+ }
935
1497
  /**
936
1498
  * The default maximum number of concurrent scraping processes.
937
1499
  *