@nitpicker/crawler 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/lib/archive/archive-accessor.d.ts +2 -2
- package/lib/archive/archive-accessor.js +2 -2
- package/lib/archive/archive-lock.d.ts +7 -0
- package/lib/archive/archive-lock.js +7 -0
- package/lib/archive/archive.d.ts +175 -13
- package/lib/archive/archive.js +198 -14
- package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
- package/lib/archive/cache/compute-archive-cache-key.js +95 -0
- package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
- package/lib/archive/cache/extract-archive-to-cache.js +309 -0
- package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
- package/lib/archive/cache/get-archive-cache-root.js +53 -0
- package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
- package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
- package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
- package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
- package/lib/archive/create-adjunct-tables.d.ts +43 -0
- package/lib/archive/create-adjunct-tables.js +213 -0
- package/lib/archive/create-entity-tables.d.ts +173 -0
- package/lib/archive/create-entity-tables.js +318 -0
- package/lib/archive/create-progress-reporter.d.ts +30 -0
- package/lib/archive/create-progress-reporter.js +38 -0
- package/lib/archive/create-ref-tables.d.ts +35 -0
- package/lib/archive/create-ref-tables.js +188 -0
- package/lib/archive/database.d.ts +130 -182
- package/lib/archive/database.js +524 -1777
- package/lib/archive/db-ops/_shared/clear-write-ref-caches.d.ts +27 -0
- package/lib/archive/db-ops/_shared/clear-write-ref-caches.js +34 -0
- package/lib/archive/db-ops/_shared/create-write-ref-caches.d.ts +17 -0
- package/lib/archive/db-ops/_shared/create-write-ref-caches.js +26 -0
- package/lib/archive/db-ops/_shared/decode-json-ref.d.ts +17 -0
- package/lib/archive/db-ops/_shared/decode-json-ref.js +31 -0
- package/lib/archive/db-ops/_shared/load-response-headers-by-set-ids.d.ts +20 -0
- package/lib/archive/db-ops/_shared/load-response-headers-by-set-ids.js +53 -0
- package/lib/archive/db-ops/_shared/resolve-content-item-id.d.ts +61 -0
- package/lib/archive/db-ops/_shared/resolve-content-item-id.js +111 -0
- package/lib/archive/db-ops/_shared/resolve-url-or-blob.d.ts +23 -0
- package/lib/archive/db-ops/_shared/resolve-url-or-blob.js +29 -0
- package/lib/archive/db-ops/_shared/retry-setting.d.ts +16 -0
- package/lib/archive/db-ops/_shared/retry-setting.js +18 -0
- package/lib/archive/db-ops/_shared/safe-parse-json.d.ts +11 -0
- package/lib/archive/db-ops/_shared/safe-parse-json.js +18 -0
- package/lib/archive/db-ops/_shared/types.d.ts +53 -0
- package/lib/archive/db-ops/_shared/types.js +1 -0
- package/lib/archive/db-ops/_shared/upsert-blob-ref.d.ts +25 -0
- package/lib/archive/db-ops/_shared/upsert-blob-ref.js +48 -0
- package/lib/archive/db-ops/_shared/upsert-content-type-ref.d.ts +30 -0
- package/lib/archive/db-ops/_shared/upsert-content-type-ref.js +45 -0
- package/lib/archive/db-ops/_shared/upsert-json-ref.d.ts +22 -0
- package/lib/archive/db-ops/_shared/upsert-json-ref.js +41 -0
- package/lib/archive/db-ops/_shared/upsert-response-headers.d.ts +35 -0
- package/lib/archive/db-ops/_shared/upsert-response-headers.js +49 -0
- package/lib/archive/db-ops/_shared/upsert-url-ref.d.ts +39 -0
- package/lib/archive/db-ops/_shared/upsert-url-ref.js +62 -0
- package/lib/archive/db-ops/analysis/replace-analysis-violations.d.ts +28 -0
- package/lib/archive/db-ops/analysis/replace-analysis-violations.js +152 -0
- package/lib/archive/db-ops/anchors/get-anchors-on-page.d.ts +10 -0
- package/lib/archive/db-ops/anchors/get-anchors-on-page.js +21 -0
- package/lib/archive/db-ops/config/get-base-url.d.ts +8 -0
- package/lib/archive/db-ops/config/get-base-url.js +14 -0
- package/lib/archive/db-ops/config/get-config.d.ts +10 -0
- package/lib/archive/db-ops/config/get-config.js +27 -0
- package/lib/archive/db-ops/config/get-name.d.ts +8 -0
- package/lib/archive/db-ops/config/get-name.js +14 -0
- package/lib/archive/db-ops/config/info-column-allowlist.d.ts +7 -0
- package/lib/archive/db-ops/config/info-column-allowlist.js +26 -0
- package/lib/archive/db-ops/config/info-json-columns.d.ts +5 -0
- package/lib/archive/db-ops/config/info-json-columns.js +10 -0
- package/lib/archive/db-ops/config/set-config.d.ts +12 -0
- package/lib/archive/db-ops/config/set-config.js +21 -0
- package/lib/archive/db-ops/config/update-config.d.ts +17 -0
- package/lib/archive/db-ops/config/update-config.js +36 -0
- package/lib/archive/db-ops/errors/insert-crawl-error.d.ts +15 -0
- package/lib/archive/db-ops/errors/insert-crawl-error.js +21 -0
- package/lib/archive/db-ops/errors/insert-page-error.d.ts +21 -0
- package/lib/archive/db-ops/errors/insert-page-error.js +28 -0
- package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.d.ts +22 -0
- package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.js +141 -0
- package/lib/archive/db-ops/html/get-html-of-page-by-id.d.ts +18 -0
- package/lib/archive/db-ops/html/get-html-of-page-by-id.js +29 -0
- package/lib/archive/db-ops/inventory/record-inventory-run.d.ts +21 -0
- package/lib/archive/db-ops/inventory/record-inventory-run.js +38 -0
- package/lib/archive/db-ops/lifecycle/checkpoint.d.ts +8 -0
- package/lib/archive/db-ops/lifecycle/checkpoint.js +9 -0
- package/lib/archive/db-ops/lifecycle/destroy.d.ts +6 -0
- package/lib/archive/db-ops/lifecycle/destroy.js +7 -0
- package/lib/archive/db-ops/lifecycle/init.d.ts +22 -0
- package/lib/archive/db-ops/lifecycle/init.js +42 -0
- package/lib/archive/db-ops/meta/get-jsonld-of-page.d.ts +13 -0
- package/lib/archive/db-ops/meta/get-jsonld-of-page.js +27 -0
- package/lib/archive/db-ops/meta/get-tags-of-page.d.ts +12 -0
- package/lib/archive/db-ops/meta/get-tags-of-page.js +28 -0
- package/lib/archive/db-ops/pages/order/set-url-order.d.ts +8 -0
- package/lib/archive/db-ops/pages/order/set-url-order.js +32 -0
- package/lib/archive/db-ops/pages/read/build-page-query.d.ts +18 -0
- package/lib/archive/db-ops/pages/read/build-page-query.js +40 -0
- package/lib/archive/db-ops/pages/read/get-crawling-state.d.ts +70 -0
- package/lib/archive/db-ops/pages/read/get-crawling-state.js +98 -0
- package/lib/archive/db-ops/pages/read/get-existing-page-urls.d.ts +15 -0
- package/lib/archive/db-ops/pages/read/get-existing-page-urls.js +30 -0
- package/lib/archive/db-ops/pages/read/get-page-count.d.ts +12 -0
- package/lib/archive/db-ops/pages/read/get-page-count.js +21 -0
- package/lib/archive/db-ops/pages/read/get-page-source-by-url.d.ts +24 -0
- package/lib/archive/db-ops/pages/read/get-page-source-by-url.js +28 -0
- package/lib/archive/db-ops/pages/read/get-pages-with-rels.d.ts +38 -0
- package/lib/archive/db-ops/pages/read/get-pages-with-rels.js +107 -0
- package/lib/archive/db-ops/pages/read/get-pages.d.ts +11 -0
- package/lib/archive/db-ops/pages/read/get-pages.js +51 -0
- package/lib/archive/db-ops/pages/read/get-scraped-html-page-count.d.ts +18 -0
- package/lib/archive/db-ops/pages/read/get-scraped-html-page-count.js +25 -0
- package/lib/archive/db-ops/pages/read/reconstruct-page-rows.d.ts +31 -0
- package/lib/archive/db-ops/pages/read/reconstruct-page-rows.js +32 -0
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.d.ts +24 -0
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +93 -0
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +47 -0
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +124 -0
- package/lib/archive/db-ops/pages/write/insert-inventory-seeds.d.ts +37 -0
- package/lib/archive/db-ops/pages/write/insert-inventory-seeds.js +72 -0
- package/lib/archive/db-ops/pages/write/insert-jsonld.d.ts +17 -0
- package/lib/archive/db-ops/pages/write/insert-jsonld.js +49 -0
- package/lib/archive/db-ops/pages/write/insert-page.d.ts +36 -0
- package/lib/archive/db-ops/pages/write/insert-page.js +208 -0
- package/lib/archive/db-ops/pages/write/insert-tags.d.ts +16 -0
- package/lib/archive/db-ops/pages/write/insert-tags.js +34 -0
- package/lib/archive/db-ops/pages/write/link-redirect-sources.d.ts +36 -0
- package/lib/archive/db-ops/pages/write/link-redirect-sources.js +93 -0
- package/lib/archive/db-ops/pages/write/record-redirect.d.ts +35 -0
- package/lib/archive/db-ops/pages/write/record-redirect.js +100 -0
- package/lib/archive/db-ops/pages/write/set-skipped-page.d.ts +13 -0
- package/lib/archive/db-ops/pages/write/set-skipped-page.js +22 -0
- package/lib/archive/db-ops/pages/write/update-page.d.ts +29 -0
- package/lib/archive/db-ops/pages/write/update-page.js +334 -0
- package/lib/archive/db-ops/pages/write/write-page-html-blob.d.ts +19 -0
- package/lib/archive/db-ops/pages/write/write-page-html-blob.js +41 -0
- package/lib/archive/db-ops/referrers/get-redirects-for-pages.d.ts +9 -0
- package/lib/archive/db-ops/referrers/get-redirects-for-pages.js +15 -0
- package/lib/archive/db-ops/referrers/get-referrers-of-page.d.ts +17 -0
- package/lib/archive/db-ops/referrers/get-referrers-of-page.js +32 -0
- package/lib/archive/db-ops/referrers/get-referrers-of-resource.d.ts +8 -0
- package/lib/archive/db-ops/referrers/get-referrers-of-resource.js +15 -0
- package/lib/archive/db-ops/resources/build-resource-query.d.ts +25 -0
- package/lib/archive/db-ops/resources/build-resource-query.js +29 -0
- package/lib/archive/db-ops/resources/get-existing-resource-urls.d.ts +9 -0
- package/lib/archive/db-ops/resources/get-existing-resource-urls.js +24 -0
- package/lib/archive/db-ops/resources/get-resource-by-url.d.ts +13 -0
- package/lib/archive/db-ops/resources/get-resource-by-url.js +22 -0
- package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +9 -0
- package/lib/archive/db-ops/resources/get-resource-url-list.js +13 -0
- package/lib/archive/db-ops/resources/get-resources.d.ts +8 -0
- package/lib/archive/db-ops/resources/get-resources.js +11 -0
- package/lib/archive/db-ops/resources/insert-inventory-resources.d.ts +24 -0
- package/lib/archive/db-ops/resources/insert-inventory-resources.js +64 -0
- package/lib/archive/db-ops/resources/insert-resource-referrers.d.ts +15 -0
- package/lib/archive/db-ops/resources/insert-resource-referrers.js +54 -0
- package/lib/archive/db-ops/resources/insert-resource.d.ts +34 -0
- package/lib/archive/db-ops/resources/insert-resource.js +73 -0
- package/lib/archive/db-ops/resources/reconstruct-resource-rows.d.ts +26 -0
- package/lib/archive/db-ops/resources/reconstruct-resource-rows.js +30 -0
- package/lib/archive/decode-html-blob.d.ts +18 -0
- package/lib/archive/decode-html-blob.js +31 -0
- package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
- package/lib/archive/derive-lineage-from-parent.js +42 -0
- package/lib/archive/drop-legacy-tables.d.ts +45 -0
- package/lib/archive/drop-legacy-tables.js +56 -0
- package/lib/archive/filesystem/rename.js +1 -1
- package/lib/archive/get-failed-page-messages.d.ts +44 -0
- package/lib/archive/get-failed-page-messages.js +132 -0
- package/lib/archive/init-schema.d.ts +35 -39
- package/lib/archive/init-schema.js +111 -320
- package/lib/archive/is-inventory-source.d.ts +21 -0
- package/lib/archive/is-inventory-source.js +22 -0
- package/lib/archive/limited-page-ids.d.ts +2 -1
- package/lib/archive/limited-page-ids.js +5 -4
- package/lib/archive/meta/assert-compatible-version.d.ts +24 -3
- package/lib/archive/meta/assert-compatible-version.js +24 -3
- package/lib/archive/meta/types.d.ts +87 -1
- package/lib/archive/meta/types.js +34 -2
- package/lib/archive/migrate-entity-tables.d.ts +45 -0
- package/lib/archive/migrate-entity-tables.js +56 -0
- package/lib/archive/migrate-ref-tables.d.ts +25 -0
- package/lib/archive/migrate-ref-tables.js +38 -0
- package/lib/archive/page-meta-column-maps.d.ts +32 -0
- package/lib/archive/page-meta-column-maps.js +43 -0
- package/lib/archive/page.d.ts +6 -6
- package/lib/archive/page.js +5 -5
- package/lib/archive/peek-archive-lock.d.ts +2 -2
- package/lib/archive/peek-archive-lock.js +2 -2
- package/lib/archive/populate-entity-tables/collapse-anchor-rows.d.ts +41 -0
- package/lib/archive/populate-entity-tables/collapse-anchor-rows.js +87 -0
- package/lib/archive/populate-entity-tables/derive-dom-path.d.ts +35 -0
- package/lib/archive/populate-entity-tables/derive-dom-path.js +72 -0
- package/lib/archive/populate-entity-tables/is-blob-ref-value.d.ts +16 -0
- package/lib/archive/populate-entity-tables/is-blob-ref-value.js +19 -0
- package/lib/archive/populate-entity-tables/match-images-to-dom-paths.d.ts +66 -0
- package/lib/archive/populate-entity-tables/match-images-to-dom-paths.js +96 -0
- package/lib/archive/populate-entity-tables/populate-anchor-edges.d.ts +33 -0
- package/lib/archive/populate-entity-tables/populate-anchor-edges.js +153 -0
- package/lib/archive/populate-entity-tables/populate-content-items.d.ts +40 -0
- package/lib/archive/populate-entity-tables/populate-content-items.js +141 -0
- package/lib/archive/populate-entity-tables/populate-entities.d.ts +81 -0
- package/lib/archive/populate-entity-tables/populate-entities.js +111 -0
- package/lib/archive/populate-entity-tables/populate-image-items.d.ts +91 -0
- package/lib/archive/populate-entity-tables/populate-image-items.js +223 -0
- package/lib/archive/populate-entity-tables/populate-page-meta.d.ts +33 -0
- package/lib/archive/populate-entity-tables/populate-page-meta.js +267 -0
- package/lib/archive/populate-entity-tables/populate-resource-items.d.ts +22 -0
- package/lib/archive/populate-entity-tables/populate-resource-items.js +114 -0
- package/lib/archive/populate-entity-tables/populate-resource-ref-edges.d.ts +31 -0
- package/lib/archive/populate-entity-tables/populate-resource-ref-edges.js +33 -0
- package/lib/archive/populate-entity-tables/resolve-blob-refs.d.ts +31 -0
- package/lib/archive/populate-entity-tables/resolve-blob-refs.js +100 -0
- package/lib/archive/populate-entity-tables/resolve-content-type-refs.d.ts +22 -0
- package/lib/archive/populate-entity-tables/resolve-content-type-refs.js +27 -0
- package/lib/archive/populate-entity-tables/resolve-header-sets.d.ts +49 -0
- package/lib/archive/populate-entity-tables/resolve-header-sets.js +122 -0
- package/lib/archive/populate-entity-tables/resolve-json-refs.d.ts +25 -0
- package/lib/archive/populate-entity-tables/resolve-json-refs.js +67 -0
- package/lib/archive/populate-entity-tables/resolve-text-refs.d.ts +30 -0
- package/lib/archive/populate-entity-tables/resolve-text-refs.js +61 -0
- package/lib/archive/populate-entity-tables/resolve-url-or-blob-from-maps.d.ts +21 -0
- package/lib/archive/populate-entity-tables/resolve-url-or-blob-from-maps.js +27 -0
- package/lib/archive/populate-entity-tables/resolve-url-refs.d.ts +33 -0
- package/lib/archive/populate-entity-tables/resolve-url-refs.js +60 -0
- package/lib/archive/populate-entity-tables/test-utils/count-rows.d.ts +17 -0
- package/lib/archive/populate-entity-tables/test-utils/count-rows.js +20 -0
- package/lib/archive/populate-entity-tables/test-utils/seed-content-items.d.ts +25 -0
- package/lib/archive/populate-entity-tables/test-utils/seed-content-items.js +42 -0
- package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.d.ts +23 -0
- package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.js +178 -0
- package/lib/archive/populate-entity-tables/types.d.ts +157 -0
- package/lib/archive/populate-entity-tables/types.js +12 -0
- package/lib/archive/populate-entity-tables/upsert-text-refs.d.ts +38 -0
- package/lib/archive/populate-entity-tables/upsert-text-refs.js +78 -0
- package/lib/archive/populate-ref-tables/classify-content-type.d.ts +16 -0
- package/lib/archive/populate-ref-tables/classify-content-type.js +52 -0
- package/lib/archive/populate-ref-tables/compute-content-hash.d.ts +22 -0
- package/lib/archive/populate-ref-tables/compute-content-hash.js +26 -0
- package/lib/archive/populate-ref-tables/compute-header-flags.d.ts +16 -0
- package/lib/archive/populate-ref-tables/compute-header-flags.js +70 -0
- package/lib/archive/populate-ref-tables/content-type-rules.d.ts +38 -0
- package/lib/archive/populate-ref-tables/content-type-rules.js +133 -0
- package/lib/archive/populate-ref-tables/create-header-table-caches.d.ts +25 -0
- package/lib/archive/populate-ref-tables/create-header-table-caches.js +49 -0
- package/lib/archive/populate-ref-tables/data-uri-url-refs-limit.d.ts +15 -0
- package/lib/archive/populate-ref-tables/data-uri-url-refs-limit.js +15 -0
- package/lib/archive/populate-ref-tables/decode-data-uri.d.ts +21 -0
- package/lib/archive/populate-ref-tables/decode-data-uri.js +126 -0
- package/lib/archive/populate-ref-tables/decompose-header-set.d.ts +29 -0
- package/lib/archive/populate-ref-tables/decompose-header-set.js +157 -0
- package/lib/archive/populate-ref-tables/decompose-url.d.ts +25 -0
- package/lib/archive/populate-ref-tables/decompose-url.js +70 -0
- package/lib/archive/populate-ref-tables/header-stability.d.ts +19 -0
- package/lib/archive/populate-ref-tables/header-stability.js +22 -0
- package/lib/archive/populate-ref-tables/header-value-cache-key.d.ts +17 -0
- package/lib/archive/populate-ref-tables/header-value-cache-key.js +19 -0
- package/lib/archive/populate-ref-tables/normalize-mime.d.ts +24 -0
- package/lib/archive/populate-ref-tables/normalize-mime.js +36 -0
- package/lib/archive/populate-ref-tables/populate-blob-refs.d.ts +38 -0
- package/lib/archive/populate-ref-tables/populate-blob-refs.js +134 -0
- package/lib/archive/populate-ref-tables/populate-content-type-refs.d.ts +27 -0
- package/lib/archive/populate-ref-tables/populate-content-type-refs.js +70 -0
- package/lib/archive/populate-ref-tables/populate-header-tables.d.ts +35 -0
- package/lib/archive/populate-ref-tables/populate-header-tables.js +80 -0
- package/lib/archive/populate-ref-tables/populate-json-refs.d.ts +29 -0
- package/lib/archive/populate-ref-tables/populate-json-refs.js +101 -0
- package/lib/archive/populate-ref-tables/populate-refs.d.ts +51 -0
- package/lib/archive/populate-ref-tables/populate-refs.js +62 -0
- package/lib/archive/populate-ref-tables/populate-text-refs.d.ts +32 -0
- package/lib/archive/populate-ref-tables/populate-text-refs.js +133 -0
- package/lib/archive/populate-ref-tables/populate-url-refs.d.ts +28 -0
- package/lib/archive/populate-ref-tables/populate-url-refs.js +148 -0
- package/lib/archive/populate-ref-tables/test-utils/count-rows.d.ts +15 -0
- package/lib/archive/populate-ref-tables/test-utils/count-rows.js +17 -0
- package/lib/archive/populate-ref-tables/types.d.ts +197 -0
- package/lib/archive/populate-ref-tables/types.js +7 -0
- package/lib/archive/populate-ref-tables/upsert-one-header-set.d.ts +34 -0
- package/lib/archive/populate-ref-tables/upsert-one-header-set.js +208 -0
- package/lib/archive/populate-ref-tables/volatile-header-names.d.ts +20 -0
- package/lib/archive/populate-ref-tables/volatile-header-names.js +33 -0
- package/lib/archive/redirect-table.d.ts +4 -2
- package/lib/archive/redirect-table.js +15 -10
- package/lib/archive/resolve-redirect-chain.d.ts +3 -3
- package/lib/archive/resolve-redirect-chain.js +2 -2
- package/lib/archive/resource.d.ts +1 -1
- package/lib/archive/retarget-legacy-fk-tables.d.ts +47 -0
- package/lib/archive/retarget-legacy-fk-tables.js +107 -0
- package/lib/archive/test-utils/fk-parent-tables.d.ts +15 -0
- package/lib/archive/test-utils/fk-parent-tables.js +19 -0
- package/lib/archive/test-utils/seed-content-item.d.ts +35 -0
- package/lib/archive/test-utils/seed-content-item.js +42 -0
- package/lib/archive/test-utils/setup-legacy-fk-db.d.ts +33 -0
- package/lib/archive/test-utils/setup-legacy-fk-db.js +270 -0
- package/lib/archive/types.d.ts +151 -15
- package/lib/archive/verify-migration/capture-rejection.d.ts +24 -0
- package/lib/archive/verify-migration/capture-rejection.js +31 -0
- package/lib/archive/verify-migration/check-anchor-edges-count.d.ts +34 -0
- package/lib/archive/verify-migration/check-anchor-edges-count.js +72 -0
- package/lib/archive/verify-migration/check-anchor-edges-sum.d.ts +13 -0
- package/lib/archive/verify-migration/check-anchor-edges-sum.js +27 -0
- package/lib/archive/verify-migration/check-content-items-count.d.ts +16 -0
- package/lib/archive/verify-migration/check-content-items-count.js +30 -0
- package/lib/archive/verify-migration/check-content-type-preservation.d.ts +22 -0
- package/lib/archive/verify-migration/check-content-type-preservation.js +40 -0
- package/lib/archive/verify-migration/check-foreign-key-integrity.d.ts +31 -0
- package/lib/archive/verify-migration/check-foreign-key-integrity.js +47 -0
- package/lib/archive/verify-migration/check-image-items-count.d.ts +12 -0
- package/lib/archive/verify-migration/check-image-items-count.js +26 -0
- package/lib/archive/verify-migration/check-page-meta-count.d.ts +15 -0
- package/lib/archive/verify-migration/check-page-meta-count.js +31 -0
- package/lib/archive/verify-migration/check-reader-parity.d.ts +23 -0
- package/lib/archive/verify-migration/check-reader-parity.js +211 -0
- package/lib/archive/verify-migration/check-resource-items-count.d.ts +17 -0
- package/lib/archive/verify-migration/check-resource-items-count.js +33 -0
- package/lib/archive/verify-migration/check-url-round-trip.d.ts +43 -0
- package/lib/archive/verify-migration/check-url-round-trip.js +112 -0
- package/lib/archive/verify-migration/types.d.ts +70 -0
- package/lib/archive/verify-migration/types.js +63 -0
- package/lib/archive/verify-migration/verify-migration.d.ts +41 -0
- package/lib/archive/verify-migration/verify-migration.js +120 -0
- package/lib/classify-error-kind.d.ts +19 -0
- package/lib/classify-error-kind.js +122 -0
- package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
- package/lib/crawler/build-js-redirect-edge.js +57 -0
- package/lib/crawler/build-redirect-event.d.ts +24 -0
- package/lib/crawler/build-redirect-event.js +28 -0
- package/lib/crawler/capture-image-dom-paths.d.ts +33 -0
- package/lib/crawler/capture-image-dom-paths.js +39 -0
- package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
- package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
- package/lib/crawler/collect-image-dom-paths.d.ts +23 -0
- package/lib/crawler/collect-image-dom-paths.js +64 -0
- package/lib/crawler/crawler.d.ts +22 -1
- package/lib/crawler/crawler.js +679 -117
- package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
- package/lib/crawler/derive-js-redirect-target.js +129 -0
- package/lib/crawler/derive-resource-source.d.ts +25 -15
- package/lib/crawler/derive-resource-source.js +28 -17
- package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
- package/lib/crawler/dns-burned-host-cache.js +25 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
- package/lib/crawler/fetch-destination.d.ts +12 -4
- package/lib/crawler/fetch-destination.js +94 -16
- package/lib/crawler/inject-scope-auth.d.ts +1 -1
- package/lib/crawler/inject-scope-auth.js +1 -1
- package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
- package/lib/crawler/is-js-redirect-error-shape.js +53 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
- package/lib/crawler/link-list.d.ts +21 -1
- package/lib/crawler/link-list.js +23 -3
- package/lib/crawler/normalize-content-type.d.ts +1 -1
- package/lib/crawler/normalize-content-type.js +1 -1
- package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
- package/lib/crawler/plan-sub-resource-emits.js +44 -0
- package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
- package/lib/crawler/preload-short-circuit-error.js +25 -0
- package/lib/crawler/should-burn-host.d.ts +78 -0
- package/lib/crawler/should-burn-host.js +61 -0
- package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
- package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
- package/lib/crawler/types.d.ts +108 -1
- package/lib/crawler-orchestrator.d.ts +22 -3
- package/lib/crawler-orchestrator.js +312 -73
- package/lib/crawler.d.ts +15 -2
- package/lib/crawler.js +24 -1
- package/lib/permanent-error-kinds.d.ts +43 -0
- package/lib/permanent-error-kinds.js +48 -0
- package/lib/types.d.ts +84 -0
- package/lib/utils/compute-file-sha256.d.ts +24 -0
- package/lib/utils/compute-file-sha256.js +56 -0
- package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
- package/lib/utils/error/emit-error-with-retry.js +44 -0
- package/lib/utils/error/emit-error.d.ts +39 -0
- package/lib/utils/error/emit-error.js +41 -0
- package/package.json +12 -12
- package/lib/archive/migrate-crawl-errors.d.ts +0 -20
- package/lib/archive/migrate-crawl-errors.js +0 -38
- package/lib/archive/migrate-html-blob-tables.d.ts +0 -24
- package/lib/archive/migrate-html-blob-tables.js +0 -53
- package/lib/archive/migrate-page-errors.d.ts +0 -16
- package/lib/archive/migrate-page-errors.js +0 -35
- package/lib/archive/migrate-pages-resources-source.d.ts +0 -16
- package/lib/archive/migrate-pages-resources-source.js +0 -46
- package/lib/utils/error/error-emitter.d.ts +0 -18
- package/lib/utils/error/error-emitter.js +0 -29
package/lib/archive/database.js
CHANGED
|
@@ -1,1791 +1,538 @@
|
|
|
1
|
-
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
2
|
-
var useValue = arguments.length > 2;
|
|
3
|
-
for (var i = 0; i < initializers.length; i++) {
|
|
4
|
-
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
5
|
-
}
|
|
6
|
-
return useValue ? value : void 0;
|
|
7
|
-
};
|
|
8
|
-
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
9
|
-
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
|
10
|
-
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
11
|
-
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
12
|
-
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
13
|
-
var _, done = false;
|
|
14
|
-
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
15
|
-
var context = {};
|
|
16
|
-
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
17
|
-
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
18
|
-
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
|
19
|
-
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
20
|
-
if (kind === "accessor") {
|
|
21
|
-
if (result === void 0) continue;
|
|
22
|
-
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
23
|
-
if (_ = accept(result.get)) descriptor.get = _;
|
|
24
|
-
if (_ = accept(result.set)) descriptor.set = _;
|
|
25
|
-
if (_ = accept(result.init)) initializers.unshift(_);
|
|
26
|
-
}
|
|
27
|
-
else if (_ = accept(result)) {
|
|
28
|
-
if (kind === "field") initializers.unshift(_);
|
|
29
|
-
else descriptor[key] = _;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
33
|
-
done = true;
|
|
34
|
-
};
|
|
35
|
-
import { createHash } from 'node:crypto';
|
|
36
1
|
import { existsSync } from 'node:fs';
|
|
37
2
|
import path from 'node:path';
|
|
38
|
-
import {
|
|
39
|
-
import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
|
|
40
|
-
import { retry } from '@d-zero/shared/retry';
|
|
41
|
-
import { pathComparator } from '@d-zero/shared/sort/path';
|
|
3
|
+
import { retryCall } from '@d-zero/shared/retry';
|
|
42
4
|
import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
|
|
43
5
|
import knex from 'knex';
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
6
|
+
import { emitErrorAndRetry } from '../utils/error/emit-error-with-retry.js';
|
|
7
|
+
import { emitError } from '../utils/error/emit-error.js';
|
|
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';
|
|
50
51
|
import { mkdir } from './filesystem/mkdir.js';
|
|
51
|
-
import { getJSON } from './get-json.js';
|
|
52
|
-
import { applyConnectionPragmas, initSchema } from './init-schema.js';
|
|
53
52
|
import { LibsqlDialect } from './libsql-dialect.js';
|
|
54
|
-
import { limitedPageIds } from './limited-page-ids.js';
|
|
55
|
-
import { assertCompatibleVersion } from './meta/assert-compatible-version.js';
|
|
56
|
-
import { classifyJsonLdType } from './meta/classify-jsonld-type.js';
|
|
57
|
-
import { computePageDenormalized } from './meta/compute-page-denormalized.js';
|
|
58
|
-
import { deriveFlatFromMeta } from './meta/derive-flat-from-meta.js';
|
|
59
|
-
import { deriveMetaExtras } from './meta/derive-meta-extras.js';
|
|
60
|
-
import { extractTagsForArchive } from './meta/extract-tags-for-archive.js';
|
|
61
|
-
import { migrateCrawlErrors } from './migrate-crawl-errors.js';
|
|
62
|
-
import { migrateHtmlBlobTables } from './migrate-html-blob-tables.js';
|
|
63
|
-
import { migrateInfoRoots } from './migrate-info-roots.js';
|
|
64
|
-
import { migratePageErrors } from './migrate-page-errors.js';
|
|
65
|
-
import { migratePagesResourcesSource } from './migrate-pages-resources-source.js';
|
|
66
|
-
import { redirectTable } from './redirect-table.js';
|
|
67
|
-
import { resolveRedirectChain } from './resolve-redirect-chain.js';
|
|
68
|
-
const retrySetting = {
|
|
69
|
-
interval: 300,
|
|
70
|
-
retries: 3,
|
|
71
|
-
};
|
|
72
|
-
/**
|
|
73
|
-
* Decodes a stored HTML body BLOB according to its codec marker. The codec
|
|
74
|
-
* column on `page_html_blobs` exists so individual rows can be migrated to
|
|
75
|
-
* a future encoder without rewriting the whole table; readers must dispatch
|
|
76
|
-
* on it. The body is typed `Uint8Array` (not `Buffer`) because libsql
|
|
77
|
-
* returns BLOB columns as bare `Uint8Array`; `Buffer.from` wraps it
|
|
78
|
-
* zero-copy.
|
|
79
|
-
* @param body - Raw bytes as stored in `page_html_blobs.body`.
|
|
80
|
-
* @param codec - The `codec` column value (e.g. `'zstd'`, `'none'`).
|
|
81
|
-
* @returns UTF-8 decoded HTML string.
|
|
82
|
-
* @throws {Error} If the codec is not recognised.
|
|
83
|
-
*/
|
|
84
53
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
54
|
+
* Low-level database abstraction layer for the archive's SQLite database.
|
|
55
|
+
*
|
|
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).
|
|
64
|
+
*
|
|
65
|
+
* **Label sync caveat**: each `emitError` / `emitErrorAndRetry` call passes
|
|
66
|
+
* the method name as a string literal (e.g. `'Database.getAnchorsOnPage'`).
|
|
67
|
+
* TypeScript cannot check that the string matches the enclosing method's
|
|
68
|
+
* real name — the two-way sync is manual. Renaming a method here **must**
|
|
69
|
+
* update the literal string too, otherwise debug logs and `RetryTimeoutError`
|
|
70
|
+
* messages will silently report the old name.
|
|
71
|
+
*
|
|
72
|
+
* Use the static {@link Database.connect} factory method to create instances.
|
|
73
|
+
* The constructor is private.
|
|
92
74
|
*/
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
75
|
+
export class Database extends EventEmitter {
|
|
76
|
+
/** The Knex query builder instance connected to the SQLite database. */
|
|
77
|
+
#instance;
|
|
78
|
+
/** Connection-scoped write-side id caches for entity/ref upserts. */
|
|
79
|
+
#writeRefCaches = createWriteRefCaches();
|
|
80
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
81
|
+
constructor(options) {
|
|
82
|
+
super();
|
|
83
|
+
// **Known caveat (libsql 0.5.x)**: passing `readonly: true` via
|
|
84
|
+
// `connection.options` is accepted by the libsql driver but is
|
|
85
|
+
// NOT enforced at the SQL layer — `CREATE TABLE` / `INSERT`
|
|
86
|
+
// against the resulting connection still succeed. The flag
|
|
87
|
+
// remains a no-op until libsql adds real read-only enforcement
|
|
88
|
+
// upstream. Read-only safety in cache mode therefore relies on:
|
|
89
|
+
//
|
|
90
|
+
// 1. `Database.#init` skipping schema init + migrations when
|
|
91
|
+
// `readOnly` is set (so no `initSchema` / `migrate*` ever
|
|
92
|
+
// writes to the shared cache directory).
|
|
93
|
+
// 2. `ArchiveAccessor.setData` rejecting writes when the
|
|
94
|
+
// `readOnly` flag is set on the accessor.
|
|
95
|
+
// 3. Code review on any future internal use of
|
|
96
|
+
// `accessor.getKnex()` — there is no driver-level guard.
|
|
97
|
+
this.#instance = knex({
|
|
98
|
+
client: LibsqlDialect,
|
|
99
|
+
connection: {
|
|
100
|
+
filename: options.filename,
|
|
101
|
+
},
|
|
102
|
+
useNullAsDefault: true,
|
|
103
|
+
pool: {
|
|
104
|
+
acquireTimeoutMillis: 600_000,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
96
107
|
}
|
|
97
|
-
|
|
98
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Forces a WAL checkpoint, writing all pending WAL data back to the main
|
|
110
|
+
* database file. Delegates to {@link checkpointOp}.
|
|
111
|
+
*/
|
|
112
|
+
async checkpoint() {
|
|
113
|
+
await checkpointOp(this.#instance);
|
|
99
114
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
function decodeStoredBlob(body, codec) {
|
|
107
|
-
// `Buffer.from(buffer)` accepts Uint8Array, Buffer, and array-like
|
|
108
|
-
// shapes uniformly; libsql may hand back any of these for a BLOB
|
|
109
|
-
// column depending on the row encoding.
|
|
110
|
-
const buffer = Buffer.from(body);
|
|
111
|
-
if (codec === 'zstd') {
|
|
112
|
-
return zstdDecompressSync(buffer).toString('utf8');
|
|
115
|
+
/**
|
|
116
|
+
* Destroys the database connection, releasing all pooled resources.
|
|
117
|
+
* Delegates to {@link destroyOp}.
|
|
118
|
+
*/
|
|
119
|
+
async destroy() {
|
|
120
|
+
await destroyOp(this.#instance);
|
|
113
121
|
}
|
|
114
|
-
|
|
115
|
-
|
|
122
|
+
/**
|
|
123
|
+
* Retrieves all anchors (outgoing links) on a specific page.
|
|
124
|
+
* Delegates to {@link getAnchorsOnPageOp}.
|
|
125
|
+
* @param pageId - The database ID of the page whose anchors to retrieve.
|
|
126
|
+
* @returns An array of anchor records with resolved URL, title, status, and content type.
|
|
127
|
+
*/
|
|
128
|
+
async getAnchorsOnPage(pageId) {
|
|
129
|
+
return emitErrorAndRetry(this, 'Database.getAnchorsOnPage', async () => await getAnchorsOnPageOp(this.#instance, pageId), retrySetting);
|
|
116
130
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Retrieves the base URL of the crawl session from the `info` table.
|
|
133
|
+
* Delegates to {@link getBaseUrlOp}.
|
|
134
|
+
* @returns The base URL string.
|
|
135
|
+
* @throws {Error} If no base URL is found in the database.
|
|
136
|
+
*/
|
|
137
|
+
async getBaseUrl() {
|
|
138
|
+
return emitErrorAndRetry(this, 'Database.getBaseUrl', async () => await getBaseUrlOp(this.#instance), retrySetting);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Retrieves the full crawl configuration from the `info` table.
|
|
142
|
+
* Delegates to {@link getConfigOp}.
|
|
143
|
+
* @returns The parsed {@link Config} object.
|
|
144
|
+
* @throws {Error} If no configuration is found in the database.
|
|
145
|
+
*/
|
|
146
|
+
async getConfig() {
|
|
147
|
+
return emitErrorAndRetry(this, 'Database.getConfig', async () => await getConfigOp(this.#instance), retrySetting);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Retrieves the current crawling state by listing scraped and pending URLs.
|
|
151
|
+
* Delegates to {@link getCrawlingStateOp} — see the op for the strict
|
|
152
|
+
* pending-set rationale.
|
|
153
|
+
* @returns An object with `scraped` (completed URLs) and `pending` (the
|
|
154
|
+
* strict set of in-scope, anchor-referenced, unfinished URLs).
|
|
155
|
+
*/
|
|
156
|
+
async getCrawlingState() {
|
|
157
|
+
return emitErrorAndRetry(this, 'Database.getCrawlingState', async () => await getCrawlingStateOp(this.#instance), retrySetting);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Return the subset of `urls` that already exist in the `pages` table.
|
|
161
|
+
* Delegates to {@link getExistingPageUrlsOp}.
|
|
162
|
+
* @param urls - URL strings to probe (already in `withoutHashAndAuth` form).
|
|
163
|
+
* @returns URLs found in `pages`. Order is not preserved.
|
|
164
|
+
*/
|
|
165
|
+
async getExistingPageUrls(urls) {
|
|
166
|
+
return emitError(this, 'Database.getExistingPageUrls', async () => await getExistingPageUrlsOp(this.#instance, urls));
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Return the subset of `urls` that already exist in the `resources` table.
|
|
170
|
+
* Delegates to {@link getExistingResourceUrlsOp}.
|
|
171
|
+
* @param urls - URL strings to probe.
|
|
172
|
+
* @returns URLs found in `resources`.
|
|
173
|
+
*/
|
|
174
|
+
async getExistingResourceUrls(urls) {
|
|
175
|
+
return emitError(this, 'Database.getExistingResourceUrls', async () => await getExistingResourceUrlsOp(this.#instance, urls));
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Reads the HTML snapshot stored as a zstd-compressed BLOB for the given page.
|
|
179
|
+
* Delegates to {@link getHtmlOfPageByIdOp}.
|
|
180
|
+
* @param pageId - The database ID of the page.
|
|
181
|
+
* @returns The decompressed HTML string, or `null` if no snapshot is stored.
|
|
182
|
+
*/
|
|
183
|
+
async getHtmlOfPageById(pageId) {
|
|
184
|
+
return emitErrorAndRetry(this, 'Database.getHtmlOfPageById', async () => await getHtmlOfPageByIdOp(this.#instance, pageId), retrySetting);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Retrieves all `page_jsonld` rows for the given page id, parsed back into
|
|
188
|
+
* {@link JsonLdRow} shape. Delegates to {@link getJsonLdOfPageOp}.
|
|
189
|
+
* @param pageId
|
|
190
|
+
*/
|
|
191
|
+
async getJsonLdOfPage(pageId) {
|
|
192
|
+
return emitErrorAndRetry(this, 'Database.getJsonLdOfPage', async () => await getJsonLdOfPageOp(this.#instance, pageId), retrySetting);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Returns the underlying Knex query builder instance for direct SQL access.
|
|
196
|
+
* This enables advanced queries (GROUP BY, HAVING, JOINs) at the database
|
|
197
|
+
* layer for performance with large datasets.
|
|
198
|
+
* @returns The Knex instance connected to the SQLite database.
|
|
199
|
+
*/
|
|
200
|
+
getKnex() {
|
|
201
|
+
return this.#instance;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Retrieves the crawl session name from the `info` table.
|
|
205
|
+
* Delegates to {@link getNameOp}.
|
|
206
|
+
* @returns The name string.
|
|
207
|
+
* @throws {Error} If no name is found in the database.
|
|
208
|
+
*/
|
|
209
|
+
async getName() {
|
|
210
|
+
return emitErrorAndRetry(this, 'Database.getName', async () => await getNameOp(this.#instance), retrySetting);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Counts the total number of pages in the database.
|
|
214
|
+
* Delegates to {@link getPageCountOp}.
|
|
215
|
+
* @returns The total page count.
|
|
216
|
+
* @throws {Error} If the count query fails.
|
|
217
|
+
*/
|
|
218
|
+
async getPageCount() {
|
|
219
|
+
return emitErrorAndRetry(this, 'Database.getPageCount', async () => await getPageCountOp(this.#instance), retrySetting);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Retrieves pages from the database with optional filtering, pagination via
|
|
223
|
+
* offset and limit. Delegates to {@link getPagesOp}.
|
|
224
|
+
* @param filter - An optional {@link PageFilter} to narrow results by content type and origin.
|
|
225
|
+
* @param offset - The number of rows to skip. Defaults to `0`.
|
|
226
|
+
* @param limit - The maximum number of rows to return. Defaults to `100000`.
|
|
227
|
+
* @returns An array of raw `DB_Page` rows.
|
|
228
|
+
*/
|
|
229
|
+
async getPages(filter, offset = 0, limit = 100_000) {
|
|
230
|
+
return emitErrorAndRetry(this, 'Database.getPages', async () => await getPagesOp(this.#instance, filter, offset, limit), retrySetting);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Look up the `source` column of a single page by its URL key.
|
|
234
|
+
* Delegates to {@link getPageSourceByUrlOp}.
|
|
235
|
+
* @param url - URL key in `url.withoutHashAndAuth` form.
|
|
236
|
+
* @returns The recorded `source`, or `undefined` when no row exists.
|
|
237
|
+
*/
|
|
238
|
+
async getPageSourceByUrl(url) {
|
|
239
|
+
return emitError(this, 'Database.getPageSourceByUrl', async () => await getPageSourceByUrlOp(this.#instance, url));
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Retrieves pages along with their related redirect, anchor, and referrer data.
|
|
243
|
+
* Results are ordered by the natural URL sort order. Only non-redirected pages
|
|
244
|
+
* are returned. Delegates to {@link getPagesWithRelsOp}.
|
|
245
|
+
* @param offset - The number of rows to skip.
|
|
246
|
+
* @param limit - The maximum number of pages to return.
|
|
247
|
+
* @returns An object containing `pages`, `redirects`, `anchors`, and `referrers` arrays.
|
|
248
|
+
*/
|
|
249
|
+
async getPagesWithRels(offset, limit) {
|
|
250
|
+
return emitErrorAndRetry(this, 'Database.getPagesWithRels', async () => await getPagesWithRelsOp(this.#instance, offset, limit), retrySetting);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Retrieves redirect sources for the given page IDs in bulk.
|
|
254
|
+
* Delegates to {@link getRedirectsForPagesOp}.
|
|
255
|
+
* @param pageIds - The database IDs of the destination pages.
|
|
256
|
+
* @returns An array of {@link DB_Redirect} records mapping destination pages to their redirect sources.
|
|
257
|
+
*/
|
|
258
|
+
async getRedirectsForPages(pageIds) {
|
|
259
|
+
return emitErrorAndRetry(this, 'Database.getRedirectsForPages', async () => await getRedirectsForPagesOp(this.#instance, pageIds), retrySetting);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Retrieves pages that link to a specific page (incoming links / referrers),
|
|
263
|
+
* resolved through redirects. Delegates to {@link getReferrersOfPageOp}.
|
|
264
|
+
* @param pageId - The database ID of the target page.
|
|
265
|
+
* @returns An array of referrer records with URL, hash, and text content.
|
|
266
|
+
*/
|
|
267
|
+
async getReferrersOfPage(pageId) {
|
|
268
|
+
return emitErrorAndRetry(this, 'Database.getReferrersOfPage', async () => await getReferrersOfPageOp(this.#instance, pageId), retrySetting);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Retrieves the page URLs that reference a specific resource.
|
|
272
|
+
* Delegates to {@link getReferrersOfResourceOp}.
|
|
273
|
+
* @param id - The database ID of the resource.
|
|
274
|
+
* @returns An array of page URL strings that reference the resource.
|
|
275
|
+
*/
|
|
276
|
+
async getReferrersOfResource(id) {
|
|
277
|
+
return emitErrorAndRetry(this, 'Database.getReferrersOfResource', async () => await getReferrersOfResourceOp(this.#instance, id), retrySetting);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Retrieves a single sub-resource from the `resources` table by its URL.
|
|
281
|
+
* Delegates to {@link getResourceByUrlOp}.
|
|
282
|
+
*
|
|
283
|
+
* Deliberately NOT wrapped with `emitError`/`emitErrorAndRetry`: the only caller (the
|
|
284
|
+
* crawler's resource-reuse hook) has a full fallback (the HEAD pre-flight),
|
|
285
|
+
* so a read failure here must not surface as a database `error` event —
|
|
286
|
+
* the orchestrator aborts the whole crawl on that event, which is the
|
|
287
|
+
* correct reaction to write failures but not to a recoverable read.
|
|
288
|
+
* @param urls - URL candidates to match against the `url` column.
|
|
289
|
+
* @returns The raw {@link DB_Resource} row, or `null` if none match.
|
|
290
|
+
*/
|
|
291
|
+
async getResourceByUrl(urls) {
|
|
292
|
+
return retryCall(async () => await getResourceByUrlOp(this.#instance, urls), {
|
|
293
|
+
...retrySetting,
|
|
294
|
+
label: 'Database.getResourceByUrl',
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Retrieves all sub-resources from the `resources` table.
|
|
299
|
+
* Delegates to {@link getResourcesOp}.
|
|
300
|
+
* @returns An array of raw {@link DB_Resource} rows.
|
|
301
|
+
*/
|
|
302
|
+
async getResources() {
|
|
303
|
+
return emitErrorAndRetry(this, 'Database.getResources', async () => await getResourcesOp(this.#instance), retrySetting);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Retrieves a flat list of all resource URLs from the `resources` table.
|
|
307
|
+
* Delegates to {@link getResourceUrlListOp}.
|
|
308
|
+
* @returns An array of resource URL strings.
|
|
309
|
+
*/
|
|
310
|
+
async getResourceUrlList() {
|
|
311
|
+
return emitErrorAndRetry(this, 'Database.getResourceUrlList', async () => await getResourceUrlListOp(this.#instance), retrySetting);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Counts pages that were scraped as crawl targets (full HTML render).
|
|
315
|
+
* Delegates to {@link getScrapedHtmlPageCountOp}.
|
|
316
|
+
* @returns The number of `text/html` rows with `isTarget = 1` and `scraped = 1`.
|
|
317
|
+
*/
|
|
318
|
+
async getScrapedHtmlPageCount() {
|
|
319
|
+
return emitErrorAndRetry(this, 'Database.getScrapedHtmlPageCount', async () => await getScrapedHtmlPageCountOp(this.#instance), retrySetting);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Retrieves all `page_tags` rows for the given page id, parsed back into
|
|
323
|
+
* {@link TagRow} shape. Delegates to {@link getTagsOfPageOp}.
|
|
324
|
+
* @param pageId
|
|
325
|
+
*/
|
|
326
|
+
async getTagsOfPage(pageId) {
|
|
327
|
+
return emitErrorAndRetry(this, 'Database.getTagsOfPage', async () => await getTagsOfPageOp(this.#instance, pageId), retrySetting);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Records a crawler-level (`error` channel) failure into `crawl_errors`.
|
|
331
|
+
* Delegates to {@link insertCrawlErrorOp}.
|
|
332
|
+
* @param url - The URL the error is about, or `null` for a process-level error.
|
|
333
|
+
* @param message - The error message (one line is enough for classification).
|
|
334
|
+
* @param isExternal - Whether the URL is external to the crawl scope.
|
|
335
|
+
*/
|
|
336
|
+
async insertCrawlError(url, message, isExternal = false) {
|
|
337
|
+
return emitErrorAndRetry(this, 'Database.insertCrawlError', async () => await insertCrawlErrorOp(this.#instance, url, message, isExternal), retrySetting);
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Pre-insert inventory non-HTML URLs into `resources` as placeholder rows.
|
|
341
|
+
* Delegates to {@link insertInventoryResourcesOp}.
|
|
342
|
+
* @param urls - URL strings (already in `withoutHashAndAuth` form).
|
|
343
|
+
*/
|
|
344
|
+
async insertInventoryResources(urls) {
|
|
345
|
+
return emitErrorAndRetry(this, 'Database.insertInventoryResources', async () => await insertInventoryResourcesOp(this.#instance, this.#writeRefCaches, urls), retrySetting);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Pre-insert inventory HTML seeds into `pages` as `scraped = 0`,
|
|
349
|
+
* `source = 'inventory-seed'` placeholders. Delegates to
|
|
350
|
+
* {@link insertInventorySeedsOp} — see the op for the Ctrl+C tolerance
|
|
351
|
+
* rationale.
|
|
352
|
+
* @param urls - URL strings already in `withoutHashAndAuth` form.
|
|
353
|
+
*/
|
|
354
|
+
async insertInventorySeeds(urls) {
|
|
355
|
+
return emitErrorAndRetry(this, 'Database.insertInventorySeeds', async () => await insertInventorySeedsOp(this.#instance, this.#writeRefCaches, urls), retrySetting);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Records a partial scrape failure against the page identified by `url`.
|
|
359
|
+
* Delegates to {@link insertPageErrorOp}.
|
|
360
|
+
* @param url - URL of the page being scraped.
|
|
361
|
+
* @param phase - Scrape phase name (typically `'retryExhausted'`).
|
|
362
|
+
* @param message - Human-readable failure message.
|
|
363
|
+
* @param isExternal - Whether the URL is external. Defaults to `false`.
|
|
364
|
+
*/
|
|
365
|
+
async insertPageError(url, phase, message, isExternal = false) {
|
|
366
|
+
return emitErrorAndRetry(this, 'Database.insertPageError', async () => await insertPageErrorOp(this.#instance, this.#writeRefCaches, url, phase, message, isExternal), retrySetting);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Inserts a sub-resource into the `resources` table.
|
|
370
|
+
* Delegates to {@link insertResourceOp}.
|
|
371
|
+
* @param resource - The resource data to insert.
|
|
372
|
+
* @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
|
|
373
|
+
*/
|
|
374
|
+
async insertResource(resource, source) {
|
|
375
|
+
return emitErrorAndRetry(this, 'Database.insertResource', async () => await insertResourceOp(this.#instance, this.#writeRefCaches, resource, source), retrySetting);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Inserts a referrer relationship between a resource and a page into the
|
|
379
|
+
* `resources-referrers` table. Delegates to {@link insertResourceReferrersOp}.
|
|
380
|
+
* @param src - The URL of the resource.
|
|
381
|
+
* @param pageUrl - The URL of the page that references the resource.
|
|
382
|
+
*/
|
|
383
|
+
async insertResourceReferrers(src, pageUrl) {
|
|
384
|
+
return emitErrorAndRetry(this, 'Database.insertResourceReferrers', async () => await insertResourceReferrersOp(this.#instance, this.#writeRefCaches, src, pageUrl), retrySetting);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Hostnames whose `crawl_errors` history is consistently DNS failures and
|
|
388
|
+
* for which no recent 2xx-3xx page or resource is recorded.
|
|
389
|
+
* Delegates to {@link listDnsBurnedHostCandidatesOp}.
|
|
390
|
+
* @returns Lower-cased hostnames safe to short-circuit.
|
|
391
|
+
*/
|
|
392
|
+
async listDnsBurnedHostCandidates() {
|
|
393
|
+
return emitErrorAndRetry(this, 'Database.listDnsBurnedHostCandidates', async () => await listDnsBurnedHostCandidatesOp(this.#instance), retrySetting);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Appends one row to the `inventory_runs` audit log.
|
|
397
|
+
* Delegates to {@link recordInventoryRunOp}.
|
|
398
|
+
* @param meta - The run metadata to record. Only `ran_at` is required.
|
|
399
|
+
* @returns The autoincremented `id` of the newly-inserted row.
|
|
400
|
+
*/
|
|
401
|
+
async recordInventoryRun(meta) {
|
|
402
|
+
return emitErrorAndRetry(this, 'Database.recordInventoryRun', async () => await recordInventoryRunOp(this.#instance, meta), retrySetting);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Records a redirect edge (source → destination) **without** re-storing the
|
|
406
|
+
* destination's content. Delegates to {@link recordRedirectOp}.
|
|
407
|
+
* @param page - HEAD-resolved page data carrying the redirect chain. Its
|
|
408
|
+
* `anchorList` / `imageList` are ignored (a redirect source owns no content).
|
|
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.
|
|
412
|
+
*/
|
|
413
|
+
async recordRedirect(page, source) {
|
|
414
|
+
return emitErrorAndRetry(this, 'Database.recordRedirect', async () => await recordRedirectOp(this.#instance, this.#writeRefCaches, page, source), retrySetting);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
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}.
|
|
428
|
+
* @param scopes - The hostname-indexed scope map after the new roots are merged.
|
|
429
|
+
* @param options - URL parsing options forwarded to the scope matcher.
|
|
430
|
+
* @returns The URLs of the pages that were promoted.
|
|
431
|
+
*/
|
|
432
|
+
async repromoteExternalPages(scopes, options) {
|
|
433
|
+
return emitErrorAndRetry(this, 'Database.repromoteExternalPages', async () => await repromoteExternalPagesOp(this.#instance, scopes, options), retrySetting);
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Reset previously-attempted pages that ended in a recoverable failure so a
|
|
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.
|
|
440
|
+
* @returns The URLs of the pages that were reset to pending.
|
|
441
|
+
*/
|
|
442
|
+
async resetFailedPages() {
|
|
443
|
+
return emitErrorAndRetry(this, 'Database.resetFailedPages', async () => await resetFailedPagesOp(this.#instance), retrySetting);
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Stores the crawl configuration in the `info` table.
|
|
447
|
+
* Delegates to {@link setConfigOp}.
|
|
448
|
+
* @param config - The {@link Config} object to store.
|
|
449
|
+
*/
|
|
450
|
+
async setConfig(config) {
|
|
451
|
+
return emitErrorAndRetry(this, 'Database.setConfig', async () => await setConfigOp(this.#instance, config), retrySetting);
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Marks a page as skipped in the database with the given reason.
|
|
455
|
+
* Delegates to {@link setSkippedPageOp}.
|
|
456
|
+
* @param url - The URL of the skipped page.
|
|
457
|
+
* @param reason - The reason the page was skipped.
|
|
458
|
+
* @param isExternal - Whether the page is on an external domain. Defaults to `false`.
|
|
459
|
+
*/
|
|
460
|
+
async setSkippedPage(url, reason, isExternal = false) {
|
|
461
|
+
return emitErrorAndRetry(this, 'Database.setSkippedPage', async () => await setSkippedPageOp(this.#instance, this.#writeRefCaches, url, reason, isExternal), retrySetting);
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Assigns natural URL sort order values to all internal pages.
|
|
465
|
+
* Delegates to {@link setUrlOrderOp}.
|
|
466
|
+
*/
|
|
467
|
+
async setUrlOrder() {
|
|
468
|
+
await setUrlOrderOp(this.#instance);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Update the single row in the `info` table with a partial config patch.
|
|
472
|
+
* Delegates to {@link updateConfigOp}.
|
|
473
|
+
* @param patch - Partial {@link Config} fields to overwrite. `undefined` values are skipped.
|
|
474
|
+
*/
|
|
475
|
+
async updateConfig(patch) {
|
|
476
|
+
return emitErrorAndRetry(this, 'Database.updateConfig', async () => await updateConfigOp(this.#instance, patch), retrySetting);
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
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}.
|
|
482
|
+
* @param page - The page data to store.
|
|
483
|
+
* @param writeHtml - When `true`, this call is allowed to insert (or clear)
|
|
484
|
+
* the page's HTML blob. `setExternalPage` passes `false` because external
|
|
485
|
+
* metadata-only scrapes never carry HTML and must not perturb an already
|
|
486
|
+
* stored body.
|
|
487
|
+
* @param isTarget - Whether this page is a crawl target.
|
|
488
|
+
* @param source - Provenance label written ONLY when the row is freshly
|
|
489
|
+
* inserted. Existing rows keep their original `source`.
|
|
490
|
+
* @returns The database `pageId` of the inserted/updated row.
|
|
491
|
+
*/
|
|
492
|
+
async updatePage(page, writeHtml, isTarget, source) {
|
|
493
|
+
return emitErrorAndRetry(this, 'Database.updatePage', async () => await updatePageOp(this.#instance, this.#writeRefCaches, page, writeHtml, isTarget, source), retrySetting);
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
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}.
|
|
499
|
+
* @param readOnly - When true, skip schema init + migrations.
|
|
500
|
+
*/
|
|
501
|
+
async #init(readOnly) {
|
|
502
|
+
await initOp(this.#instance, readOnly);
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Creates and initializes a new Database instance.
|
|
506
|
+
*
|
|
507
|
+
* **Writer mode (default)**: creates the parent directory for the
|
|
508
|
+
* database file if needed, establishes the connection, and initializes
|
|
509
|
+
* the schema + migrations.
|
|
510
|
+
*
|
|
511
|
+
* **Read-only mode** (`options.readOnly`): refuses to resurrect a
|
|
512
|
+
* missing parent directory or db file — throws if either is absent at
|
|
513
|
+
* the time of the call. Skips schema init and migrations entirely so
|
|
514
|
+
* the user's tmpDir is never modified. Required by viewer / MCP
|
|
515
|
+
* stub-mode opens, where a TOCTOU window between classification and
|
|
516
|
+
* `connect()` could otherwise leave behind a phantom empty tmpDir.
|
|
517
|
+
* @param options - Database connection options.
|
|
518
|
+
* @returns A fully initialized Database instance.
|
|
519
|
+
* @throws {Error} In read-only mode, if the parent directory or db
|
|
520
|
+
* file does not exist when `connect()` runs.
|
|
521
|
+
*/
|
|
522
|
+
static async connect(options) {
|
|
523
|
+
if (options.readOnly) {
|
|
524
|
+
if (!existsSync(path.dirname(options.filename))) {
|
|
525
|
+
throw new Error(`Cannot open archive read-only: parent directory disappeared (${path.dirname(options.filename)}). The source may have been removed by another process.`);
|
|
526
|
+
}
|
|
527
|
+
if (!existsSync(options.filename)) {
|
|
528
|
+
throw new Error(`Cannot open archive read-only: database file missing (${options.filename}). The source may have been removed by another process.`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
mkdir(options.filename);
|
|
533
|
+
}
|
|
534
|
+
const db = new Database(options);
|
|
535
|
+
await db.#init(options.readOnly ?? false);
|
|
536
|
+
return db;
|
|
237
537
|
}
|
|
238
|
-
return payload;
|
|
239
538
|
}
|
|
240
|
-
/**
|
|
241
|
-
* Low-level database abstraction layer for the archive's SQLite database.
|
|
242
|
-
*
|
|
243
|
-
* Public methods that perform database queries use the `@retryable`
|
|
244
|
-
* decorator for automatic retry on transient failures, and `@ErrorEmitter`
|
|
245
|
-
* to propagate errors as events. The set of tables this layer manages is
|
|
246
|
-
* defined by `init-schema.ts` (the source of truth — query that file for
|
|
247
|
-
* the canonical list).
|
|
248
|
-
*
|
|
249
|
-
* Use the static {@link Database.connect} factory method to create instances.
|
|
250
|
-
* The constructor is private.
|
|
251
|
-
*/
|
|
252
|
-
let Database = (() => {
|
|
253
|
-
let _classSuper = EventEmitter;
|
|
254
|
-
let _instanceExtraInitializers = [];
|
|
255
|
-
let _getAnchorsOnPage_decorators;
|
|
256
|
-
let _getBaseUrl_decorators;
|
|
257
|
-
let _getConfig_decorators;
|
|
258
|
-
let _getCrawlingState_decorators;
|
|
259
|
-
let _getExistingPageUrls_decorators;
|
|
260
|
-
let _getExistingResourceUrls_decorators;
|
|
261
|
-
let _getHtmlOfPageById_decorators;
|
|
262
|
-
let _getJsonLdOfPage_decorators;
|
|
263
|
-
let _getName_decorators;
|
|
264
|
-
let _getPageCount_decorators;
|
|
265
|
-
let _getPages_decorators;
|
|
266
|
-
let _getPagesWithRels_decorators;
|
|
267
|
-
let _getRedirectsForPages_decorators;
|
|
268
|
-
let _getReferrersOfPage_decorators;
|
|
269
|
-
let _getReferrersOfResource_decorators;
|
|
270
|
-
let _getResourceByUrl_decorators;
|
|
271
|
-
let _getResources_decorators;
|
|
272
|
-
let _getResourceUrlList_decorators;
|
|
273
|
-
let _getScrapedHtmlPageCount_decorators;
|
|
274
|
-
let _getTagsOfPage_decorators;
|
|
275
|
-
let _insertCrawlError_decorators;
|
|
276
|
-
let _insertPageError_decorators;
|
|
277
|
-
let _insertResource_decorators;
|
|
278
|
-
let _insertResourceReferrers_decorators;
|
|
279
|
-
let _recordRedirect_decorators;
|
|
280
|
-
let _repromoteExternalPages_decorators;
|
|
281
|
-
let _resetFailedPages_decorators;
|
|
282
|
-
let _setConfig_decorators;
|
|
283
|
-
let _setSkippedPage_decorators;
|
|
284
|
-
let _updateConfig_decorators;
|
|
285
|
-
let _updatePage_decorators;
|
|
286
|
-
return class Database extends _classSuper {
|
|
287
|
-
static {
|
|
288
|
-
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
289
|
-
_getAnchorsOnPage_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
290
|
-
_getBaseUrl_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
291
|
-
_getConfig_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
292
|
-
_getCrawlingState_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
293
|
-
_getExistingPageUrls_decorators = [ErrorEmitter()];
|
|
294
|
-
_getExistingResourceUrls_decorators = [ErrorEmitter()];
|
|
295
|
-
_getHtmlOfPageById_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
296
|
-
_getJsonLdOfPage_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
297
|
-
_getName_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
298
|
-
_getPageCount_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
299
|
-
_getPages_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
300
|
-
_getPagesWithRels_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
301
|
-
_getRedirectsForPages_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
302
|
-
_getReferrersOfPage_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
303
|
-
_getReferrersOfResource_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
304
|
-
_getResourceByUrl_decorators = [retry(retrySetting)];
|
|
305
|
-
_getResources_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
306
|
-
_getResourceUrlList_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
307
|
-
_getScrapedHtmlPageCount_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
308
|
-
_getTagsOfPage_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
309
|
-
_insertCrawlError_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
310
|
-
_insertPageError_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
311
|
-
_insertResource_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
312
|
-
_insertResourceReferrers_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
313
|
-
_recordRedirect_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
314
|
-
_repromoteExternalPages_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
315
|
-
_resetFailedPages_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
316
|
-
_setConfig_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
317
|
-
_setSkippedPage_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
318
|
-
_updateConfig_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
319
|
-
_updatePage_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
320
|
-
__esDecorate(this, null, _getAnchorsOnPage_decorators, { kind: "method", name: "getAnchorsOnPage", static: false, private: false, access: { has: obj => "getAnchorsOnPage" in obj, get: obj => obj.getAnchorsOnPage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
321
|
-
__esDecorate(this, null, _getBaseUrl_decorators, { kind: "method", name: "getBaseUrl", static: false, private: false, access: { has: obj => "getBaseUrl" in obj, get: obj => obj.getBaseUrl }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
322
|
-
__esDecorate(this, null, _getConfig_decorators, { kind: "method", name: "getConfig", static: false, private: false, access: { has: obj => "getConfig" in obj, get: obj => obj.getConfig }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
323
|
-
__esDecorate(this, null, _getCrawlingState_decorators, { kind: "method", name: "getCrawlingState", static: false, private: false, access: { has: obj => "getCrawlingState" in obj, get: obj => obj.getCrawlingState }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
324
|
-
__esDecorate(this, null, _getExistingPageUrls_decorators, { kind: "method", name: "getExistingPageUrls", static: false, private: false, access: { has: obj => "getExistingPageUrls" in obj, get: obj => obj.getExistingPageUrls }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
325
|
-
__esDecorate(this, null, _getExistingResourceUrls_decorators, { kind: "method", name: "getExistingResourceUrls", static: false, private: false, access: { has: obj => "getExistingResourceUrls" in obj, get: obj => obj.getExistingResourceUrls }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
326
|
-
__esDecorate(this, null, _getHtmlOfPageById_decorators, { kind: "method", name: "getHtmlOfPageById", static: false, private: false, access: { has: obj => "getHtmlOfPageById" in obj, get: obj => obj.getHtmlOfPageById }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
327
|
-
__esDecorate(this, null, _getJsonLdOfPage_decorators, { kind: "method", name: "getJsonLdOfPage", static: false, private: false, access: { has: obj => "getJsonLdOfPage" in obj, get: obj => obj.getJsonLdOfPage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
328
|
-
__esDecorate(this, null, _getName_decorators, { kind: "method", name: "getName", static: false, private: false, access: { has: obj => "getName" in obj, get: obj => obj.getName }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
329
|
-
__esDecorate(this, null, _getPageCount_decorators, { kind: "method", name: "getPageCount", static: false, private: false, access: { has: obj => "getPageCount" in obj, get: obj => obj.getPageCount }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
330
|
-
__esDecorate(this, null, _getPages_decorators, { kind: "method", name: "getPages", static: false, private: false, access: { has: obj => "getPages" in obj, get: obj => obj.getPages }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
331
|
-
__esDecorate(this, null, _getPagesWithRels_decorators, { kind: "method", name: "getPagesWithRels", static: false, private: false, access: { has: obj => "getPagesWithRels" in obj, get: obj => obj.getPagesWithRels }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
332
|
-
__esDecorate(this, null, _getRedirectsForPages_decorators, { kind: "method", name: "getRedirectsForPages", static: false, private: false, access: { has: obj => "getRedirectsForPages" in obj, get: obj => obj.getRedirectsForPages }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
333
|
-
__esDecorate(this, null, _getReferrersOfPage_decorators, { kind: "method", name: "getReferrersOfPage", static: false, private: false, access: { has: obj => "getReferrersOfPage" in obj, get: obj => obj.getReferrersOfPage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
334
|
-
__esDecorate(this, null, _getReferrersOfResource_decorators, { kind: "method", name: "getReferrersOfResource", static: false, private: false, access: { has: obj => "getReferrersOfResource" in obj, get: obj => obj.getReferrersOfResource }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
335
|
-
__esDecorate(this, null, _getResourceByUrl_decorators, { kind: "method", name: "getResourceByUrl", static: false, private: false, access: { has: obj => "getResourceByUrl" in obj, get: obj => obj.getResourceByUrl }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
336
|
-
__esDecorate(this, null, _getResources_decorators, { kind: "method", name: "getResources", static: false, private: false, access: { has: obj => "getResources" in obj, get: obj => obj.getResources }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
337
|
-
__esDecorate(this, null, _getResourceUrlList_decorators, { kind: "method", name: "getResourceUrlList", static: false, private: false, access: { has: obj => "getResourceUrlList" in obj, get: obj => obj.getResourceUrlList }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
338
|
-
__esDecorate(this, null, _getScrapedHtmlPageCount_decorators, { kind: "method", name: "getScrapedHtmlPageCount", static: false, private: false, access: { has: obj => "getScrapedHtmlPageCount" in obj, get: obj => obj.getScrapedHtmlPageCount }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
339
|
-
__esDecorate(this, null, _getTagsOfPage_decorators, { kind: "method", name: "getTagsOfPage", static: false, private: false, access: { has: obj => "getTagsOfPage" in obj, get: obj => obj.getTagsOfPage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
340
|
-
__esDecorate(this, null, _insertCrawlError_decorators, { kind: "method", name: "insertCrawlError", static: false, private: false, access: { has: obj => "insertCrawlError" in obj, get: obj => obj.insertCrawlError }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
341
|
-
__esDecorate(this, null, _insertPageError_decorators, { kind: "method", name: "insertPageError", static: false, private: false, access: { has: obj => "insertPageError" in obj, get: obj => obj.insertPageError }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
342
|
-
__esDecorate(this, null, _insertResource_decorators, { kind: "method", name: "insertResource", static: false, private: false, access: { has: obj => "insertResource" in obj, get: obj => obj.insertResource }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
343
|
-
__esDecorate(this, null, _insertResourceReferrers_decorators, { kind: "method", name: "insertResourceReferrers", static: false, private: false, access: { has: obj => "insertResourceReferrers" in obj, get: obj => obj.insertResourceReferrers }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
344
|
-
__esDecorate(this, null, _recordRedirect_decorators, { kind: "method", name: "recordRedirect", static: false, private: false, access: { has: obj => "recordRedirect" in obj, get: obj => obj.recordRedirect }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
345
|
-
__esDecorate(this, null, _repromoteExternalPages_decorators, { kind: "method", name: "repromoteExternalPages", static: false, private: false, access: { has: obj => "repromoteExternalPages" in obj, get: obj => obj.repromoteExternalPages }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
346
|
-
__esDecorate(this, null, _resetFailedPages_decorators, { kind: "method", name: "resetFailedPages", static: false, private: false, access: { has: obj => "resetFailedPages" in obj, get: obj => obj.resetFailedPages }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
347
|
-
__esDecorate(this, null, _setConfig_decorators, { kind: "method", name: "setConfig", static: false, private: false, access: { has: obj => "setConfig" in obj, get: obj => obj.setConfig }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
348
|
-
__esDecorate(this, null, _setSkippedPage_decorators, { kind: "method", name: "setSkippedPage", static: false, private: false, access: { has: obj => "setSkippedPage" in obj, get: obj => obj.setSkippedPage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
349
|
-
__esDecorate(this, null, _updateConfig_decorators, { kind: "method", name: "updateConfig", static: false, private: false, access: { has: obj => "updateConfig" in obj, get: obj => obj.updateConfig }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
350
|
-
__esDecorate(this, null, _updatePage_decorators, { kind: "method", name: "updatePage", static: false, private: false, access: { has: obj => "updatePage" in obj, get: obj => obj.updatePage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
351
|
-
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
352
|
-
}
|
|
353
|
-
/** The Knex query builder instance connected to the SQLite database. */
|
|
354
|
-
#instance = __runInitializers(this, _instanceExtraInitializers);
|
|
355
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
356
|
-
constructor(options) {
|
|
357
|
-
super();
|
|
358
|
-
this.#instance = knex({
|
|
359
|
-
client: LibsqlDialect,
|
|
360
|
-
connection: {
|
|
361
|
-
filename: options.filename,
|
|
362
|
-
},
|
|
363
|
-
useNullAsDefault: true,
|
|
364
|
-
pool: {
|
|
365
|
-
acquireTimeoutMillis: 600_000,
|
|
366
|
-
},
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
/**
|
|
370
|
-
* Adds the `order` column to the `pages` table for URL sort ordering.
|
|
371
|
-
* If the column already exists, this method does nothing.
|
|
372
|
-
* @deprecated Since v0.1.x. The column is now created during table initialization.
|
|
373
|
-
* @returns The result of the schema alteration, or void if the column already exists.
|
|
374
|
-
*/
|
|
375
|
-
async addOrderField() {
|
|
376
|
-
const hasColumn = await this.#instance.schema.hasColumn('pages', 'order');
|
|
377
|
-
if (hasColumn) {
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
return await this.#instance.schema.table('pages', (t) => {
|
|
381
|
-
t.integer('order').unsigned().nullable().defaultTo(null);
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
/**
|
|
385
|
-
* Forces a WAL checkpoint, writing all pending WAL data back to the main database file.
|
|
386
|
-
* Uses TRUNCATE mode to reset the WAL file to zero bytes after checkpointing.
|
|
387
|
-
* This ensures the database is fully self-contained in `db.sqlite` before archiving.
|
|
388
|
-
*/
|
|
389
|
-
async checkpoint() {
|
|
390
|
-
await this.#instance.raw('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
391
|
-
}
|
|
392
|
-
/**
|
|
393
|
-
* Destroys the database connection, releasing all pooled resources.
|
|
394
|
-
*/
|
|
395
|
-
async destroy() {
|
|
396
|
-
await this.#instance.destroy();
|
|
397
|
-
}
|
|
398
|
-
/**
|
|
399
|
-
* Retrieves all anchors (outgoing links) on a specific page.
|
|
400
|
-
* Joins the `anchors` table with the `pages` table to resolve link destinations.
|
|
401
|
-
* @param pageId - The database ID of the page whose anchors to retrieve.
|
|
402
|
-
* @returns An array of anchor records with resolved URL, title, status, and content type.
|
|
403
|
-
*/
|
|
404
|
-
async getAnchorsOnPage(pageId) {
|
|
405
|
-
const res = await this.#instance
|
|
406
|
-
.select('pages.url', 'pages.title', 'pages.status', 'pages.statusText', 'pages.contentType', 'anchors.hash', 'anchors.textContent')
|
|
407
|
-
.from('anchors')
|
|
408
|
-
.join('pages', 'anchors.hrefId', '=', 'pages.id')
|
|
409
|
-
.where('anchors.pageId', pageId);
|
|
410
|
-
return res;
|
|
411
|
-
}
|
|
412
|
-
/**
|
|
413
|
-
* Retrieves the base URL of the crawl session from the `info` table.
|
|
414
|
-
* @returns The base URL string.
|
|
415
|
-
* @throws {Error} If no base URL is found in the database.
|
|
416
|
-
*/
|
|
417
|
-
async getBaseUrl() {
|
|
418
|
-
const selected = await this.#instance.select('baseUrl').from('info');
|
|
419
|
-
if (!selected[0]) {
|
|
420
|
-
throw new Error('No baseUrl');
|
|
421
|
-
}
|
|
422
|
-
const [{ baseUrl }] = selected;
|
|
423
|
-
return baseUrl || '';
|
|
424
|
-
}
|
|
425
|
-
/**
|
|
426
|
-
* Retrieves the full crawl configuration from the `info` table.
|
|
427
|
-
* Deserializes JSON-encoded fields (`roots`, `excludes`, `excludeKeywords`, `excludeUrls`).
|
|
428
|
-
* @returns The parsed {@link Config} object.
|
|
429
|
-
* @throws {Error} If no configuration is found in the database.
|
|
430
|
-
*/
|
|
431
|
-
async getConfig() {
|
|
432
|
-
const [config] = await this.#instance.select('*').from('info');
|
|
433
|
-
if (!config) {
|
|
434
|
-
throw new Error('No config');
|
|
435
|
-
}
|
|
436
|
-
const opt = {
|
|
437
|
-
...config,
|
|
438
|
-
excludes: getJSON(config.excludes, []),
|
|
439
|
-
excludeKeywords: getJSON(config.excludeKeywords, []),
|
|
440
|
-
excludeUrls: getJSON(config.excludeUrls, []),
|
|
441
|
-
roots: getJSON(config.roots, []),
|
|
442
|
-
retry: config.retry ?? 3,
|
|
443
|
-
};
|
|
444
|
-
// @ts-expect-error — `id` is the primary key, not part of the public Config shape
|
|
445
|
-
delete opt.id;
|
|
446
|
-
dbLog('Table `info`: %O => %O', config, opt);
|
|
447
|
-
return opt;
|
|
448
|
-
}
|
|
449
|
-
/**
|
|
450
|
-
* Retrieves the current crawling state by listing scraped and pending URLs.
|
|
451
|
-
* @returns An object with `scraped` (completed URLs) and `pending` (remaining URLs) arrays.
|
|
452
|
-
*/
|
|
453
|
-
async getCrawlingState() {
|
|
454
|
-
const ex = (r) => r.url;
|
|
455
|
-
const $scraped = await this.#instance
|
|
456
|
-
.select('url')
|
|
457
|
-
.from('pages')
|
|
458
|
-
.where('scraped', 1);
|
|
459
|
-
const scraped = $scraped.map(ex);
|
|
460
|
-
const $pending = await this.#instance
|
|
461
|
-
.select('url')
|
|
462
|
-
.from('pages')
|
|
463
|
-
.where('scraped', 0);
|
|
464
|
-
const pending = $pending.map(ex);
|
|
465
|
-
return {
|
|
466
|
-
scraped,
|
|
467
|
-
pending,
|
|
468
|
-
};
|
|
469
|
-
}
|
|
470
|
-
/**
|
|
471
|
-
* Return the subset of `urls` that already exist in the `pages` table.
|
|
472
|
-
* Chunked into batches so SQLite's `IN (?, ?, …)` parameter limit
|
|
473
|
-
* (`SQLITE_MAX_VARIABLE_NUMBER`, default 999) cannot be hit even when the
|
|
474
|
-
* inventory list contains tens of thousands of URLs.
|
|
475
|
-
*
|
|
476
|
-
* Read-only — no transaction, no lock contention with the crawler write
|
|
477
|
-
* pipeline (callers run this BEFORE the `<archive>.bak` is taken and the
|
|
478
|
-
* crawl is started).
|
|
479
|
-
* @param urls - URL strings to probe (already in `withoutHashAndAuth` form).
|
|
480
|
-
* @returns URLs found in `pages`. Order is not preserved.
|
|
481
|
-
*/
|
|
482
|
-
async getExistingPageUrls(urls) {
|
|
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('pages')
|
|
491
|
-
.whereIn('url', chunk);
|
|
492
|
-
for (const row of rows) {
|
|
493
|
-
found.push(row.url);
|
|
494
|
-
}
|
|
495
|
-
});
|
|
496
|
-
return found;
|
|
497
|
-
}
|
|
498
|
-
/**
|
|
499
|
-
* Return the subset of `urls` that already exist in the `resources` table.
|
|
500
|
-
* See {@link Database.getExistingPageUrls} — same chunking strategy.
|
|
501
|
-
* @param urls - URL strings to probe.
|
|
502
|
-
* @returns URLs found in `resources`.
|
|
503
|
-
*/
|
|
504
|
-
async getExistingResourceUrls(urls) {
|
|
505
|
-
if (urls.length === 0) {
|
|
506
|
-
return [];
|
|
507
|
-
}
|
|
508
|
-
const found = [];
|
|
509
|
-
await eachSplitted([...urls], 500, async (chunk) => {
|
|
510
|
-
const rows = await this.#instance
|
|
511
|
-
.select('url')
|
|
512
|
-
.from('resources')
|
|
513
|
-
.whereIn('url', chunk);
|
|
514
|
-
for (const row of rows) {
|
|
515
|
-
found.push(row.url);
|
|
516
|
-
}
|
|
517
|
-
});
|
|
518
|
-
return found;
|
|
519
|
-
}
|
|
520
|
-
/**
|
|
521
|
-
* Reads the HTML snapshot stored as a zstd-compressed BLOB for the given page.
|
|
522
|
-
*
|
|
523
|
-
* Joins `page_html_ref` → `page_html_blobs` and decompresses inline. Returns
|
|
524
|
-
* `null` when the page has no stored body (a non-HTML resource, a redirect
|
|
525
|
-
* source, a degraded render). Read works identically on read-only / stub
|
|
526
|
-
* connections — the special-cased "do we have a loose dir vs zip?" branching
|
|
527
|
-
* the previous file-backed layout required is gone.
|
|
528
|
-
*
|
|
529
|
-
* Tables `page_html_ref` and `page_html_blobs` are created by `initSchema`.
|
|
530
|
-
* Older `.nitpicker` archives that predate this migration must be passed
|
|
531
|
-
* through `scripts/migrate-to-0.10.mjs` before they can be read.
|
|
532
|
-
* @param pageId - The database ID of the page.
|
|
533
|
-
* @returns The decompressed HTML string, or `null` if no snapshot is stored.
|
|
534
|
-
*/
|
|
535
|
-
async getHtmlOfPageById(pageId) {
|
|
536
|
-
const row = await this.#instance
|
|
537
|
-
.from('page_html_ref')
|
|
538
|
-
.join('page_html_blobs', 'page_html_ref.hash', '=', 'page_html_blobs.hash')
|
|
539
|
-
.select('page_html_blobs.body as body', 'page_html_blobs.codec as codec')
|
|
540
|
-
.where('page_html_ref.page_id', pageId)
|
|
541
|
-
.first();
|
|
542
|
-
if (!row) {
|
|
543
|
-
return null;
|
|
544
|
-
}
|
|
545
|
-
return decodeStoredBlob(row.body, row.codec);
|
|
546
|
-
}
|
|
547
|
-
/**
|
|
548
|
-
* Retrieves all `page_jsonld` rows for the given page id, parsed back into
|
|
549
|
-
* {@link JsonLdRow} shape (with `parsed` deserialised from its JSON column).
|
|
550
|
-
*
|
|
551
|
-
* Read-side counterpart to `#insertJsonLd`. Returns rows in insertion order
|
|
552
|
-
* by `id` so the order observed by `get-page-jsonld` matches the order the
|
|
553
|
-
* scraper saw them.
|
|
554
|
-
* @param pageId
|
|
555
|
-
*/
|
|
556
|
-
async getJsonLdOfPage(pageId) {
|
|
557
|
-
const rows = await this.#instance
|
|
558
|
-
.select('id', 'pageId', 'kind', 'type', 'raw', 'parsed', 'parseError')
|
|
559
|
-
.from('page_jsonld')
|
|
560
|
-
.where('pageId', pageId)
|
|
561
|
-
.orderBy('id', 'asc');
|
|
562
|
-
return rows.map((r) => ({
|
|
563
|
-
id: r.id,
|
|
564
|
-
pageId: r.pageId,
|
|
565
|
-
kind: r.kind === 'speculationrules' ? 'speculationrules' : 'ld+json',
|
|
566
|
-
type: r.type,
|
|
567
|
-
raw: r.raw,
|
|
568
|
-
parsed: r.parsed === null ? null : safeParseJson(r.parsed),
|
|
569
|
-
parseError: r.parseError,
|
|
570
|
-
}));
|
|
571
|
-
}
|
|
572
|
-
/**
|
|
573
|
-
* Returns the underlying Knex query builder instance for direct SQL access.
|
|
574
|
-
* This enables advanced queries (GROUP BY, HAVING, JOINs) at the database
|
|
575
|
-
* layer for performance with large datasets.
|
|
576
|
-
* @returns The Knex instance connected to the SQLite database.
|
|
577
|
-
*/
|
|
578
|
-
getKnex() {
|
|
579
|
-
return this.#instance;
|
|
580
|
-
}
|
|
581
|
-
/**
|
|
582
|
-
* Retrieves the crawl session name from the `info` table.
|
|
583
|
-
* @returns The name string.
|
|
584
|
-
* @throws {Error} If no name is found in the database.
|
|
585
|
-
*/
|
|
586
|
-
async getName() {
|
|
587
|
-
const selected = await this.#instance.select('name').from('info');
|
|
588
|
-
if (!selected[0]) {
|
|
589
|
-
throw new Error('No name');
|
|
590
|
-
}
|
|
591
|
-
const [{ name }] = selected;
|
|
592
|
-
return name;
|
|
593
|
-
}
|
|
594
|
-
/**
|
|
595
|
-
* Counts the total number of pages in the database.
|
|
596
|
-
* @returns The total page count.
|
|
597
|
-
* @throws {Error} If the count query fails.
|
|
598
|
-
*/
|
|
599
|
-
async getPageCount() {
|
|
600
|
-
const selected = await this.#instance.count('id').from('pages');
|
|
601
|
-
if (!selected[0]) {
|
|
602
|
-
throw new Error('No count');
|
|
603
|
-
}
|
|
604
|
-
// @ts-expect-error
|
|
605
|
-
const count = selected[0]['count(`id`)'];
|
|
606
|
-
dbLog('Number of pages: %d', count);
|
|
607
|
-
return count;
|
|
608
|
-
}
|
|
609
|
-
/**
|
|
610
|
-
* Retrieves pages from the database with optional filtering, pagination via offset and limit.
|
|
611
|
-
* @param filter - An optional {@link PageFilter} to narrow results by content type and origin.
|
|
612
|
-
* @param offset - The number of rows to skip. Defaults to `0`.
|
|
613
|
-
* @param limit - The maximum number of rows to return. Defaults to `100000`.
|
|
614
|
-
* @returns An array of raw {@link DB_Page} rows.
|
|
615
|
-
*/
|
|
616
|
-
async getPages(filter, offset = 0, limit = 100_000) {
|
|
617
|
-
const q = this.#instance.select('*').from('pages');
|
|
618
|
-
switch (filter) {
|
|
619
|
-
case 'page': {
|
|
620
|
-
return q
|
|
621
|
-
.where({
|
|
622
|
-
contentType: 'text/html',
|
|
623
|
-
isTarget: 1,
|
|
624
|
-
})
|
|
625
|
-
.limit(limit)
|
|
626
|
-
.offset(offset);
|
|
627
|
-
}
|
|
628
|
-
case 'page-included-no-target': {
|
|
629
|
-
return q
|
|
630
|
-
.where({
|
|
631
|
-
contentType: 'text/html',
|
|
632
|
-
})
|
|
633
|
-
.limit(limit)
|
|
634
|
-
.offset(offset);
|
|
635
|
-
}
|
|
636
|
-
case 'external-page': {
|
|
637
|
-
return q
|
|
638
|
-
.where({
|
|
639
|
-
contentType: 'text/html',
|
|
640
|
-
isExternal: 1,
|
|
641
|
-
})
|
|
642
|
-
.limit(limit)
|
|
643
|
-
.offset(offset);
|
|
644
|
-
}
|
|
645
|
-
case 'internal-page': {
|
|
646
|
-
return q
|
|
647
|
-
.where({
|
|
648
|
-
contentType: 'text/html',
|
|
649
|
-
isExternal: 0,
|
|
650
|
-
})
|
|
651
|
-
.limit(limit)
|
|
652
|
-
.offset(offset);
|
|
653
|
-
}
|
|
654
|
-
case 'no-page': {
|
|
655
|
-
return q
|
|
656
|
-
.whereNull('contentType')
|
|
657
|
-
.orWhereNot({
|
|
658
|
-
contentType: 'text/html',
|
|
659
|
-
})
|
|
660
|
-
.limit(limit)
|
|
661
|
-
.offset(offset);
|
|
662
|
-
}
|
|
663
|
-
case 'external-no-page': {
|
|
664
|
-
return q
|
|
665
|
-
.where((qb) => {
|
|
666
|
-
qb.whereNull('contentType').orWhereNot({
|
|
667
|
-
contentType: 'text/html',
|
|
668
|
-
});
|
|
669
|
-
})
|
|
670
|
-
.andWhere({
|
|
671
|
-
isExternal: 1,
|
|
672
|
-
})
|
|
673
|
-
.limit(limit)
|
|
674
|
-
.offset(offset);
|
|
675
|
-
}
|
|
676
|
-
case 'internal-no-page': {
|
|
677
|
-
return q
|
|
678
|
-
.where((qb) => {
|
|
679
|
-
qb.whereNull('contentType').orWhereNot({
|
|
680
|
-
contentType: 'text/html',
|
|
681
|
-
});
|
|
682
|
-
})
|
|
683
|
-
.andWhere({
|
|
684
|
-
isExternal: 0,
|
|
685
|
-
})
|
|
686
|
-
.limit(limit)
|
|
687
|
-
.offset(offset);
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
return q.limit(limit).offset(offset);
|
|
691
|
-
}
|
|
692
|
-
/**
|
|
693
|
-
* Retrieves pages along with their related redirect, anchor, and referrer data.
|
|
694
|
-
* Results are ordered by the natural URL sort order. Only non-redirected pages are returned.
|
|
695
|
-
* @param offset - The number of rows to skip.
|
|
696
|
-
* @param limit - The maximum number of pages to return.
|
|
697
|
-
* @returns An object containing `pages`, `redirects`, `anchors`, and `referrers` arrays.
|
|
698
|
-
*/
|
|
699
|
-
async getPagesWithRels(offset, limit) {
|
|
700
|
-
await this.addOrderField();
|
|
701
|
-
await this.setUrlOrder();
|
|
702
|
-
dbLog('Get Pages');
|
|
703
|
-
const pages = await this.#instance
|
|
704
|
-
.select('*')
|
|
705
|
-
.from('pages')
|
|
706
|
-
.orderByRaw('`order` ASC NULLS LAST')
|
|
707
|
-
.whereNull('redirectDestId')
|
|
708
|
-
.limit(limit)
|
|
709
|
-
.offset(offset);
|
|
710
|
-
// When empty
|
|
711
|
-
if (pages.length === 0) {
|
|
712
|
-
return {
|
|
713
|
-
pages: [],
|
|
714
|
-
redirects: [],
|
|
715
|
-
referrers: [],
|
|
716
|
-
anchors: [],
|
|
717
|
-
};
|
|
718
|
-
}
|
|
719
|
-
dbLog('Get Pages: Redirects');
|
|
720
|
-
const redirects = await this.#instance
|
|
721
|
-
.with('limitedPages', limitedPageIds(limit, offset))
|
|
722
|
-
.with('redirect', redirectTable(false))
|
|
723
|
-
.select('id as pageId', 'from', 'fromId')
|
|
724
|
-
.from('redirect')
|
|
725
|
-
// Filter
|
|
726
|
-
.join('limitedPages', 'redirect.toId', '=', 'limitedPages.id')
|
|
727
|
-
// Sort
|
|
728
|
-
.orderBy('id', 'asc');
|
|
729
|
-
dbLog('Get Pages: Anchors');
|
|
730
|
-
const anchors = await this.#instance
|
|
731
|
-
.with('limitedPages', limitedPageIds(limit, offset))
|
|
732
|
-
.with('redirect', redirectTable())
|
|
733
|
-
.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')
|
|
734
|
-
.from('anchors')
|
|
735
|
-
// Filters
|
|
736
|
-
.join('limitedPages', 'anchors.pageId', '=', 'limitedPages.id')
|
|
737
|
-
// Resolves redirect
|
|
738
|
-
.join('redirect', 'anchors.hrefId', '=', 'redirect.fromId')
|
|
739
|
-
// Target
|
|
740
|
-
.join('pages as href', 'redirect.toId', '=', 'href.id')
|
|
741
|
-
// Sort
|
|
742
|
-
.orderBy('anchors.id', 'asc');
|
|
743
|
-
dbLog('Get Pages: Referrers');
|
|
744
|
-
const referrers = await this.#instance
|
|
745
|
-
.with('limitedPages', limitedPageIds(limit, offset))
|
|
746
|
-
.with('redirect', redirectTable())
|
|
747
|
-
.select('redirect.toId as pageId', 'referrer.url', 'redirect.from as through', 'redirect.fromId as throughId', 'anchors.hash', 'anchors.textContent')
|
|
748
|
-
.from('anchors')
|
|
749
|
-
// Resolves redirect
|
|
750
|
-
.join('redirect', 'anchors.hrefId', '=', 'redirect.fromId')
|
|
751
|
-
// Referrer
|
|
752
|
-
.join('pages as referrer', 'anchors.pageId', '=', 'referrer.id')
|
|
753
|
-
// Filters
|
|
754
|
-
.join('limitedPages', 'redirect.toId', '=', 'limitedPages.id')
|
|
755
|
-
// Sort
|
|
756
|
-
.orderBy('anchors.id', 'asc');
|
|
757
|
-
dbLog('Get Pages: Done');
|
|
758
|
-
return {
|
|
759
|
-
pages,
|
|
760
|
-
redirects,
|
|
761
|
-
anchors,
|
|
762
|
-
referrers,
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
/**
|
|
766
|
-
* Retrieves redirect sources for the given page IDs in bulk.
|
|
767
|
-
* @param pageIds - The database IDs of the destination pages.
|
|
768
|
-
* @returns An array of {@link DB_Redirect} records mapping destination pages to their redirect sources.
|
|
769
|
-
*/
|
|
770
|
-
async getRedirectsForPages(pageIds) {
|
|
771
|
-
if (pageIds.length === 0)
|
|
772
|
-
return [];
|
|
773
|
-
return this.#instance
|
|
774
|
-
.select('redirectDestId as pageId', 'url as from', 'id as fromId')
|
|
775
|
-
.from('pages')
|
|
776
|
-
.whereIn('redirectDestId', pageIds);
|
|
777
|
-
}
|
|
778
|
-
/**
|
|
779
|
-
* Retrieves pages that link to a specific page (incoming links / referrers).
|
|
780
|
-
*
|
|
781
|
-
* Incoming links are resolved **through redirects**: an anchor pointing at a
|
|
782
|
-
* redirect source (e.g. `http://x` that 301s to `https://x`) counts as a
|
|
783
|
-
* referrer of the redirect's final destination, not of the source. This keeps
|
|
784
|
-
* backlinks merged on the canonical page instead of splitting them across the
|
|
785
|
-
* `http`/`https` (or any redirect source/dest) pair. The resolution mirrors
|
|
786
|
-
* `redirectTable()` — `redirectDestId` is pre-flattened to the final
|
|
787
|
-
* destination, so `COALESCE(target.redirectDestId, target.id)` is a single hop.
|
|
788
|
-
* @param pageId - The database ID of the target page.
|
|
789
|
-
* @returns An array of referrer records with URL, hash, and text content.
|
|
790
|
-
*/
|
|
791
|
-
async getReferrersOfPage(pageId) {
|
|
792
|
-
const res = await this.#instance
|
|
793
|
-
.select('referrer.url',
|
|
794
|
-
// `through` / `throughId` = the URL the anchor actually pointed at (the
|
|
795
|
-
// redirect source, e.g. `http://x`), mirroring `getPagesWithRels`'
|
|
796
|
-
// `redirect.from` / `redirect.fromId`. Lets report code print the
|
|
797
|
-
// "[REDIRECTED FROM]" note even on this (non-preloaded) referrer path.
|
|
798
|
-
'target.url as through', 'target.id as throughId', 'anchors.hash', 'anchors.textContent')
|
|
799
|
-
.from('anchors')
|
|
800
|
-
.join('pages as referrer', 'anchors.pageId', '=', 'referrer.id')
|
|
801
|
-
.join('pages as target', 'anchors.hrefId', '=', 'target.id')
|
|
802
|
-
.whereRaw('coalesce("target"."redirectDestId", "target"."id") = ?', [pageId]);
|
|
803
|
-
return res;
|
|
804
|
-
}
|
|
805
|
-
/**
|
|
806
|
-
* Retrieves the page URLs that reference a specific resource.
|
|
807
|
-
* @param id - The database ID of the resource.
|
|
808
|
-
* @returns An array of page URL strings that reference the resource.
|
|
809
|
-
*/
|
|
810
|
-
async getReferrersOfResource(id) {
|
|
811
|
-
const res = await this.#instance
|
|
812
|
-
.select('pages.url')
|
|
813
|
-
.from('resources-referrers')
|
|
814
|
-
.join('resources', 'resources.id', '=', 'resources-referrers.resourceId')
|
|
815
|
-
.join('pages', 'pages.id', '=', 'resources-referrers.pageId')
|
|
816
|
-
.where('resources.id', id);
|
|
817
|
-
return res.map((r) => r.url);
|
|
818
|
-
}
|
|
819
|
-
/**
|
|
820
|
-
* Retrieves a single sub-resource from the `resources` table by its URL.
|
|
821
|
-
*
|
|
822
|
-
* Accepts multiple URL candidates because the stored key is the resource's
|
|
823
|
-
* `href` while callers may only know the hash-stripped form; the first match
|
|
824
|
-
* wins.
|
|
825
|
-
*
|
|
826
|
-
* Deliberately NOT decorated with `@ErrorEmitter`: the only caller (the
|
|
827
|
-
* crawler's resource-reuse hook) has a full fallback (the HEAD pre-flight),
|
|
828
|
-
* so a read failure here must not surface as a database `error` event —
|
|
829
|
-
* the orchestrator aborts the whole crawl on that event, which is the
|
|
830
|
-
* correct reaction to write failures but not to a recoverable read.
|
|
831
|
-
* @param urls - URL candidates to match against the `url` column.
|
|
832
|
-
* @returns The raw {@link DB_Resource} row, or `null` if none match.
|
|
833
|
-
*/
|
|
834
|
-
async getResourceByUrl(urls) {
|
|
835
|
-
const res = await this.#instance
|
|
836
|
-
.select('*')
|
|
837
|
-
.from('resources')
|
|
838
|
-
.whereIn('url', [...urls])
|
|
839
|
-
.first();
|
|
840
|
-
return res ?? null;
|
|
841
|
-
}
|
|
842
|
-
/**
|
|
843
|
-
* Retrieves all sub-resources from the `resources` table.
|
|
844
|
-
* @returns An array of raw {@link DB_Resource} rows.
|
|
845
|
-
*/
|
|
846
|
-
async getResources() {
|
|
847
|
-
return this.#instance.select('*').from('resources');
|
|
848
|
-
}
|
|
849
|
-
/**
|
|
850
|
-
* Retrieves a flat list of all resource URLs from the `resources` table.
|
|
851
|
-
* @returns An array of resource URL strings.
|
|
852
|
-
*/
|
|
853
|
-
async getResourceUrlList() {
|
|
854
|
-
const res = await this.#instance.select('url').from('resources');
|
|
855
|
-
return res.map((r) => r.url);
|
|
856
|
-
}
|
|
857
|
-
/**
|
|
858
|
-
* Counts pages that were scraped as crawl targets (full HTML render).
|
|
859
|
-
*
|
|
860
|
-
* Used by the crawler to seed its `pagesScraped` counter on resume so the
|
|
861
|
-
* progress display reflects all browser-rendered HTML pages across sessions,
|
|
862
|
-
* not just the current one.
|
|
863
|
-
*
|
|
864
|
-
* "HTML page" is guaranteed by `contentType = 'text/html'`, NOT by `isTarget`
|
|
865
|
-
* alone: `isTarget` means "in-scope crawl target" and is set for in-scope
|
|
866
|
-
* non-HTML resources too (e.g. a PDF reached via the HEAD pre-flight is
|
|
867
|
-
* `isTarget = 1`). Counting those would over-report the HTML page total, so
|
|
868
|
-
* page-ness is asserted at the read layer here rather than by trusting
|
|
869
|
-
* `isTarget`.
|
|
870
|
-
* @returns The number of `text/html` rows with `isTarget = 1` and `scraped = 1`.
|
|
871
|
-
*/
|
|
872
|
-
async getScrapedHtmlPageCount() {
|
|
873
|
-
const [row] = await this.#instance
|
|
874
|
-
.from('pages')
|
|
875
|
-
.where('isTarget', 1)
|
|
876
|
-
.andWhere('scraped', 1)
|
|
877
|
-
.andWhere('contentType', 'text/html')
|
|
878
|
-
.count('* as count');
|
|
879
|
-
return row ? Number(row.count) : 0;
|
|
880
|
-
}
|
|
881
|
-
/**
|
|
882
|
-
* Retrieves all `page_tags` rows for the given page id, parsed back into
|
|
883
|
-
* {@link TagRow} shape (with `categories` and `sources` JSON columns
|
|
884
|
-
* deserialised).
|
|
885
|
-
*
|
|
886
|
-
* Read-side counterpart to `#insertTags`.
|
|
887
|
-
* @param pageId
|
|
888
|
-
*/
|
|
889
|
-
async getTagsOfPage(pageId) {
|
|
890
|
-
const rows = await this.#instance
|
|
891
|
-
.select('id', 'pageId', 'provider', 'category', 'externalId', 'version', 'confidence', 'categories', 'sources')
|
|
892
|
-
.from('page_tags')
|
|
893
|
-
.where('pageId', pageId)
|
|
894
|
-
.orderBy('id', 'asc');
|
|
895
|
-
return rows.map((r) => ({
|
|
896
|
-
id: r.id,
|
|
897
|
-
pageId: r.pageId,
|
|
898
|
-
provider: r.provider,
|
|
899
|
-
category: r.category,
|
|
900
|
-
externalId: r.externalId,
|
|
901
|
-
version: r.version,
|
|
902
|
-
confidence: r.confidence,
|
|
903
|
-
categories: r.categories === null ? [] : (safeParseJson(r.categories) ?? []),
|
|
904
|
-
sources: r.sources === null ? [] : (safeParseJson(r.sources) ?? []),
|
|
905
|
-
}));
|
|
906
|
-
}
|
|
907
|
-
/**
|
|
908
|
-
* Records a crawler-level (`error` channel) failure into `crawl_errors`.
|
|
909
|
-
*
|
|
910
|
-
* Unlike {@link insertPageError} this is not tied to a scraped page: `url`
|
|
911
|
-
* may be an external link that never became a page row, or `null` for a
|
|
912
|
-
* process-level error. The cause is intentionally not stored — it is derived
|
|
913
|
-
* on read so that older archives (which only have `error.log`) and freshly
|
|
914
|
-
* captured rows classify identically.
|
|
915
|
-
* @param url - The URL the error is about, or `null` for a process-level error.
|
|
916
|
-
* @param message - The error message (one line is enough for classification).
|
|
917
|
-
* @param isExternal - Whether the URL is external to the crawl scope.
|
|
918
|
-
*/
|
|
919
|
-
async insertCrawlError(url, message, isExternal = false) {
|
|
920
|
-
await this.#instance('crawl_errors').insert({
|
|
921
|
-
url,
|
|
922
|
-
isExternal: isExternal ? 1 : 0,
|
|
923
|
-
message,
|
|
924
|
-
createdAt: Date.now(),
|
|
925
|
-
});
|
|
926
|
-
}
|
|
927
|
-
/**
|
|
928
|
-
* Records a partial scrape failure against the page identified by `url`.
|
|
929
|
-
*
|
|
930
|
-
* The page row is resolved (or inserted as a stub) via
|
|
931
|
-
* {@link Database.#getIdByUrl} so the error can be recorded even before
|
|
932
|
-
* `setPage` has run — useful when the failure fires during scraping
|
|
933
|
-
* (e.g. mid-`scrapeStart`) and the orchestrator enqueues this write
|
|
934
|
-
* before the success write for the same URL.
|
|
935
|
-
*
|
|
936
|
-
* A single page can have multiple `page_errors` rows (e.g. both
|
|
937
|
-
* `desktop-compact` and `mobile-small` viewports failing).
|
|
938
|
-
* @param url - URL of the page being scraped.
|
|
939
|
-
* @param phase - Scrape phase name (typically `'retryExhausted'`).
|
|
940
|
-
* @param message - Human-readable failure message.
|
|
941
|
-
* @param isExternal - Whether the URL is external. Defaults to `false`.
|
|
942
|
-
*/
|
|
943
|
-
async insertPageError(url, phase, message, isExternal = false) {
|
|
944
|
-
const pageId = await this.#getIdByUrl(url, isExternal ? 1 : 0);
|
|
945
|
-
await this.#instance('page_errors').insert({
|
|
946
|
-
pageId,
|
|
947
|
-
phase,
|
|
948
|
-
message,
|
|
949
|
-
createdAt: Date.now(),
|
|
950
|
-
});
|
|
951
|
-
}
|
|
952
|
-
/**
|
|
953
|
-
* Inserts a sub-resource into the `resources` table.
|
|
954
|
-
* Ignores duplicate URLs (uses `ON CONFLICT IGNORE`).
|
|
955
|
-
*
|
|
956
|
-
* The `source` provenance label is written ONLY on insert; an
|
|
957
|
-
* `ON CONFLICT IGNORE` collision leaves an existing row's source untouched
|
|
958
|
-
* (this is what makes a second `crawl --inventory` non-destructive — see
|
|
959
|
-
* the inventory plan).
|
|
960
|
-
* @param resource - The resource data to insert.
|
|
961
|
-
* @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
|
|
962
|
-
*/
|
|
963
|
-
async insertResource(resource, source) {
|
|
964
|
-
await this.#instance
|
|
965
|
-
.from('resources')
|
|
966
|
-
.insert({
|
|
967
|
-
url: resource.url.href,
|
|
968
|
-
isExternal: resource.isExternal ? 1 : 0,
|
|
969
|
-
status: resource.status,
|
|
970
|
-
statusText: resource.statusText,
|
|
971
|
-
// Canonicalize like `pages.contentType` (see #insertPage) so resource
|
|
972
|
-
// content-type filters / dedupe keys are case- and whitespace-stable.
|
|
973
|
-
contentType: normalizeContentType(resource.contentType),
|
|
974
|
-
contentLength: resource.contentLength,
|
|
975
|
-
compress: resource.compress || 0,
|
|
976
|
-
cdn: resource.cdn || 0,
|
|
977
|
-
responseHeaders: JSON.stringify(resource.headers),
|
|
978
|
-
...(source === undefined ? {} : { source }),
|
|
979
|
-
})
|
|
980
|
-
.onConflict('url')
|
|
981
|
-
.ignore();
|
|
982
|
-
}
|
|
983
|
-
/**
|
|
984
|
-
* Inserts a referrer relationship between a resource and a page into the
|
|
985
|
-
* `resources-referrers` table. Silently skips if the resource is not found.
|
|
986
|
-
* @param src - The URL of the resource.
|
|
987
|
-
* @param pageUrl - The URL of the page that references the resource.
|
|
988
|
-
*/
|
|
989
|
-
async insertResourceReferrers(src, pageUrl) {
|
|
990
|
-
const selected = await this.#instance
|
|
991
|
-
.select('id')
|
|
992
|
-
.from('resources')
|
|
993
|
-
.where('url', src);
|
|
994
|
-
if (!selected[0]) {
|
|
995
|
-
// Ignore when the resource is not found
|
|
996
|
-
return;
|
|
997
|
-
}
|
|
998
|
-
const [{ id: resourceId }] = selected;
|
|
999
|
-
const pageId = await this.#getIdByUrl(pageUrl);
|
|
1000
|
-
await this.#instance('resources-referrers')
|
|
1001
|
-
.insert({
|
|
1002
|
-
resourceId,
|
|
1003
|
-
pageId,
|
|
1004
|
-
})
|
|
1005
|
-
.onConflict(['resourceId', 'pageId'])
|
|
1006
|
-
.ignore();
|
|
1007
|
-
}
|
|
1008
|
-
/**
|
|
1009
|
-
* Records a redirect edge (source → destination) **without** re-storing the
|
|
1010
|
-
* destination's content.
|
|
1011
|
-
*
|
|
1012
|
-
* The crawler renders a many-to-one redirect destination exactly once. For
|
|
1013
|
-
* every subsequent source URL that redirects to that already-rendered
|
|
1014
|
-
* destination, it calls this instead of {@link updatePage} (#73). Routing a
|
|
1015
|
-
* content-less HEAD result through `updatePage` would funnel it into
|
|
1016
|
-
* `#insertPage` and overwrite the destination's good title / meta with empty
|
|
1017
|
-
* values, so the dedicated edge-only path is required.
|
|
1018
|
-
*
|
|
1019
|
-
* The destination row is resolved (created on demand if a concurrent in-flight
|
|
1020
|
-
* render has not committed it yet) so the edge always points at a valid id;
|
|
1021
|
-
* the single render fills in the destination's content under that same id.
|
|
1022
|
-
* The destination's existing anchors / images are never touched here.
|
|
1023
|
-
* @param page - HEAD-resolved page data carrying the redirect chain. Its
|
|
1024
|
-
* `anchorList` / `imageList` are ignored (a redirect source owns no content).
|
|
1025
|
-
*/
|
|
1026
|
-
async recordRedirect(page) {
|
|
1027
|
-
const { destUrl, sources } = resolveRedirectChain(page.url.withoutHashAndAuth, page.redirectPaths);
|
|
1028
|
-
// No redirect chain (the URL is itself the already-rendered destination,
|
|
1029
|
-
// reached both directly and via a redirect) → there is no edge to write.
|
|
1030
|
-
// Returning here avoids opening a transaction and, crucially, avoids
|
|
1031
|
-
// `#getIdByUrl` inserting a content-less placeholder row for a destination
|
|
1032
|
-
// that may not have been written yet.
|
|
1033
|
-
if (sources.length === 0) {
|
|
1034
|
-
return;
|
|
1035
|
-
}
|
|
1036
|
-
const destUrlObject = parseUrl(destUrl);
|
|
1037
|
-
if (!destUrlObject) {
|
|
1038
|
-
// A malformed redirect target should not abort the whole crawl (this
|
|
1039
|
-
// runs inside the WriteQueue, whose rejection aborts the run). Recording
|
|
1040
|
-
// a single redirect edge is best-effort, so skip it and move on. Unlike
|
|
1041
|
-
// `updatePage`, there is no page content at stake here.
|
|
1042
|
-
dbLog('recordRedirect: skip malformed destination URL: %s', destUrl);
|
|
1043
|
-
return;
|
|
1044
|
-
}
|
|
1045
|
-
await this.#instance.transaction(async (trx) => {
|
|
1046
|
-
const destId = await this.#getIdByUrl(destUrlObject.withoutHashAndAuth, undefined, trx);
|
|
1047
|
-
await this.#linkRedirectSources(trx, sources, destId, destUrlObject.withoutHashAndAuth, page.isExternal);
|
|
1048
|
-
});
|
|
1049
|
-
}
|
|
1050
|
-
/**
|
|
1051
|
-
* Promote previously-external pages whose URL falls under any of the new scope
|
|
1052
|
-
* entries back to a "needs scraping" state so that the next crawl picks them up
|
|
1053
|
-
* as full internal pages.
|
|
1054
|
-
*
|
|
1055
|
-
* For each matching page:
|
|
1056
|
-
* - clears the scrape metadata (status, headers, snapshot path, etc.),
|
|
1057
|
-
* - flips `isExternal` to `0` and `scraped` to `0`,
|
|
1058
|
-
* - removes stale `anchors`, `images`, and `resources-referrers` rows so that
|
|
1059
|
-
* the re-scrape can re-insert fresh ones without duplicates.
|
|
1060
|
-
*
|
|
1061
|
-
* The page row itself is kept (id is preserved) so existing referrers via
|
|
1062
|
-
* `anchors.hrefId` remain valid. SELECT and UPDATE/DELETE statements are
|
|
1063
|
-
* chunked to stay below SQLite's `SQLITE_LIMIT_VARIABLE_NUMBER`.
|
|
1064
|
-
* @param scopes - The hostname-indexed scope map after the new roots are merged.
|
|
1065
|
-
* @param options - URL parsing options forwarded to {@link findScopeEntry}.
|
|
1066
|
-
* @returns The URLs of the pages that were promoted.
|
|
1067
|
-
*/
|
|
1068
|
-
async repromoteExternalPages(scopes, options) {
|
|
1069
|
-
if (scopes.size === 0) {
|
|
1070
|
-
return [];
|
|
1071
|
-
}
|
|
1072
|
-
const candidates = await this.#instance
|
|
1073
|
-
.select('id', 'url')
|
|
1074
|
-
.from('pages')
|
|
1075
|
-
.where('isExternal', 1);
|
|
1076
|
-
const promotedIds = [];
|
|
1077
|
-
const promotedUrls = [];
|
|
1078
|
-
for (const row of candidates) {
|
|
1079
|
-
const parsed = parseUrl(row.url, options);
|
|
1080
|
-
if (!parsed) {
|
|
1081
|
-
continue;
|
|
1082
|
-
}
|
|
1083
|
-
if (findScopeEntry(parsed, scopes, options) === null) {
|
|
1084
|
-
continue;
|
|
1085
|
-
}
|
|
1086
|
-
promotedIds.push(row.id);
|
|
1087
|
-
promotedUrls.push(row.url);
|
|
1088
|
-
}
|
|
1089
|
-
if (promotedIds.length === 0) {
|
|
1090
|
-
return [];
|
|
1091
|
-
}
|
|
1092
|
-
const chunkSize = 500;
|
|
1093
|
-
const metaReset = makeMetaResetPayload();
|
|
1094
|
-
for (let i = 0; i < promotedIds.length; i += chunkSize) {
|
|
1095
|
-
const chunk = promotedIds.slice(i, i + chunkSize);
|
|
1096
|
-
await this.#instance('pages')
|
|
1097
|
-
.whereIn('id', chunk)
|
|
1098
|
-
.update({
|
|
1099
|
-
scraped: 0,
|
|
1100
|
-
isExternal: 0,
|
|
1101
|
-
isSkipped: 0,
|
|
1102
|
-
skipReason: null,
|
|
1103
|
-
status: null,
|
|
1104
|
-
statusText: null,
|
|
1105
|
-
contentType: null,
|
|
1106
|
-
contentLength: null,
|
|
1107
|
-
responseHeaders: '{}',
|
|
1108
|
-
redirectDestId: null,
|
|
1109
|
-
// Null every flat meta column + denormalised aggregates +
|
|
1110
|
-
// meta_extras. `firstCrawledAt` / `lastCrawledAt` are
|
|
1111
|
-
// deliberately omitted from META_NULLABLE_COLUMNS — the
|
|
1112
|
-
// last-success timestamp survives the demotion.
|
|
1113
|
-
...metaReset,
|
|
1114
|
-
});
|
|
1115
|
-
// Clear the prior crawl's data for the repromoted pages. `updatePage`
|
|
1116
|
-
// also replaces anchors/images/tags/jsonld when it re-scrapes them, but
|
|
1117
|
-
// only when the new scrape is non-empty — so this pre-clear is still
|
|
1118
|
-
// load-bearing for pages that get repromoted but then re-scrape to
|
|
1119
|
-
// nothing (or are never reached again), and it is the only place
|
|
1120
|
-
// `resources-referrers` is cleared. The HTML body ref is also cleared
|
|
1121
|
-
// so a repromoted page whose re-scrape ends up degraded does not keep
|
|
1122
|
-
// its old external-render snapshot. `page_tags` / `page_jsonld` are
|
|
1123
|
-
// cleared explicitly even though both tables also carry ON DELETE
|
|
1124
|
-
// CASCADE — we keep the existing pattern of explicit chunked DELETEs
|
|
1125
|
-
// rather than relying on CASCADE indirectly (and would not cascade
|
|
1126
|
-
// anyway: the parent `pages` row is updated, not deleted). Orphan
|
|
1127
|
-
// blobs in `page_html_blobs` are left behind; #23 will add GC.
|
|
1128
|
-
await this.#instance('anchors').whereIn('pageId', chunk).delete();
|
|
1129
|
-
await this.#instance('images').whereIn('pageId', chunk).delete();
|
|
1130
|
-
await this.#instance('resources-referrers').whereIn('pageId', chunk).delete();
|
|
1131
|
-
await this.#instance('page_html_ref').whereIn('page_id', chunk).delete();
|
|
1132
|
-
await this.#instance('page_tags').whereIn('pageId', chunk).delete();
|
|
1133
|
-
await this.#instance('page_jsonld').whereIn('pageId', chunk).delete();
|
|
1134
|
-
}
|
|
1135
|
-
dbLog('Repromoted %d external pages back to pending', promotedUrls.length);
|
|
1136
|
-
return promotedUrls;
|
|
1137
|
-
}
|
|
1138
|
-
/**
|
|
1139
|
-
* Reset previously-attempted pages that ended in a recoverable failure so a
|
|
1140
|
-
* follow-up crawl can re-fetch them from scratch.
|
|
1141
|
-
*
|
|
1142
|
-
* A page qualifies as a recoverable failure when it was already scraped
|
|
1143
|
-
* (`scraped = 1`), is not a redirect source (`redirectDestId IS NULL`), was
|
|
1144
|
-
* not intentionally skipped (`isSkipped` is not `1`), and one of the
|
|
1145
|
-
* following holds:
|
|
1146
|
-
*
|
|
1147
|
-
* - `status = -1` — the sentinel a hard scrape failure (network error,
|
|
1148
|
-
* timeout, browser crash) is recorded with (see `handle-scrape-error.ts`);
|
|
1149
|
-
* - `status IS NULL` — no status was ever stored for the row;
|
|
1150
|
-
* - `contentType IS NULL` — the content type could not be determined;
|
|
1151
|
-
* - `status` is in the `5xx` range — a (frequently transient) server error.
|
|
1152
|
-
*
|
|
1153
|
-
* Definitive `4xx` responses are intentionally excluded: re-fetching a 404
|
|
1154
|
-
* almost always yields the same answer. Matching rows — internal and
|
|
1155
|
-
* external alike — are demoted back to pending (`scraped = 0`) and have their
|
|
1156
|
-
* stale scrape metadata cleared. The page row itself is kept (id preserved)
|
|
1157
|
-
* so existing `anchors.hrefId` referrers stay valid, and `isExternal` is left
|
|
1158
|
-
* untouched so the next pass re-classifies each page from the crawl scope.
|
|
1159
|
-
* Related `anchors`, `images`, `resources-referrers`, and `page_errors` rows
|
|
1160
|
-
* are deleted so the re-scrape can re-insert fresh data without duplicates.
|
|
1161
|
-
*
|
|
1162
|
-
* SELECT and UPDATE/DELETE statements are chunked to stay below SQLite's
|
|
1163
|
-
* `SQLITE_LIMIT_VARIABLE_NUMBER`.
|
|
1164
|
-
* @returns The URLs of the pages that were reset to pending.
|
|
1165
|
-
*/
|
|
1166
|
-
async resetFailedPages() {
|
|
1167
|
-
const candidates = await this.#instance
|
|
1168
|
-
.select('id', 'url')
|
|
1169
|
-
.from('pages')
|
|
1170
|
-
.where('scraped', 1)
|
|
1171
|
-
.whereNull('redirectDestId')
|
|
1172
|
-
.where((qb) => {
|
|
1173
|
-
qb.where('isSkipped', 0).orWhereNull('isSkipped');
|
|
1174
|
-
})
|
|
1175
|
-
.where((qb) => {
|
|
1176
|
-
qb.whereNull('status')
|
|
1177
|
-
.orWhere('status', -1)
|
|
1178
|
-
.orWhereNull('contentType')
|
|
1179
|
-
.orWhereBetween('status', [500, 599]);
|
|
1180
|
-
});
|
|
1181
|
-
if (candidates.length === 0) {
|
|
1182
|
-
return [];
|
|
1183
|
-
}
|
|
1184
|
-
const ids = candidates.map((row) => row.id);
|
|
1185
|
-
const urls = candidates.map((row) => row.url);
|
|
1186
|
-
const chunkSize = 500;
|
|
1187
|
-
const metaReset = makeMetaResetPayload();
|
|
1188
|
-
for (let i = 0; i < ids.length; i += chunkSize) {
|
|
1189
|
-
const chunk = ids.slice(i, i + chunkSize);
|
|
1190
|
-
await this.#instance('pages')
|
|
1191
|
-
.whereIn('id', chunk)
|
|
1192
|
-
.update({
|
|
1193
|
-
scraped: 0,
|
|
1194
|
-
status: null,
|
|
1195
|
-
statusText: null,
|
|
1196
|
-
contentType: null,
|
|
1197
|
-
contentLength: null,
|
|
1198
|
-
responseHeaders: '{}',
|
|
1199
|
-
// Null every flat meta column + denormalised aggregates +
|
|
1200
|
-
// meta_extras. `firstCrawledAt` / `lastCrawledAt` are
|
|
1201
|
-
// deliberately omitted from META_NULLABLE_COLUMNS so the
|
|
1202
|
-
// last-success timestamp records survive the demotion (the
|
|
1203
|
-
// within-archive observation axis for #11/#17/#19).
|
|
1204
|
-
...metaReset,
|
|
1205
|
-
});
|
|
1206
|
-
// Clear the prior crawl's per-page data so the re-scrape starts clean.
|
|
1207
|
-
// `updatePage` only replaces anchors/images/tags/jsonld when the new
|
|
1208
|
-
// scrape is non-empty, so this pre-clear is load-bearing for pages that
|
|
1209
|
-
// reset but then fail again (or are never reached), and it is the only
|
|
1210
|
-
// place `resources-referrers` and `page_errors` are cleared. The HTML
|
|
1211
|
-
// body ref is also cleared so a previously-rendered page that now fails
|
|
1212
|
-
// to re-scrape does not keep its old snapshot.
|
|
1213
|
-
await this.#instance('anchors').whereIn('pageId', chunk).delete();
|
|
1214
|
-
await this.#instance('images').whereIn('pageId', chunk).delete();
|
|
1215
|
-
await this.#instance('resources-referrers').whereIn('pageId', chunk).delete();
|
|
1216
|
-
await this.#instance('page_errors').whereIn('pageId', chunk).delete();
|
|
1217
|
-
await this.#instance('page_html_ref').whereIn('page_id', chunk).delete();
|
|
1218
|
-
await this.#instance('page_tags').whereIn('pageId', chunk).delete();
|
|
1219
|
-
await this.#instance('page_jsonld').whereIn('pageId', chunk).delete();
|
|
1220
|
-
}
|
|
1221
|
-
dbLog('Reset %d failed pages back to pending', urls.length);
|
|
1222
|
-
return urls;
|
|
1223
|
-
}
|
|
1224
|
-
/**
|
|
1225
|
-
* Stores the crawl configuration in the `info` table.
|
|
1226
|
-
* Only fields in {@link INFO_COLUMN_ALLOWLIST} are forwarded — any extra
|
|
1227
|
-
* runtime-only field on the input is silently dropped so callers can splat
|
|
1228
|
-
* a wider config object without producing SQL errors. JSON-array fields
|
|
1229
|
-
* are serialized via `JSON.stringify`.
|
|
1230
|
-
* @param config - The {@link Config} object to store.
|
|
1231
|
-
*/
|
|
1232
|
-
async setConfig(config) {
|
|
1233
|
-
const payload = {};
|
|
1234
|
-
for (const [key, value] of Object.entries(config)) {
|
|
1235
|
-
if (!INFO_COLUMN_ALLOWLIST.has(key)) {
|
|
1236
|
-
continue;
|
|
1237
|
-
}
|
|
1238
|
-
payload[key] = INFO_JSON_COLUMNS.has(key) ? JSON.stringify(value) : value;
|
|
1239
|
-
}
|
|
1240
|
-
return this.#instance.from('info').insert(payload);
|
|
1241
|
-
}
|
|
1242
|
-
/**
|
|
1243
|
-
* Marks a page as skipped in the database with the given reason.
|
|
1244
|
-
* Creates the page row if it does not already exist.
|
|
1245
|
-
* @param url - The URL of the skipped page.
|
|
1246
|
-
* @param reason - The reason the page was skipped.
|
|
1247
|
-
* @param isExternal - Whether the page is on an external domain. Defaults to `false`.
|
|
1248
|
-
*/
|
|
1249
|
-
async setSkippedPage(url, reason, isExternal = false) {
|
|
1250
|
-
const pageId = await this.#getIdByUrl(url, isExternal ? 1 : 0);
|
|
1251
|
-
await this.#instance('pages')
|
|
1252
|
-
.where('id', pageId)
|
|
1253
|
-
.update({
|
|
1254
|
-
scraped: 1,
|
|
1255
|
-
isExternal: isExternal ? 1 : 0,
|
|
1256
|
-
isSkipped: 1,
|
|
1257
|
-
skipReason: reason,
|
|
1258
|
-
});
|
|
1259
|
-
}
|
|
1260
|
-
/**
|
|
1261
|
-
* Assigns natural URL sort order values to all internal pages.
|
|
1262
|
-
* Pages are sorted using {@link pathComparator} and assigned sequential order numbers.
|
|
1263
|
-
*/
|
|
1264
|
-
async setUrlOrder() {
|
|
1265
|
-
dbLog('Set URL Order');
|
|
1266
|
-
const res = await this.#instance
|
|
1267
|
-
.select('id', 'url')
|
|
1268
|
-
.from('pages')
|
|
1269
|
-
.where('isExternal', '=', 0);
|
|
1270
|
-
const sorted = res.toSorted((a, b) => pathComparator(a.url, b.url));
|
|
1271
|
-
// Batch update using chunked CASE statements to avoid N+1 queries
|
|
1272
|
-
const BATCH_SIZE = 500;
|
|
1273
|
-
for (let i = 0; i < sorted.length; i += BATCH_SIZE) {
|
|
1274
|
-
const batch = sorted.slice(i, i + BATCH_SIZE);
|
|
1275
|
-
const ids = batch.map((row) => row.id);
|
|
1276
|
-
const bindings = [];
|
|
1277
|
-
const cases = batch
|
|
1278
|
-
.map((row, j) => {
|
|
1279
|
-
bindings.push(row.id, i + j + 1);
|
|
1280
|
-
return 'WHEN ? THEN ?';
|
|
1281
|
-
})
|
|
1282
|
-
.join(' ');
|
|
1283
|
-
const placeholders = ids.map(() => '?').join(',');
|
|
1284
|
-
await this.#instance.raw(`UPDATE pages SET \`order\` = CASE id ${cases} END WHERE id IN (${placeholders})`, [...bindings, ...ids]);
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1287
|
-
/**
|
|
1288
|
-
* Update the single row in the `info` table with a partial config patch.
|
|
1289
|
-
*
|
|
1290
|
-
* Used by the append flow to extend `roots` (and any other tweakable
|
|
1291
|
-
* field) without replacing the entire row. JSON-array fields are serialized on
|
|
1292
|
-
* the fly; primitive fields are written verbatim. Unspecified fields stay as-is.
|
|
1293
|
-
*
|
|
1294
|
-
* Unknown keys (anything outside the allow-list of `info`-table columns) are
|
|
1295
|
-
* silently dropped instead of being passed to SQL, so callers that splat a
|
|
1296
|
-
* wider runtime config (e.g. `CrawlConfig` with `cwd` / `executablePath`)
|
|
1297
|
-
* cannot accidentally trigger a "no such column" SQL error.
|
|
1298
|
-
* @param patch - Partial {@link Config} fields to overwrite. `undefined` values are skipped.
|
|
1299
|
-
*/
|
|
1300
|
-
async updateConfig(patch) {
|
|
1301
|
-
const payload = {};
|
|
1302
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
1303
|
-
if (value === undefined) {
|
|
1304
|
-
continue;
|
|
1305
|
-
}
|
|
1306
|
-
if (!INFO_COLUMN_ALLOWLIST.has(key)) {
|
|
1307
|
-
continue;
|
|
1308
|
-
}
|
|
1309
|
-
if (INFO_JSON_COLUMNS.has(key)) {
|
|
1310
|
-
payload[key] = JSON.stringify(value);
|
|
1311
|
-
continue;
|
|
1312
|
-
}
|
|
1313
|
-
payload[key] = value;
|
|
1314
|
-
}
|
|
1315
|
-
if (Object.keys(payload).length === 0) {
|
|
1316
|
-
return;
|
|
1317
|
-
}
|
|
1318
|
-
await this.#instance.from('info').update(payload);
|
|
1319
|
-
}
|
|
1320
|
-
/**
|
|
1321
|
-
* Inserts or updates a crawled page in the database, including its redirect chain,
|
|
1322
|
-
* anchors, images, and (when `writeHtml`) its compressed HTML snapshot BLOB.
|
|
1323
|
-
*
|
|
1324
|
-
* Self-redirects (where the source URL equals the destination URL after normalization)
|
|
1325
|
-
* are skipped to avoid marking a page as redirected to itself — a situation caused by
|
|
1326
|
-
* authentication challenges (e.g. Basic Auth 302) that would otherwise exclude the page
|
|
1327
|
-
* from reports via the `whereNull('redirectDestId')` filter.
|
|
1328
|
-
* @param page - The page data to store.
|
|
1329
|
-
* @param writeHtml - When `true`, this call is allowed to insert (or clear)
|
|
1330
|
-
* the page's HTML blob. `setExternalPage` passes `false` because external
|
|
1331
|
-
* metadata-only scrapes never carry HTML and must not perturb an already
|
|
1332
|
-
* stored body.
|
|
1333
|
-
* @param isTarget - Whether this page is a crawl target.
|
|
1334
|
-
* @param source - Provenance label written ONLY when the row is freshly
|
|
1335
|
-
* inserted. Existing rows keep their original `source` (this is why a
|
|
1336
|
-
* second `crawl --inventory` does not "demote" an `'inventory-seed'` row
|
|
1337
|
-
* that was discovered earlier).
|
|
1338
|
-
* @returns The database `pageId` of the inserted/updated row.
|
|
1339
|
-
*/
|
|
1340
|
-
async updatePage(page, writeHtml, isTarget, source) {
|
|
1341
|
-
const { destUrl, sources } = resolveRedirectChain(page.url.withoutHashAndAuth, page.redirectPaths);
|
|
1342
|
-
const destUrlObject = parseUrl(destUrl);
|
|
1343
|
-
if (!destUrlObject) {
|
|
1344
|
-
throw new Error(`Failed to parse URL: ${destUrl}`);
|
|
1345
|
-
}
|
|
1346
|
-
return await this.#instance.transaction(async (trx) => {
|
|
1347
|
-
const pageId = await this.#insertPage({
|
|
1348
|
-
...page,
|
|
1349
|
-
url: destUrlObject,
|
|
1350
|
-
}, isTarget, trx, source);
|
|
1351
|
-
// Wappalyzer tag detection is HTML-body independent (relies on
|
|
1352
|
-
// `<script src>` / `<iframe src>` / window globals / response
|
|
1353
|
-
// headers) so it runs for every page including external /
|
|
1354
|
-
// metadata-only. JSON-LD on the other hand lives inside the
|
|
1355
|
-
// rendered HTML body, so we only write it when there is HTML to
|
|
1356
|
-
// scrape — see the same `writeHtml` gate as `#writePageHtmlBlob`
|
|
1357
|
-
// below.
|
|
1358
|
-
await this.#insertTags(pageId, page.meta, trx);
|
|
1359
|
-
if (writeHtml) {
|
|
1360
|
-
await this.#insertJsonLd(pageId, page.meta, trx);
|
|
1361
|
-
}
|
|
1362
|
-
await this.#linkRedirectSources(trx, sources, pageId, destUrlObject.withoutHashAndAuth, page.isExternal);
|
|
1363
|
-
// Only insert a snapshot blob when there is actual HTML to write.
|
|
1364
|
-
// `page.html.length > 0` is the precise signal: the scraper returns
|
|
1365
|
-
// `html: ''` for everything that is not a rendered `text/html` document
|
|
1366
|
-
// (non-HTML responses, metadata-only, external, degraded renders), so a
|
|
1367
|
-
// non-empty `html` is exactly "a rendered HTML body exists". Gating on
|
|
1368
|
-
// `isTarget` alone would store an empty body for every internal non-HTML
|
|
1369
|
-
// resource — PDF / zip / images are isTarget=1 (#72).
|
|
1370
|
-
//
|
|
1371
|
-
// `isTarget` is intentionally NOT part of this condition: it is implied by
|
|
1372
|
-
// `html.length > 0` (only in-scope target pages are browser-rendered into a
|
|
1373
|
-
// non-empty body; metadata-only and external pages carry `html: ''`), so the
|
|
1374
|
-
// content check alone expresses the intent without a redundant term.
|
|
1375
|
-
if (writeHtml && page.html.length > 0) {
|
|
1376
|
-
await this.#writePageHtmlBlob(pageId, page.html, trx);
|
|
1377
|
-
}
|
|
1378
|
-
else if (writeHtml &&
|
|
1379
|
-
page.contentType !== null &&
|
|
1380
|
-
!isHtmlContentType(page.contentType)) {
|
|
1381
|
-
// The page is now a *known* non-HTML type. If a previous scrape stored
|
|
1382
|
-
// an HTML body for this URL (e.g. it served HTML then was replaced by
|
|
1383
|
-
// a PDF across `crawl --resume` / `--append`), drop the stale ref so
|
|
1384
|
-
// `page_html_ref` never contradicts `contentType`. A degraded HTML
|
|
1385
|
-
// re-scrape (text/html or unknown content type with empty html) is NOT
|
|
1386
|
-
// cleared — the last good snapshot is preserved, mirroring the
|
|
1387
|
-
// anchors / images empty-guard below. Gated on `writeHtml` because a
|
|
1388
|
-
// stale ref can only have been written by a snapshot-capable call
|
|
1389
|
-
// (`setPage`); `setExternalPage` passes `writeHtml = false` and never
|
|
1390
|
-
// sets `html`, so it has nothing to clear.
|
|
1391
|
-
await trx('page_html_ref').where('page_id', pageId).delete();
|
|
1392
|
-
}
|
|
1393
|
-
// Re-scrape semantics: the same URL can be scraped more than once
|
|
1394
|
-
// (e.g. `crawl --resume`, re-visits, `--append` re-promotion). The
|
|
1395
|
-
// `anchors` / `images` tables have no uniqueness constraint, so
|
|
1396
|
-
// re-inserting without clearing would accumulate a full duplicate set
|
|
1397
|
-
// on every re-scrape (the bug fixed in #70). So we delete-then-insert
|
|
1398
|
-
// to *replace* the previous rows.
|
|
1399
|
-
//
|
|
1400
|
-
// The delete is paired with — and guarded by — a non-empty new list:
|
|
1401
|
-
// a degraded re-scrape (navigation timeout / partial render) can return
|
|
1402
|
-
// an empty `anchorList` for a page that previously had links, and
|
|
1403
|
-
// wiping the prior good data in that case would be destructive. We
|
|
1404
|
-
// cannot tell a transient empty result apart from a page that has
|
|
1405
|
-
// legitimately lost all its links, so we err on the side of keeping
|
|
1406
|
-
// what we already had. The accepted trade-off is that a page which
|
|
1407
|
-
// genuinely dropped to zero links keeps its stale rows until the next
|
|
1408
|
-
// non-empty re-scrape replaces them.
|
|
1409
|
-
//
|
|
1410
|
-
// (A DB-level unique constraint + `onConflict` would also prevent
|
|
1411
|
-
// duplication, but multiple distinct anchors can share the same
|
|
1412
|
-
// hrefId/hash/textContent legitimately, so there is no natural unique
|
|
1413
|
-
// key to enforce — replace-on-write is the correct mechanism here.)
|
|
1414
|
-
const anchors = await Promise.all(page.anchorList.map(async (anchor) => {
|
|
1415
|
-
const hrefId = await this.#getIdByUrl(anchor.href.withoutHashAndAuth, anchor.isExternal ? 1 : 0, trx);
|
|
1416
|
-
return {
|
|
1417
|
-
pageId,
|
|
1418
|
-
hrefId,
|
|
1419
|
-
hash: anchor.href.hash,
|
|
1420
|
-
textContent: anchor.textContent,
|
|
1421
|
-
};
|
|
1422
|
-
}));
|
|
1423
|
-
dbLog('Insert anchors.length: %d', anchors.length);
|
|
1424
|
-
if (anchors.length > 0) {
|
|
1425
|
-
await trx('anchors').where('pageId', pageId).delete();
|
|
1426
|
-
await eachSplitted(anchors, 100, async (_anchors) => {
|
|
1427
|
-
await trx('anchors').insert(_anchors);
|
|
1428
|
-
});
|
|
1429
|
-
}
|
|
1430
|
-
const images = page.imageList.map((image) => ({
|
|
1431
|
-
pageId,
|
|
1432
|
-
...image,
|
|
1433
|
-
}));
|
|
1434
|
-
dbLog('Insert images.length: %d', images.length);
|
|
1435
|
-
if (images.length > 0) {
|
|
1436
|
-
await trx('images').where('pageId', pageId).delete();
|
|
1437
|
-
await eachSplitted(images, 100, async (_images) => {
|
|
1438
|
-
await trx('images').insert(_images);
|
|
1439
|
-
});
|
|
1440
|
-
}
|
|
1441
|
-
return pageId;
|
|
1442
|
-
});
|
|
1443
|
-
}
|
|
1444
|
-
/**
|
|
1445
|
-
* Returns the database ID for a URL, creating a new page row if needed.
|
|
1446
|
-
* Uses `ON CONFLICT IGNORE` to handle race conditions in concurrent inserts.
|
|
1447
|
-
*
|
|
1448
|
-
* `source` is written ONLY on the INSERT path — when the row already
|
|
1449
|
-
* exists, we never reach the INSERT and the existing row's `source`
|
|
1450
|
-
* stays untouched. This is what keeps a second `crawl --inventory` from
|
|
1451
|
-
* "demoting" a page that was first labelled `'inventory-seed'` back to
|
|
1452
|
-
* `'inventory-discovered'` on later passes.
|
|
1453
|
-
* @param url
|
|
1454
|
-
* @param isExternal
|
|
1455
|
-
* @param trx
|
|
1456
|
-
* @param source - Provenance label to put on the newly-inserted row. `undefined` lets the DB DEFAULT (`'crawled'`) apply.
|
|
1457
|
-
*/
|
|
1458
|
-
async #getIdByUrl(url, isExternal, trx, source) {
|
|
1459
|
-
const qb = trx ?? this.#instance;
|
|
1460
|
-
const [record] = await qb.select('id').from('pages').where('url', url);
|
|
1461
|
-
// Must use `?` because it may be `undefined`
|
|
1462
|
-
const pageId = record?.id ?? Number.NaN;
|
|
1463
|
-
if (Number.isFinite(pageId)) {
|
|
1464
|
-
return pageId;
|
|
1465
|
-
}
|
|
1466
|
-
const insertedRows = await qb('pages')
|
|
1467
|
-
.insert({
|
|
1468
|
-
url,
|
|
1469
|
-
scraped: 0,
|
|
1470
|
-
isTarget: 0,
|
|
1471
|
-
...(isExternal != null && { isExternal }),
|
|
1472
|
-
...(source === undefined ? {} : { source }),
|
|
1473
|
-
})
|
|
1474
|
-
.onConflict('url')
|
|
1475
|
-
.ignore();
|
|
1476
|
-
const [insertedId] = insertedRows;
|
|
1477
|
-
if (!insertedId) {
|
|
1478
|
-
// onConflict.ignore() returns 0 on race condition — re-select
|
|
1479
|
-
const [existing] = await qb.select('id').from('pages').where('url', url);
|
|
1480
|
-
if (existing?.id) {
|
|
1481
|
-
return existing.id;
|
|
1482
|
-
}
|
|
1483
|
-
throw new Error(`Failed to insert a new page: ${url}`);
|
|
1484
|
-
}
|
|
1485
|
-
return insertedId;
|
|
1486
|
-
}
|
|
1487
|
-
/**
|
|
1488
|
-
* Initializes the database schema if tables do not exist, then runs lightweight
|
|
1489
|
-
* migrations that bring older archives up to the current schema.
|
|
1490
|
-
*
|
|
1491
|
-
* Migrations are idempotent and run on every writer-side {@link Database.connect};
|
|
1492
|
-
* in read-only mode they are SKIPPED so the same DB can be opened safely
|
|
1493
|
-
* by a viewer attached to a live (or interrupted) crawl without rewriting
|
|
1494
|
-
* the user's tmpDir.
|
|
1495
|
-
* @param readOnly - When true, skip schema init + migrations.
|
|
1496
|
-
*/
|
|
1497
|
-
async #init(readOnly) {
|
|
1498
|
-
// Connection-level PRAGMAs (foreign_keys, mmap_size, …) must be
|
|
1499
|
-
// reapplied on every connect — they are not persisted across opens.
|
|
1500
|
-
// They are safe in read-only mode because they don't write to the
|
|
1501
|
-
// user's tmpDir, just configure the libsql connection.
|
|
1502
|
-
await applyConnectionPragmas(this.#instance);
|
|
1503
|
-
// Reject pre-0.10 archives before any further work. Runs for both
|
|
1504
|
-
// writer and read-only (stub viewer) connections so old
|
|
1505
|
-
// `._nitpicker-*` stubs surface a clear error instead of
|
|
1506
|
-
// dereferencing missing columns at query time. New archives (no
|
|
1507
|
-
// `info` table yet) pass through; the schema is filled in by
|
|
1508
|
-
// `initSchema` below.
|
|
1509
|
-
await assertCompatibleVersion(this.#instance);
|
|
1510
|
-
if (readOnly) {
|
|
1511
|
-
return;
|
|
1512
|
-
}
|
|
1513
|
-
await initSchema(this.#instance);
|
|
1514
|
-
await migrateInfoRoots(this.#instance);
|
|
1515
|
-
await migratePageErrors(this.#instance);
|
|
1516
|
-
await migrateCrawlErrors(this.#instance);
|
|
1517
|
-
await migrateHtmlBlobTables(this.#instance);
|
|
1518
|
-
await migratePagesResourcesSource(this.#instance);
|
|
1519
|
-
}
|
|
1520
|
-
/**
|
|
1521
|
-
* Replaces the page's JSON-LD / SpeculationRules rows with the freshly
|
|
1522
|
-
* captured set. Called inside `updatePage`'s transaction.
|
|
1523
|
-
*
|
|
1524
|
-
* `writeHtml = false` branches (`setExternalPage`, metadata-only) skip
|
|
1525
|
-
* this entirely — JSON-LD lives inside the HTML body, so external pages
|
|
1526
|
-
* that are not rendered have no entries to write. An empty array on a
|
|
1527
|
-
* normally-rendered page is treated as a degraded re-scrape: prior rows
|
|
1528
|
-
* are kept (same `delete-only-when-replacing` invariant as `anchors` /
|
|
1529
|
-
* `images`).
|
|
1530
|
-
* @param pageId
|
|
1531
|
-
* @param meta
|
|
1532
|
-
* @param trx
|
|
1533
|
-
*/
|
|
1534
|
-
async #insertJsonLd(pageId, meta, trx) {
|
|
1535
|
-
// `??` guards tolerate the legacy "minimal meta" shape from older test
|
|
1536
|
-
// fixtures. Real beholder 3.0.0 always populates these required fields.
|
|
1537
|
-
const jsonLd = meta.jsonLd ?? [];
|
|
1538
|
-
const speculationRules = meta.speculationRules ?? [];
|
|
1539
|
-
const rows = [];
|
|
1540
|
-
for (const entry of jsonLd) {
|
|
1541
|
-
rows.push({
|
|
1542
|
-
pageId,
|
|
1543
|
-
kind: 'ld+json',
|
|
1544
|
-
type: classifyJsonLdType(entry),
|
|
1545
|
-
raw: entry.raw,
|
|
1546
|
-
parsed: entry.parsed === undefined ? null : JSON.stringify(entry.parsed),
|
|
1547
|
-
parseError: entry.parseError ?? null,
|
|
1548
|
-
});
|
|
1549
|
-
}
|
|
1550
|
-
for (const entry of speculationRules) {
|
|
1551
|
-
rows.push({
|
|
1552
|
-
pageId,
|
|
1553
|
-
kind: 'speculationrules',
|
|
1554
|
-
type: classifyJsonLdType(entry),
|
|
1555
|
-
raw: entry.raw,
|
|
1556
|
-
parsed: entry.parsed === undefined ? null : JSON.stringify(entry.parsed),
|
|
1557
|
-
parseError: entry.parseError ?? null,
|
|
1558
|
-
});
|
|
1559
|
-
}
|
|
1560
|
-
if (rows.length === 0)
|
|
1561
|
-
return;
|
|
1562
|
-
await trx('page_jsonld').where('pageId', pageId).delete();
|
|
1563
|
-
await eachSplitted(rows, 100, async (chunk) => {
|
|
1564
|
-
await trx('page_jsonld').insert(chunk);
|
|
1565
|
-
});
|
|
1566
|
-
}
|
|
1567
|
-
/**
|
|
1568
|
-
* Upserts page data into the `pages` table (inserts if new, updates if existing).
|
|
1569
|
-
*
|
|
1570
|
-
* `source` is intentionally NOT in the UPDATE clause — provenance is set
|
|
1571
|
-
* once at INSERT time inside `#getIdByUrl`, and existing rows keep
|
|
1572
|
-
* whatever label they were first inserted with.
|
|
1573
|
-
* @param page
|
|
1574
|
-
* @param isTarget
|
|
1575
|
-
* @param trx
|
|
1576
|
-
* @param source - Inventory provenance for the INSERT path. Ignored on UPDATE.
|
|
1577
|
-
*/
|
|
1578
|
-
async #insertPage(page, isTarget, trx, source) {
|
|
1579
|
-
const qb = trx ?? this.#instance;
|
|
1580
|
-
const pageId = await this.#getIdByUrl(page.url.withoutHashAndAuth, undefined, trx, source);
|
|
1581
|
-
const flat = deriveFlatFromMeta(page.meta, page.url.href);
|
|
1582
|
-
const denorm = computePageDenormalized(page.meta);
|
|
1583
|
-
const extras = deriveMetaExtras(page.meta);
|
|
1584
|
-
const now = Date.now();
|
|
1585
|
-
// Source promotion on UPDATE: when an inventory-mode scrape lands on
|
|
1586
|
-
// a row that was created earlier as a placeholder (e.g. an anchor
|
|
1587
|
-
// from a seed page pointed at this URL and `#getIdByUrl` inserted a
|
|
1588
|
-
// row with the DB DEFAULT `'crawled'`), bump the label to the
|
|
1589
|
-
// inventory variant. But never demote an already-inventoried row —
|
|
1590
|
-
// `CASE WHEN source = 'crawled' THEN ? ELSE source END` keeps a
|
|
1591
|
-
// previously labelled `'inventory-seed'` or `'inventory-discovered'`
|
|
1592
|
-
// row intact on a second pass.
|
|
1593
|
-
const sourceUpdate = source === undefined
|
|
1594
|
-
? {}
|
|
1595
|
-
: {
|
|
1596
|
-
source: qb.raw("CASE WHEN source = 'crawled' THEN ? ELSE source END", [
|
|
1597
|
-
source,
|
|
1598
|
-
]),
|
|
1599
|
-
};
|
|
1600
|
-
await qb('pages')
|
|
1601
|
-
.where('id', pageId)
|
|
1602
|
-
.update({
|
|
1603
|
-
scraped: true,
|
|
1604
|
-
isTarget,
|
|
1605
|
-
isExternal: page.isExternal,
|
|
1606
|
-
status: page.status,
|
|
1607
|
-
statusText: page.statusText,
|
|
1608
|
-
// Canonicalize so the stored value matches the exact-string page-ness
|
|
1609
|
-
// predicate (`WHERE contentType = 'text/html'`) used by the read layer
|
|
1610
|
-
// and the case-insensitive `isHtmlContentType` used in code. Responses
|
|
1611
|
-
// are recorded verbatim upstream, so `Text/HTML` / `text/html ` can
|
|
1612
|
-
// otherwise be stored and silently misclassified.
|
|
1613
|
-
contentType: normalizeContentType(page.contentType),
|
|
1614
|
-
contentLength: page.contentLength,
|
|
1615
|
-
responseHeaders: JSON.stringify(page.responseHeaders),
|
|
1616
|
-
// Flat meta columns derived from beholder 3.0.0 nested Meta.
|
|
1617
|
-
// URL-shaped columns (canonical / og_url / og_image / amphtml / manifest /
|
|
1618
|
-
// icon_href / appleTouchIcon_href / twitter_image) are already absolutised
|
|
1619
|
-
// by `deriveFlatFromMeta` against the page URL — `find-mismatches` compares
|
|
1620
|
-
// `canonical != url` directly, so storing the raw `getAttribute('href')`
|
|
1621
|
-
// would generate false positives for sites using relative canonicals.
|
|
1622
|
-
...flat,
|
|
1623
|
-
// Denormalised aggregates: written once at scrape time so list reads
|
|
1624
|
-
// (Sheets, page-detail summary) can answer "how many JSON-LD entries?"
|
|
1625
|
-
// and "which Wappalyzer providers?" by selecting a single pages column
|
|
1626
|
-
// rather than running a GROUP BY join on every read.
|
|
1627
|
-
tag_count: denorm.tag_count,
|
|
1628
|
-
jsonld_count: denorm.jsonld_count,
|
|
1629
|
-
tags_providers_csv: denorm.tags_providers_csv,
|
|
1630
|
-
// JSON catch-all for nested Meta sub-objects not flattened above.
|
|
1631
|
-
meta_extras: JSON.stringify(extras),
|
|
1632
|
-
// Timestamps: `firstCrawledAt` is set only on first INSERT — `COALESCE`
|
|
1633
|
-
// preserves the existing value so a re-scrape (`--append`, `--retry-failed`)
|
|
1634
|
-
// does not erase the discovery time. `lastCrawledAt` is updated every
|
|
1635
|
-
// successful scrape.
|
|
1636
|
-
firstCrawledAt: qb.raw('COALESCE(firstCrawledAt, ?)', [now]),
|
|
1637
|
-
lastCrawledAt: now,
|
|
1638
|
-
isSkipped: page.isSkipped,
|
|
1639
|
-
...sourceUpdate,
|
|
1640
|
-
});
|
|
1641
|
-
return pageId;
|
|
1642
|
-
}
|
|
1643
|
-
/**
|
|
1644
|
-
* Replaces the page's Wappalyzer tag rows with the freshly captured set.
|
|
1645
|
-
* Called inside `updatePage`'s transaction unconditionally — tag
|
|
1646
|
-
* detection draws on `<script src>` / `<iframe src>` / window globals /
|
|
1647
|
-
* response headers, not the HTML body, so external pages that skip
|
|
1648
|
-
* rendering still contribute tags.
|
|
1649
|
-
*
|
|
1650
|
-
* Same empty-guard as `#insertJsonLd`: an empty array does not wipe
|
|
1651
|
-
* prior rows on a degraded re-scrape.
|
|
1652
|
-
* @param pageId
|
|
1653
|
-
* @param meta
|
|
1654
|
-
* @param trx
|
|
1655
|
-
*/
|
|
1656
|
-
async #insertTags(pageId, meta, trx) {
|
|
1657
|
-
const partial = extractTagsForArchive(meta.tags);
|
|
1658
|
-
if (partial.length === 0)
|
|
1659
|
-
return;
|
|
1660
|
-
const rows = partial.map((p) => ({
|
|
1661
|
-
pageId,
|
|
1662
|
-
provider: p.provider,
|
|
1663
|
-
category: p.category,
|
|
1664
|
-
externalId: p.externalId,
|
|
1665
|
-
version: p.version,
|
|
1666
|
-
confidence: p.confidence,
|
|
1667
|
-
categories: JSON.stringify(p.categories),
|
|
1668
|
-
sources: JSON.stringify(p.sources),
|
|
1669
|
-
}));
|
|
1670
|
-
await trx('page_tags').where('pageId', pageId).delete();
|
|
1671
|
-
await eachSplitted(rows, 100, async (chunk) => {
|
|
1672
|
-
await trx('page_tags').insert(chunk);
|
|
1673
|
-
});
|
|
1674
|
-
}
|
|
1675
|
-
/**
|
|
1676
|
-
* Points each redirect-source URL at the destination page, marking it scraped
|
|
1677
|
-
* and clearing any content it owned in a former life.
|
|
1678
|
-
*
|
|
1679
|
-
* Shared by {@link updatePage} (which also renders and stores the destination)
|
|
1680
|
-
* and {@link recordRedirect} (which only records the edge for a destination
|
|
1681
|
-
* rendered elsewhere). Self-redirects (source equal to the destination) are
|
|
1682
|
-
* skipped so a page is never marked as redirecting to itself — that would
|
|
1683
|
-
* exclude it from reports via the `whereNull('redirectDestId')` filter.
|
|
1684
|
-
* @param trx - The active transaction.
|
|
1685
|
-
* @param sources - Redirect-source URLs (normalised): the original URL plus
|
|
1686
|
-
* any intermediate hops. Empty when the page was not redirected.
|
|
1687
|
-
* @param destId - Database id of the redirect destination page.
|
|
1688
|
-
* @param destUrlNormalized - Normalised destination URL, used to detect and
|
|
1689
|
-
* skip self-redirects.
|
|
1690
|
-
* @param isExternal - Whether the sources are external to the crawl scope.
|
|
1691
|
-
*/
|
|
1692
|
-
async #linkRedirectSources(trx, sources, destId, destUrlNormalized, isExternal) {
|
|
1693
|
-
for (const redirect of sources) {
|
|
1694
|
-
if (redirect === destUrlNormalized) {
|
|
1695
|
-
dbLog('Skip self-redirect: %s', redirect);
|
|
1696
|
-
continue;
|
|
1697
|
-
}
|
|
1698
|
-
dbLog('Set redirected url: %s -> id:%d', redirect, destId);
|
|
1699
|
-
const redirectId = await this.#getIdByUrl(redirect, undefined, trx);
|
|
1700
|
-
await trx('pages')
|
|
1701
|
-
.where('id', redirectId)
|
|
1702
|
-
.update({
|
|
1703
|
-
scraped: 1,
|
|
1704
|
-
redirectDestId: destId,
|
|
1705
|
-
isExternal: isExternal ? 1 : 0,
|
|
1706
|
-
});
|
|
1707
|
-
// A page that used to be scraped as content can later turn into a
|
|
1708
|
-
// redirect source. It owns no content anymore, so drop any anchors /
|
|
1709
|
-
// images it captured in its former life — otherwise they linger and
|
|
1710
|
-
// leak into referrer / incoming-link reads (which do not filter out
|
|
1711
|
-
// redirect sources).
|
|
1712
|
-
await trx('anchors').where('pageId', redirectId).delete();
|
|
1713
|
-
await trx('images').where('pageId', redirectId).delete();
|
|
1714
|
-
}
|
|
1715
|
-
}
|
|
1716
|
-
/**
|
|
1717
|
-
* Encodes, dedups, and persists a page's HTML snapshot.
|
|
1718
|
-
*
|
|
1719
|
-
* Computes SHA-256 over the raw UTF-8 bytes, compresses them with zstd,
|
|
1720
|
-
* inserts into `page_html_blobs` only if the hash is new (so identical
|
|
1721
|
-
* bodies — 404 templates, error pages, redirect destinations — share a
|
|
1722
|
-
* single row), and then upserts `page_html_ref(page_id → hash)` so the
|
|
1723
|
-
* latest scrape always points at the right body.
|
|
1724
|
-
*
|
|
1725
|
-
* Runs entirely inside the caller's transaction; a failure here rolls
|
|
1726
|
-
* back the rest of `updatePage`, which is the desired semantics (an
|
|
1727
|
-
* archive that lost its HTML for a page would otherwise serve stale
|
|
1728
|
-
* meta against a missing body).
|
|
1729
|
-
* @param pageId - The database id of the page.
|
|
1730
|
-
* @param html - The raw HTML string (UTF-8).
|
|
1731
|
-
* @param trx - The active transaction.
|
|
1732
|
-
*/
|
|
1733
|
-
async #writePageHtmlBlob(pageId, html, trx) {
|
|
1734
|
-
const rawBytes = Buffer.from(html, 'utf8');
|
|
1735
|
-
const hash = createHash('sha256').update(rawBytes).digest();
|
|
1736
|
-
const compressed = zstdCompressSync(rawBytes);
|
|
1737
|
-
await trx('page_html_blobs')
|
|
1738
|
-
.insert({
|
|
1739
|
-
hash,
|
|
1740
|
-
body: compressed,
|
|
1741
|
-
codec: 'zstd',
|
|
1742
|
-
size_raw: rawBytes.byteLength,
|
|
1743
|
-
size_stored: compressed.byteLength,
|
|
1744
|
-
})
|
|
1745
|
-
.onConflict('hash')
|
|
1746
|
-
.ignore();
|
|
1747
|
-
// Upsert so a re-scrape's body cleanly supersedes the prior pointer.
|
|
1748
|
-
// The old blob row is intentionally left in place — a future #23 GC
|
|
1749
|
-
// pass will sweep unreachable hashes.
|
|
1750
|
-
await trx('page_html_ref')
|
|
1751
|
-
.insert({ page_id: pageId, hash })
|
|
1752
|
-
.onConflict('page_id')
|
|
1753
|
-
.merge(['hash']);
|
|
1754
|
-
}
|
|
1755
|
-
/**
|
|
1756
|
-
* Creates and initializes a new Database instance.
|
|
1757
|
-
*
|
|
1758
|
-
* **Writer mode (default)**: creates the parent directory for the
|
|
1759
|
-
* database file if needed, establishes the connection, and initializes
|
|
1760
|
-
* the schema + migrations.
|
|
1761
|
-
*
|
|
1762
|
-
* **Read-only mode** (`options.readOnly`): refuses to resurrect a
|
|
1763
|
-
* missing parent directory or db file — throws if either is absent at
|
|
1764
|
-
* the time of the call. Skips schema init and migrations entirely so
|
|
1765
|
-
* the user's tmpDir is never modified. Required by viewer / MCP
|
|
1766
|
-
* stub-mode opens, where a TOCTOU window between classification and
|
|
1767
|
-
* `connect()` could otherwise leave behind a phantom empty tmpDir.
|
|
1768
|
-
* @param options - Database connection options.
|
|
1769
|
-
* @returns A fully initialized Database instance.
|
|
1770
|
-
* @throws {Error} In read-only mode, if the parent directory or db
|
|
1771
|
-
* file does not exist when `connect()` runs.
|
|
1772
|
-
*/
|
|
1773
|
-
static async connect(options) {
|
|
1774
|
-
if (options.readOnly) {
|
|
1775
|
-
if (!existsSync(path.dirname(options.filename))) {
|
|
1776
|
-
throw new Error(`Cannot open archive read-only: parent directory disappeared (${path.dirname(options.filename)}). The source may have been removed by another process.`);
|
|
1777
|
-
}
|
|
1778
|
-
if (!existsSync(options.filename)) {
|
|
1779
|
-
throw new Error(`Cannot open archive read-only: database file missing (${options.filename}). The source may have been removed by another process.`);
|
|
1780
|
-
}
|
|
1781
|
-
}
|
|
1782
|
-
else {
|
|
1783
|
-
mkdir(options.filename);
|
|
1784
|
-
}
|
|
1785
|
-
const db = new Database(options);
|
|
1786
|
-
await db.#init(options.readOnly ?? false);
|
|
1787
|
-
return db;
|
|
1788
|
-
}
|
|
1789
|
-
};
|
|
1790
|
-
})();
|
|
1791
|
-
export { Database };
|