@nitpicker/crawler 0.12.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 +63 -16
- package/lib/archive/archive.js +56 -17
- 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 +92 -345
- package/lib/archive/database.js +168 -1942
- 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 +1 -1
- package/lib/archive/derive-lineage-from-parent.js +1 -1
- 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 +5 -4
- package/lib/archive/get-failed-page-messages.js +5 -4
- package/lib/archive/init-schema.d.ts +35 -39
- package/lib/archive/init-schema.js +99 -460
- 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 +127 -24
- 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/crawler/build-redirect-event.d.ts +1 -1
- package/lib/crawler/build-redirect-event.js +1 -1
- 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 +1 -1
- package/lib/crawler/clear-dns-burned-host-cache.js +1 -1
- 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 +19 -0
- package/lib/crawler/crawler.js +40 -26
- package/lib/crawler/dns-burned-host-cache.d.ts +3 -3
- package/lib/crawler/dns-burned-host-cache.js +3 -3
- package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +2 -2
- package/lib/crawler/dns-burned-host-short-circuit-counter.js +2 -2
- package/lib/crawler/inject-scope-auth.d.ts +1 -1
- package/lib/crawler/inject-scope-auth.js +1 -1
- package/lib/crawler/normalize-content-type.d.ts +1 -1
- package/lib/crawler/normalize-content-type.js +1 -1
- package/lib/crawler/types.d.ts +3 -3
- package/lib/crawler-orchestrator.d.ts +9 -0
- package/lib/crawler-orchestrator.js +44 -28
- package/lib/crawler.d.ts +12 -0
- package/lib/crawler.js +21 -0
- package/lib/permanent-error-kinds.d.ts +1 -1
- package/lib/permanent-error-kinds.js +1 -1
- package/lib/types.d.ts +1 -1
- package/lib/utils/compute-file-sha256.d.ts +5 -4
- package/lib/utils/compute-file-sha256.js +5 -4
- package/lib/utils/error/emit-error-with-retry.d.ts +1 -1
- package/lib/utils/error/emit-error-with-retry.js +1 -1
- package/package.json +10 -10
- 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-inventory-runs.d.ts +0 -29
- package/lib/archive/migrate-inventory-runs.js +0 -52
- 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/archive/database.js
CHANGED
|
@@ -1,223 +1,66 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
1
|
import { existsSync } from 'node:fs';
|
|
3
2
|
import path from 'node:path';
|
|
4
|
-
import { zstdCompressSync, zstdDecompressSync } from 'node:zlib';
|
|
5
|
-
import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
|
|
6
3
|
import { retryCall } from '@d-zero/shared/retry';
|
|
7
|
-
import { pathComparator } from '@d-zero/shared/sort/path';
|
|
8
4
|
import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
|
|
9
5
|
import knex from 'knex';
|
|
10
|
-
import { classifyErrorKind } from '../classify-error-kind.js';
|
|
11
|
-
import { findScopeEntry } from '../crawler/find-scope-entry.js';
|
|
12
|
-
import { isHtmlContentType } from '../crawler/is-html-content-type.js';
|
|
13
|
-
import { normalizeContentType } from '../crawler/normalize-content-type.js';
|
|
14
|
-
import { PERMANENT_ERROR_KINDS } from '../permanent-error-kinds.js';
|
|
15
|
-
import { eachSplitted } from '../utils/array/each-splitted.js';
|
|
16
6
|
import { emitErrorAndRetry } from '../utils/error/emit-error-with-retry.js';
|
|
17
7
|
import { emitError } from '../utils/error/emit-error.js';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
8
|
+
import { createWriteRefCaches } from './db-ops/_shared/create-write-ref-caches.js';
|
|
9
|
+
import { retrySetting } from './db-ops/_shared/retry-setting.js';
|
|
10
|
+
import { replaceAnalysisViolations as replaceAnalysisViolationsOp } from './db-ops/analysis/replace-analysis-violations.js';
|
|
11
|
+
import { getAnchorsOnPage as getAnchorsOnPageOp } from './db-ops/anchors/get-anchors-on-page.js';
|
|
12
|
+
import { getBaseUrl as getBaseUrlOp } from './db-ops/config/get-base-url.js';
|
|
13
|
+
import { getConfig as getConfigOp } from './db-ops/config/get-config.js';
|
|
14
|
+
import { getName as getNameOp } from './db-ops/config/get-name.js';
|
|
15
|
+
import { setConfig as setConfigOp } from './db-ops/config/set-config.js';
|
|
16
|
+
import { updateConfig as updateConfigOp } from './db-ops/config/update-config.js';
|
|
17
|
+
import { insertCrawlError as insertCrawlErrorOp } from './db-ops/errors/insert-crawl-error.js';
|
|
18
|
+
import { insertPageError as insertPageErrorOp } from './db-ops/errors/insert-page-error.js';
|
|
19
|
+
import { listDnsBurnedHostCandidates as listDnsBurnedHostCandidatesOp } from './db-ops/errors/list-dns-burned-host-candidates.js';
|
|
20
|
+
import { getHtmlOfPageById as getHtmlOfPageByIdOp } from './db-ops/html/get-html-of-page-by-id.js';
|
|
21
|
+
import { recordInventoryRun as recordInventoryRunOp } from './db-ops/inventory/record-inventory-run.js';
|
|
22
|
+
import { checkpoint as checkpointOp } from './db-ops/lifecycle/checkpoint.js';
|
|
23
|
+
import { destroy as destroyOp } from './db-ops/lifecycle/destroy.js';
|
|
24
|
+
import { init as initOp } from './db-ops/lifecycle/init.js';
|
|
25
|
+
import { getJsonLdOfPage as getJsonLdOfPageOp } from './db-ops/meta/get-jsonld-of-page.js';
|
|
26
|
+
import { getTagsOfPage as getTagsOfPageOp } from './db-ops/meta/get-tags-of-page.js';
|
|
27
|
+
import { setUrlOrder as setUrlOrderOp } from './db-ops/pages/order/set-url-order.js';
|
|
28
|
+
import { getCrawlingState as getCrawlingStateOp } from './db-ops/pages/read/get-crawling-state.js';
|
|
29
|
+
import { getExistingPageUrls as getExistingPageUrlsOp } from './db-ops/pages/read/get-existing-page-urls.js';
|
|
30
|
+
import { getPageCount as getPageCountOp } from './db-ops/pages/read/get-page-count.js';
|
|
31
|
+
import { getPageSourceByUrl as getPageSourceByUrlOp } from './db-ops/pages/read/get-page-source-by-url.js';
|
|
32
|
+
import { getPagesWithRels as getPagesWithRelsOp } from './db-ops/pages/read/get-pages-with-rels.js';
|
|
33
|
+
import { getPages as getPagesOp } from './db-ops/pages/read/get-pages.js';
|
|
34
|
+
import { getScrapedHtmlPageCount as getScrapedHtmlPageCountOp } from './db-ops/pages/read/get-scraped-html-page-count.js';
|
|
35
|
+
import { repromoteExternalPages as repromoteExternalPagesOp } from './db-ops/pages/reset/repromote-external-pages.js';
|
|
36
|
+
import { resetFailedPages as resetFailedPagesOp } from './db-ops/pages/reset/reset-failed-pages.js';
|
|
37
|
+
import { insertInventorySeeds as insertInventorySeedsOp } from './db-ops/pages/write/insert-inventory-seeds.js';
|
|
38
|
+
import { recordRedirect as recordRedirectOp } from './db-ops/pages/write/record-redirect.js';
|
|
39
|
+
import { setSkippedPage as setSkippedPageOp } from './db-ops/pages/write/set-skipped-page.js';
|
|
40
|
+
import { updatePage as updatePageOp } from './db-ops/pages/write/update-page.js';
|
|
41
|
+
import { getRedirectsForPages as getRedirectsForPagesOp } from './db-ops/referrers/get-redirects-for-pages.js';
|
|
42
|
+
import { getReferrersOfPage as getReferrersOfPageOp } from './db-ops/referrers/get-referrers-of-page.js';
|
|
43
|
+
import { getReferrersOfResource as getReferrersOfResourceOp } from './db-ops/referrers/get-referrers-of-resource.js';
|
|
44
|
+
import { getExistingResourceUrls as getExistingResourceUrlsOp } from './db-ops/resources/get-existing-resource-urls.js';
|
|
45
|
+
import { getResourceByUrl as getResourceByUrlOp } from './db-ops/resources/get-resource-by-url.js';
|
|
46
|
+
import { getResourceUrlList as getResourceUrlListOp } from './db-ops/resources/get-resource-url-list.js';
|
|
47
|
+
import { getResources as getResourcesOp } from './db-ops/resources/get-resources.js';
|
|
48
|
+
import { insertInventoryResources as insertInventoryResourcesOp } from './db-ops/resources/insert-inventory-resources.js';
|
|
49
|
+
import { insertResourceReferrers as insertResourceReferrersOp } from './db-ops/resources/insert-resource-referrers.js';
|
|
50
|
+
import { insertResource as insertResourceOp } from './db-ops/resources/insert-resource.js';
|
|
20
51
|
import { mkdir } from './filesystem/mkdir.js';
|
|
21
|
-
import { getFailedPageMessages } from './get-failed-page-messages.js';
|
|
22
|
-
import { getJSON } from './get-json.js';
|
|
23
|
-
import { applyConnectionPragmas, initSchema } from './init-schema.js';
|
|
24
52
|
import { LibsqlDialect } from './libsql-dialect.js';
|
|
25
|
-
import { limitedPageIds } from './limited-page-ids.js';
|
|
26
|
-
import { assertCompatibleVersion } from './meta/assert-compatible-version.js';
|
|
27
|
-
import { classifyJsonLdType } from './meta/classify-jsonld-type.js';
|
|
28
|
-
import { computePageDenormalized } from './meta/compute-page-denormalized.js';
|
|
29
|
-
import { deriveFlatFromMeta } from './meta/derive-flat-from-meta.js';
|
|
30
|
-
import { deriveMetaExtras } from './meta/derive-meta-extras.js';
|
|
31
|
-
import { extractTagsForArchive } from './meta/extract-tags-for-archive.js';
|
|
32
|
-
import { migrateCrawlErrors } from './migrate-crawl-errors.js';
|
|
33
|
-
import { migrateHtmlBlobTables } from './migrate-html-blob-tables.js';
|
|
34
|
-
import { migrateInfoRoots } from './migrate-info-roots.js';
|
|
35
|
-
import { migrateInventoryRuns } from './migrate-inventory-runs.js';
|
|
36
|
-
import { migratePageErrors } from './migrate-page-errors.js';
|
|
37
|
-
import { migratePagesResourcesSource } from './migrate-pages-resources-source.js';
|
|
38
|
-
import { redirectTable } from './redirect-table.js';
|
|
39
|
-
import { resolveRedirectChain } from './resolve-redirect-chain.js';
|
|
40
|
-
const retrySetting = {
|
|
41
|
-
interval: 300,
|
|
42
|
-
retries: 3,
|
|
43
|
-
};
|
|
44
|
-
/**
|
|
45
|
-
* Decodes a stored HTML body BLOB according to its codec marker. The codec
|
|
46
|
-
* column on `page_html_blobs` exists so individual rows can be migrated to
|
|
47
|
-
* a future encoder without rewriting the whole table; readers must dispatch
|
|
48
|
-
* on it. The body is typed `Uint8Array` (not `Buffer`) because libsql
|
|
49
|
-
* returns BLOB columns as bare `Uint8Array`; `Buffer.from` wraps it
|
|
50
|
-
* zero-copy.
|
|
51
|
-
* @param body - Raw bytes as stored in `page_html_blobs.body`.
|
|
52
|
-
* @param codec - The `codec` column value (e.g. `'zstd'`, `'none'`).
|
|
53
|
-
* @returns UTF-8 decoded HTML string.
|
|
54
|
-
* @throws {Error} If the codec is not recognised.
|
|
55
|
-
*/
|
|
56
|
-
/**
|
|
57
|
-
* Parses a JSON column value, returning `null` on parse failure rather than
|
|
58
|
-
* throwing. JSON columns in `page_jsonld` (`parsed`) and `page_tags`
|
|
59
|
-
* (`categories`, `sources`) are written by `JSON.stringify` and round-trip
|
|
60
|
-
* cleanly under normal conditions; a hand-edited archive that has
|
|
61
|
-
* malformed JSON in those columns should degrade gracefully rather than
|
|
62
|
-
* propagate a parse error up to the consumer.
|
|
63
|
-
* @param value - JSON-encoded text.
|
|
64
|
-
*/
|
|
65
|
-
function safeParseJson(value) {
|
|
66
|
-
try {
|
|
67
|
-
return JSON.parse(value);
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
/**
|
|
74
|
-
*
|
|
75
|
-
* @param body
|
|
76
|
-
* @param codec
|
|
77
|
-
*/
|
|
78
|
-
function decodeStoredBlob(body, codec) {
|
|
79
|
-
// `Buffer.from(buffer)` accepts Uint8Array, Buffer, and array-like
|
|
80
|
-
// shapes uniformly; libsql may hand back any of these for a BLOB
|
|
81
|
-
// column depending on the row encoding.
|
|
82
|
-
const buffer = Buffer.from(body);
|
|
83
|
-
if (codec === 'zstd') {
|
|
84
|
-
return zstdDecompressSync(buffer).toString('utf8');
|
|
85
|
-
}
|
|
86
|
-
if (codec === 'none') {
|
|
87
|
-
return buffer.toString('utf8');
|
|
88
|
-
}
|
|
89
|
-
throw new Error(`Unknown page_html_blobs.codec: ${codec}`);
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* Columns of the `info` table that `setConfig` / `updateConfig` are allowed to
|
|
93
|
-
* write. Any key outside this set is silently dropped so callers can splat a
|
|
94
|
-
* wider runtime config (with extras like `cwd`) without hitting "no such
|
|
95
|
-
* column" at the SQL layer.
|
|
96
|
-
*/
|
|
97
|
-
const INFO_COLUMN_ALLOWLIST = new Set([
|
|
98
|
-
'version',
|
|
99
|
-
'name',
|
|
100
|
-
'baseUrl',
|
|
101
|
-
'roots',
|
|
102
|
-
'recursive',
|
|
103
|
-
'interval',
|
|
104
|
-
'image',
|
|
105
|
-
'fetchExternal',
|
|
106
|
-
'parallels',
|
|
107
|
-
'excludes',
|
|
108
|
-
'excludeKeywords',
|
|
109
|
-
'excludeUrls',
|
|
110
|
-
'maxExcludedDepth',
|
|
111
|
-
'retry',
|
|
112
|
-
'fromList',
|
|
113
|
-
'disableQueries',
|
|
114
|
-
'userAgent',
|
|
115
|
-
'ignoreRobots',
|
|
116
|
-
]);
|
|
117
|
-
/**
|
|
118
|
-
* Subset of {@link INFO_COLUMN_ALLOWLIST} that is stored as a JSON-encoded
|
|
119
|
-
* string and therefore needs `JSON.stringify` on write.
|
|
120
|
-
*/
|
|
121
|
-
const INFO_JSON_COLUMNS = new Set([
|
|
122
|
-
'roots',
|
|
123
|
-
'excludes',
|
|
124
|
-
'excludeKeywords',
|
|
125
|
-
'excludeUrls',
|
|
126
|
-
]);
|
|
127
|
-
/**
|
|
128
|
-
* Columns of the `pages` table that should be reset to `null` whenever a
|
|
129
|
-
* previously-scraped row is demoted back to "pending" (i.e. by
|
|
130
|
-
* `resetFailedPages` and `repromoteExternalPages`).
|
|
131
|
-
*
|
|
132
|
-
* Includes all flat meta columns, the denormalised aggregates, and the
|
|
133
|
-
* `meta_extras` JSON catch-all. **Excludes** `firstCrawledAt` / `lastCrawledAt`
|
|
134
|
-
* by design — failure reset must not erase the last-success timestamp, which
|
|
135
|
-
* is the within-archive observation axis for #11 / #17 / #19 use cases.
|
|
136
|
-
*
|
|
137
|
-
* Centralised in one constant so schema growth and reset logic stay in lock-
|
|
138
|
-
* step: adding a flat meta column without updating this list would leave
|
|
139
|
-
* stale data after a reset.
|
|
140
|
-
*/
|
|
141
|
-
const META_NULLABLE_COLUMNS = [
|
|
142
|
-
// Document basics
|
|
143
|
-
'lang',
|
|
144
|
-
'dir',
|
|
145
|
-
'charset',
|
|
146
|
-
'baseHref',
|
|
147
|
-
'viewport_raw',
|
|
148
|
-
'themeColor',
|
|
149
|
-
'applicationName',
|
|
150
|
-
'author',
|
|
151
|
-
'generator',
|
|
152
|
-
'publisher',
|
|
153
|
-
// Title / description / keywords
|
|
154
|
-
'title',
|
|
155
|
-
'description',
|
|
156
|
-
'keywords',
|
|
157
|
-
// Robots
|
|
158
|
-
'robots_raw',
|
|
159
|
-
'robots_noindex',
|
|
160
|
-
'robots_nofollow',
|
|
161
|
-
'robots_noarchive',
|
|
162
|
-
'robots_noimageindex',
|
|
163
|
-
'googlebot',
|
|
164
|
-
// Link (1:1)
|
|
165
|
-
'canonical',
|
|
166
|
-
'amphtml',
|
|
167
|
-
'manifest',
|
|
168
|
-
'icon_href',
|
|
169
|
-
'appleTouchIcon_href',
|
|
170
|
-
// Open Graph
|
|
171
|
-
'og_type',
|
|
172
|
-
'og_title',
|
|
173
|
-
'og_url',
|
|
174
|
-
'og_site_name',
|
|
175
|
-
'og_description',
|
|
176
|
-
'og_image',
|
|
177
|
-
'og_image_alt',
|
|
178
|
-
'og_image_width',
|
|
179
|
-
'og_image_height',
|
|
180
|
-
'og_locale',
|
|
181
|
-
'og_article_published_time',
|
|
182
|
-
'og_article_modified_time',
|
|
183
|
-
// Twitter
|
|
184
|
-
'twitter_card',
|
|
185
|
-
'twitter_site',
|
|
186
|
-
'twitter_creator',
|
|
187
|
-
'twitter_title',
|
|
188
|
-
'twitter_description',
|
|
189
|
-
'twitter_image',
|
|
190
|
-
// One-offs
|
|
191
|
-
'fb_app_id',
|
|
192
|
-
'verification_google',
|
|
193
|
-
'formatDetection_telephone',
|
|
194
|
-
// Denormalised aggregates
|
|
195
|
-
'tag_count',
|
|
196
|
-
'jsonld_count',
|
|
197
|
-
'tags_providers_csv',
|
|
198
|
-
// Catch-all
|
|
199
|
-
'meta_extras',
|
|
200
|
-
];
|
|
201
|
-
/**
|
|
202
|
-
* Builds the reset payload for {@link META_NULLABLE_COLUMNS} as a plain object
|
|
203
|
-
* suitable for `knex.update(...)`. All listed columns are mapped to `null`.
|
|
204
|
-
*/
|
|
205
|
-
function makeMetaResetPayload() {
|
|
206
|
-
const payload = {};
|
|
207
|
-
for (const col of META_NULLABLE_COLUMNS) {
|
|
208
|
-
payload[col] = null;
|
|
209
|
-
}
|
|
210
|
-
return payload;
|
|
211
|
-
}
|
|
212
53
|
/**
|
|
213
54
|
* Low-level database abstraction layer for the archive's SQLite database.
|
|
214
55
|
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
56
|
+
* Every method is a thin dispatcher: the SQL itself lives in a dedicated
|
|
57
|
+
* single-export op module under `./db-ops/` (one file per operation), and
|
|
58
|
+
* the class contributes only the connection (`this.#instance`) plus the
|
|
59
|
+
* error/retry wrapper. Public methods that perform database queries use the
|
|
60
|
+
* `emitErrorAndRetry` HOF for automatic retry on transient failures combined
|
|
61
|
+
* with error-event propagation, or `emitError` when retry is not appropriate.
|
|
62
|
+
* The set of tables this layer manages is defined by `init-schema.ts` (the
|
|
63
|
+
* source of truth — query that file for the canonical list).
|
|
221
64
|
*
|
|
222
65
|
* **Label sync caveat**: each `emitError` / `emitErrorAndRetry` call passes
|
|
223
66
|
* the method name as a string literal (e.g. `'Database.getAnchorsOnPage'`).
|
|
@@ -232,6 +75,8 @@ function makeMetaResetPayload() {
|
|
|
232
75
|
export class Database extends EventEmitter {
|
|
233
76
|
/** The Knex query builder instance connected to the SQLite database. */
|
|
234
77
|
#instance;
|
|
78
|
+
/** Connection-scoped write-side id caches for entity/ref upserts. */
|
|
79
|
+
#writeRefCaches = createWriteRefCaches();
|
|
235
80
|
// eslint-disable-next-line no-restricted-syntax
|
|
236
81
|
constructor(options) {
|
|
237
82
|
super();
|
|
@@ -261,296 +106,90 @@ export class Database extends EventEmitter {
|
|
|
261
106
|
});
|
|
262
107
|
}
|
|
263
108
|
/**
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
* @deprecated Since v0.1.x. The column is now created during table initialization.
|
|
267
|
-
* @returns The result of the schema alteration, or void if the column already exists.
|
|
268
|
-
*/
|
|
269
|
-
async addOrderField() {
|
|
270
|
-
const hasColumn = await this.#instance.schema.hasColumn('pages', 'order');
|
|
271
|
-
if (hasColumn) {
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
return await this.#instance.schema.table('pages', (t) => {
|
|
275
|
-
t.integer('order').unsigned().nullable().defaultTo(null);
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
/**
|
|
279
|
-
* Forces a WAL checkpoint, writing all pending WAL data back to the main database file.
|
|
280
|
-
* Uses TRUNCATE mode to reset the WAL file to zero bytes after checkpointing.
|
|
281
|
-
* This ensures the database is fully self-contained in `db.sqlite` before archiving.
|
|
109
|
+
* Forces a WAL checkpoint, writing all pending WAL data back to the main
|
|
110
|
+
* database file. Delegates to {@link checkpointOp}.
|
|
282
111
|
*/
|
|
283
112
|
async checkpoint() {
|
|
284
|
-
await this.#instance
|
|
113
|
+
await checkpointOp(this.#instance);
|
|
285
114
|
}
|
|
286
115
|
/**
|
|
287
116
|
* Destroys the database connection, releasing all pooled resources.
|
|
117
|
+
* Delegates to {@link destroyOp}.
|
|
288
118
|
*/
|
|
289
119
|
async destroy() {
|
|
290
|
-
await this.#instance
|
|
120
|
+
await destroyOp(this.#instance);
|
|
291
121
|
}
|
|
292
122
|
/**
|
|
293
123
|
* Retrieves all anchors (outgoing links) on a specific page.
|
|
294
|
-
*
|
|
124
|
+
* Delegates to {@link getAnchorsOnPageOp}.
|
|
295
125
|
* @param pageId - The database ID of the page whose anchors to retrieve.
|
|
296
126
|
* @returns An array of anchor records with resolved URL, title, status, and content type.
|
|
297
127
|
*/
|
|
298
128
|
async getAnchorsOnPage(pageId) {
|
|
299
|
-
return emitErrorAndRetry(this, 'Database.getAnchorsOnPage', async () =>
|
|
300
|
-
const res = await this.#instance
|
|
301
|
-
.select('pages.url', 'pages.title', 'pages.status', 'pages.statusText', 'pages.contentType', 'anchors.hash', 'anchors.textContent')
|
|
302
|
-
.from('anchors')
|
|
303
|
-
.join('pages', 'anchors.hrefId', '=', 'pages.id')
|
|
304
|
-
.where('anchors.pageId', pageId);
|
|
305
|
-
return res;
|
|
306
|
-
}, retrySetting);
|
|
129
|
+
return emitErrorAndRetry(this, 'Database.getAnchorsOnPage', async () => await getAnchorsOnPageOp(this.#instance, pageId), retrySetting);
|
|
307
130
|
}
|
|
308
131
|
/**
|
|
309
132
|
* Retrieves the base URL of the crawl session from the `info` table.
|
|
133
|
+
* Delegates to {@link getBaseUrlOp}.
|
|
310
134
|
* @returns The base URL string.
|
|
311
135
|
* @throws {Error} If no base URL is found in the database.
|
|
312
136
|
*/
|
|
313
137
|
async getBaseUrl() {
|
|
314
|
-
return emitErrorAndRetry(this, 'Database.getBaseUrl', async () =>
|
|
315
|
-
const selected = await this.#instance.select('baseUrl').from('info');
|
|
316
|
-
if (!selected[0]) {
|
|
317
|
-
throw new Error('No baseUrl');
|
|
318
|
-
}
|
|
319
|
-
const [{ baseUrl }] = selected;
|
|
320
|
-
return baseUrl || '';
|
|
321
|
-
}, retrySetting);
|
|
138
|
+
return emitErrorAndRetry(this, 'Database.getBaseUrl', async () => await getBaseUrlOp(this.#instance), retrySetting);
|
|
322
139
|
}
|
|
323
140
|
/**
|
|
324
141
|
* Retrieves the full crawl configuration from the `info` table.
|
|
325
|
-
*
|
|
142
|
+
* Delegates to {@link getConfigOp}.
|
|
326
143
|
* @returns The parsed {@link Config} object.
|
|
327
144
|
* @throws {Error} If no configuration is found in the database.
|
|
328
145
|
*/
|
|
329
146
|
async getConfig() {
|
|
330
|
-
return emitErrorAndRetry(this, 'Database.getConfig', async () =>
|
|
331
|
-
const [config] = await this.#instance.select('*').from('info');
|
|
332
|
-
if (!config) {
|
|
333
|
-
throw new Error('No config');
|
|
334
|
-
}
|
|
335
|
-
const opt = {
|
|
336
|
-
...config,
|
|
337
|
-
excludes: getJSON(config.excludes, []),
|
|
338
|
-
excludeKeywords: getJSON(config.excludeKeywords, []),
|
|
339
|
-
excludeUrls: getJSON(config.excludeUrls, []),
|
|
340
|
-
roots: getJSON(config.roots, []),
|
|
341
|
-
retry: config.retry ?? 3,
|
|
342
|
-
};
|
|
343
|
-
// @ts-expect-error — `id` is the primary key, not part of the public Config shape
|
|
344
|
-
delete opt.id;
|
|
345
|
-
dbLog('Table `info`: %O => %O', config, opt);
|
|
346
|
-
return opt;
|
|
347
|
-
}, retrySetting);
|
|
147
|
+
return emitErrorAndRetry(this, 'Database.getConfig', async () => await getConfigOp(this.#instance), retrySetting);
|
|
348
148
|
}
|
|
349
149
|
/**
|
|
350
150
|
* Retrieves the current crawling state by listing scraped and pending URLs.
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
* — that is, every URL the crawl reached a terminal state on, including
|
|
354
|
-
* setSkippedPage / setExternalPage / outright setPage success or failure.
|
|
355
|
-
*
|
|
356
|
-
* `pending` is intentionally STRICT — not "every `scraped = 0` row".
|
|
357
|
-
* Three filters apply:
|
|
358
|
-
*
|
|
359
|
-
* 1. `scraped = 0` — work still incomplete.
|
|
360
|
-
* 2. `isExternal = 0` — only in-scope work. External URLs go through a
|
|
361
|
-
* HEAD-only path that always lands on `scraped = 1` (either setPage or
|
|
362
|
-
* setExternalPage). A row with `isExternal = 1 AND scraped = 0` is
|
|
363
|
-
* therefore a data anomaly, and resume / inventory / append have no
|
|
364
|
-
* business retrying it on the next session.
|
|
365
|
-
* 3. `EXISTS (anchor with hrefId = pages.id) OR source != 'crawled'` —
|
|
366
|
-
* the row was either discovered as an anchor destination during a
|
|
367
|
-
* previous scrape OR was explicitly tagged with a non-default
|
|
368
|
-
* source label (`'inventory-seed'`, `'inventory-discovered'`, …).
|
|
369
|
-
* Both halves of the OR represent "deliberately enqueued, expected
|
|
370
|
-
* to be processed", which is exactly what `resume` should pick up.
|
|
371
|
-
*
|
|
372
|
-
* The orphan filter targets the **predicted-discard leak** in
|
|
373
|
-
* `crawler.ts` where `shouldDiscardPredicted` returns true but no
|
|
374
|
-
* `emit('skip')` follows. Such placeholders are inserted with the
|
|
375
|
-
* DB DEFAULT `source = 'crawled'` (no caller explicitly labels
|
|
376
|
-
* them) AND have no anchor referrer (predicted URLs are
|
|
377
|
-
* synthesised from pagination patterns, never anchored from a
|
|
378
|
-
* rendered page) — both halves of the OR are therefore false and
|
|
379
|
-
* the leak is excluded.
|
|
380
|
-
*
|
|
381
|
-
* The `source != 'crawled'` clause specifically saves the
|
|
382
|
-
* `--inventory` × `--retry-failed` interaction: an inventory-seed
|
|
383
|
-
* URL came from the operator's URL list (no anchor referrer) and
|
|
384
|
-
* `resetFailedPages` puts it back at `scraped = 0`. Without this
|
|
385
|
-
* clause those legitimate retries would be dropped on resume.
|
|
386
|
-
*
|
|
387
|
-
* The defensive shape is on purpose: the data source can drift into
|
|
388
|
-
* anomalous states under interruption, but the reader must never throw
|
|
389
|
-
* or feed garbage back into the dealer. A real in-scope URL that was
|
|
390
|
-
* truly interrupted mid-crawl will always have at least one anchor
|
|
391
|
-
* referrer (otherwise the dealer would not have queued it), so the
|
|
392
|
-
* strict filter loses no legitimate pending work.
|
|
393
|
-
*
|
|
394
|
-
* Seeds passed directly to `Crawler.start()` are NOT in the strict
|
|
395
|
-
* pending set when they were never picked by the dealer — they have no
|
|
396
|
-
* DB row at all in that case (`linkList.add` is purely in-memory until
|
|
397
|
-
* `setPage` runs). A Ctrl-C between dealer pick and `setPage` likewise
|
|
398
|
-
* leaves no row to recover. Recovery of un-picked seeds is the
|
|
399
|
-
* responsibility of the caller (e.g. re-running `--inventory ./list.txt`
|
|
400
|
-
* with the same URL list).
|
|
401
|
-
*
|
|
402
|
-
* The query uses an explicit `p` alias on the `pages` table so the
|
|
403
|
-
* correlated `EXISTS` subquery can join via `whereRaw('anchors.hrefId =
|
|
404
|
-
* p.id')`. A future refactor that renames the alias must update both
|
|
405
|
-
* sites — the raw string in the subquery cannot be grep-resolved
|
|
406
|
-
* automatically. Read-only / stub viewer connections never call this
|
|
407
|
-
* method (they do not need to know about pending state), so the EXISTS
|
|
408
|
-
* shape is safe to use without the `migrate*` guards that other writer
|
|
409
|
-
* methods carry.
|
|
151
|
+
* Delegates to {@link getCrawlingStateOp} — see the op for the strict
|
|
152
|
+
* pending-set rationale.
|
|
410
153
|
* @returns An object with `scraped` (completed URLs) and `pending` (the
|
|
411
154
|
* strict set of in-scope, anchor-referenced, unfinished URLs).
|
|
412
155
|
*/
|
|
413
156
|
async getCrawlingState() {
|
|
414
|
-
return emitErrorAndRetry(this, 'Database.getCrawlingState', async () =>
|
|
415
|
-
const ex = (r) => r.url;
|
|
416
|
-
const $scraped = await this.#instance
|
|
417
|
-
.select('url')
|
|
418
|
-
.from('pages')
|
|
419
|
-
.where('scraped', 1);
|
|
420
|
-
const scraped = $scraped.map(ex);
|
|
421
|
-
const $pending = await this.#instance
|
|
422
|
-
.select('p.url')
|
|
423
|
-
.from({ p: 'pages' })
|
|
424
|
-
.where('p.scraped', 0)
|
|
425
|
-
.where('p.isExternal', 0)
|
|
426
|
-
.where((qb) => {
|
|
427
|
-
// "Anchored OR explicitly labelled". Either side is evidence
|
|
428
|
-
// that the row was deliberately enqueued for processing —
|
|
429
|
-
// only the predicted-discard leak (DEFAULT 'crawled' + no
|
|
430
|
-
// anchor) fails both halves. The `whereExists` callback
|
|
431
|
-
// uses `select('*')` since the column list is irrelevant
|
|
432
|
-
// inside an EXISTS check; calling through `client.raw(...)`
|
|
433
|
-
// would reach a private builder field.
|
|
434
|
-
qb.whereExists(function () {
|
|
435
|
-
this.select('*').from('anchors').whereRaw('anchors.hrefId = p.id');
|
|
436
|
-
}).orWhereNot('p.source', 'crawled');
|
|
437
|
-
});
|
|
438
|
-
const pending = $pending.map(ex);
|
|
439
|
-
return {
|
|
440
|
-
scraped,
|
|
441
|
-
pending,
|
|
442
|
-
};
|
|
443
|
-
}, retrySetting);
|
|
157
|
+
return emitErrorAndRetry(this, 'Database.getCrawlingState', async () => await getCrawlingStateOp(this.#instance), retrySetting);
|
|
444
158
|
}
|
|
445
159
|
/**
|
|
446
160
|
* Return the subset of `urls` that already exist in the `pages` table.
|
|
447
|
-
*
|
|
448
|
-
* (`SQLITE_MAX_VARIABLE_NUMBER`, default 999) cannot be hit even when the
|
|
449
|
-
* inventory list contains tens of thousands of URLs.
|
|
450
|
-
*
|
|
451
|
-
* Read-only — no transaction, no lock contention with the crawler write
|
|
452
|
-
* pipeline (callers run this BEFORE the `<archive>.bak` is taken and the
|
|
453
|
-
* crawl is started).
|
|
161
|
+
* Delegates to {@link getExistingPageUrlsOp}.
|
|
454
162
|
* @param urls - URL strings to probe (already in `withoutHashAndAuth` form).
|
|
455
163
|
* @returns URLs found in `pages`. Order is not preserved.
|
|
456
164
|
*/
|
|
457
165
|
async getExistingPageUrls(urls) {
|
|
458
|
-
return emitError(this, 'Database.getExistingPageUrls', async () =>
|
|
459
|
-
if (urls.length === 0) {
|
|
460
|
-
return [];
|
|
461
|
-
}
|
|
462
|
-
const found = [];
|
|
463
|
-
await eachSplitted([...urls], 500, async (chunk) => {
|
|
464
|
-
const rows = await this.#instance
|
|
465
|
-
.select('url')
|
|
466
|
-
.from('pages')
|
|
467
|
-
.whereIn('url', chunk);
|
|
468
|
-
for (const row of rows) {
|
|
469
|
-
found.push(row.url);
|
|
470
|
-
}
|
|
471
|
-
});
|
|
472
|
-
return found;
|
|
473
|
-
});
|
|
166
|
+
return emitError(this, 'Database.getExistingPageUrls', async () => await getExistingPageUrlsOp(this.#instance, urls));
|
|
474
167
|
}
|
|
475
168
|
/**
|
|
476
169
|
* Return the subset of `urls` that already exist in the `resources` table.
|
|
477
|
-
*
|
|
170
|
+
* Delegates to {@link getExistingResourceUrlsOp}.
|
|
478
171
|
* @param urls - URL strings to probe.
|
|
479
172
|
* @returns URLs found in `resources`.
|
|
480
173
|
*/
|
|
481
174
|
async getExistingResourceUrls(urls) {
|
|
482
|
-
return emitError(this, 'Database.getExistingResourceUrls', async () =>
|
|
483
|
-
if (urls.length === 0) {
|
|
484
|
-
return [];
|
|
485
|
-
}
|
|
486
|
-
const found = [];
|
|
487
|
-
await eachSplitted([...urls], 500, async (chunk) => {
|
|
488
|
-
const rows = await this.#instance
|
|
489
|
-
.select('url')
|
|
490
|
-
.from('resources')
|
|
491
|
-
.whereIn('url', chunk);
|
|
492
|
-
for (const row of rows) {
|
|
493
|
-
found.push(row.url);
|
|
494
|
-
}
|
|
495
|
-
});
|
|
496
|
-
return found;
|
|
497
|
-
});
|
|
175
|
+
return emitError(this, 'Database.getExistingResourceUrls', async () => await getExistingResourceUrlsOp(this.#instance, urls));
|
|
498
176
|
}
|
|
499
177
|
/**
|
|
500
178
|
* Reads the HTML snapshot stored as a zstd-compressed BLOB for the given page.
|
|
501
|
-
*
|
|
502
|
-
* Joins `page_html_ref` → `page_html_blobs` and decompresses inline. Returns
|
|
503
|
-
* `null` when the page has no stored body (a non-HTML resource, a redirect
|
|
504
|
-
* source, a degraded render). Read works identically on read-only / stub
|
|
505
|
-
* connections — the special-cased "do we have a loose dir vs zip?" branching
|
|
506
|
-
* the previous file-backed layout required is gone.
|
|
507
|
-
*
|
|
508
|
-
* Tables `page_html_ref` and `page_html_blobs` are created by `initSchema`.
|
|
509
|
-
* Older `.nitpicker` archives that predate this migration must be passed
|
|
510
|
-
* through `scripts/migrate-to-0.10.mjs` before they can be read.
|
|
179
|
+
* Delegates to {@link getHtmlOfPageByIdOp}.
|
|
511
180
|
* @param pageId - The database ID of the page.
|
|
512
181
|
* @returns The decompressed HTML string, or `null` if no snapshot is stored.
|
|
513
182
|
*/
|
|
514
183
|
async getHtmlOfPageById(pageId) {
|
|
515
|
-
return emitErrorAndRetry(this, 'Database.getHtmlOfPageById', async () =>
|
|
516
|
-
const row = await this.#instance
|
|
517
|
-
.from('page_html_ref')
|
|
518
|
-
.join('page_html_blobs', 'page_html_ref.hash', '=', 'page_html_blobs.hash')
|
|
519
|
-
.select('page_html_blobs.body as body', 'page_html_blobs.codec as codec')
|
|
520
|
-
.where('page_html_ref.page_id', pageId)
|
|
521
|
-
.first();
|
|
522
|
-
if (!row) {
|
|
523
|
-
return null;
|
|
524
|
-
}
|
|
525
|
-
return decodeStoredBlob(row.body, row.codec);
|
|
526
|
-
}, retrySetting);
|
|
184
|
+
return emitErrorAndRetry(this, 'Database.getHtmlOfPageById', async () => await getHtmlOfPageByIdOp(this.#instance, pageId), retrySetting);
|
|
527
185
|
}
|
|
528
186
|
/**
|
|
529
187
|
* Retrieves all `page_jsonld` rows for the given page id, parsed back into
|
|
530
|
-
* {@link JsonLdRow} shape
|
|
531
|
-
*
|
|
532
|
-
* Read-side counterpart to `#insertJsonLd`. Returns rows in insertion order
|
|
533
|
-
* by `id` so the order observed by `get-page-jsonld` matches the order the
|
|
534
|
-
* scraper saw them.
|
|
188
|
+
* {@link JsonLdRow} shape. Delegates to {@link getJsonLdOfPageOp}.
|
|
535
189
|
* @param pageId
|
|
536
190
|
*/
|
|
537
191
|
async getJsonLdOfPage(pageId) {
|
|
538
|
-
return emitErrorAndRetry(this, 'Database.getJsonLdOfPage', async () =>
|
|
539
|
-
const rows = await this.#instance
|
|
540
|
-
.select('id', 'pageId', 'kind', 'type', 'raw', 'parsed', 'parseError')
|
|
541
|
-
.from('page_jsonld')
|
|
542
|
-
.where('pageId', pageId)
|
|
543
|
-
.orderBy('id', 'asc');
|
|
544
|
-
return rows.map((r) => ({
|
|
545
|
-
id: r.id,
|
|
546
|
-
pageId: r.pageId,
|
|
547
|
-
kind: r.kind === 'speculationrules' ? 'speculationrules' : 'ld+json',
|
|
548
|
-
type: r.type,
|
|
549
|
-
raw: r.raw,
|
|
550
|
-
parsed: r.parsed === null ? null : safeParseJson(r.parsed),
|
|
551
|
-
parseError: r.parseError,
|
|
552
|
-
}));
|
|
553
|
-
}, retrySetting);
|
|
192
|
+
return emitErrorAndRetry(this, 'Database.getJsonLdOfPage', async () => await getJsonLdOfPageOp(this.#instance, pageId), retrySetting);
|
|
554
193
|
}
|
|
555
194
|
/**
|
|
556
195
|
* Returns the underlying Knex query builder instance for direct SQL access.
|
|
@@ -563,291 +202,83 @@ export class Database extends EventEmitter {
|
|
|
563
202
|
}
|
|
564
203
|
/**
|
|
565
204
|
* Retrieves the crawl session name from the `info` table.
|
|
205
|
+
* Delegates to {@link getNameOp}.
|
|
566
206
|
* @returns The name string.
|
|
567
207
|
* @throws {Error} If no name is found in the database.
|
|
568
208
|
*/
|
|
569
209
|
async getName() {
|
|
570
|
-
return emitErrorAndRetry(this, 'Database.getName', async () =>
|
|
571
|
-
const selected = await this.#instance.select('name').from('info');
|
|
572
|
-
if (!selected[0]) {
|
|
573
|
-
throw new Error('No name');
|
|
574
|
-
}
|
|
575
|
-
const [{ name }] = selected;
|
|
576
|
-
return name;
|
|
577
|
-
}, retrySetting);
|
|
210
|
+
return emitErrorAndRetry(this, 'Database.getName', async () => await getNameOp(this.#instance), retrySetting);
|
|
578
211
|
}
|
|
579
212
|
/**
|
|
580
213
|
* Counts the total number of pages in the database.
|
|
214
|
+
* Delegates to {@link getPageCountOp}.
|
|
581
215
|
* @returns The total page count.
|
|
582
216
|
* @throws {Error} If the count query fails.
|
|
583
217
|
*/
|
|
584
218
|
async getPageCount() {
|
|
585
|
-
return emitErrorAndRetry(this, 'Database.getPageCount', async () =>
|
|
586
|
-
const selected = await this.#instance.count('id').from('pages');
|
|
587
|
-
if (!selected[0]) {
|
|
588
|
-
throw new Error('No count');
|
|
589
|
-
}
|
|
590
|
-
// @ts-expect-error
|
|
591
|
-
const count = selected[0]['count(`id`)'];
|
|
592
|
-
dbLog('Number of pages: %d', count);
|
|
593
|
-
return count;
|
|
594
|
-
}, retrySetting);
|
|
219
|
+
return emitErrorAndRetry(this, 'Database.getPageCount', async () => await getPageCountOp(this.#instance), retrySetting);
|
|
595
220
|
}
|
|
596
221
|
/**
|
|
597
|
-
* Retrieves pages from the database with optional filtering, pagination via
|
|
222
|
+
* Retrieves pages from the database with optional filtering, pagination via
|
|
223
|
+
* offset and limit. Delegates to {@link getPagesOp}.
|
|
598
224
|
* @param filter - An optional {@link PageFilter} to narrow results by content type and origin.
|
|
599
225
|
* @param offset - The number of rows to skip. Defaults to `0`.
|
|
600
226
|
* @param limit - The maximum number of rows to return. Defaults to `100000`.
|
|
601
|
-
* @returns An array of raw
|
|
227
|
+
* @returns An array of raw `DB_Page` rows.
|
|
602
228
|
*/
|
|
603
229
|
async getPages(filter, offset = 0, limit = 100_000) {
|
|
604
|
-
return emitErrorAndRetry(this, 'Database.getPages', async () =>
|
|
605
|
-
const q = this.#instance.select('*').from('pages');
|
|
606
|
-
switch (filter) {
|
|
607
|
-
case 'page': {
|
|
608
|
-
return q
|
|
609
|
-
.where({
|
|
610
|
-
contentType: 'text/html',
|
|
611
|
-
isTarget: 1,
|
|
612
|
-
})
|
|
613
|
-
.limit(limit)
|
|
614
|
-
.offset(offset);
|
|
615
|
-
}
|
|
616
|
-
case 'page-included-no-target': {
|
|
617
|
-
return q
|
|
618
|
-
.where({
|
|
619
|
-
contentType: 'text/html',
|
|
620
|
-
})
|
|
621
|
-
.limit(limit)
|
|
622
|
-
.offset(offset);
|
|
623
|
-
}
|
|
624
|
-
case 'external-page': {
|
|
625
|
-
return q
|
|
626
|
-
.where({
|
|
627
|
-
contentType: 'text/html',
|
|
628
|
-
isExternal: 1,
|
|
629
|
-
})
|
|
630
|
-
.limit(limit)
|
|
631
|
-
.offset(offset);
|
|
632
|
-
}
|
|
633
|
-
case 'internal-page': {
|
|
634
|
-
return q
|
|
635
|
-
.where({
|
|
636
|
-
contentType: 'text/html',
|
|
637
|
-
isExternal: 0,
|
|
638
|
-
})
|
|
639
|
-
.limit(limit)
|
|
640
|
-
.offset(offset);
|
|
641
|
-
}
|
|
642
|
-
case 'no-page': {
|
|
643
|
-
return q
|
|
644
|
-
.whereNull('contentType')
|
|
645
|
-
.orWhereNot({
|
|
646
|
-
contentType: 'text/html',
|
|
647
|
-
})
|
|
648
|
-
.limit(limit)
|
|
649
|
-
.offset(offset);
|
|
650
|
-
}
|
|
651
|
-
case 'external-no-page': {
|
|
652
|
-
return q
|
|
653
|
-
.where((qb) => {
|
|
654
|
-
qb.whereNull('contentType').orWhereNot({
|
|
655
|
-
contentType: 'text/html',
|
|
656
|
-
});
|
|
657
|
-
})
|
|
658
|
-
.andWhere({
|
|
659
|
-
isExternal: 1,
|
|
660
|
-
})
|
|
661
|
-
.limit(limit)
|
|
662
|
-
.offset(offset);
|
|
663
|
-
}
|
|
664
|
-
case 'internal-no-page': {
|
|
665
|
-
return q
|
|
666
|
-
.where((qb) => {
|
|
667
|
-
qb.whereNull('contentType').orWhereNot({
|
|
668
|
-
contentType: 'text/html',
|
|
669
|
-
});
|
|
670
|
-
})
|
|
671
|
-
.andWhere({
|
|
672
|
-
isExternal: 0,
|
|
673
|
-
})
|
|
674
|
-
.limit(limit)
|
|
675
|
-
.offset(offset);
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
return q.limit(limit).offset(offset);
|
|
679
|
-
}, retrySetting);
|
|
230
|
+
return emitErrorAndRetry(this, 'Database.getPages', async () => await getPagesOp(this.#instance, filter, offset, limit), retrySetting);
|
|
680
231
|
}
|
|
681
232
|
/**
|
|
682
|
-
* Look up the `source` column of a single page by its URL key.
|
|
683
|
-
*
|
|
684
|
-
* resolve a parent page's lineage on `--resume` / `--retry-failed`
|
|
685
|
-
* sessions, where the in-memory `inventoryMode` is no longer
|
|
686
|
-
* available but the DB still remembers what label was last persisted.
|
|
687
|
-
*
|
|
688
|
-
* Returns `undefined` when the URL has no `pages` row (e.g. a brand-new
|
|
689
|
-
* URL that has not been seen yet) so the caller can fall through to
|
|
690
|
-
* its default behaviour without distinguishing "row absent" from "row
|
|
691
|
-
* present with NULL source" — the schema's `NOT NULL DEFAULT 'crawled'`
|
|
692
|
-
* makes a NULL value impossible in practice.
|
|
693
|
-
*
|
|
694
|
-
* Read-only — no transaction, single PK-equivalent lookup on
|
|
695
|
-
* `pages.url` (a UNIQUE column), so the cost is constant per call. The
|
|
696
|
-
* Crawler calls this at most once per page render, NOT per
|
|
697
|
-
* sub-resource, so the N+1 risk does not apply.
|
|
233
|
+
* Look up the `source` column of a single page by its URL key.
|
|
234
|
+
* Delegates to {@link getPageSourceByUrlOp}.
|
|
698
235
|
* @param url - URL key in `url.withoutHashAndAuth` form.
|
|
699
236
|
* @returns The recorded `source`, or `undefined` when no row exists.
|
|
700
237
|
*/
|
|
701
238
|
async getPageSourceByUrl(url) {
|
|
702
|
-
return emitError(this, 'Database.getPageSourceByUrl', async () =>
|
|
703
|
-
const [row] = await this.#instance
|
|
704
|
-
.select('source')
|
|
705
|
-
.from('pages')
|
|
706
|
-
.where('url', url);
|
|
707
|
-
return row?.source;
|
|
708
|
-
});
|
|
239
|
+
return emitError(this, 'Database.getPageSourceByUrl', async () => await getPageSourceByUrlOp(this.#instance, url));
|
|
709
240
|
}
|
|
710
241
|
/**
|
|
711
242
|
* Retrieves pages along with their related redirect, anchor, and referrer data.
|
|
712
|
-
* Results are ordered by the natural URL sort order. Only non-redirected pages
|
|
243
|
+
* Results are ordered by the natural URL sort order. Only non-redirected pages
|
|
244
|
+
* are returned. Delegates to {@link getPagesWithRelsOp}.
|
|
713
245
|
* @param offset - The number of rows to skip.
|
|
714
246
|
* @param limit - The maximum number of pages to return.
|
|
715
247
|
* @returns An object containing `pages`, `redirects`, `anchors`, and `referrers` arrays.
|
|
716
248
|
*/
|
|
717
249
|
async getPagesWithRels(offset, limit) {
|
|
718
|
-
return emitErrorAndRetry(this, 'Database.getPagesWithRels', async () =>
|
|
719
|
-
await this.addOrderField();
|
|
720
|
-
await this.setUrlOrder();
|
|
721
|
-
dbLog('Get Pages');
|
|
722
|
-
const pages = await this.#instance
|
|
723
|
-
.select('*')
|
|
724
|
-
.from('pages')
|
|
725
|
-
.orderByRaw('`order` ASC NULLS LAST')
|
|
726
|
-
.whereNull('redirectDestId')
|
|
727
|
-
.limit(limit)
|
|
728
|
-
.offset(offset);
|
|
729
|
-
// When empty
|
|
730
|
-
if (pages.length === 0) {
|
|
731
|
-
return {
|
|
732
|
-
pages: [],
|
|
733
|
-
redirects: [],
|
|
734
|
-
referrers: [],
|
|
735
|
-
anchors: [],
|
|
736
|
-
};
|
|
737
|
-
}
|
|
738
|
-
dbLog('Get Pages: Redirects');
|
|
739
|
-
const redirects = await this.#instance
|
|
740
|
-
.with('limitedPages', limitedPageIds(limit, offset))
|
|
741
|
-
.with('redirect', redirectTable(false))
|
|
742
|
-
.select('id as pageId', 'from', 'fromId')
|
|
743
|
-
.from('redirect')
|
|
744
|
-
// Filter
|
|
745
|
-
.join('limitedPages', 'redirect.toId', '=', 'limitedPages.id')
|
|
746
|
-
// Sort
|
|
747
|
-
.orderBy('id', 'asc');
|
|
748
|
-
dbLog('Get Pages: Anchors');
|
|
749
|
-
const anchors = await this.#instance
|
|
750
|
-
.with('limitedPages', limitedPageIds(limit, offset))
|
|
751
|
-
.with('redirect', redirectTable())
|
|
752
|
-
.select('limitedPages.id as pageId', 'href.url', 'redirect.from as href', 'href.isExternal', 'href.title', 'href.status', 'href.statusText', 'href.contentType', 'anchors.hash', 'anchors.textContent')
|
|
753
|
-
.from('anchors')
|
|
754
|
-
// Filters
|
|
755
|
-
.join('limitedPages', 'anchors.pageId', '=', 'limitedPages.id')
|
|
756
|
-
// Resolves redirect
|
|
757
|
-
.join('redirect', 'anchors.hrefId', '=', 'redirect.fromId')
|
|
758
|
-
// Target
|
|
759
|
-
.join('pages as href', 'redirect.toId', '=', 'href.id')
|
|
760
|
-
// Sort
|
|
761
|
-
.orderBy('anchors.id', 'asc');
|
|
762
|
-
dbLog('Get Pages: Referrers');
|
|
763
|
-
const referrers = await this.#instance
|
|
764
|
-
.with('limitedPages', limitedPageIds(limit, offset))
|
|
765
|
-
.with('redirect', redirectTable())
|
|
766
|
-
.select('redirect.toId as pageId', 'referrer.url', 'redirect.from as through', 'redirect.fromId as throughId', 'anchors.hash', 'anchors.textContent')
|
|
767
|
-
.from('anchors')
|
|
768
|
-
// Resolves redirect
|
|
769
|
-
.join('redirect', 'anchors.hrefId', '=', 'redirect.fromId')
|
|
770
|
-
// Referrer
|
|
771
|
-
.join('pages as referrer', 'anchors.pageId', '=', 'referrer.id')
|
|
772
|
-
// Filters
|
|
773
|
-
.join('limitedPages', 'redirect.toId', '=', 'limitedPages.id')
|
|
774
|
-
// Sort
|
|
775
|
-
.orderBy('anchors.id', 'asc');
|
|
776
|
-
dbLog('Get Pages: Done');
|
|
777
|
-
return {
|
|
778
|
-
pages,
|
|
779
|
-
redirects,
|
|
780
|
-
anchors,
|
|
781
|
-
referrers,
|
|
782
|
-
};
|
|
783
|
-
}, retrySetting);
|
|
250
|
+
return emitErrorAndRetry(this, 'Database.getPagesWithRels', async () => await getPagesWithRelsOp(this.#instance, offset, limit), retrySetting);
|
|
784
251
|
}
|
|
785
252
|
/**
|
|
786
253
|
* Retrieves redirect sources for the given page IDs in bulk.
|
|
254
|
+
* Delegates to {@link getRedirectsForPagesOp}.
|
|
787
255
|
* @param pageIds - The database IDs of the destination pages.
|
|
788
256
|
* @returns An array of {@link DB_Redirect} records mapping destination pages to their redirect sources.
|
|
789
257
|
*/
|
|
790
258
|
async getRedirectsForPages(pageIds) {
|
|
791
|
-
return emitErrorAndRetry(this, 'Database.getRedirectsForPages', async () =>
|
|
792
|
-
if (pageIds.length === 0)
|
|
793
|
-
return [];
|
|
794
|
-
return this.#instance
|
|
795
|
-
.select('redirectDestId as pageId', 'url as from', 'id as fromId')
|
|
796
|
-
.from('pages')
|
|
797
|
-
.whereIn('redirectDestId', pageIds);
|
|
798
|
-
}, retrySetting);
|
|
259
|
+
return emitErrorAndRetry(this, 'Database.getRedirectsForPages', async () => await getRedirectsForPagesOp(this.#instance, pageIds), retrySetting);
|
|
799
260
|
}
|
|
800
261
|
/**
|
|
801
|
-
* Retrieves pages that link to a specific page (incoming links / referrers)
|
|
802
|
-
*
|
|
803
|
-
* Incoming links are resolved **through redirects**: an anchor pointing at a
|
|
804
|
-
* redirect source (e.g. `http://x` that 301s to `https://x`) counts as a
|
|
805
|
-
* referrer of the redirect's final destination, not of the source. This keeps
|
|
806
|
-
* backlinks merged on the canonical page instead of splitting them across the
|
|
807
|
-
* `http`/`https` (or any redirect source/dest) pair. The resolution mirrors
|
|
808
|
-
* `redirectTable()` — `redirectDestId` is pre-flattened to the final
|
|
809
|
-
* destination, so `COALESCE(target.redirectDestId, target.id)` is a single hop.
|
|
262
|
+
* Retrieves pages that link to a specific page (incoming links / referrers),
|
|
263
|
+
* resolved through redirects. Delegates to {@link getReferrersOfPageOp}.
|
|
810
264
|
* @param pageId - The database ID of the target page.
|
|
811
265
|
* @returns An array of referrer records with URL, hash, and text content.
|
|
812
266
|
*/
|
|
813
267
|
async getReferrersOfPage(pageId) {
|
|
814
|
-
return emitErrorAndRetry(this, 'Database.getReferrersOfPage', async () =>
|
|
815
|
-
const res = await this.#instance
|
|
816
|
-
.select('referrer.url',
|
|
817
|
-
// `through` / `throughId` = the URL the anchor actually pointed at (the
|
|
818
|
-
// redirect source, e.g. `http://x`), mirroring `getPagesWithRels`'
|
|
819
|
-
// `redirect.from` / `redirect.fromId`. Lets report code print the
|
|
820
|
-
// "[REDIRECTED FROM]" note even on this (non-preloaded) referrer path.
|
|
821
|
-
'target.url as through', 'target.id as throughId', 'anchors.hash', 'anchors.textContent')
|
|
822
|
-
.from('anchors')
|
|
823
|
-
.join('pages as referrer', 'anchors.pageId', '=', 'referrer.id')
|
|
824
|
-
.join('pages as target', 'anchors.hrefId', '=', 'target.id')
|
|
825
|
-
.whereRaw('coalesce("target"."redirectDestId", "target"."id") = ?', [pageId]);
|
|
826
|
-
return res;
|
|
827
|
-
}, retrySetting);
|
|
268
|
+
return emitErrorAndRetry(this, 'Database.getReferrersOfPage', async () => await getReferrersOfPageOp(this.#instance, pageId), retrySetting);
|
|
828
269
|
}
|
|
829
270
|
/**
|
|
830
271
|
* Retrieves the page URLs that reference a specific resource.
|
|
272
|
+
* Delegates to {@link getReferrersOfResourceOp}.
|
|
831
273
|
* @param id - The database ID of the resource.
|
|
832
274
|
* @returns An array of page URL strings that reference the resource.
|
|
833
275
|
*/
|
|
834
276
|
async getReferrersOfResource(id) {
|
|
835
|
-
return emitErrorAndRetry(this, 'Database.getReferrersOfResource', async () =>
|
|
836
|
-
const res = await this.#instance
|
|
837
|
-
.select('pages.url')
|
|
838
|
-
.from('resources-referrers')
|
|
839
|
-
.join('resources', 'resources.id', '=', 'resources-referrers.resourceId')
|
|
840
|
-
.join('pages', 'pages.id', '=', 'resources-referrers.pageId')
|
|
841
|
-
.where('resources.id', id);
|
|
842
|
-
return res.map((r) => r.url);
|
|
843
|
-
}, retrySetting);
|
|
277
|
+
return emitErrorAndRetry(this, 'Database.getReferrersOfResource', async () => await getReferrersOfResourceOp(this.#instance, id), retrySetting);
|
|
844
278
|
}
|
|
845
279
|
/**
|
|
846
280
|
* Retrieves a single sub-resource from the `resources` table by its URL.
|
|
847
|
-
*
|
|
848
|
-
* Accepts multiple URL candidates because the stored key is the resource's
|
|
849
|
-
* `href` while callers may only know the hash-stripped form; the first match
|
|
850
|
-
* wins.
|
|
281
|
+
* Delegates to {@link getResourceByUrlOp}.
|
|
851
282
|
*
|
|
852
283
|
* Deliberately NOT wrapped with `emitError`/`emitErrorAndRetry`: the only caller (the
|
|
853
284
|
* crawler's resource-reuse hook) has a full fallback (the HEAD pre-flight),
|
|
@@ -858,872 +289,196 @@ export class Database extends EventEmitter {
|
|
|
858
289
|
* @returns The raw {@link DB_Resource} row, or `null` if none match.
|
|
859
290
|
*/
|
|
860
291
|
async getResourceByUrl(urls) {
|
|
861
|
-
return retryCall(async () => {
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
.whereIn('url', [...urls])
|
|
866
|
-
.first();
|
|
867
|
-
return res ?? null;
|
|
868
|
-
}, { ...retrySetting, label: 'Database.getResourceByUrl' });
|
|
292
|
+
return retryCall(async () => await getResourceByUrlOp(this.#instance, urls), {
|
|
293
|
+
...retrySetting,
|
|
294
|
+
label: 'Database.getResourceByUrl',
|
|
295
|
+
});
|
|
869
296
|
}
|
|
870
297
|
/**
|
|
871
298
|
* Retrieves all sub-resources from the `resources` table.
|
|
299
|
+
* Delegates to {@link getResourcesOp}.
|
|
872
300
|
* @returns An array of raw {@link DB_Resource} rows.
|
|
873
301
|
*/
|
|
874
302
|
async getResources() {
|
|
875
|
-
return emitErrorAndRetry(this, 'Database.getResources', async () =>
|
|
876
|
-
return this.#instance.select('*').from('resources');
|
|
877
|
-
}, retrySetting);
|
|
303
|
+
return emitErrorAndRetry(this, 'Database.getResources', async () => await getResourcesOp(this.#instance), retrySetting);
|
|
878
304
|
}
|
|
879
305
|
/**
|
|
880
306
|
* Retrieves a flat list of all resource URLs from the `resources` table.
|
|
307
|
+
* Delegates to {@link getResourceUrlListOp}.
|
|
881
308
|
* @returns An array of resource URL strings.
|
|
882
309
|
*/
|
|
883
310
|
async getResourceUrlList() {
|
|
884
|
-
return emitErrorAndRetry(this, 'Database.getResourceUrlList', async () =>
|
|
885
|
-
const res = await this.#instance.select('url').from('resources');
|
|
886
|
-
return res.map((r) => r.url);
|
|
887
|
-
}, retrySetting);
|
|
311
|
+
return emitErrorAndRetry(this, 'Database.getResourceUrlList', async () => await getResourceUrlListOp(this.#instance), retrySetting);
|
|
888
312
|
}
|
|
889
313
|
/**
|
|
890
314
|
* Counts pages that were scraped as crawl targets (full HTML render).
|
|
891
|
-
*
|
|
892
|
-
* Used by the crawler to seed its `pagesScraped` counter on resume so the
|
|
893
|
-
* progress display reflects all browser-rendered HTML pages across sessions,
|
|
894
|
-
* not just the current one.
|
|
895
|
-
*
|
|
896
|
-
* "HTML page" is guaranteed by `contentType = 'text/html'`, NOT by `isTarget`
|
|
897
|
-
* alone: `isTarget` means "in-scope crawl target" and is set for in-scope
|
|
898
|
-
* non-HTML resources too (e.g. a PDF reached via the HEAD pre-flight is
|
|
899
|
-
* `isTarget = 1`). Counting those would over-report the HTML page total, so
|
|
900
|
-
* page-ness is asserted at the read layer here rather than by trusting
|
|
901
|
-
* `isTarget`.
|
|
315
|
+
* Delegates to {@link getScrapedHtmlPageCountOp}.
|
|
902
316
|
* @returns The number of `text/html` rows with `isTarget = 1` and `scraped = 1`.
|
|
903
317
|
*/
|
|
904
318
|
async getScrapedHtmlPageCount() {
|
|
905
|
-
return emitErrorAndRetry(this, 'Database.getScrapedHtmlPageCount', async () =>
|
|
906
|
-
const [row] = await this.#instance
|
|
907
|
-
.from('pages')
|
|
908
|
-
.where('isTarget', 1)
|
|
909
|
-
.andWhere('scraped', 1)
|
|
910
|
-
.andWhere('contentType', 'text/html')
|
|
911
|
-
.count('* as count');
|
|
912
|
-
return row ? Number(row.count) : 0;
|
|
913
|
-
}, retrySetting);
|
|
319
|
+
return emitErrorAndRetry(this, 'Database.getScrapedHtmlPageCount', async () => await getScrapedHtmlPageCountOp(this.#instance), retrySetting);
|
|
914
320
|
}
|
|
915
321
|
/**
|
|
916
322
|
* Retrieves all `page_tags` rows for the given page id, parsed back into
|
|
917
|
-
* {@link TagRow} shape
|
|
918
|
-
* deserialised).
|
|
919
|
-
*
|
|
920
|
-
* Read-side counterpart to `#insertTags`.
|
|
323
|
+
* {@link TagRow} shape. Delegates to {@link getTagsOfPageOp}.
|
|
921
324
|
* @param pageId
|
|
922
325
|
*/
|
|
923
326
|
async getTagsOfPage(pageId) {
|
|
924
|
-
return emitErrorAndRetry(this, 'Database.getTagsOfPage', async () =>
|
|
925
|
-
const rows = await this.#instance
|
|
926
|
-
.select('id', 'pageId', 'provider', 'category', 'externalId', 'version', 'confidence', 'categories', 'sources')
|
|
927
|
-
.from('page_tags')
|
|
928
|
-
.where('pageId', pageId)
|
|
929
|
-
.orderBy('id', 'asc');
|
|
930
|
-
return rows.map((r) => ({
|
|
931
|
-
id: r.id,
|
|
932
|
-
pageId: r.pageId,
|
|
933
|
-
provider: r.provider,
|
|
934
|
-
category: r.category,
|
|
935
|
-
externalId: r.externalId,
|
|
936
|
-
version: r.version,
|
|
937
|
-
confidence: r.confidence,
|
|
938
|
-
categories: r.categories === null
|
|
939
|
-
? []
|
|
940
|
-
: (safeParseJson(r.categories) ?? []),
|
|
941
|
-
sources: r.sources === null
|
|
942
|
-
? []
|
|
943
|
-
: (safeParseJson(r.sources) ?? []),
|
|
944
|
-
}));
|
|
945
|
-
}, retrySetting);
|
|
327
|
+
return emitErrorAndRetry(this, 'Database.getTagsOfPage', async () => await getTagsOfPageOp(this.#instance, pageId), retrySetting);
|
|
946
328
|
}
|
|
947
329
|
/**
|
|
948
330
|
* Records a crawler-level (`error` channel) failure into `crawl_errors`.
|
|
949
|
-
*
|
|
950
|
-
* Unlike {@link insertPageError} this is not tied to a scraped page: `url`
|
|
951
|
-
* may be an external link that never became a page row, or `null` for a
|
|
952
|
-
* process-level error. The cause is intentionally not stored — it is derived
|
|
953
|
-
* on read so that older archives (which only have `error.log`) and freshly
|
|
954
|
-
* captured rows classify identically.
|
|
331
|
+
* Delegates to {@link insertCrawlErrorOp}.
|
|
955
332
|
* @param url - The URL the error is about, or `null` for a process-level error.
|
|
956
333
|
* @param message - The error message (one line is enough for classification).
|
|
957
334
|
* @param isExternal - Whether the URL is external to the crawl scope.
|
|
958
335
|
*/
|
|
959
336
|
async insertCrawlError(url, message, isExternal = false) {
|
|
960
|
-
return emitErrorAndRetry(this, 'Database.insertCrawlError', async () =>
|
|
961
|
-
await this.#instance('crawl_errors').insert({
|
|
962
|
-
url,
|
|
963
|
-
isExternal: isExternal ? 1 : 0,
|
|
964
|
-
message,
|
|
965
|
-
createdAt: Date.now(),
|
|
966
|
-
});
|
|
967
|
-
}, retrySetting);
|
|
337
|
+
return emitErrorAndRetry(this, 'Database.insertCrawlError', async () => await insertCrawlErrorOp(this.#instance, url, message, isExternal), retrySetting);
|
|
968
338
|
}
|
|
969
339
|
/**
|
|
970
|
-
* Pre-insert inventory non-HTML URLs into `resources` as placeholder rows
|
|
971
|
-
*
|
|
972
|
-
* non-HTML counterpart of {@link Database.insertInventorySeeds}. Used by
|
|
973
|
-
* `CrawlerOrchestrator.inventory` so the ingestion phase commits all of
|
|
974
|
-
* its non-HTML URLs in one chunked round-trip per 500 instead of N
|
|
975
|
-
* sequential `insertResource` awaits. On a 50k-URL inventory list the
|
|
976
|
-
* old per-URL loop spent minutes inside the `.bak`-protected window;
|
|
977
|
-
* the bulk path finishes in seconds.
|
|
978
|
-
*
|
|
979
|
-
* Idempotent: `onConflict('url').ignore()` leaves existing rows untouched
|
|
980
|
-
* (the orchestrator's `getExistingResourceUrls` filter is what keeps a
|
|
981
|
-
* crawled-lineage `resources` row from being downgraded to the
|
|
982
|
-
* inventory label here).
|
|
983
|
-
*
|
|
984
|
-
* Chunked at 500 to stay well under SQLite's `SQLITE_MAX_VARIABLE_NUMBER`
|
|
985
|
-
* (default 999) — every row binds the URL plus the `responseHeaders`
|
|
986
|
-
* JSON null, so the per-chunk bound budget is well within limits.
|
|
340
|
+
* Pre-insert inventory non-HTML URLs into `resources` as placeholder rows.
|
|
341
|
+
* Delegates to {@link insertInventoryResourcesOp}.
|
|
987
342
|
* @param urls - URL strings (already in `withoutHashAndAuth` form).
|
|
988
343
|
*/
|
|
989
344
|
async insertInventoryResources(urls) {
|
|
990
|
-
return emitErrorAndRetry(this, 'Database.insertInventoryResources', async () =>
|
|
991
|
-
if (urls.length === 0) {
|
|
992
|
-
return;
|
|
993
|
-
}
|
|
994
|
-
await eachSplitted([...urls], 500, async (chunk) => {
|
|
995
|
-
await this.#instance('resources')
|
|
996
|
-
.insert(chunk.map((url) => ({
|
|
997
|
-
url,
|
|
998
|
-
isExternal: 0,
|
|
999
|
-
status: null,
|
|
1000
|
-
statusText: null,
|
|
1001
|
-
contentType: null,
|
|
1002
|
-
contentLength: null,
|
|
1003
|
-
compress: 0,
|
|
1004
|
-
cdn: 0,
|
|
1005
|
-
responseHeaders: null,
|
|
1006
|
-
source: 'inventory-seed',
|
|
1007
|
-
})))
|
|
1008
|
-
.onConflict('url')
|
|
1009
|
-
.ignore();
|
|
1010
|
-
});
|
|
1011
|
-
}, retrySetting);
|
|
345
|
+
return emitErrorAndRetry(this, 'Database.insertInventoryResources', async () => await insertInventoryResourcesOp(this.#instance, this.#writeRefCaches, urls), retrySetting);
|
|
1012
346
|
}
|
|
1013
347
|
/**
|
|
1014
348
|
* Pre-insert inventory HTML seeds into `pages` as `scraped = 0`,
|
|
1015
|
-
* `source = 'inventory-seed'` placeholders
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
1018
|
-
* Why this is the linchpin of `--inventory` Ctrl+C tolerance: HTML seeds
|
|
1019
|
-
* used to live only in the Crawler's in-memory `LinkList` until the
|
|
1020
|
-
* dealer eventually called `setPage`. A Ctrl+C / crash before that point
|
|
1021
|
-
* lost the seed without trace, and `--resume` could not recover it
|
|
1022
|
-
* because `getCrawlingState`'s strict pending set requires a `pages` row.
|
|
1023
|
-
* Pre-inserting fills exactly that gap: the strict pending set picks
|
|
1024
|
-
* these rows up via its `OR p.source != 'crawled'` clause, so
|
|
1025
|
-
* `--resume` after an interrupted inventory pass picks every seed back
|
|
1026
|
-
* up. See {@link Database.getCrawlingState} for the strict-set rationale.
|
|
1027
|
-
*
|
|
1028
|
-
* Idempotent: `onConflict('url').ignore()` keeps existing rows intact.
|
|
1029
|
-
* The {@link Database.#getIdByUrl} crawled-wins downgrade still fires
|
|
1030
|
-
* later when a crawled-lineage anchor reaches one of these seeds —
|
|
1031
|
-
* that's the right behaviour (a seed that turned out to be reachable
|
|
1032
|
-
* is not an orphan and should not retain the inventory label).
|
|
1033
|
-
*
|
|
1034
|
-
* Chunked into 500-URL batches so SQLite's bound-parameter limit
|
|
1035
|
-
* (`SQLITE_MAX_VARIABLE_NUMBER`, default 999) cannot be hit even on a
|
|
1036
|
-
* tens-of-thousands inventory list.
|
|
1037
|
-
*
|
|
1038
|
-
* Called by {@link CrawlerOrchestrator.inventory} during the
|
|
1039
|
-
* `.bak`-protected ingestion phase, so any failure here aborts the run
|
|
1040
|
-
* and restores from backup — the operator reruns from scratch.
|
|
349
|
+
* `source = 'inventory-seed'` placeholders. Delegates to
|
|
350
|
+
* {@link insertInventorySeedsOp} — see the op for the Ctrl+C tolerance
|
|
351
|
+
* rationale.
|
|
1041
352
|
* @param urls - URL strings already in `withoutHashAndAuth` form.
|
|
1042
353
|
*/
|
|
1043
354
|
async insertInventorySeeds(urls) {
|
|
1044
|
-
return emitErrorAndRetry(this, 'Database.insertInventorySeeds', async () =>
|
|
1045
|
-
if (urls.length === 0) {
|
|
1046
|
-
return;
|
|
1047
|
-
}
|
|
1048
|
-
await eachSplitted([...urls], 500, async (chunk) => {
|
|
1049
|
-
await this.#instance('pages')
|
|
1050
|
-
.insert(chunk.map((url) => ({
|
|
1051
|
-
url,
|
|
1052
|
-
scraped: 0,
|
|
1053
|
-
isExternal: 0,
|
|
1054
|
-
isTarget: 0,
|
|
1055
|
-
source: 'inventory-seed',
|
|
1056
|
-
})))
|
|
1057
|
-
.onConflict('url')
|
|
1058
|
-
.ignore();
|
|
1059
|
-
});
|
|
1060
|
-
}, retrySetting);
|
|
355
|
+
return emitErrorAndRetry(this, 'Database.insertInventorySeeds', async () => await insertInventorySeedsOp(this.#instance, this.#writeRefCaches, urls), retrySetting);
|
|
1061
356
|
}
|
|
1062
357
|
/**
|
|
1063
358
|
* Records a partial scrape failure against the page identified by `url`.
|
|
1064
|
-
*
|
|
1065
|
-
* The page row is resolved (or inserted as a stub) via
|
|
1066
|
-
* {@link Database.#getIdByUrl} so the error can be recorded even before
|
|
1067
|
-
* `setPage` has run — useful when the failure fires during scraping
|
|
1068
|
-
* (e.g. mid-`scrapeStart`) and the orchestrator enqueues this write
|
|
1069
|
-
* before the success write for the same URL.
|
|
1070
|
-
*
|
|
1071
|
-
* A single page can have multiple `page_errors` rows (e.g. both
|
|
1072
|
-
* `desktop-compact` and `mobile-small` viewports failing).
|
|
359
|
+
* Delegates to {@link insertPageErrorOp}.
|
|
1073
360
|
* @param url - URL of the page being scraped.
|
|
1074
361
|
* @param phase - Scrape phase name (typically `'retryExhausted'`).
|
|
1075
362
|
* @param message - Human-readable failure message.
|
|
1076
363
|
* @param isExternal - Whether the URL is external. Defaults to `false`.
|
|
1077
364
|
*/
|
|
1078
365
|
async insertPageError(url, phase, message, isExternal = false) {
|
|
1079
|
-
return emitErrorAndRetry(this, 'Database.insertPageError', async () =>
|
|
1080
|
-
const pageId = await this.#getIdByUrl(url, isExternal ? 1 : 0);
|
|
1081
|
-
await this.#instance('page_errors').insert({
|
|
1082
|
-
pageId,
|
|
1083
|
-
phase,
|
|
1084
|
-
message,
|
|
1085
|
-
createdAt: Date.now(),
|
|
1086
|
-
});
|
|
1087
|
-
}, retrySetting);
|
|
366
|
+
return emitErrorAndRetry(this, 'Database.insertPageError', async () => await insertPageErrorOp(this.#instance, this.#writeRefCaches, url, phase, message, isExternal), retrySetting);
|
|
1088
367
|
}
|
|
1089
368
|
/**
|
|
1090
369
|
* Inserts a sub-resource into the `resources` table.
|
|
1091
|
-
*
|
|
1092
|
-
*
|
|
1093
|
-
* The `source` provenance label is written ONLY on insert; an
|
|
1094
|
-
* `ON CONFLICT IGNORE` collision leaves an existing row's source untouched
|
|
1095
|
-
* (this is what makes a second `crawl --inventory` non-destructive — see
|
|
1096
|
-
* the inventory plan).
|
|
370
|
+
* Delegates to {@link insertResourceOp}.
|
|
1097
371
|
* @param resource - The resource data to insert.
|
|
1098
372
|
* @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
|
|
1099
373
|
*/
|
|
1100
374
|
async insertResource(resource, source) {
|
|
1101
|
-
return emitErrorAndRetry(this, 'Database.insertResource', async () =>
|
|
1102
|
-
await this.#instance
|
|
1103
|
-
.from('resources')
|
|
1104
|
-
.insert({
|
|
1105
|
-
url: resource.url.href,
|
|
1106
|
-
isExternal: resource.isExternal ? 1 : 0,
|
|
1107
|
-
status: resource.status,
|
|
1108
|
-
statusText: resource.statusText,
|
|
1109
|
-
// Canonicalize like `pages.contentType` (see #insertPage) so resource
|
|
1110
|
-
// content-type filters / dedupe keys are case- and whitespace-stable.
|
|
1111
|
-
contentType: normalizeContentType(resource.contentType),
|
|
1112
|
-
contentLength: resource.contentLength,
|
|
1113
|
-
compress: resource.compress || 0,
|
|
1114
|
-
cdn: resource.cdn || 0,
|
|
1115
|
-
responseHeaders: JSON.stringify(resource.headers),
|
|
1116
|
-
...(source === undefined ? {} : { source }),
|
|
1117
|
-
})
|
|
1118
|
-
.onConflict('url')
|
|
1119
|
-
.ignore();
|
|
1120
|
-
}, retrySetting);
|
|
375
|
+
return emitErrorAndRetry(this, 'Database.insertResource', async () => await insertResourceOp(this.#instance, this.#writeRefCaches, resource, source), retrySetting);
|
|
1121
376
|
}
|
|
1122
377
|
/**
|
|
1123
378
|
* Inserts a referrer relationship between a resource and a page into the
|
|
1124
|
-
* `resources-referrers` table.
|
|
379
|
+
* `resources-referrers` table. Delegates to {@link insertResourceReferrersOp}.
|
|
1125
380
|
* @param src - The URL of the resource.
|
|
1126
381
|
* @param pageUrl - The URL of the page that references the resource.
|
|
1127
382
|
*/
|
|
1128
383
|
async insertResourceReferrers(src, pageUrl) {
|
|
1129
|
-
return emitErrorAndRetry(this, 'Database.insertResourceReferrers', async () =>
|
|
1130
|
-
const selected = await this.#instance
|
|
1131
|
-
.select('id')
|
|
1132
|
-
.from('resources')
|
|
1133
|
-
.where('url', src);
|
|
1134
|
-
if (!selected[0]) {
|
|
1135
|
-
// Ignore when the resource is not found
|
|
1136
|
-
return;
|
|
1137
|
-
}
|
|
1138
|
-
const [{ id: resourceId }] = selected;
|
|
1139
|
-
const pageId = await this.#getIdByUrl(pageUrl);
|
|
1140
|
-
await this.#instance('resources-referrers')
|
|
1141
|
-
.insert({
|
|
1142
|
-
resourceId,
|
|
1143
|
-
pageId,
|
|
1144
|
-
})
|
|
1145
|
-
.onConflict(['resourceId', 'pageId'])
|
|
1146
|
-
.ignore();
|
|
1147
|
-
}, retrySetting);
|
|
384
|
+
return emitErrorAndRetry(this, 'Database.insertResourceReferrers', async () => await insertResourceReferrersOp(this.#instance, this.#writeRefCaches, src, pageUrl), retrySetting);
|
|
1148
385
|
}
|
|
1149
386
|
/**
|
|
1150
387
|
* Hostnames whose `crawl_errors` history is consistently DNS failures and
|
|
1151
|
-
* for which no recent 2xx-3xx page or resource is recorded
|
|
1152
|
-
*
|
|
1153
|
-
* form. Used by `CrawlerOrchestrator.#preloadDnsBurnedHostCache` so the
|
|
1154
|
-
* next session short-circuits HEAD pre-flight on these hosts.
|
|
1155
|
-
*
|
|
1156
|
-
* Implementation: a coarse `LIKE` filter over `crawl_errors.message`
|
|
1157
|
-
* narrows the row set, then `classifyErrorKind` confirms `'dns'` in JS
|
|
1158
|
-
* (the regex is the single truth source — DB-side filters never narrow
|
|
1159
|
-
* it). Exclusion bags are built from a single `pages` and a single
|
|
1160
|
-
* `resources` scan: any host with a 2xx-3xx page, a 2xx-3xx resource, or
|
|
1161
|
-
* a `pages.lastCrawledAt` newer than its latest DNS error is dropped
|
|
1162
|
-
* (the host probably recovered between the failure and the last crawl).
|
|
1163
|
-
*
|
|
1164
|
-
* Returns `[]` on legacy archives that pre-date the `crawl_errors`
|
|
1165
|
-
* table — the `hasTable` guard keeps the call non-destructive.
|
|
388
|
+
* for which no recent 2xx-3xx page or resource is recorded.
|
|
389
|
+
* Delegates to {@link listDnsBurnedHostCandidatesOp}.
|
|
1166
390
|
* @returns Lower-cased hostnames safe to short-circuit.
|
|
1167
391
|
*/
|
|
1168
392
|
async listDnsBurnedHostCandidates() {
|
|
1169
|
-
return emitErrorAndRetry(this, 'Database.listDnsBurnedHostCandidates', async () =>
|
|
1170
|
-
const hasCrawlErrors = await this.#instance.schema.hasTable('crawl_errors');
|
|
1171
|
-
if (!hasCrawlErrors) {
|
|
1172
|
-
return [];
|
|
1173
|
-
}
|
|
1174
|
-
// Coarse SQL filter: cheap LIKE OR-chain over `message`. The dns regex
|
|
1175
|
-
// truth source lives in `classifyErrorKind`, so we only need to feed it
|
|
1176
|
-
// rows that COULD match a DNS token. Each LIKE is anchored on a known
|
|
1177
|
-
// substring of the regex so future additions to the regex (without
|
|
1178
|
-
// matching new SQL terms) widen the JS-side filter only — never narrow it.
|
|
1179
|
-
//
|
|
1180
|
-
// `%EAI_AGAIN%` is deliberately NOT in the SQL filter: it now classifies
|
|
1181
|
-
// as `dns-transient` (local resolver hiccup), not `dns`, so it must not
|
|
1182
|
-
// reach this candidate set. The `%getaddrinfo%` term still pulls
|
|
1183
|
-
// `getaddrinfo EAI_AGAIN ...` rows but the JS-side `classifyErrorKind`
|
|
1184
|
-
// check (first-match-wins) routes them to `dns-transient` and they
|
|
1185
|
-
// silently drop out — keeping the cache focused on real NXDOMAIN.
|
|
1186
|
-
const dnsLikeRows = (await this.#instance('crawl_errors')
|
|
1187
|
-
.select('url', 'message', 'createdAt')
|
|
1188
|
-
.whereNotNull('url')
|
|
1189
|
-
.where((qb) => {
|
|
1190
|
-
qb.where('message', 'like', '%ENOTFOUND%')
|
|
1191
|
-
.orWhere('message', 'like', '%getaddrinfo%')
|
|
1192
|
-
.orWhere('message', 'like', '%ERR_NAME_NOT_RESOLVED%')
|
|
1193
|
-
.orWhere('message', 'like', '%ERR_NAME_RESOLUTION_FAILED%');
|
|
1194
|
-
}));
|
|
1195
|
-
if (dnsLikeRows.length === 0) {
|
|
1196
|
-
return [];
|
|
1197
|
-
}
|
|
1198
|
-
// Map<hostname, latestErrorCreatedAt> for hosts whose error message
|
|
1199
|
-
// confidently classifies as DNS (LIKE matched but classifyErrorKind says
|
|
1200
|
-
// e.g. `unknown` → drop).
|
|
1201
|
-
const candidateLatestErrorAt = new Map();
|
|
1202
|
-
for (const row of dnsLikeRows) {
|
|
1203
|
-
if (classifyErrorKind(row.message) !== 'dns') {
|
|
1204
|
-
continue;
|
|
1205
|
-
}
|
|
1206
|
-
let host;
|
|
1207
|
-
try {
|
|
1208
|
-
host = new URL(row.url).hostname.toLowerCase();
|
|
1209
|
-
}
|
|
1210
|
-
catch {
|
|
1211
|
-
continue;
|
|
1212
|
-
}
|
|
1213
|
-
if (!host) {
|
|
1214
|
-
continue;
|
|
1215
|
-
}
|
|
1216
|
-
const createdAt = typeof row.createdAt === 'number' ? row.createdAt : 0;
|
|
1217
|
-
const previous = candidateLatestErrorAt.get(host) ?? 0;
|
|
1218
|
-
if (createdAt > previous) {
|
|
1219
|
-
candidateLatestErrorAt.set(host, createdAt);
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
if (candidateLatestErrorAt.size === 0) {
|
|
1223
|
-
return [];
|
|
1224
|
-
}
|
|
1225
|
-
// Exclusion-bag #1: pages with a 2xx-3xx status anywhere on the host.
|
|
1226
|
-
// Tracking the latest `lastCrawledAt` per host lets us additionally
|
|
1227
|
-
// drop hosts whose last successful contact post-dates the most recent
|
|
1228
|
-
// DNS error (the host probably came back after a transient outage).
|
|
1229
|
-
const pageOkRows = (await this.#instance('pages')
|
|
1230
|
-
.select('url', 'lastCrawledAt')
|
|
1231
|
-
.whereBetween('status', [200, 399]));
|
|
1232
|
-
const pageOkHosts = new Set();
|
|
1233
|
-
const latestPageOkAt = new Map();
|
|
1234
|
-
for (const row of pageOkRows) {
|
|
1235
|
-
let host;
|
|
1236
|
-
try {
|
|
1237
|
-
host = new URL(row.url).hostname.toLowerCase();
|
|
1238
|
-
}
|
|
1239
|
-
catch {
|
|
1240
|
-
continue;
|
|
1241
|
-
}
|
|
1242
|
-
pageOkHosts.add(host);
|
|
1243
|
-
if (typeof row.lastCrawledAt === 'number') {
|
|
1244
|
-
const previous = latestPageOkAt.get(host) ?? 0;
|
|
1245
|
-
if (row.lastCrawledAt > previous) {
|
|
1246
|
-
latestPageOkAt.set(host, row.lastCrawledAt);
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
}
|
|
1250
|
-
// Exclusion-bag #2: non-HTML resources with a 2xx-3xx status. resources
|
|
1251
|
-
// have no timestamp column so this is presence-only.
|
|
1252
|
-
const resourceOkRows = (await this.#instance('resources')
|
|
1253
|
-
.select('url')
|
|
1254
|
-
.whereBetween('status', [200, 399]));
|
|
1255
|
-
const resourceOkHosts = new Set();
|
|
1256
|
-
for (const row of resourceOkRows) {
|
|
1257
|
-
let host;
|
|
1258
|
-
try {
|
|
1259
|
-
host = new URL(row.url).hostname.toLowerCase();
|
|
1260
|
-
}
|
|
1261
|
-
catch {
|
|
1262
|
-
continue;
|
|
1263
|
-
}
|
|
1264
|
-
resourceOkHosts.add(host);
|
|
1265
|
-
}
|
|
1266
|
-
// A candidate host is burned only if neither pages nor resources hold a
|
|
1267
|
-
// 2xx-3xx for it, AND its latest 2xx page (if any) is not newer than
|
|
1268
|
-
// the latest DNS error. The third check guards against re-burning a
|
|
1269
|
-
// host that recovered between the last DNS failure and the most recent
|
|
1270
|
-
// crawl.
|
|
1271
|
-
const burned = [];
|
|
1272
|
-
for (const [host, latestErrorAt] of candidateLatestErrorAt) {
|
|
1273
|
-
if (pageOkHosts.has(host)) {
|
|
1274
|
-
continue;
|
|
1275
|
-
}
|
|
1276
|
-
if (resourceOkHosts.has(host)) {
|
|
1277
|
-
continue;
|
|
1278
|
-
}
|
|
1279
|
-
const latestOkAt = latestPageOkAt.get(host);
|
|
1280
|
-
if (typeof latestOkAt === 'number' && latestOkAt > latestErrorAt) {
|
|
1281
|
-
continue;
|
|
1282
|
-
}
|
|
1283
|
-
burned.push(host);
|
|
1284
|
-
}
|
|
1285
|
-
return burned;
|
|
1286
|
-
}, retrySetting);
|
|
393
|
+
return emitErrorAndRetry(this, 'Database.listDnsBurnedHostCandidates', async () => await listDnsBurnedHostCandidatesOp(this.#instance), retrySetting);
|
|
1287
394
|
}
|
|
1288
395
|
/**
|
|
1289
396
|
* Appends one row to the `inventory_runs` audit log.
|
|
1290
|
-
*
|
|
1291
|
-
* Called by {@link CrawlerOrchestrator.inventory} on every successful
|
|
1292
|
-
* `--inventory <list>` invocation so the archive carries a durable
|
|
1293
|
-
* record of which deploy list was applied when and at what scale —
|
|
1294
|
-
* the operational question "did we apply last month's list" the
|
|
1295
|
-
* archive itself can answer without consulting external bookkeeping.
|
|
1296
|
-
*
|
|
1297
|
-
* Append-only at Phase 1. There is intentionally no UPDATE path and
|
|
1298
|
-
* no UNIQUE constraint on `source_file_sha256`; two applies of the
|
|
1299
|
-
* same list each get their own row, and `Phase 3 --refresh` is where
|
|
1300
|
-
* dedupe / pre-flight against the hash will land. Field-level NULL
|
|
1301
|
-
* semantics live on {@link InventoryRunMeta}.
|
|
397
|
+
* Delegates to {@link recordInventoryRunOp}.
|
|
1302
398
|
* @param meta - The run metadata to record. Only `ran_at` is required.
|
|
1303
399
|
* @returns The autoincremented `id` of the newly-inserted row.
|
|
1304
400
|
*/
|
|
1305
401
|
async recordInventoryRun(meta) {
|
|
1306
|
-
return emitErrorAndRetry(this, 'Database.recordInventoryRun', async () =>
|
|
1307
|
-
const inserted = await this.#instance
|
|
1308
|
-
.from('inventory_runs')
|
|
1309
|
-
.insert({
|
|
1310
|
-
ran_at: meta.ran_at,
|
|
1311
|
-
list_label: meta.list_label ?? null,
|
|
1312
|
-
source_file_sha256: meta.source_file_sha256 ?? null,
|
|
1313
|
-
total_lines: meta.total_lines ?? null,
|
|
1314
|
-
new_pages: meta.new_pages ?? null,
|
|
1315
|
-
new_resources: meta.new_resources ?? null,
|
|
1316
|
-
scope_skipped: meta.scope_skipped ?? null,
|
|
1317
|
-
notes: meta.notes ?? null,
|
|
1318
|
-
})
|
|
1319
|
-
.returning('id');
|
|
1320
|
-
const id = inserted[0]?.id;
|
|
1321
|
-
if (typeof id !== 'number') {
|
|
1322
|
-
throw new TypeError('recordInventoryRun: INSERT returned no row id');
|
|
1323
|
-
}
|
|
1324
|
-
return id;
|
|
1325
|
-
}, retrySetting);
|
|
402
|
+
return emitErrorAndRetry(this, 'Database.recordInventoryRun', async () => await recordInventoryRunOp(this.#instance, meta), retrySetting);
|
|
1326
403
|
}
|
|
1327
404
|
/**
|
|
1328
405
|
* Records a redirect edge (source → destination) **without** re-storing the
|
|
1329
|
-
* destination's content.
|
|
1330
|
-
*
|
|
1331
|
-
* The crawler renders a many-to-one redirect destination exactly once. For
|
|
1332
|
-
* every subsequent source URL that redirects to that already-rendered
|
|
1333
|
-
* destination, it calls this instead of {@link updatePage} (#73). Routing a
|
|
1334
|
-
* content-less HEAD result through `updatePage` would funnel it into
|
|
1335
|
-
* `#insertPage` and overwrite the destination's good title / meta with empty
|
|
1336
|
-
* values, so the dedicated edge-only path is required.
|
|
1337
|
-
*
|
|
1338
|
-
* The destination row is resolved (created on demand if a concurrent in-flight
|
|
1339
|
-
* render has not committed it yet) so the edge always points at a valid id;
|
|
1340
|
-
* the single render fills in the destination's content under that same id.
|
|
1341
|
-
* The destination's existing anchors / images are never touched here.
|
|
406
|
+
* destination's content. Delegates to {@link recordRedirectOp}.
|
|
1342
407
|
* @param page - HEAD-resolved page data carrying the redirect chain. Its
|
|
1343
408
|
* `anchorList` / `imageList` are ignored (a redirect source owns no content).
|
|
1344
|
-
* @param source - Inventory provenance forwarded by the orchestrator
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1347
|
-
* the archive (`#73` convergence on first sight, js-redirect rescue
|
|
1348
|
-
* before any prior write). When the originating row already exists
|
|
1349
|
-
* (e.g. anchor-lineage INSERT from a prior pass), its stored `source`
|
|
1350
|
-
* takes precedence so transitive lineage is preserved across resume /
|
|
1351
|
-
* retry-failed sessions. `undefined` keeps the DB DEFAULT `'crawled'`
|
|
1352
|
-
* on a brand-new destination row.
|
|
409
|
+
* @param source - Inventory provenance forwarded by the orchestrator for
|
|
410
|
+
* the redirect-edge fast path. `undefined` keeps the DB DEFAULT
|
|
411
|
+
* `'crawled'` on a brand-new destination row.
|
|
1353
412
|
*/
|
|
1354
413
|
async recordRedirect(page, source) {
|
|
1355
|
-
return emitErrorAndRetry(this, 'Database.recordRedirect', async () =>
|
|
1356
|
-
const { destUrl, sources } = resolveRedirectChain(page.url.withoutHashAndAuth, page.redirectPaths);
|
|
1357
|
-
// No redirect chain (the URL is itself the already-rendered destination,
|
|
1358
|
-
// reached both directly and via a redirect) → there is no edge to write.
|
|
1359
|
-
// Returning here avoids opening a transaction and, crucially, avoids
|
|
1360
|
-
// `#getIdByUrl` inserting a content-less placeholder row for a destination
|
|
1361
|
-
// that may not have been written yet.
|
|
1362
|
-
if (sources.length === 0) {
|
|
1363
|
-
return;
|
|
1364
|
-
}
|
|
1365
|
-
const destUrlObject = parseUrl(destUrl);
|
|
1366
|
-
if (!destUrlObject) {
|
|
1367
|
-
// A malformed redirect target should not abort the whole crawl (this
|
|
1368
|
-
// runs inside the WriteQueue, whose rejection aborts the run). Recording
|
|
1369
|
-
// a single redirect edge is best-effort, so skip it and move on. Unlike
|
|
1370
|
-
// `updatePage`, there is no page content at stake here.
|
|
1371
|
-
dbLog('recordRedirect: skip malformed destination URL: %s', destUrl);
|
|
1372
|
-
return;
|
|
1373
|
-
}
|
|
1374
|
-
await this.#instance.transaction(async (trx) => {
|
|
1375
|
-
// Pass the caller-supplied `source` straight through so a
|
|
1376
|
-
// brand-new destination row INSERTed here picks up the
|
|
1377
|
-
// inventory lineage (instead of the DB DEFAULT `'crawled'`)
|
|
1378
|
-
// when the caller is in the inventory chain — closes the
|
|
1379
|
-
// hole where `recordRedirect` was previously laundering
|
|
1380
|
-
// inventory lineage to `'crawled'` for js-redirect rescue /
|
|
1381
|
-
// #73 convergence destinations that had not yet been
|
|
1382
|
-
// rendered.
|
|
1383
|
-
const destId = await this.#getIdByUrl(destUrlObject.withoutHashAndAuth, undefined, trx, source);
|
|
1384
|
-
// Chain lineage propagates FROM the originating URL
|
|
1385
|
-
// (`page.url`), NOT from the destination. The originating
|
|
1386
|
-
// URL is what initiated the redirect chain, so its lineage
|
|
1387
|
-
// is what every intermediate hop transitively inherits.
|
|
1388
|
-
// Reading from the destination would mis-propagate in
|
|
1389
|
-
// "inventory-seed → ... → existing crawled dest" chains:
|
|
1390
|
-
// the intermediates are reached only via the inventory
|
|
1391
|
-
// chain, so they belong to the inventory chain even though
|
|
1392
|
-
// the chain happens to land on a crawled URL. The
|
|
1393
|
-
// `'crawled'` fallback arms the crawled-wins downgrade for
|
|
1394
|
-
// existing `'inventory-*'` intermediates that a crawled
|
|
1395
|
-
// chain reaches.
|
|
1396
|
-
const [originatingRow] = await trx
|
|
1397
|
-
.select('source')
|
|
1398
|
-
.from('pages')
|
|
1399
|
-
.where('url', page.url.withoutHashAndAuth);
|
|
1400
|
-
const originatingSource = originatingRow?.source ?? source;
|
|
1401
|
-
const chainLineageSource = deriveLineageFromParent(originatingSource, 'crawled');
|
|
1402
|
-
await this.#linkRedirectSources(trx, sources, destId, destUrlObject.withoutHashAndAuth, page.isExternal, chainLineageSource);
|
|
1403
|
-
});
|
|
1404
|
-
}, retrySetting);
|
|
414
|
+
return emitErrorAndRetry(this, 'Database.recordRedirect', async () => await recordRedirectOp(this.#instance, this.#writeRefCaches, page, source), retrySetting);
|
|
1405
415
|
}
|
|
1406
416
|
/**
|
|
1407
|
-
*
|
|
1408
|
-
*
|
|
1409
|
-
*
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
*
|
|
1416
|
-
*
|
|
1417
|
-
*
|
|
1418
|
-
* `anchors.hrefId` remain valid. SELECT and UPDATE/DELETE statements are
|
|
1419
|
-
* chunked to stay below SQLite's `SQLITE_LIMIT_VARIABLE_NUMBER`.
|
|
417
|
+
* Replaces the stored analysis violations with a freshly generated set.
|
|
418
|
+
* Delegates to {@link replaceAnalysisViolationsOp}.
|
|
419
|
+
* @param violations - Flat violation list from the analyze phase.
|
|
420
|
+
*/
|
|
421
|
+
async replaceAnalysisViolations(violations) {
|
|
422
|
+
return emitErrorAndRetry(this, 'Database.replaceAnalysisViolations', async () => await replaceAnalysisViolationsOp(this.#instance, violations), retrySetting);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Promote previously-external pages whose URL falls under any of the new
|
|
426
|
+
* scope entries back to a "needs scraping" state.
|
|
427
|
+
* Delegates to {@link repromoteExternalPagesOp}.
|
|
1420
428
|
* @param scopes - The hostname-indexed scope map after the new roots are merged.
|
|
1421
|
-
* @param options - URL parsing options forwarded to
|
|
429
|
+
* @param options - URL parsing options forwarded to the scope matcher.
|
|
1422
430
|
* @returns The URLs of the pages that were promoted.
|
|
1423
431
|
*/
|
|
1424
432
|
async repromoteExternalPages(scopes, options) {
|
|
1425
|
-
return emitErrorAndRetry(this, 'Database.repromoteExternalPages', async () =>
|
|
1426
|
-
if (scopes.size === 0) {
|
|
1427
|
-
return [];
|
|
1428
|
-
}
|
|
1429
|
-
const candidates = await this.#instance
|
|
1430
|
-
.select('id', 'url')
|
|
1431
|
-
.from('pages')
|
|
1432
|
-
.where('isExternal', 1);
|
|
1433
|
-
const promotedIds = [];
|
|
1434
|
-
const promotedUrls = [];
|
|
1435
|
-
for (const row of candidates) {
|
|
1436
|
-
const parsed = parseUrl(row.url, options);
|
|
1437
|
-
if (!parsed) {
|
|
1438
|
-
continue;
|
|
1439
|
-
}
|
|
1440
|
-
if (findScopeEntry(parsed, scopes, options) === null) {
|
|
1441
|
-
continue;
|
|
1442
|
-
}
|
|
1443
|
-
promotedIds.push(row.id);
|
|
1444
|
-
promotedUrls.push(row.url);
|
|
1445
|
-
}
|
|
1446
|
-
if (promotedIds.length === 0) {
|
|
1447
|
-
return [];
|
|
1448
|
-
}
|
|
1449
|
-
const chunkSize = 500;
|
|
1450
|
-
const metaReset = makeMetaResetPayload();
|
|
1451
|
-
for (let i = 0; i < promotedIds.length; i += chunkSize) {
|
|
1452
|
-
const chunk = promotedIds.slice(i, i + chunkSize);
|
|
1453
|
-
await this.#instance('pages')
|
|
1454
|
-
.whereIn('id', chunk)
|
|
1455
|
-
.update({
|
|
1456
|
-
scraped: 0,
|
|
1457
|
-
isExternal: 0,
|
|
1458
|
-
isSkipped: 0,
|
|
1459
|
-
skipReason: null,
|
|
1460
|
-
status: null,
|
|
1461
|
-
statusText: null,
|
|
1462
|
-
contentType: null,
|
|
1463
|
-
contentLength: null,
|
|
1464
|
-
responseHeaders: '{}',
|
|
1465
|
-
redirectDestId: null,
|
|
1466
|
-
// Null every flat meta column + denormalised aggregates +
|
|
1467
|
-
// meta_extras. `firstCrawledAt` / `lastCrawledAt` are
|
|
1468
|
-
// deliberately omitted from META_NULLABLE_COLUMNS — the
|
|
1469
|
-
// last-success timestamp survives the demotion.
|
|
1470
|
-
...metaReset,
|
|
1471
|
-
});
|
|
1472
|
-
// Clear the prior crawl's data for the repromoted pages. `updatePage`
|
|
1473
|
-
// also replaces anchors/images/tags/jsonld when it re-scrapes them, but
|
|
1474
|
-
// only when the new scrape is non-empty — so this pre-clear is still
|
|
1475
|
-
// load-bearing for pages that get repromoted but then re-scrape to
|
|
1476
|
-
// nothing (or are never reached again), and it is the only place
|
|
1477
|
-
// `resources-referrers` is cleared. The HTML body ref is also cleared
|
|
1478
|
-
// so a repromoted page whose re-scrape ends up degraded does not keep
|
|
1479
|
-
// its old external-render snapshot. `page_tags` / `page_jsonld` are
|
|
1480
|
-
// cleared explicitly even though both tables also carry ON DELETE
|
|
1481
|
-
// CASCADE — we keep the existing pattern of explicit chunked DELETEs
|
|
1482
|
-
// rather than relying on CASCADE indirectly (and would not cascade
|
|
1483
|
-
// anyway: the parent `pages` row is updated, not deleted). Orphan
|
|
1484
|
-
// blobs in `page_html_blobs` are left behind; #23 will add GC.
|
|
1485
|
-
await this.#instance('anchors').whereIn('pageId', chunk).delete();
|
|
1486
|
-
await this.#instance('images').whereIn('pageId', chunk).delete();
|
|
1487
|
-
await this.#instance('resources-referrers').whereIn('pageId', chunk).delete();
|
|
1488
|
-
await this.#instance('page_html_ref').whereIn('page_id', chunk).delete();
|
|
1489
|
-
await this.#instance('page_tags').whereIn('pageId', chunk).delete();
|
|
1490
|
-
await this.#instance('page_jsonld').whereIn('pageId', chunk).delete();
|
|
1491
|
-
}
|
|
1492
|
-
dbLog('Repromoted %d external pages back to pending', promotedUrls.length);
|
|
1493
|
-
return promotedUrls;
|
|
1494
|
-
}, retrySetting);
|
|
433
|
+
return emitErrorAndRetry(this, 'Database.repromoteExternalPages', async () => await repromoteExternalPagesOp(this.#instance, scopes, options), retrySetting);
|
|
1495
434
|
}
|
|
1496
435
|
/**
|
|
1497
436
|
* Reset previously-attempted pages that ended in a recoverable failure so a
|
|
1498
|
-
* follow-up crawl can re-fetch them from scratch.
|
|
1499
|
-
*
|
|
1500
|
-
*
|
|
1501
|
-
* (`scraped = 1`), is not a redirect source (`redirectDestId IS NULL`), was
|
|
1502
|
-
* not intentionally skipped (`isSkipped` is not `1`), and one of the
|
|
1503
|
-
* following holds:
|
|
1504
|
-
*
|
|
1505
|
-
* - `status = -1` — the sentinel a hard scrape failure (network error,
|
|
1506
|
-
* timeout, browser crash) is recorded with (see `handle-scrape-error.ts`);
|
|
1507
|
-
* - `status IS NULL` — no status was ever stored for the row;
|
|
1508
|
-
* - `contentType IS NULL` — the content type could not be determined;
|
|
1509
|
-
* - `status` is in the `5xx` range — a (frequently transient) server error.
|
|
1510
|
-
*
|
|
1511
|
-
* Definitive `4xx` responses are intentionally excluded: re-fetching a 404
|
|
1512
|
-
* almost always yields the same answer.
|
|
1513
|
-
*
|
|
1514
|
-
* A second exclusion runs in JS after the SQL candidate scan: any page whose
|
|
1515
|
-
* latest recorded `page_errors` / `crawl_errors` message classifies into a
|
|
1516
|
-
* permanent {@link PERMANENT_ERROR_KINDS} kind (dns / tls / client-blocked /
|
|
1517
|
-
* parse-error / connection-refused) is left as-is rather than reset to
|
|
1518
|
-
* pending. Without this filter, `--retry-failed` never converges: NXDOMAIN
|
|
1519
|
-
* hosts, expired-cert hosts, and `ERR_BLOCKED_BY_CLIENT` ad pixels would be
|
|
1520
|
-
* reset every iteration, re-attempted, fail identically, and rejoin the
|
|
1521
|
-
* candidate pool for the next iteration. The exclusion keeps the retry
|
|
1522
|
-
* target shrinking across `--retry-failed` passes by leaving deterministic
|
|
1523
|
-
* dead-ends alone.
|
|
1524
|
-
*
|
|
1525
|
-
* Matching rows — internal and external alike — are demoted back to pending
|
|
1526
|
-
* (`scraped = 0`) and have their stale scrape metadata cleared. The page row
|
|
1527
|
-
* itself is kept (id preserved) so existing `anchors.hrefId` referrers stay
|
|
1528
|
-
* valid, and `isExternal` is left untouched so the next pass re-classifies
|
|
1529
|
-
* each page from the crawl scope. Related `anchors`, `images`,
|
|
1530
|
-
* `resources-referrers`, and `page_errors` rows are deleted so the re-scrape
|
|
1531
|
-
* can re-insert fresh data without duplicates.
|
|
1532
|
-
*
|
|
1533
|
-
* SELECT and UPDATE/DELETE statements are chunked to stay below SQLite's
|
|
1534
|
-
* `SQLITE_LIMIT_VARIABLE_NUMBER`.
|
|
437
|
+
* follow-up crawl can re-fetch them from scratch. Delegates to
|
|
438
|
+
* {@link resetFailedPagesOp} — see the op for the permanent-failure
|
|
439
|
+
* exclusion rationale.
|
|
1535
440
|
* @returns The URLs of the pages that were reset to pending.
|
|
1536
441
|
*/
|
|
1537
442
|
async resetFailedPages() {
|
|
1538
|
-
return emitErrorAndRetry(this, 'Database.resetFailedPages', async () =>
|
|
1539
|
-
const candidates = await this.#instance
|
|
1540
|
-
.select('id', 'url')
|
|
1541
|
-
.from('pages')
|
|
1542
|
-
.where('scraped', 1)
|
|
1543
|
-
.whereNull('redirectDestId')
|
|
1544
|
-
.where((qb) => {
|
|
1545
|
-
qb.where('isSkipped', 0).orWhereNull('isSkipped');
|
|
1546
|
-
})
|
|
1547
|
-
.where((qb) => {
|
|
1548
|
-
qb.whereNull('status')
|
|
1549
|
-
.orWhere('status', -1)
|
|
1550
|
-
.orWhereNull('contentType')
|
|
1551
|
-
.orWhereBetween('status', [500, 599]);
|
|
1552
|
-
});
|
|
1553
|
-
if (candidates.length === 0) {
|
|
1554
|
-
return [];
|
|
1555
|
-
}
|
|
1556
|
-
const candidateIds = candidates.map((row) => row.id);
|
|
1557
|
-
const candidateUrls = candidates.map((row) => row.url);
|
|
1558
|
-
const messages = await getFailedPageMessages(this.#instance, candidateIds, candidateUrls);
|
|
1559
|
-
// Drop candidates whose latest recorded message classifies as permanent.
|
|
1560
|
-
// An empty/absent message stays in the retry pool — we keep retrying when
|
|
1561
|
-
// we don't know it's permanent, erring on the side of investigation.
|
|
1562
|
-
const retryable = candidates.filter((row) => {
|
|
1563
|
-
const message = messages.get(row.id) ?? '';
|
|
1564
|
-
if (message === '') {
|
|
1565
|
-
return true;
|
|
1566
|
-
}
|
|
1567
|
-
return !PERMANENT_ERROR_KINDS.has(classifyErrorKind(message));
|
|
1568
|
-
});
|
|
1569
|
-
const excludedCount = candidates.length - retryable.length;
|
|
1570
|
-
if (excludedCount > 0) {
|
|
1571
|
-
dbLog('Excluded %d page(s) from retry — permanent failure kinds (dns/tls/client-blocked/parse-error/connection-refused)', excludedCount);
|
|
1572
|
-
}
|
|
1573
|
-
if (retryable.length === 0) {
|
|
1574
|
-
return [];
|
|
1575
|
-
}
|
|
1576
|
-
const ids = retryable.map((row) => row.id);
|
|
1577
|
-
const urls = retryable.map((row) => row.url);
|
|
1578
|
-
const chunkSize = 500;
|
|
1579
|
-
const metaReset = makeMetaResetPayload();
|
|
1580
|
-
for (let i = 0; i < ids.length; i += chunkSize) {
|
|
1581
|
-
const chunk = ids.slice(i, i + chunkSize);
|
|
1582
|
-
await this.#instance('pages')
|
|
1583
|
-
.whereIn('id', chunk)
|
|
1584
|
-
.update({
|
|
1585
|
-
scraped: 0,
|
|
1586
|
-
status: null,
|
|
1587
|
-
statusText: null,
|
|
1588
|
-
contentType: null,
|
|
1589
|
-
contentLength: null,
|
|
1590
|
-
responseHeaders: '{}',
|
|
1591
|
-
// Null every flat meta column + denormalised aggregates +
|
|
1592
|
-
// meta_extras. `firstCrawledAt` / `lastCrawledAt` are
|
|
1593
|
-
// deliberately omitted from META_NULLABLE_COLUMNS so the
|
|
1594
|
-
// last-success timestamp records survive the demotion (the
|
|
1595
|
-
// within-archive observation axis for #11/#17/#19).
|
|
1596
|
-
...metaReset,
|
|
1597
|
-
});
|
|
1598
|
-
// Clear the prior crawl's per-page data so the re-scrape starts clean.
|
|
1599
|
-
// `updatePage` only replaces anchors/images/tags/jsonld when the new
|
|
1600
|
-
// scrape is non-empty, so this pre-clear is load-bearing for pages that
|
|
1601
|
-
// reset but then fail again (or are never reached), and it is the only
|
|
1602
|
-
// place `resources-referrers` and `page_errors` are cleared. The HTML
|
|
1603
|
-
// body ref is also cleared so a previously-rendered page that now fails
|
|
1604
|
-
// to re-scrape does not keep its old snapshot.
|
|
1605
|
-
await this.#instance('anchors').whereIn('pageId', chunk).delete();
|
|
1606
|
-
await this.#instance('images').whereIn('pageId', chunk).delete();
|
|
1607
|
-
await this.#instance('resources-referrers').whereIn('pageId', chunk).delete();
|
|
1608
|
-
await this.#instance('page_errors').whereIn('pageId', chunk).delete();
|
|
1609
|
-
await this.#instance('page_html_ref').whereIn('page_id', chunk).delete();
|
|
1610
|
-
await this.#instance('page_tags').whereIn('pageId', chunk).delete();
|
|
1611
|
-
await this.#instance('page_jsonld').whereIn('pageId', chunk).delete();
|
|
1612
|
-
}
|
|
1613
|
-
dbLog('Reset %d failed pages back to pending', urls.length);
|
|
1614
|
-
return urls;
|
|
1615
|
-
}, retrySetting);
|
|
443
|
+
return emitErrorAndRetry(this, 'Database.resetFailedPages', async () => await resetFailedPagesOp(this.#instance), retrySetting);
|
|
1616
444
|
}
|
|
1617
445
|
/**
|
|
1618
446
|
* Stores the crawl configuration in the `info` table.
|
|
1619
|
-
*
|
|
1620
|
-
* runtime-only field on the input is silently dropped so callers can splat
|
|
1621
|
-
* a wider config object without producing SQL errors. JSON-array fields
|
|
1622
|
-
* are serialized via `JSON.stringify`.
|
|
447
|
+
* Delegates to {@link setConfigOp}.
|
|
1623
448
|
* @param config - The {@link Config} object to store.
|
|
1624
449
|
*/
|
|
1625
450
|
async setConfig(config) {
|
|
1626
|
-
return emitErrorAndRetry(this, 'Database.setConfig', async () =>
|
|
1627
|
-
const payload = {};
|
|
1628
|
-
for (const [key, value] of Object.entries(config)) {
|
|
1629
|
-
if (!INFO_COLUMN_ALLOWLIST.has(key)) {
|
|
1630
|
-
continue;
|
|
1631
|
-
}
|
|
1632
|
-
payload[key] = INFO_JSON_COLUMNS.has(key) ? JSON.stringify(value) : value;
|
|
1633
|
-
}
|
|
1634
|
-
return this.#instance.from('info').insert(payload);
|
|
1635
|
-
}, retrySetting);
|
|
451
|
+
return emitErrorAndRetry(this, 'Database.setConfig', async () => await setConfigOp(this.#instance, config), retrySetting);
|
|
1636
452
|
}
|
|
1637
453
|
/**
|
|
1638
454
|
* Marks a page as skipped in the database with the given reason.
|
|
1639
|
-
*
|
|
455
|
+
* Delegates to {@link setSkippedPageOp}.
|
|
1640
456
|
* @param url - The URL of the skipped page.
|
|
1641
457
|
* @param reason - The reason the page was skipped.
|
|
1642
458
|
* @param isExternal - Whether the page is on an external domain. Defaults to `false`.
|
|
1643
459
|
*/
|
|
1644
460
|
async setSkippedPage(url, reason, isExternal = false) {
|
|
1645
|
-
return emitErrorAndRetry(this, 'Database.setSkippedPage', async () =>
|
|
1646
|
-
const pageId = await this.#getIdByUrl(url, isExternal ? 1 : 0);
|
|
1647
|
-
await this.#instance('pages')
|
|
1648
|
-
.where('id', pageId)
|
|
1649
|
-
.update({
|
|
1650
|
-
scraped: 1,
|
|
1651
|
-
isExternal: isExternal ? 1 : 0,
|
|
1652
|
-
isSkipped: 1,
|
|
1653
|
-
skipReason: reason,
|
|
1654
|
-
});
|
|
1655
|
-
}, retrySetting);
|
|
461
|
+
return emitErrorAndRetry(this, 'Database.setSkippedPage', async () => await setSkippedPageOp(this.#instance, this.#writeRefCaches, url, reason, isExternal), retrySetting);
|
|
1656
462
|
}
|
|
1657
463
|
/**
|
|
1658
464
|
* Assigns natural URL sort order values to all internal pages.
|
|
1659
|
-
*
|
|
465
|
+
* Delegates to {@link setUrlOrderOp}.
|
|
1660
466
|
*/
|
|
1661
467
|
async setUrlOrder() {
|
|
1662
|
-
|
|
1663
|
-
const res = await this.#instance
|
|
1664
|
-
.select('id', 'url')
|
|
1665
|
-
.from('pages')
|
|
1666
|
-
.where('isExternal', '=', 0);
|
|
1667
|
-
const sorted = res.toSorted((a, b) => pathComparator(a.url, b.url));
|
|
1668
|
-
// Batch update using chunked CASE statements to avoid N+1 queries
|
|
1669
|
-
const BATCH_SIZE = 500;
|
|
1670
|
-
for (let i = 0; i < sorted.length; i += BATCH_SIZE) {
|
|
1671
|
-
const batch = sorted.slice(i, i + BATCH_SIZE);
|
|
1672
|
-
const ids = batch.map((row) => row.id);
|
|
1673
|
-
const bindings = [];
|
|
1674
|
-
const cases = batch
|
|
1675
|
-
.map((row, j) => {
|
|
1676
|
-
bindings.push(row.id, i + j + 1);
|
|
1677
|
-
return 'WHEN ? THEN ?';
|
|
1678
|
-
})
|
|
1679
|
-
.join(' ');
|
|
1680
|
-
const placeholders = ids.map(() => '?').join(',');
|
|
1681
|
-
await this.#instance.raw(`UPDATE pages SET \`order\` = CASE id ${cases} END WHERE id IN (${placeholders})`, [...bindings, ...ids]);
|
|
1682
|
-
}
|
|
468
|
+
await setUrlOrderOp(this.#instance);
|
|
1683
469
|
}
|
|
1684
470
|
/**
|
|
1685
471
|
* Update the single row in the `info` table with a partial config patch.
|
|
1686
|
-
*
|
|
1687
|
-
* Used by the append flow to extend `roots` (and any other tweakable
|
|
1688
|
-
* field) without replacing the entire row. JSON-array fields are serialized on
|
|
1689
|
-
* the fly; primitive fields are written verbatim. Unspecified fields stay as-is.
|
|
1690
|
-
*
|
|
1691
|
-
* Unknown keys (anything outside the allow-list of `info`-table columns) are
|
|
1692
|
-
* silently dropped instead of being passed to SQL, so callers that splat a
|
|
1693
|
-
* wider runtime config (e.g. `CrawlConfig` with `cwd` / `executablePath`)
|
|
1694
|
-
* cannot accidentally trigger a "no such column" SQL error.
|
|
472
|
+
* Delegates to {@link updateConfigOp}.
|
|
1695
473
|
* @param patch - Partial {@link Config} fields to overwrite. `undefined` values are skipped.
|
|
1696
474
|
*/
|
|
1697
475
|
async updateConfig(patch) {
|
|
1698
|
-
return emitErrorAndRetry(this, 'Database.updateConfig', async () =>
|
|
1699
|
-
const payload = {};
|
|
1700
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
1701
|
-
if (value === undefined) {
|
|
1702
|
-
continue;
|
|
1703
|
-
}
|
|
1704
|
-
if (!INFO_COLUMN_ALLOWLIST.has(key)) {
|
|
1705
|
-
continue;
|
|
1706
|
-
}
|
|
1707
|
-
if (INFO_JSON_COLUMNS.has(key)) {
|
|
1708
|
-
payload[key] = JSON.stringify(value);
|
|
1709
|
-
continue;
|
|
1710
|
-
}
|
|
1711
|
-
payload[key] = value;
|
|
1712
|
-
}
|
|
1713
|
-
if (Object.keys(payload).length === 0) {
|
|
1714
|
-
return;
|
|
1715
|
-
}
|
|
1716
|
-
await this.#instance.from('info').update(payload);
|
|
1717
|
-
}, retrySetting);
|
|
476
|
+
return emitErrorAndRetry(this, 'Database.updateConfig', async () => await updateConfigOp(this.#instance, patch), retrySetting);
|
|
1718
477
|
}
|
|
1719
478
|
/**
|
|
1720
|
-
* Inserts or updates a crawled page in the database, including its redirect
|
|
1721
|
-
* anchors, images, and (when `writeHtml`) its compressed HTML
|
|
1722
|
-
*
|
|
1723
|
-
* Self-redirects (where the source URL equals the destination URL after normalization)
|
|
1724
|
-
* are skipped to avoid marking a page as redirected to itself — a situation caused by
|
|
1725
|
-
* authentication challenges (e.g. Basic Auth 302) that would otherwise exclude the page
|
|
1726
|
-
* from reports via the `whereNull('redirectDestId')` filter.
|
|
479
|
+
* Inserts or updates a crawled page in the database, including its redirect
|
|
480
|
+
* chain, anchors, images, and (when `writeHtml`) its compressed HTML
|
|
481
|
+
* snapshot BLOB. Delegates to {@link updatePageOp}.
|
|
1727
482
|
* @param page - The page data to store.
|
|
1728
483
|
* @param writeHtml - When `true`, this call is allowed to insert (or clear)
|
|
1729
484
|
* the page's HTML blob. `setExternalPage` passes `false` because external
|
|
@@ -1731,549 +486,20 @@ export class Database extends EventEmitter {
|
|
|
1731
486
|
* stored body.
|
|
1732
487
|
* @param isTarget - Whether this page is a crawl target.
|
|
1733
488
|
* @param source - Provenance label written ONLY when the row is freshly
|
|
1734
|
-
* inserted. Existing rows keep their original `source
|
|
1735
|
-
* second `crawl --inventory` does not "demote" an `'inventory-seed'` row
|
|
1736
|
-
* that was discovered earlier).
|
|
489
|
+
* inserted. Existing rows keep their original `source`.
|
|
1737
490
|
* @returns The database `pageId` of the inserted/updated row.
|
|
1738
491
|
*/
|
|
1739
492
|
async updatePage(page, writeHtml, isTarget, source) {
|
|
1740
|
-
return emitErrorAndRetry(this, 'Database.updatePage', async () =>
|
|
1741
|
-
const { destUrl, sources } = resolveRedirectChain(page.url.withoutHashAndAuth, page.redirectPaths);
|
|
1742
|
-
const destUrlObject = parseUrl(destUrl);
|
|
1743
|
-
if (!destUrlObject) {
|
|
1744
|
-
throw new Error(`Failed to parse URL: ${destUrl}`);
|
|
1745
|
-
}
|
|
1746
|
-
return await this.#instance.transaction(async (trx) => {
|
|
1747
|
-
const pageId = await this.#insertPage({
|
|
1748
|
-
...page,
|
|
1749
|
-
url: destUrlObject,
|
|
1750
|
-
}, isTarget, trx, source);
|
|
1751
|
-
// Wappalyzer tag detection is HTML-body independent (relies on
|
|
1752
|
-
// `<script src>` / `<iframe src>` / window globals / response
|
|
1753
|
-
// headers) so it runs for every page including external /
|
|
1754
|
-
// metadata-only. JSON-LD on the other hand lives inside the
|
|
1755
|
-
// rendered HTML body, so we only write it when there is HTML to
|
|
1756
|
-
// scrape — see the same `writeHtml` gate as `#writePageHtmlBlob`
|
|
1757
|
-
// below.
|
|
1758
|
-
await this.#insertTags(pageId, page.meta, trx);
|
|
1759
|
-
if (writeHtml) {
|
|
1760
|
-
await this.#insertJsonLd(pageId, page.meta, trx);
|
|
1761
|
-
}
|
|
1762
|
-
// Chain lineage propagates FROM the originating URL
|
|
1763
|
-
// (`page.url`), NOT from the destination. See the matching
|
|
1764
|
-
// rationale in `recordRedirect` above: intermediates are
|
|
1765
|
-
// reached transitively from the originating URL's render,
|
|
1766
|
-
// so they inherit its lineage. The `source` argument is the
|
|
1767
|
-
// authoritative origin label when inventoryMode is live;
|
|
1768
|
-
// fall through to a DB lookup of `page.url` for the resume
|
|
1769
|
-
// / retry-failed path where the call-site has no source.
|
|
1770
|
-
let originatingSource = source;
|
|
1771
|
-
if (originatingSource === undefined) {
|
|
1772
|
-
const [originatingRow] = await trx
|
|
1773
|
-
.select('source')
|
|
1774
|
-
.from('pages')
|
|
1775
|
-
.where('url', page.url.withoutHashAndAuth);
|
|
1776
|
-
originatingSource = originatingRow?.source;
|
|
1777
|
-
}
|
|
1778
|
-
const chainLineageSource = deriveLineageFromParent(originatingSource, 'crawled');
|
|
1779
|
-
await this.#linkRedirectSources(trx, sources, pageId, destUrlObject.withoutHashAndAuth, page.isExternal, chainLineageSource);
|
|
1780
|
-
// Only insert a snapshot blob when there is actual HTML to write.
|
|
1781
|
-
// `page.html.length > 0` is the precise signal: the scraper returns
|
|
1782
|
-
// `html: ''` for everything that is not a rendered `text/html` document
|
|
1783
|
-
// (non-HTML responses, metadata-only, external, degraded renders), so a
|
|
1784
|
-
// non-empty `html` is exactly "a rendered HTML body exists". Gating on
|
|
1785
|
-
// `isTarget` alone would store an empty body for every internal non-HTML
|
|
1786
|
-
// resource — PDF / zip / images are isTarget=1 (#72).
|
|
1787
|
-
//
|
|
1788
|
-
// `isTarget` is intentionally NOT part of this condition: it is implied by
|
|
1789
|
-
// `html.length > 0` (only in-scope target pages are browser-rendered into a
|
|
1790
|
-
// non-empty body; metadata-only and external pages carry `html: ''`), so the
|
|
1791
|
-
// content check alone expresses the intent without a redundant term.
|
|
1792
|
-
if (writeHtml && page.html.length > 0) {
|
|
1793
|
-
await this.#writePageHtmlBlob(pageId, page.html, trx);
|
|
1794
|
-
}
|
|
1795
|
-
else if (writeHtml &&
|
|
1796
|
-
page.contentType !== null &&
|
|
1797
|
-
!isHtmlContentType(page.contentType)) {
|
|
1798
|
-
// The page is now a *known* non-HTML type. If a previous scrape stored
|
|
1799
|
-
// an HTML body for this URL (e.g. it served HTML then was replaced by
|
|
1800
|
-
// a PDF across `crawl --resume` / `--append`), drop the stale ref so
|
|
1801
|
-
// `page_html_ref` never contradicts `contentType`. A degraded HTML
|
|
1802
|
-
// re-scrape (text/html or unknown content type with empty html) is NOT
|
|
1803
|
-
// cleared — the last good snapshot is preserved, mirroring the
|
|
1804
|
-
// anchors / images empty-guard below. Gated on `writeHtml` because a
|
|
1805
|
-
// stale ref can only have been written by a snapshot-capable call
|
|
1806
|
-
// (`setPage`); `setExternalPage` passes `writeHtml = false` and never
|
|
1807
|
-
// sets `html`, so it has nothing to clear.
|
|
1808
|
-
await trx('page_html_ref').where('page_id', pageId).delete();
|
|
1809
|
-
}
|
|
1810
|
-
// Re-scrape semantics: the same URL can be scraped more than once
|
|
1811
|
-
// (e.g. `crawl --resume`, re-visits, `--append` re-promotion). The
|
|
1812
|
-
// `anchors` / `images` tables have no uniqueness constraint, so
|
|
1813
|
-
// re-inserting without clearing would accumulate a full duplicate set
|
|
1814
|
-
// on every re-scrape (the bug fixed in #70). So we delete-then-insert
|
|
1815
|
-
// to *replace* the previous rows.
|
|
1816
|
-
//
|
|
1817
|
-
// The delete is paired with — and guarded by — a non-empty new list:
|
|
1818
|
-
// a degraded re-scrape (navigation timeout / partial render) can return
|
|
1819
|
-
// an empty `anchorList` for a page that previously had links, and
|
|
1820
|
-
// wiping the prior good data in that case would be destructive. We
|
|
1821
|
-
// cannot tell a transient empty result apart from a page that has
|
|
1822
|
-
// legitimately lost all its links, so we err on the side of keeping
|
|
1823
|
-
// what we already had. The accepted trade-off is that a page which
|
|
1824
|
-
// genuinely dropped to zero links keeps its stale rows until the next
|
|
1825
|
-
// non-empty re-scrape replaces them.
|
|
1826
|
-
//
|
|
1827
|
-
// (A DB-level unique constraint + `onConflict` would also prevent
|
|
1828
|
-
// duplication, but multiple distinct anchors can share the same
|
|
1829
|
-
// hrefId/hash/textContent legitimately, so there is no natural unique
|
|
1830
|
-
// key to enforce — replace-on-write is the correct mechanism here.)
|
|
1831
|
-
// Lineage propagation: read the current page's merged source
|
|
1832
|
-
// (post-UPDATE by `#insertPage`) so anchor placeholder rows
|
|
1833
|
-
// inherit a label that reflects the parent's chain. A
|
|
1834
|
-
// `'crawled'`-lineage parent passes `'crawled'` explicitly so the
|
|
1835
|
-
// crawled-wins downgrade in `#getIdByUrl` fires when an anchor
|
|
1836
|
-
// hits an existing `'inventory-*'` row. An inventory-lineage
|
|
1837
|
-
// parent passes `'inventory-discovered'` to label transitively-
|
|
1838
|
-
// reached URLs correctly without the orchestrator needing to
|
|
1839
|
-
// rehydrate `inventoryMode` from disk.
|
|
1840
|
-
//
|
|
1841
|
-
// Cost: one extra SELECT on `pages` per scraped page (the
|
|
1842
|
-
// `id` is a PK index lookup so it is sub-millisecond even at
|
|
1843
|
-
// 1M-row scale). The alternative — passing `mergedSource`
|
|
1844
|
-
// through from the UPDATE result — would require RETURNING
|
|
1845
|
-
// support that knex's SQLite dialect handles inconsistently;
|
|
1846
|
-
// the small per-page round-trip is the cheaper trade.
|
|
1847
|
-
const [parentRow] = await trx
|
|
1848
|
-
.select('source')
|
|
1849
|
-
.from('pages')
|
|
1850
|
-
.where('id', pageId);
|
|
1851
|
-
// `deriveLineageFromParent` collapses the three call sites
|
|
1852
|
-
// (anchor / redirect intermediate × updatePage / recordRedirect)
|
|
1853
|
-
// onto the same rule. `'crawled'` fallback (vs `undefined`)
|
|
1854
|
-
// arms the crawled-wins downgrade in `#getIdByUrl` for
|
|
1855
|
-
// existing `'inventory-*'` rows reached from a crawled
|
|
1856
|
-
// parent — see `isInventorySource` for the membership rule.
|
|
1857
|
-
const anchorLineageSource = deriveLineageFromParent(parentRow?.source, 'crawled');
|
|
1858
|
-
const anchors = await Promise.all(page.anchorList.map(async (anchor) => {
|
|
1859
|
-
const hrefId = await this.#getIdByUrl(anchor.href.withoutHashAndAuth, anchor.isExternal ? 1 : 0, trx, anchorLineageSource);
|
|
1860
|
-
return {
|
|
1861
|
-
pageId,
|
|
1862
|
-
hrefId,
|
|
1863
|
-
hash: anchor.href.hash,
|
|
1864
|
-
textContent: anchor.textContent,
|
|
1865
|
-
};
|
|
1866
|
-
}));
|
|
1867
|
-
dbLog('Insert anchors.length: %d', anchors.length);
|
|
1868
|
-
if (anchors.length > 0) {
|
|
1869
|
-
await trx('anchors').where('pageId', pageId).delete();
|
|
1870
|
-
await eachSplitted(anchors, 100, async (_anchors) => {
|
|
1871
|
-
await trx('anchors').insert(_anchors);
|
|
1872
|
-
});
|
|
1873
|
-
}
|
|
1874
|
-
const images = page.imageList.map((image) => ({
|
|
1875
|
-
pageId,
|
|
1876
|
-
...image,
|
|
1877
|
-
}));
|
|
1878
|
-
dbLog('Insert images.length: %d', images.length);
|
|
1879
|
-
if (images.length > 0) {
|
|
1880
|
-
await trx('images').where('pageId', pageId).delete();
|
|
1881
|
-
await eachSplitted(images, 100, async (_images) => {
|
|
1882
|
-
await trx('images').insert(_images);
|
|
1883
|
-
});
|
|
1884
|
-
}
|
|
1885
|
-
return pageId;
|
|
1886
|
-
});
|
|
1887
|
-
}, retrySetting);
|
|
493
|
+
return emitErrorAndRetry(this, 'Database.updatePage', async () => await updatePageOp(this.#instance, this.#writeRefCaches, page, writeHtml, isTarget, source), retrySetting);
|
|
1888
494
|
}
|
|
1889
495
|
/**
|
|
1890
|
-
*
|
|
1891
|
-
*
|
|
1892
|
-
*
|
|
1893
|
-
* `source` is written ONLY on the INSERT path — when the row already
|
|
1894
|
-
* exists, we never reach the INSERT and the existing row's `source`
|
|
1895
|
-
* stays untouched. This is what keeps a second `crawl --inventory` from
|
|
1896
|
-
* "demoting" a page that was first labelled `'inventory-seed'` back to
|
|
1897
|
-
* `'inventory-discovered'` on later passes.
|
|
1898
|
-
* @param url
|
|
1899
|
-
* @param isExternal
|
|
1900
|
-
* @param trx
|
|
1901
|
-
* @param source - Provenance label to put on the newly-inserted row. `undefined` lets the DB DEFAULT (`'crawled'`) apply.
|
|
1902
|
-
*/
|
|
1903
|
-
async #getIdByUrl(url, isExternal, trx, source) {
|
|
1904
|
-
const qb = trx ?? this.#instance;
|
|
1905
|
-
const [record] = await qb
|
|
1906
|
-
.select('id', 'source')
|
|
1907
|
-
.from('pages')
|
|
1908
|
-
.where('url', url);
|
|
1909
|
-
// Must use `?` because it may be `undefined`
|
|
1910
|
-
const pageId = record?.id ?? Number.NaN;
|
|
1911
|
-
if (Number.isFinite(pageId)) {
|
|
1912
|
-
// Crawled-wins downgrade: when a row that was previously labelled
|
|
1913
|
-
// `'inventory-seed'` or `'inventory-discovered'` is re-encountered
|
|
1914
|
-
// via a `'crawled'`-lineage anchor (the parent page is part of the
|
|
1915
|
-
// graph reachable from the original crawl roots), downgrade it to
|
|
1916
|
-
// `'crawled'`. The inventory goal is finding orphans — anything
|
|
1917
|
-
// reachable from the crawled chain is NOT an orphan and should
|
|
1918
|
-
// not retain an inventory label.
|
|
1919
|
-
if (source === 'crawled' && record?.source && record.source !== 'crawled') {
|
|
1920
|
-
await qb('pages').where('id', pageId).update({ source: 'crawled' });
|
|
1921
|
-
}
|
|
1922
|
-
return pageId;
|
|
1923
|
-
}
|
|
1924
|
-
const insertedRows = await qb('pages')
|
|
1925
|
-
.insert({
|
|
1926
|
-
url,
|
|
1927
|
-
scraped: 0,
|
|
1928
|
-
isTarget: 0,
|
|
1929
|
-
...(isExternal != null && { isExternal }),
|
|
1930
|
-
...(source === undefined ? {} : { source }),
|
|
1931
|
-
})
|
|
1932
|
-
.onConflict('url')
|
|
1933
|
-
.ignore();
|
|
1934
|
-
const [insertedId] = insertedRows;
|
|
1935
|
-
if (!insertedId) {
|
|
1936
|
-
// onConflict.ignore() returns 0 on race condition — re-select
|
|
1937
|
-
const [existing] = await qb.select('id').from('pages').where('url', url);
|
|
1938
|
-
if (existing?.id) {
|
|
1939
|
-
return existing.id;
|
|
1940
|
-
}
|
|
1941
|
-
throw new Error(`Failed to insert a new page: ${url}`);
|
|
1942
|
-
}
|
|
1943
|
-
return insertedId;
|
|
1944
|
-
}
|
|
1945
|
-
/**
|
|
1946
|
-
* Initializes the database schema if tables do not exist, then runs lightweight
|
|
1947
|
-
* migrations that bring older archives up to the current schema.
|
|
1948
|
-
*
|
|
1949
|
-
* Migrations are idempotent and run on every writer-side {@link Database.connect};
|
|
1950
|
-
* in read-only mode they are SKIPPED so the same DB can be opened safely
|
|
1951
|
-
* by a viewer attached to a live (or interrupted) crawl without rewriting
|
|
1952
|
-
* the user's tmpDir.
|
|
496
|
+
* Initializes the database schema if tables do not exist, then runs
|
|
497
|
+
* lightweight migrations; in read-only mode both are skipped.
|
|
498
|
+
* Delegates to {@link initOp}.
|
|
1953
499
|
* @param readOnly - When true, skip schema init + migrations.
|
|
1954
500
|
*/
|
|
1955
501
|
async #init(readOnly) {
|
|
1956
|
-
|
|
1957
|
-
// reapplied on every connect — they are not persisted across opens.
|
|
1958
|
-
// They are safe in read-only mode because they don't write to the
|
|
1959
|
-
// user's tmpDir, just configure the libsql connection.
|
|
1960
|
-
await applyConnectionPragmas(this.#instance);
|
|
1961
|
-
// Reject pre-0.10 archives before any further work. Runs for both
|
|
1962
|
-
// writer and read-only (stub viewer) connections so old
|
|
1963
|
-
// `._nitpicker-*` stubs surface a clear error instead of
|
|
1964
|
-
// dereferencing missing columns at query time. New archives (no
|
|
1965
|
-
// `info` table yet) pass through; the schema is filled in by
|
|
1966
|
-
// `initSchema` below.
|
|
1967
|
-
await assertCompatibleVersion(this.#instance);
|
|
1968
|
-
if (readOnly) {
|
|
1969
|
-
return;
|
|
1970
|
-
}
|
|
1971
|
-
await initSchema(this.#instance);
|
|
1972
|
-
await migrateInfoRoots(this.#instance);
|
|
1973
|
-
await migratePageErrors(this.#instance);
|
|
1974
|
-
await migrateCrawlErrors(this.#instance);
|
|
1975
|
-
await migrateHtmlBlobTables(this.#instance);
|
|
1976
|
-
await migratePagesResourcesSource(this.#instance);
|
|
1977
|
-
await migrateInventoryRuns(this.#instance);
|
|
1978
|
-
}
|
|
1979
|
-
/**
|
|
1980
|
-
* Replaces the page's JSON-LD / SpeculationRules rows with the freshly
|
|
1981
|
-
* captured set. Called inside `updatePage`'s transaction.
|
|
1982
|
-
*
|
|
1983
|
-
* `writeHtml = false` branches (`setExternalPage`, metadata-only) skip
|
|
1984
|
-
* this entirely — JSON-LD lives inside the HTML body, so external pages
|
|
1985
|
-
* that are not rendered have no entries to write. An empty array on a
|
|
1986
|
-
* normally-rendered page is treated as a degraded re-scrape: prior rows
|
|
1987
|
-
* are kept (same `delete-only-when-replacing` invariant as `anchors` /
|
|
1988
|
-
* `images`).
|
|
1989
|
-
* @param pageId
|
|
1990
|
-
* @param meta
|
|
1991
|
-
* @param trx
|
|
1992
|
-
*/
|
|
1993
|
-
async #insertJsonLd(pageId, meta, trx) {
|
|
1994
|
-
// `??` guards tolerate the legacy "minimal meta" shape from older test
|
|
1995
|
-
// fixtures. Real beholder 3.0.0 always populates these required fields.
|
|
1996
|
-
const jsonLd = meta.jsonLd ?? [];
|
|
1997
|
-
const speculationRules = meta.speculationRules ?? [];
|
|
1998
|
-
const rows = [];
|
|
1999
|
-
for (const entry of jsonLd) {
|
|
2000
|
-
rows.push({
|
|
2001
|
-
pageId,
|
|
2002
|
-
kind: 'ld+json',
|
|
2003
|
-
type: classifyJsonLdType(entry),
|
|
2004
|
-
raw: entry.raw,
|
|
2005
|
-
parsed: entry.parsed === undefined ? null : JSON.stringify(entry.parsed),
|
|
2006
|
-
parseError: entry.parseError ?? null,
|
|
2007
|
-
});
|
|
2008
|
-
}
|
|
2009
|
-
for (const entry of speculationRules) {
|
|
2010
|
-
rows.push({
|
|
2011
|
-
pageId,
|
|
2012
|
-
kind: 'speculationrules',
|
|
2013
|
-
type: classifyJsonLdType(entry),
|
|
2014
|
-
raw: entry.raw,
|
|
2015
|
-
parsed: entry.parsed === undefined ? null : JSON.stringify(entry.parsed),
|
|
2016
|
-
parseError: entry.parseError ?? null,
|
|
2017
|
-
});
|
|
2018
|
-
}
|
|
2019
|
-
if (rows.length === 0)
|
|
2020
|
-
return;
|
|
2021
|
-
await trx('page_jsonld').where('pageId', pageId).delete();
|
|
2022
|
-
await eachSplitted(rows, 100, async (chunk) => {
|
|
2023
|
-
await trx('page_jsonld').insert(chunk);
|
|
2024
|
-
});
|
|
2025
|
-
}
|
|
2026
|
-
/**
|
|
2027
|
-
* Upserts page data into the `pages` table (inserts if new, updates if existing).
|
|
2028
|
-
*
|
|
2029
|
-
* `source` is intentionally NOT in the UPDATE clause — provenance is set
|
|
2030
|
-
* once at INSERT time inside `#getIdByUrl`, and existing rows keep
|
|
2031
|
-
* whatever label they were first inserted with.
|
|
2032
|
-
* @param page
|
|
2033
|
-
* @param isTarget
|
|
2034
|
-
* @param trx
|
|
2035
|
-
* @param source - Inventory provenance for the INSERT path. Ignored on UPDATE.
|
|
2036
|
-
*/
|
|
2037
|
-
async #insertPage(page, isTarget, trx, source) {
|
|
2038
|
-
const qb = trx ?? this.#instance;
|
|
2039
|
-
const pageId = await this.#getIdByUrl(page.url.withoutHashAndAuth, undefined, trx, source);
|
|
2040
|
-
const flat = deriveFlatFromMeta(page.meta, page.url.href);
|
|
2041
|
-
const denorm = computePageDenormalized(page.meta);
|
|
2042
|
-
const extras = deriveMetaExtras(page.meta);
|
|
2043
|
-
const now = Date.now();
|
|
2044
|
-
// Source priority on UPDATE: 'crawled' > 'inventory-seed' >
|
|
2045
|
-
// 'inventory-discovered'. The inventory feature exists to surface
|
|
2046
|
-
// orphans (= URLs NOT reachable from the original crawl roots).
|
|
2047
|
-
// Anything reachable via the crawled chain is therefore NOT an
|
|
2048
|
-
// orphan and must be labelled `'crawled'`, even if previously
|
|
2049
|
-
// labelled `'inventory-*'`. Within the inventory variants, the
|
|
2050
|
-
// explicit user-listed `'inventory-seed'` wins over the transitive
|
|
2051
|
-
// `'inventory-discovered'`.
|
|
2052
|
-
//
|
|
2053
|
-
// Note: in current callers, `source` only arrives as
|
|
2054
|
-
// `'inventory-seed'` / `'inventory-discovered'` / `undefined`
|
|
2055
|
-
// (`derivePageSource` never emits `'crawled'`, and outside inventory
|
|
2056
|
-
// mode `source` is `undefined` so this CASE never runs). The
|
|
2057
|
-
// `? = 'crawled'` branch is therefore reachable only via a future
|
|
2058
|
-
// call site that wants to explicitly assert a crawled lineage —
|
|
2059
|
-
// today the actual crawled-wins downgrade fires in `#getIdByUrl`'s
|
|
2060
|
-
// SELECT path when an anchor lineage `'crawled'` lands on an
|
|
2061
|
-
// existing `'inventory-*'` row. The branch is kept so the CASE
|
|
2062
|
-
// completely describes the priority lattice in one place.
|
|
2063
|
-
const sourceUpdate = source === undefined
|
|
2064
|
-
? {}
|
|
2065
|
-
: {
|
|
2066
|
-
source: qb.raw(`CASE
|
|
2067
|
-
WHEN source = 'crawled' OR ? = 'crawled' THEN 'crawled'
|
|
2068
|
-
WHEN source = 'inventory-seed' OR ? = 'inventory-seed' THEN 'inventory-seed'
|
|
2069
|
-
WHEN source = 'inventory-discovered' OR ? = 'inventory-discovered' THEN 'inventory-discovered'
|
|
2070
|
-
ELSE source
|
|
2071
|
-
END`, [source, source, source]),
|
|
2072
|
-
};
|
|
2073
|
-
await qb('pages')
|
|
2074
|
-
.where('id', pageId)
|
|
2075
|
-
.update({
|
|
2076
|
-
scraped: true,
|
|
2077
|
-
isTarget,
|
|
2078
|
-
isExternal: page.isExternal,
|
|
2079
|
-
status: page.status,
|
|
2080
|
-
statusText: page.statusText,
|
|
2081
|
-
// Canonicalize so the stored value matches the exact-string page-ness
|
|
2082
|
-
// predicate (`WHERE contentType = 'text/html'`) used by the read layer
|
|
2083
|
-
// and the case-insensitive `isHtmlContentType` used in code. Responses
|
|
2084
|
-
// are recorded verbatim upstream, so `Text/HTML` / `text/html ` can
|
|
2085
|
-
// otherwise be stored and silently misclassified.
|
|
2086
|
-
contentType: normalizeContentType(page.contentType),
|
|
2087
|
-
contentLength: page.contentLength,
|
|
2088
|
-
responseHeaders: JSON.stringify(page.responseHeaders),
|
|
2089
|
-
// Flat meta columns derived from beholder 3.0.0 nested Meta.
|
|
2090
|
-
// URL-shaped columns (canonical / og_url / og_image / amphtml / manifest /
|
|
2091
|
-
// icon_href / appleTouchIcon_href / twitter_image) are already absolutised
|
|
2092
|
-
// by `deriveFlatFromMeta` against the page URL — `find-mismatches` compares
|
|
2093
|
-
// `canonical != url` directly, so storing the raw `getAttribute('href')`
|
|
2094
|
-
// would generate false positives for sites using relative canonicals.
|
|
2095
|
-
...flat,
|
|
2096
|
-
// Denormalised aggregates: written once at scrape time so list reads
|
|
2097
|
-
// (Sheets, page-detail summary) can answer "how many JSON-LD entries?"
|
|
2098
|
-
// and "which Wappalyzer providers?" by selecting a single pages column
|
|
2099
|
-
// rather than running a GROUP BY join on every read.
|
|
2100
|
-
tag_count: denorm.tag_count,
|
|
2101
|
-
jsonld_count: denorm.jsonld_count,
|
|
2102
|
-
tags_providers_csv: denorm.tags_providers_csv,
|
|
2103
|
-
// JSON catch-all for nested Meta sub-objects not flattened above.
|
|
2104
|
-
meta_extras: JSON.stringify(extras),
|
|
2105
|
-
// Timestamps: `firstCrawledAt` is set only on first INSERT — `COALESCE`
|
|
2106
|
-
// preserves the existing value so a re-scrape (`--append`, `--retry-failed`)
|
|
2107
|
-
// does not erase the discovery time. `lastCrawledAt` is updated every
|
|
2108
|
-
// successful scrape.
|
|
2109
|
-
firstCrawledAt: qb.raw('COALESCE(firstCrawledAt, ?)', [now]),
|
|
2110
|
-
lastCrawledAt: now,
|
|
2111
|
-
isSkipped: page.isSkipped,
|
|
2112
|
-
...sourceUpdate,
|
|
2113
|
-
});
|
|
2114
|
-
return pageId;
|
|
2115
|
-
}
|
|
2116
|
-
/**
|
|
2117
|
-
* Replaces the page's Wappalyzer tag rows with the freshly captured set.
|
|
2118
|
-
* Called inside `updatePage`'s transaction unconditionally — tag
|
|
2119
|
-
* detection draws on `<script src>` / `<iframe src>` / window globals /
|
|
2120
|
-
* response headers, not the HTML body, so external pages that skip
|
|
2121
|
-
* rendering still contribute tags.
|
|
2122
|
-
*
|
|
2123
|
-
* Same empty-guard as `#insertJsonLd`: an empty array does not wipe
|
|
2124
|
-
* prior rows on a degraded re-scrape.
|
|
2125
|
-
* @param pageId
|
|
2126
|
-
* @param meta
|
|
2127
|
-
* @param trx
|
|
2128
|
-
*/
|
|
2129
|
-
async #insertTags(pageId, meta, trx) {
|
|
2130
|
-
const partial = extractTagsForArchive(meta.tags);
|
|
2131
|
-
if (partial.length === 0)
|
|
2132
|
-
return;
|
|
2133
|
-
const rows = partial.map((p) => ({
|
|
2134
|
-
pageId,
|
|
2135
|
-
provider: p.provider,
|
|
2136
|
-
category: p.category,
|
|
2137
|
-
externalId: p.externalId,
|
|
2138
|
-
version: p.version,
|
|
2139
|
-
confidence: p.confidence,
|
|
2140
|
-
categories: JSON.stringify(p.categories),
|
|
2141
|
-
sources: JSON.stringify(p.sources),
|
|
2142
|
-
}));
|
|
2143
|
-
await trx('page_tags').where('pageId', pageId).delete();
|
|
2144
|
-
await eachSplitted(rows, 100, async (chunk) => {
|
|
2145
|
-
await trx('page_tags').insert(chunk);
|
|
2146
|
-
});
|
|
2147
|
-
}
|
|
2148
|
-
/**
|
|
2149
|
-
* Points each redirect-source URL at the destination page, marking it scraped
|
|
2150
|
-
* and clearing any content it owned in a former life.
|
|
2151
|
-
*
|
|
2152
|
-
* Shared by {@link updatePage} (which also renders and stores the destination)
|
|
2153
|
-
* and {@link recordRedirect} (which only records the edge for a destination
|
|
2154
|
-
* rendered elsewhere). Self-redirects (source equal to the destination) are
|
|
2155
|
-
* skipped so a page is never marked as redirecting to itself — that would
|
|
2156
|
-
* exclude it from reports via the `whereNull('redirectDestId')` filter.
|
|
2157
|
-
* @param trx - The active transaction.
|
|
2158
|
-
* @param sources - Redirect-source URLs (normalised): the original URL plus
|
|
2159
|
-
* any intermediate hops. Empty when the page was not redirected.
|
|
2160
|
-
* @param destId - Database id of the redirect destination page.
|
|
2161
|
-
* @param destUrlNormalized - Normalised destination URL, used to detect and
|
|
2162
|
-
* skip self-redirects.
|
|
2163
|
-
* @param isExternal - Whether the sources are external to the crawl scope.
|
|
2164
|
-
* @param chainLineageSource - Lineage label propagated to each intermediate
|
|
2165
|
-
* hop's row (passed through to {@link #getIdByUrl}). Derived by the caller
|
|
2166
|
-
* from the **originating** page's source (`page.url`), not from the
|
|
2167
|
-
* destination — intermediates are reached transitively from the
|
|
2168
|
-
* originating render, so they inherit its lineage. Pass `'inventory-discovered'`
|
|
2169
|
-
* for chains rooted at inventory-seed/discovered pages so new intermediates
|
|
2170
|
-
* stay in the inventory chain; pass `'crawled'` for crawled chains so the
|
|
2171
|
-
* crawled-wins downgrade inside `#getIdByUrl` fires on existing
|
|
2172
|
-
* `'inventory-*'` intermediates a crawled chain reaches. Pass `undefined`
|
|
2173
|
-
* to fall back to the DB DEFAULT (`'crawled'`) on INSERT without
|
|
2174
|
-
* triggering the downgrade on existing rows.
|
|
2175
|
-
*/
|
|
2176
|
-
async #linkRedirectSources(trx, sources, destId, destUrlNormalized, isExternal, chainLineageSource) {
|
|
2177
|
-
for (const redirect of sources) {
|
|
2178
|
-
if (redirect === destUrlNormalized) {
|
|
2179
|
-
dbLog('Skip self-redirect: %s', redirect);
|
|
2180
|
-
continue;
|
|
2181
|
-
}
|
|
2182
|
-
dbLog('Set redirected url: %s -> id:%d', redirect, destId);
|
|
2183
|
-
// Pass `chainLineageSource` through so a brand-new
|
|
2184
|
-
// intermediate hop INSERTed here inherits the originating
|
|
2185
|
-
// page's lineage label (inventory-discovered when the
|
|
2186
|
-
// originating chain is in the inventory chain, undefined
|
|
2187
|
-
// otherwise). The crawled-wins downgrade inside
|
|
2188
|
-
// `#getIdByUrl` still fires when this argument is `'crawled'`,
|
|
2189
|
-
// matching the anchor-lineage propagation contract — an
|
|
2190
|
-
// existing inventory-* intermediate that is later traversed
|
|
2191
|
-
// by a `'crawled'` chain gets downgraded.
|
|
2192
|
-
const redirectId = await this.#getIdByUrl(redirect, undefined, trx, chainLineageSource);
|
|
2193
|
-
await trx('pages')
|
|
2194
|
-
.where('id', redirectId)
|
|
2195
|
-
.update({
|
|
2196
|
-
scraped: 1,
|
|
2197
|
-
redirectDestId: destId,
|
|
2198
|
-
isExternal: isExternal ? 1 : 0,
|
|
2199
|
-
});
|
|
2200
|
-
// Conditional `301 Moved Permanently` stamp — applied ONLY
|
|
2201
|
-
// when the row carries no definitive status yet (NULL or
|
|
2202
|
-
// the `-1` hard-failure sentinel). HEAD pre-flight does not
|
|
2203
|
-
// retain each hop's individual status code (`redirectPaths`
|
|
2204
|
-
// is a URL[] without statuses), so the only honest answer
|
|
2205
|
-
// for an unknown-status hop is "some 3xx" — 301 is the
|
|
2206
|
-
// canonical representative.
|
|
2207
|
-
//
|
|
2208
|
-
// We deliberately do NOT overwrite an existing definitive
|
|
2209
|
-
// status (200 / 302 / 307 / etc.): a row that already
|
|
2210
|
-
// captured a concrete status from a prior direct scrape
|
|
2211
|
-
// would lose accuracy. The stamp only flips two cases:
|
|
2212
|
-
// - NULL: a placeholder row created by `#getIdByUrl`
|
|
2213
|
-
// because the URL was reached only as a redirect
|
|
2214
|
-
// target / source, never directly scraped. Without the
|
|
2215
|
-
// stamp the row is invisible on the Errors view's status
|
|
2216
|
-
// distribution.
|
|
2217
|
-
// - -1: a row that recorded a hard scrape failure (e.g. a
|
|
2218
|
-
// puppeteer goto returned null on a HTTPS→HTTP downgrade
|
|
2219
|
-
// redirect) BEFORE the chain was understood. That `-1`
|
|
2220
|
-
// then conflated "real failure" with "actually a redirect
|
|
2221
|
-
// source we now know about", polluting the `-1` bucket
|
|
2222
|
-
// AND inflating the `--retry-failed` target (via the
|
|
2223
|
-
// `whereNull('redirectDestId')` filter — the redirectDestId
|
|
2224
|
-
// update above already excludes the row from retry; this
|
|
2225
|
-
// stamp restores the visible identity).
|
|
2226
|
-
await trx('pages')
|
|
2227
|
-
.where('id', redirectId)
|
|
2228
|
-
.where((qb) => qb.whereNull('status').orWhere('status', -1))
|
|
2229
|
-
.update({ status: 301, statusText: 'Moved Permanently' });
|
|
2230
|
-
// A page that used to be scraped as content can later turn into a
|
|
2231
|
-
// redirect source. It owns no content anymore, so drop any anchors /
|
|
2232
|
-
// images it captured in its former life — otherwise they linger and
|
|
2233
|
-
// leak into referrer / incoming-link reads (which do not filter out
|
|
2234
|
-
// redirect sources).
|
|
2235
|
-
await trx('anchors').where('pageId', redirectId).delete();
|
|
2236
|
-
await trx('images').where('pageId', redirectId).delete();
|
|
2237
|
-
}
|
|
2238
|
-
}
|
|
2239
|
-
/**
|
|
2240
|
-
* Encodes, dedups, and persists a page's HTML snapshot.
|
|
2241
|
-
*
|
|
2242
|
-
* Computes SHA-256 over the raw UTF-8 bytes, compresses them with zstd,
|
|
2243
|
-
* inserts into `page_html_blobs` only if the hash is new (so identical
|
|
2244
|
-
* bodies — 404 templates, error pages, redirect destinations — share a
|
|
2245
|
-
* single row), and then upserts `page_html_ref(page_id → hash)` so the
|
|
2246
|
-
* latest scrape always points at the right body.
|
|
2247
|
-
*
|
|
2248
|
-
* Runs entirely inside the caller's transaction; a failure here rolls
|
|
2249
|
-
* back the rest of `updatePage`, which is the desired semantics (an
|
|
2250
|
-
* archive that lost its HTML for a page would otherwise serve stale
|
|
2251
|
-
* meta against a missing body).
|
|
2252
|
-
* @param pageId - The database id of the page.
|
|
2253
|
-
* @param html - The raw HTML string (UTF-8).
|
|
2254
|
-
* @param trx - The active transaction.
|
|
2255
|
-
*/
|
|
2256
|
-
async #writePageHtmlBlob(pageId, html, trx) {
|
|
2257
|
-
const rawBytes = Buffer.from(html, 'utf8');
|
|
2258
|
-
const hash = createHash('sha256').update(rawBytes).digest();
|
|
2259
|
-
const compressed = zstdCompressSync(rawBytes);
|
|
2260
|
-
await trx('page_html_blobs')
|
|
2261
|
-
.insert({
|
|
2262
|
-
hash,
|
|
2263
|
-
body: compressed,
|
|
2264
|
-
codec: 'zstd',
|
|
2265
|
-
size_raw: rawBytes.byteLength,
|
|
2266
|
-
size_stored: compressed.byteLength,
|
|
2267
|
-
})
|
|
2268
|
-
.onConflict('hash')
|
|
2269
|
-
.ignore();
|
|
2270
|
-
// Upsert so a re-scrape's body cleanly supersedes the prior pointer.
|
|
2271
|
-
// The old blob row is intentionally left in place — a future #23 GC
|
|
2272
|
-
// pass will sweep unreachable hashes.
|
|
2273
|
-
await trx('page_html_ref')
|
|
2274
|
-
.insert({ page_id: pageId, hash })
|
|
2275
|
-
.onConflict('page_id')
|
|
2276
|
-
.merge(['hash']);
|
|
502
|
+
await initOp(this.#instance, readOnly);
|
|
2277
503
|
}
|
|
2278
504
|
/**
|
|
2279
505
|
* Creates and initializes a new Database instance.
|