@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.
- package/README.md +6 -4
- package/lib/archive/archive-accessor.d.ts +2 -2
- package/lib/archive/archive-accessor.js +2 -2
- package/lib/archive/archive-lock.d.ts +7 -0
- package/lib/archive/archive-lock.js +7 -0
- package/lib/archive/archive.d.ts +175 -13
- package/lib/archive/archive.js +198 -14
- package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
- package/lib/archive/cache/compute-archive-cache-key.js +95 -0
- package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
- package/lib/archive/cache/extract-archive-to-cache.js +309 -0
- package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
- package/lib/archive/cache/get-archive-cache-root.js +53 -0
- package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
- package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
- package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
- package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
- package/lib/archive/create-adjunct-tables.d.ts +43 -0
- package/lib/archive/create-adjunct-tables.js +213 -0
- package/lib/archive/create-entity-tables.d.ts +173 -0
- package/lib/archive/create-entity-tables.js +318 -0
- package/lib/archive/create-progress-reporter.d.ts +30 -0
- package/lib/archive/create-progress-reporter.js +38 -0
- package/lib/archive/create-ref-tables.d.ts +35 -0
- package/lib/archive/create-ref-tables.js +188 -0
- package/lib/archive/database.d.ts +130 -182
- package/lib/archive/database.js +524 -1777
- package/lib/archive/db-ops/_shared/clear-write-ref-caches.d.ts +27 -0
- package/lib/archive/db-ops/_shared/clear-write-ref-caches.js +34 -0
- package/lib/archive/db-ops/_shared/create-write-ref-caches.d.ts +17 -0
- package/lib/archive/db-ops/_shared/create-write-ref-caches.js +26 -0
- package/lib/archive/db-ops/_shared/decode-json-ref.d.ts +17 -0
- package/lib/archive/db-ops/_shared/decode-json-ref.js +31 -0
- package/lib/archive/db-ops/_shared/load-response-headers-by-set-ids.d.ts +20 -0
- package/lib/archive/db-ops/_shared/load-response-headers-by-set-ids.js +53 -0
- package/lib/archive/db-ops/_shared/resolve-content-item-id.d.ts +61 -0
- package/lib/archive/db-ops/_shared/resolve-content-item-id.js +111 -0
- package/lib/archive/db-ops/_shared/resolve-url-or-blob.d.ts +23 -0
- package/lib/archive/db-ops/_shared/resolve-url-or-blob.js +29 -0
- package/lib/archive/db-ops/_shared/retry-setting.d.ts +16 -0
- package/lib/archive/db-ops/_shared/retry-setting.js +18 -0
- package/lib/archive/db-ops/_shared/safe-parse-json.d.ts +11 -0
- package/lib/archive/db-ops/_shared/safe-parse-json.js +18 -0
- package/lib/archive/db-ops/_shared/types.d.ts +53 -0
- package/lib/archive/db-ops/_shared/types.js +1 -0
- package/lib/archive/db-ops/_shared/upsert-blob-ref.d.ts +25 -0
- package/lib/archive/db-ops/_shared/upsert-blob-ref.js +48 -0
- package/lib/archive/db-ops/_shared/upsert-content-type-ref.d.ts +30 -0
- package/lib/archive/db-ops/_shared/upsert-content-type-ref.js +45 -0
- package/lib/archive/db-ops/_shared/upsert-json-ref.d.ts +22 -0
- package/lib/archive/db-ops/_shared/upsert-json-ref.js +41 -0
- package/lib/archive/db-ops/_shared/upsert-response-headers.d.ts +35 -0
- package/lib/archive/db-ops/_shared/upsert-response-headers.js +49 -0
- package/lib/archive/db-ops/_shared/upsert-url-ref.d.ts +39 -0
- package/lib/archive/db-ops/_shared/upsert-url-ref.js +62 -0
- package/lib/archive/db-ops/analysis/replace-analysis-violations.d.ts +28 -0
- package/lib/archive/db-ops/analysis/replace-analysis-violations.js +152 -0
- package/lib/archive/db-ops/anchors/get-anchors-on-page.d.ts +10 -0
- package/lib/archive/db-ops/anchors/get-anchors-on-page.js +21 -0
- package/lib/archive/db-ops/config/get-base-url.d.ts +8 -0
- package/lib/archive/db-ops/config/get-base-url.js +14 -0
- package/lib/archive/db-ops/config/get-config.d.ts +10 -0
- package/lib/archive/db-ops/config/get-config.js +27 -0
- package/lib/archive/db-ops/config/get-name.d.ts +8 -0
- package/lib/archive/db-ops/config/get-name.js +14 -0
- package/lib/archive/db-ops/config/info-column-allowlist.d.ts +7 -0
- package/lib/archive/db-ops/config/info-column-allowlist.js +26 -0
- package/lib/archive/db-ops/config/info-json-columns.d.ts +5 -0
- package/lib/archive/db-ops/config/info-json-columns.js +10 -0
- package/lib/archive/db-ops/config/set-config.d.ts +12 -0
- package/lib/archive/db-ops/config/set-config.js +21 -0
- package/lib/archive/db-ops/config/update-config.d.ts +17 -0
- package/lib/archive/db-ops/config/update-config.js +36 -0
- package/lib/archive/db-ops/errors/insert-crawl-error.d.ts +15 -0
- package/lib/archive/db-ops/errors/insert-crawl-error.js +21 -0
- package/lib/archive/db-ops/errors/insert-page-error.d.ts +21 -0
- package/lib/archive/db-ops/errors/insert-page-error.js +28 -0
- package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.d.ts +22 -0
- package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.js +141 -0
- package/lib/archive/db-ops/html/get-html-of-page-by-id.d.ts +18 -0
- package/lib/archive/db-ops/html/get-html-of-page-by-id.js +29 -0
- package/lib/archive/db-ops/inventory/record-inventory-run.d.ts +21 -0
- package/lib/archive/db-ops/inventory/record-inventory-run.js +38 -0
- package/lib/archive/db-ops/lifecycle/checkpoint.d.ts +8 -0
- package/lib/archive/db-ops/lifecycle/checkpoint.js +9 -0
- package/lib/archive/db-ops/lifecycle/destroy.d.ts +6 -0
- package/lib/archive/db-ops/lifecycle/destroy.js +7 -0
- package/lib/archive/db-ops/lifecycle/init.d.ts +22 -0
- package/lib/archive/db-ops/lifecycle/init.js +42 -0
- package/lib/archive/db-ops/meta/get-jsonld-of-page.d.ts +13 -0
- package/lib/archive/db-ops/meta/get-jsonld-of-page.js +27 -0
- package/lib/archive/db-ops/meta/get-tags-of-page.d.ts +12 -0
- package/lib/archive/db-ops/meta/get-tags-of-page.js +28 -0
- package/lib/archive/db-ops/pages/order/set-url-order.d.ts +8 -0
- package/lib/archive/db-ops/pages/order/set-url-order.js +32 -0
- package/lib/archive/db-ops/pages/read/build-page-query.d.ts +18 -0
- package/lib/archive/db-ops/pages/read/build-page-query.js +40 -0
- package/lib/archive/db-ops/pages/read/get-crawling-state.d.ts +70 -0
- package/lib/archive/db-ops/pages/read/get-crawling-state.js +98 -0
- package/lib/archive/db-ops/pages/read/get-existing-page-urls.d.ts +15 -0
- package/lib/archive/db-ops/pages/read/get-existing-page-urls.js +30 -0
- package/lib/archive/db-ops/pages/read/get-page-count.d.ts +12 -0
- package/lib/archive/db-ops/pages/read/get-page-count.js +21 -0
- package/lib/archive/db-ops/pages/read/get-page-source-by-url.d.ts +24 -0
- package/lib/archive/db-ops/pages/read/get-page-source-by-url.js +28 -0
- package/lib/archive/db-ops/pages/read/get-pages-with-rels.d.ts +38 -0
- package/lib/archive/db-ops/pages/read/get-pages-with-rels.js +107 -0
- package/lib/archive/db-ops/pages/read/get-pages.d.ts +11 -0
- package/lib/archive/db-ops/pages/read/get-pages.js +51 -0
- package/lib/archive/db-ops/pages/read/get-scraped-html-page-count.d.ts +18 -0
- package/lib/archive/db-ops/pages/read/get-scraped-html-page-count.js +25 -0
- package/lib/archive/db-ops/pages/read/reconstruct-page-rows.d.ts +31 -0
- package/lib/archive/db-ops/pages/read/reconstruct-page-rows.js +32 -0
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.d.ts +24 -0
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +93 -0
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +47 -0
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +124 -0
- package/lib/archive/db-ops/pages/write/insert-inventory-seeds.d.ts +37 -0
- package/lib/archive/db-ops/pages/write/insert-inventory-seeds.js +72 -0
- package/lib/archive/db-ops/pages/write/insert-jsonld.d.ts +17 -0
- package/lib/archive/db-ops/pages/write/insert-jsonld.js +49 -0
- package/lib/archive/db-ops/pages/write/insert-page.d.ts +36 -0
- package/lib/archive/db-ops/pages/write/insert-page.js +208 -0
- package/lib/archive/db-ops/pages/write/insert-tags.d.ts +16 -0
- package/lib/archive/db-ops/pages/write/insert-tags.js +34 -0
- package/lib/archive/db-ops/pages/write/link-redirect-sources.d.ts +36 -0
- package/lib/archive/db-ops/pages/write/link-redirect-sources.js +93 -0
- package/lib/archive/db-ops/pages/write/record-redirect.d.ts +35 -0
- package/lib/archive/db-ops/pages/write/record-redirect.js +100 -0
- package/lib/archive/db-ops/pages/write/set-skipped-page.d.ts +13 -0
- package/lib/archive/db-ops/pages/write/set-skipped-page.js +22 -0
- package/lib/archive/db-ops/pages/write/update-page.d.ts +29 -0
- package/lib/archive/db-ops/pages/write/update-page.js +334 -0
- package/lib/archive/db-ops/pages/write/write-page-html-blob.d.ts +19 -0
- package/lib/archive/db-ops/pages/write/write-page-html-blob.js +41 -0
- package/lib/archive/db-ops/referrers/get-redirects-for-pages.d.ts +9 -0
- package/lib/archive/db-ops/referrers/get-redirects-for-pages.js +15 -0
- package/lib/archive/db-ops/referrers/get-referrers-of-page.d.ts +17 -0
- package/lib/archive/db-ops/referrers/get-referrers-of-page.js +32 -0
- package/lib/archive/db-ops/referrers/get-referrers-of-resource.d.ts +8 -0
- package/lib/archive/db-ops/referrers/get-referrers-of-resource.js +15 -0
- package/lib/archive/db-ops/resources/build-resource-query.d.ts +25 -0
- package/lib/archive/db-ops/resources/build-resource-query.js +29 -0
- package/lib/archive/db-ops/resources/get-existing-resource-urls.d.ts +9 -0
- package/lib/archive/db-ops/resources/get-existing-resource-urls.js +24 -0
- package/lib/archive/db-ops/resources/get-resource-by-url.d.ts +13 -0
- package/lib/archive/db-ops/resources/get-resource-by-url.js +22 -0
- package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +9 -0
- package/lib/archive/db-ops/resources/get-resource-url-list.js +13 -0
- package/lib/archive/db-ops/resources/get-resources.d.ts +8 -0
- package/lib/archive/db-ops/resources/get-resources.js +11 -0
- package/lib/archive/db-ops/resources/insert-inventory-resources.d.ts +24 -0
- package/lib/archive/db-ops/resources/insert-inventory-resources.js +64 -0
- package/lib/archive/db-ops/resources/insert-resource-referrers.d.ts +15 -0
- package/lib/archive/db-ops/resources/insert-resource-referrers.js +54 -0
- package/lib/archive/db-ops/resources/insert-resource.d.ts +34 -0
- package/lib/archive/db-ops/resources/insert-resource.js +73 -0
- package/lib/archive/db-ops/resources/reconstruct-resource-rows.d.ts +26 -0
- package/lib/archive/db-ops/resources/reconstruct-resource-rows.js +30 -0
- package/lib/archive/decode-html-blob.d.ts +18 -0
- package/lib/archive/decode-html-blob.js +31 -0
- package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
- package/lib/archive/derive-lineage-from-parent.js +42 -0
- package/lib/archive/drop-legacy-tables.d.ts +45 -0
- package/lib/archive/drop-legacy-tables.js +56 -0
- package/lib/archive/filesystem/rename.js +1 -1
- package/lib/archive/get-failed-page-messages.d.ts +44 -0
- package/lib/archive/get-failed-page-messages.js +132 -0
- package/lib/archive/init-schema.d.ts +35 -39
- package/lib/archive/init-schema.js +111 -320
- package/lib/archive/is-inventory-source.d.ts +21 -0
- package/lib/archive/is-inventory-source.js +22 -0
- package/lib/archive/limited-page-ids.d.ts +2 -1
- package/lib/archive/limited-page-ids.js +5 -4
- package/lib/archive/meta/assert-compatible-version.d.ts +24 -3
- package/lib/archive/meta/assert-compatible-version.js +24 -3
- package/lib/archive/meta/types.d.ts +87 -1
- package/lib/archive/meta/types.js +34 -2
- package/lib/archive/migrate-entity-tables.d.ts +45 -0
- package/lib/archive/migrate-entity-tables.js +56 -0
- package/lib/archive/migrate-ref-tables.d.ts +25 -0
- package/lib/archive/migrate-ref-tables.js +38 -0
- package/lib/archive/page-meta-column-maps.d.ts +32 -0
- package/lib/archive/page-meta-column-maps.js +43 -0
- package/lib/archive/page.d.ts +6 -6
- package/lib/archive/page.js +5 -5
- package/lib/archive/peek-archive-lock.d.ts +2 -2
- package/lib/archive/peek-archive-lock.js +2 -2
- package/lib/archive/populate-entity-tables/collapse-anchor-rows.d.ts +41 -0
- package/lib/archive/populate-entity-tables/collapse-anchor-rows.js +87 -0
- package/lib/archive/populate-entity-tables/derive-dom-path.d.ts +35 -0
- package/lib/archive/populate-entity-tables/derive-dom-path.js +72 -0
- package/lib/archive/populate-entity-tables/is-blob-ref-value.d.ts +16 -0
- package/lib/archive/populate-entity-tables/is-blob-ref-value.js +19 -0
- package/lib/archive/populate-entity-tables/match-images-to-dom-paths.d.ts +66 -0
- package/lib/archive/populate-entity-tables/match-images-to-dom-paths.js +96 -0
- package/lib/archive/populate-entity-tables/populate-anchor-edges.d.ts +33 -0
- package/lib/archive/populate-entity-tables/populate-anchor-edges.js +153 -0
- package/lib/archive/populate-entity-tables/populate-content-items.d.ts +40 -0
- package/lib/archive/populate-entity-tables/populate-content-items.js +141 -0
- package/lib/archive/populate-entity-tables/populate-entities.d.ts +81 -0
- package/lib/archive/populate-entity-tables/populate-entities.js +111 -0
- package/lib/archive/populate-entity-tables/populate-image-items.d.ts +91 -0
- package/lib/archive/populate-entity-tables/populate-image-items.js +223 -0
- package/lib/archive/populate-entity-tables/populate-page-meta.d.ts +33 -0
- package/lib/archive/populate-entity-tables/populate-page-meta.js +267 -0
- package/lib/archive/populate-entity-tables/populate-resource-items.d.ts +22 -0
- package/lib/archive/populate-entity-tables/populate-resource-items.js +114 -0
- package/lib/archive/populate-entity-tables/populate-resource-ref-edges.d.ts +31 -0
- package/lib/archive/populate-entity-tables/populate-resource-ref-edges.js +33 -0
- package/lib/archive/populate-entity-tables/resolve-blob-refs.d.ts +31 -0
- package/lib/archive/populate-entity-tables/resolve-blob-refs.js +100 -0
- package/lib/archive/populate-entity-tables/resolve-content-type-refs.d.ts +22 -0
- package/lib/archive/populate-entity-tables/resolve-content-type-refs.js +27 -0
- package/lib/archive/populate-entity-tables/resolve-header-sets.d.ts +49 -0
- package/lib/archive/populate-entity-tables/resolve-header-sets.js +122 -0
- package/lib/archive/populate-entity-tables/resolve-json-refs.d.ts +25 -0
- package/lib/archive/populate-entity-tables/resolve-json-refs.js +67 -0
- package/lib/archive/populate-entity-tables/resolve-text-refs.d.ts +30 -0
- package/lib/archive/populate-entity-tables/resolve-text-refs.js +61 -0
- package/lib/archive/populate-entity-tables/resolve-url-or-blob-from-maps.d.ts +21 -0
- package/lib/archive/populate-entity-tables/resolve-url-or-blob-from-maps.js +27 -0
- package/lib/archive/populate-entity-tables/resolve-url-refs.d.ts +33 -0
- package/lib/archive/populate-entity-tables/resolve-url-refs.js +60 -0
- package/lib/archive/populate-entity-tables/test-utils/count-rows.d.ts +17 -0
- package/lib/archive/populate-entity-tables/test-utils/count-rows.js +20 -0
- package/lib/archive/populate-entity-tables/test-utils/seed-content-items.d.ts +25 -0
- package/lib/archive/populate-entity-tables/test-utils/seed-content-items.js +42 -0
- package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.d.ts +23 -0
- package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.js +178 -0
- package/lib/archive/populate-entity-tables/types.d.ts +157 -0
- package/lib/archive/populate-entity-tables/types.js +12 -0
- package/lib/archive/populate-entity-tables/upsert-text-refs.d.ts +38 -0
- package/lib/archive/populate-entity-tables/upsert-text-refs.js +78 -0
- package/lib/archive/populate-ref-tables/classify-content-type.d.ts +16 -0
- package/lib/archive/populate-ref-tables/classify-content-type.js +52 -0
- package/lib/archive/populate-ref-tables/compute-content-hash.d.ts +22 -0
- package/lib/archive/populate-ref-tables/compute-content-hash.js +26 -0
- package/lib/archive/populate-ref-tables/compute-header-flags.d.ts +16 -0
- package/lib/archive/populate-ref-tables/compute-header-flags.js +70 -0
- package/lib/archive/populate-ref-tables/content-type-rules.d.ts +38 -0
- package/lib/archive/populate-ref-tables/content-type-rules.js +133 -0
- package/lib/archive/populate-ref-tables/create-header-table-caches.d.ts +25 -0
- package/lib/archive/populate-ref-tables/create-header-table-caches.js +49 -0
- package/lib/archive/populate-ref-tables/data-uri-url-refs-limit.d.ts +15 -0
- package/lib/archive/populate-ref-tables/data-uri-url-refs-limit.js +15 -0
- package/lib/archive/populate-ref-tables/decode-data-uri.d.ts +21 -0
- package/lib/archive/populate-ref-tables/decode-data-uri.js +126 -0
- package/lib/archive/populate-ref-tables/decompose-header-set.d.ts +29 -0
- package/lib/archive/populate-ref-tables/decompose-header-set.js +157 -0
- package/lib/archive/populate-ref-tables/decompose-url.d.ts +25 -0
- package/lib/archive/populate-ref-tables/decompose-url.js +70 -0
- package/lib/archive/populate-ref-tables/header-stability.d.ts +19 -0
- package/lib/archive/populate-ref-tables/header-stability.js +22 -0
- package/lib/archive/populate-ref-tables/header-value-cache-key.d.ts +17 -0
- package/lib/archive/populate-ref-tables/header-value-cache-key.js +19 -0
- package/lib/archive/populate-ref-tables/normalize-mime.d.ts +24 -0
- package/lib/archive/populate-ref-tables/normalize-mime.js +36 -0
- package/lib/archive/populate-ref-tables/populate-blob-refs.d.ts +38 -0
- package/lib/archive/populate-ref-tables/populate-blob-refs.js +134 -0
- package/lib/archive/populate-ref-tables/populate-content-type-refs.d.ts +27 -0
- package/lib/archive/populate-ref-tables/populate-content-type-refs.js +70 -0
- package/lib/archive/populate-ref-tables/populate-header-tables.d.ts +35 -0
- package/lib/archive/populate-ref-tables/populate-header-tables.js +80 -0
- package/lib/archive/populate-ref-tables/populate-json-refs.d.ts +29 -0
- package/lib/archive/populate-ref-tables/populate-json-refs.js +101 -0
- package/lib/archive/populate-ref-tables/populate-refs.d.ts +51 -0
- package/lib/archive/populate-ref-tables/populate-refs.js +62 -0
- package/lib/archive/populate-ref-tables/populate-text-refs.d.ts +32 -0
- package/lib/archive/populate-ref-tables/populate-text-refs.js +133 -0
- package/lib/archive/populate-ref-tables/populate-url-refs.d.ts +28 -0
- package/lib/archive/populate-ref-tables/populate-url-refs.js +148 -0
- package/lib/archive/populate-ref-tables/test-utils/count-rows.d.ts +15 -0
- package/lib/archive/populate-ref-tables/test-utils/count-rows.js +17 -0
- package/lib/archive/populate-ref-tables/types.d.ts +197 -0
- package/lib/archive/populate-ref-tables/types.js +7 -0
- package/lib/archive/populate-ref-tables/upsert-one-header-set.d.ts +34 -0
- package/lib/archive/populate-ref-tables/upsert-one-header-set.js +208 -0
- package/lib/archive/populate-ref-tables/volatile-header-names.d.ts +20 -0
- package/lib/archive/populate-ref-tables/volatile-header-names.js +33 -0
- package/lib/archive/redirect-table.d.ts +4 -2
- package/lib/archive/redirect-table.js +15 -10
- package/lib/archive/resolve-redirect-chain.d.ts +3 -3
- package/lib/archive/resolve-redirect-chain.js +2 -2
- package/lib/archive/resource.d.ts +1 -1
- package/lib/archive/retarget-legacy-fk-tables.d.ts +47 -0
- package/lib/archive/retarget-legacy-fk-tables.js +107 -0
- package/lib/archive/test-utils/fk-parent-tables.d.ts +15 -0
- package/lib/archive/test-utils/fk-parent-tables.js +19 -0
- package/lib/archive/test-utils/seed-content-item.d.ts +35 -0
- package/lib/archive/test-utils/seed-content-item.js +42 -0
- package/lib/archive/test-utils/setup-legacy-fk-db.d.ts +33 -0
- package/lib/archive/test-utils/setup-legacy-fk-db.js +270 -0
- package/lib/archive/types.d.ts +151 -15
- package/lib/archive/verify-migration/capture-rejection.d.ts +24 -0
- package/lib/archive/verify-migration/capture-rejection.js +31 -0
- package/lib/archive/verify-migration/check-anchor-edges-count.d.ts +34 -0
- package/lib/archive/verify-migration/check-anchor-edges-count.js +72 -0
- package/lib/archive/verify-migration/check-anchor-edges-sum.d.ts +13 -0
- package/lib/archive/verify-migration/check-anchor-edges-sum.js +27 -0
- package/lib/archive/verify-migration/check-content-items-count.d.ts +16 -0
- package/lib/archive/verify-migration/check-content-items-count.js +30 -0
- package/lib/archive/verify-migration/check-content-type-preservation.d.ts +22 -0
- package/lib/archive/verify-migration/check-content-type-preservation.js +40 -0
- package/lib/archive/verify-migration/check-foreign-key-integrity.d.ts +31 -0
- package/lib/archive/verify-migration/check-foreign-key-integrity.js +47 -0
- package/lib/archive/verify-migration/check-image-items-count.d.ts +12 -0
- package/lib/archive/verify-migration/check-image-items-count.js +26 -0
- package/lib/archive/verify-migration/check-page-meta-count.d.ts +15 -0
- package/lib/archive/verify-migration/check-page-meta-count.js +31 -0
- package/lib/archive/verify-migration/check-reader-parity.d.ts +23 -0
- package/lib/archive/verify-migration/check-reader-parity.js +211 -0
- package/lib/archive/verify-migration/check-resource-items-count.d.ts +17 -0
- package/lib/archive/verify-migration/check-resource-items-count.js +33 -0
- package/lib/archive/verify-migration/check-url-round-trip.d.ts +43 -0
- package/lib/archive/verify-migration/check-url-round-trip.js +112 -0
- package/lib/archive/verify-migration/types.d.ts +70 -0
- package/lib/archive/verify-migration/types.js +63 -0
- package/lib/archive/verify-migration/verify-migration.d.ts +41 -0
- package/lib/archive/verify-migration/verify-migration.js +120 -0
- package/lib/classify-error-kind.d.ts +19 -0
- package/lib/classify-error-kind.js +122 -0
- package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
- package/lib/crawler/build-js-redirect-edge.js +57 -0
- package/lib/crawler/build-redirect-event.d.ts +24 -0
- package/lib/crawler/build-redirect-event.js +28 -0
- package/lib/crawler/capture-image-dom-paths.d.ts +33 -0
- package/lib/crawler/capture-image-dom-paths.js +39 -0
- package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
- package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
- package/lib/crawler/collect-image-dom-paths.d.ts +23 -0
- package/lib/crawler/collect-image-dom-paths.js +64 -0
- package/lib/crawler/crawler.d.ts +22 -1
- package/lib/crawler/crawler.js +679 -117
- package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
- package/lib/crawler/derive-js-redirect-target.js +129 -0
- package/lib/crawler/derive-resource-source.d.ts +25 -15
- package/lib/crawler/derive-resource-source.js +28 -17
- package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
- package/lib/crawler/dns-burned-host-cache.js +25 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
- package/lib/crawler/fetch-destination.d.ts +12 -4
- package/lib/crawler/fetch-destination.js +94 -16
- package/lib/crawler/inject-scope-auth.d.ts +1 -1
- package/lib/crawler/inject-scope-auth.js +1 -1
- package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
- package/lib/crawler/is-js-redirect-error-shape.js +53 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
- package/lib/crawler/link-list.d.ts +21 -1
- package/lib/crawler/link-list.js +23 -3
- package/lib/crawler/normalize-content-type.d.ts +1 -1
- package/lib/crawler/normalize-content-type.js +1 -1
- package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
- package/lib/crawler/plan-sub-resource-emits.js +44 -0
- package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
- package/lib/crawler/preload-short-circuit-error.js +25 -0
- package/lib/crawler/should-burn-host.d.ts +78 -0
- package/lib/crawler/should-burn-host.js +61 -0
- package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
- package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
- package/lib/crawler/types.d.ts +108 -1
- package/lib/crawler-orchestrator.d.ts +22 -3
- package/lib/crawler-orchestrator.js +312 -73
- package/lib/crawler.d.ts +15 -2
- package/lib/crawler.js +24 -1
- package/lib/permanent-error-kinds.d.ts +43 -0
- package/lib/permanent-error-kinds.js +48 -0
- package/lib/types.d.ts +84 -0
- package/lib/utils/compute-file-sha256.d.ts +24 -0
- package/lib/utils/compute-file-sha256.js +56 -0
- package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
- package/lib/utils/error/emit-error-with-retry.js +44 -0
- package/lib/utils/error/emit-error.d.ts +39 -0
- package/lib/utils/error/emit-error.js +41 -0
- package/package.json +12 -12
- package/lib/archive/migrate-crawl-errors.d.ts +0 -20
- package/lib/archive/migrate-crawl-errors.js +0 -38
- package/lib/archive/migrate-html-blob-tables.d.ts +0 -24
- package/lib/archive/migrate-html-blob-tables.js +0 -53
- package/lib/archive/migrate-page-errors.d.ts +0 -16
- package/lib/archive/migrate-page-errors.js +0 -35
- package/lib/archive/migrate-pages-resources-source.d.ts +0 -16
- package/lib/archive/migrate-pages-resources-source.js +0 -46
- package/lib/utils/error/error-emitter.d.ts +0 -18
- package/lib/utils/error/error-emitter.js +0 -29
package/lib/crawler/crawler.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
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 #
|
|
444
|
-
|
|
445
|
-
if (
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
644
|
-
|
|
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.#
|
|
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
|
-
|
|
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.
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
*
|