@nitpicker/crawler 0.18.1 → 0.19.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/lib/append-retry-failed-common-setup-phases.d.ts +15 -0
- package/lib/append-retry-failed-common-setup-phases.js +21 -0
- package/lib/append-setup-phases.d.ts +17 -0
- package/lib/append-setup-phases.js +22 -0
- package/lib/archive/archive-accessor.d.ts +31 -5
- package/lib/archive/archive-accessor.js +46 -24
- package/lib/archive/archive.d.ts +115 -37
- package/lib/archive/archive.js +118 -52
- package/lib/archive/cache/extract-archive-to-cache.d.ts +10 -1
- package/lib/archive/cache/extract-archive-to-cache.js +14 -4
- package/lib/archive/create-adjunct-tables.d.ts +12 -2
- package/lib/archive/create-adjunct-tables.js +91 -29
- package/lib/archive/create-entity-tables.d.ts +5 -1
- package/lib/archive/create-entity-tables.js +6 -1
- package/lib/archive/database.d.ts +39 -9
- package/lib/archive/database.js +63 -18
- package/lib/archive/db-ops/lifecycle/init.d.ts +14 -2
- package/lib/archive/db-ops/lifecycle/init.js +29 -11
- package/lib/archive/db-ops/meta/get-custom-elements-of-page.d.ts +11 -0
- package/lib/archive/db-ops/meta/get-custom-elements-of-page.js +15 -0
- package/lib/archive/db-ops/meta/get-page-technologies-of-page.d.ts +11 -0
- package/lib/archive/db-ops/meta/get-page-technologies-of-page.js +15 -0
- package/lib/archive/db-ops/meta/get-technology-signals-of-page.d.ts +11 -0
- package/lib/archive/db-ops/meta/get-technology-signals-of-page.js +15 -0
- package/lib/archive/db-ops/pages/order/set-url-order.d.ts +6 -1
- package/lib/archive/db-ops/pages/order/set-url-order.js +7 -1
- package/lib/archive/db-ops/pages/read/build-page-query.js +1 -1
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.d.ts +6 -1
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +12 -3
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +6 -1
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +10 -2
- package/lib/archive/db-ops/pages/write/insert-custom-elements.d.ts +27 -0
- package/lib/archive/db-ops/pages/write/insert-custom-elements.js +40 -0
- package/lib/archive/db-ops/pages/write/insert-page.d.ts +4 -4
- package/lib/archive/db-ops/pages/write/insert-page.js +3 -2
- package/lib/archive/db-ops/pages/write/insert-technologies.d.ts +25 -0
- package/lib/archive/db-ops/pages/write/insert-technologies.js +45 -0
- package/lib/archive/db-ops/pages/write/update-page.d.ts +4 -1
- package/lib/archive/db-ops/pages/write/update-page.js +38 -16
- package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +12 -1
- package/lib/archive/db-ops/resources/get-resource-url-list.js +43 -5
- package/lib/archive/filesystem/copy-file-with-progress.d.ts +20 -0
- package/lib/archive/filesystem/copy-file-with-progress.js +34 -0
- package/lib/archive/filesystem/parse-pax-path.d.ts +18 -0
- package/lib/archive/filesystem/parse-pax-path.js +47 -0
- package/lib/archive/filesystem/parse-tar-size-field.d.ts +13 -0
- package/lib/archive/filesystem/parse-tar-size-field.js +40 -0
- package/lib/archive/filesystem/peek-tar-top-dir.d.ts +10 -3
- package/lib/archive/filesystem/peek-tar-top-dir.js +166 -16
- package/lib/archive/filesystem/tar.d.ts +13 -1
- package/lib/archive/filesystem/tar.js +63 -7
- package/lib/archive/filesystem/untar.d.ts +13 -0
- package/lib/archive/filesystem/untar.js +68 -8
- package/lib/archive/init-schema.d.ts +1 -1
- package/lib/archive/init-schema.js +6 -4
- package/lib/archive/meta/compute-main-contents-denormalized.d.ts +13 -2
- package/lib/archive/meta/compute-main-contents-denormalized.js +15 -2
- package/lib/archive/meta/technologies/combine-technology-confidence.d.ts +46 -0
- package/lib/archive/meta/technologies/combine-technology-confidence.js +93 -0
- package/lib/archive/meta/technologies/convert-legacy-page-tags-to-inserts.d.ts +45 -0
- package/lib/archive/meta/technologies/convert-legacy-page-tags-to-inserts.js +53 -0
- package/lib/archive/meta/technologies/convert-tag-row-to-wappalyzer-signal.d.ts +33 -0
- package/lib/archive/meta/technologies/convert-tag-row-to-wappalyzer-signal.js +46 -0
- package/lib/archive/meta/technologies/extract-technologies-for-archive.d.ts +30 -0
- package/lib/archive/meta/technologies/extract-technologies-for-archive.js +27 -0
- package/lib/archive/meta/technologies/match-structural-technology-signals.d.ts +13 -0
- package/lib/archive/meta/technologies/match-structural-technology-signals.js +44 -0
- package/lib/archive/meta/technologies/normalize-wappalyzer-entries.d.ts +25 -0
- package/lib/archive/meta/technologies/normalize-wappalyzer-entries.js +35 -0
- package/lib/archive/meta/technologies/technology-signal-definitions.d.ts +43 -0
- package/lib/archive/meta/technologies/technology-signal-definitions.js +193 -0
- package/lib/archive/meta/technologies/types.d.ts +68 -0
- package/lib/archive/meta/technologies/types.js +13 -0
- package/lib/archive/meta/types.d.ts +97 -30
- package/lib/archive/migrate-content-items-alias-of-id.d.ts +6 -1
- package/lib/archive/migrate-content-items-alias-of-id.js +14 -3
- package/lib/archive/migrate-content-items-dedupe-cap-event-id.d.ts +6 -1
- package/lib/archive/migrate-content-items-dedupe-cap-event-id.js +14 -3
- package/lib/archive/migrate-info-main-content-selector.d.ts +6 -1
- package/lib/archive/migrate-info-main-content-selector.js +14 -3
- package/lib/archive/migrate-info-roots.d.ts +6 -1
- package/lib/archive/migrate-info-roots.js +14 -3
- package/lib/archive/migrate-inventory-runs-exclude-skipped.d.ts +6 -1
- package/lib/archive/migrate-inventory-runs-exclude-skipped.js +14 -3
- package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +6 -1
- package/lib/archive/migrate-inventory-runs-invalid-skipped.js +14 -3
- package/lib/archive/migrate-main-contents-columns.d.ts +6 -1
- package/lib/archive/migrate-main-contents-columns.js +14 -3
- package/lib/archive/migrate-page-meta-body-hash.d.ts +6 -1
- package/lib/archive/migrate-page-meta-body-hash.js +14 -3
- package/lib/archive/migrate-page-meta-console-error-count.d.ts +6 -1
- package/lib/archive/migrate-page-meta-console-error-count.js +14 -3
- package/lib/archive/migrate-page-meta-custom-element-count.d.ts +33 -0
- package/lib/archive/migrate-page-meta-custom-element-count.js +51 -0
- package/lib/archive/migrate-page-tags-to-page-technologies.d.ts +39 -0
- package/lib/archive/migrate-page-tags-to-page-technologies.js +79 -0
- package/lib/archive/page.d.ts +37 -13
- package/lib/archive/page.js +45 -17
- package/lib/archive/retarget-legacy-fk-tables.d.ts +27 -14
- package/lib/archive/retarget-legacy-fk-tables.js +61 -15
- package/lib/archive/types.d.ts +18 -0
- package/lib/crawler/assert-puppeteer-shared-with-beholder.d.ts +24 -0
- package/lib/crawler/assert-puppeteer-shared-with-beholder.js +36 -0
- package/lib/crawler/capture-custom-elements.d.ts +33 -0
- package/lib/crawler/capture-custom-elements.js +39 -0
- package/lib/crawler/close-browser-safely.d.ts +3 -3
- package/lib/crawler/close-browser-safely.js +8 -17
- package/lib/crawler/collect-custom-elements.d.ts +33 -0
- package/lib/crawler/collect-custom-elements.js +110 -0
- package/lib/crawler/crawler.js +101 -46
- package/lib/crawler/fetch-destination.js +6 -13
- package/lib/crawler/find-package-dir.d.ts +24 -0
- package/lib/crawler/find-package-dir.js +39 -0
- package/lib/crawler/scan-js-resource-for-license-comment.d.ts +32 -0
- package/lib/crawler/scan-js-resource-for-license-comment.js +140 -0
- package/lib/crawler/scan-js-resources-for-technology-signals.d.ts +58 -0
- package/lib/crawler/scan-js-resources-for-technology-signals.js +196 -0
- package/lib/crawler/types.d.ts +23 -0
- package/lib/crawler-orchestrator.d.ts +53 -9
- package/lib/crawler-orchestrator.js +223 -46
- package/lib/crawler.d.ts +11 -1
- package/lib/crawler.js +9 -0
- package/lib/inventory-setup-phases.d.ts +32 -0
- package/lib/inventory-setup-phases.js +45 -0
- package/lib/resume-setup-phases.d.ts +16 -0
- package/lib/resume-setup-phases.js +24 -0
- package/lib/retry-failed-setup-phases.d.ts +19 -0
- package/lib/retry-failed-setup-phases.js +24 -0
- package/lib/setup-recovery-phase-labels.d.ts +21 -0
- package/lib/setup-recovery-phase-labels.js +24 -0
- package/lib/types.d.ts +145 -0
- package/package.json +11 -8
- package/lib/archive/db-ops/meta/get-tags-of-page.d.ts +0 -12
- package/lib/archive/db-ops/meta/get-tags-of-page.js +0 -28
- package/lib/archive/db-ops/pages/write/insert-tags.d.ts +0 -16
- package/lib/archive/db-ops/pages/write/insert-tags.js +0 -34
- package/lib/archive/meta/summarize-tags.d.ts +0 -16
- package/lib/archive/meta/summarize-tags.js +0 -33
package/lib/crawler/crawler.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
var _a;
|
|
2
1
|
import { existsSync } from 'node:fs';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import Scraper from '@d-zero/beholder';
|
|
@@ -14,6 +13,7 @@ import { classifyErrorKind } from '../classify-error-kind.js';
|
|
|
14
13
|
import { crawlerLog } from '../debug.js';
|
|
15
14
|
import { buildJsRedirectEdge } from './build-js-redirect-edge.js';
|
|
16
15
|
import { buildRedirectEvent } from './build-redirect-event.js';
|
|
16
|
+
import { captureCustomElements } from './capture-custom-elements.js';
|
|
17
17
|
import { captureImageDomPaths } from './capture-image-dom-paths.js';
|
|
18
18
|
import { chooseProbeHost } from './choose-probe-host.js';
|
|
19
19
|
import { createChangePhaseHandler } from './create-change-phase-handler.js';
|
|
@@ -92,7 +92,7 @@ const DEFAULT_DEDUPE_MAP_CAP = 100_000;
|
|
|
92
92
|
* Crawling is performed concurrently using the dealer pattern, with
|
|
93
93
|
* configurable parallelism up to {@link Crawler.MAX_PROCESS_LENGTH}.
|
|
94
94
|
*/
|
|
95
|
-
class Crawler extends EventEmitter {
|
|
95
|
+
export default class Crawler extends EventEmitter {
|
|
96
96
|
/** Controller used to cancel the deal-based crawl via its AbortSignal. */
|
|
97
97
|
#abortController = new AbortController();
|
|
98
98
|
/**
|
|
@@ -556,10 +556,11 @@ class Crawler extends EventEmitter {
|
|
|
556
556
|
* queue, prioritising likely-HTML URLs to the front (see {@link partitionUrlsByHtml}).
|
|
557
557
|
* Accepts a batch so a group of URLs (e.g. predicted pagination) keeps its order.
|
|
558
558
|
* @param concurrency - Current concurrency level, used to determine predicted URL count
|
|
559
|
-
* @param precomputedBodyHash - This page's body hash,
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
559
|
+
* @param precomputedBodyHash - This page's body hash, computed once by the
|
|
560
|
+
* caller for every internal page with a rendered HTML body (see the
|
|
561
|
+
* computation site's comment) — reused here for the dedupe-cap
|
|
562
|
+
* observation, and forwarded via the `page` event so `update-page.ts`
|
|
563
|
+
* does not hash the same html again.
|
|
563
564
|
*/
|
|
564
565
|
#handleResult(result, url, enqueue, concurrency, precomputedBodyHash) {
|
|
565
566
|
switch (result.type) {
|
|
@@ -595,6 +596,14 @@ class Crawler extends EventEmitter {
|
|
|
595
596
|
const shapeKey = computeShapeKey(result.pageData.url.withoutHashAndAuth);
|
|
596
597
|
const metaSig = computeMetaSignature(result.pageData.meta);
|
|
597
598
|
if (shapeKey && metaSig) {
|
|
599
|
+
// `precomputedBodyHash` is non-null here in every reachable
|
|
600
|
+
// case: this branch's guard (`!isExternal && html.length > 0`,
|
|
601
|
+
// modulo the `isMetadataOnly` exclusion which only narrows it)
|
|
602
|
+
// is a subset of the unconditional computation site's condition
|
|
603
|
+
// above. The `??` fallback is not expected to ever fire — it is
|
|
604
|
+
// kept only as a defensive backstop against a future edit to
|
|
605
|
+
// either condition silently breaking that invariant, favouring
|
|
606
|
+
// a slow-but-correct recomputation over a crash.
|
|
598
607
|
const bodyHash = precomputedBodyHash ?? computeBodyHash(result.pageData.html);
|
|
599
608
|
const ogUrlMismatch = resolveOgUrlMismatch(result.pageData.meta, result.pageData.url.href);
|
|
600
609
|
const event = this.#dedupeCapTracker.observe({
|
|
@@ -702,6 +711,7 @@ class Crawler extends EventEmitter {
|
|
|
702
711
|
void this.emit('page', {
|
|
703
712
|
result: result.pageData,
|
|
704
713
|
source: pageSource,
|
|
714
|
+
bodyHash: precomputedBodyHash,
|
|
705
715
|
});
|
|
706
716
|
}
|
|
707
717
|
}
|
|
@@ -740,7 +750,16 @@ class Crawler extends EventEmitter {
|
|
|
740
750
|
});
|
|
741
751
|
}
|
|
742
752
|
else {
|
|
743
|
-
|
|
753
|
+
// `pageResult` here always carries `html: ''` (`linkToPageData`'s
|
|
754
|
+
// error-fallback shape), so it never reaches `update-page.ts`'s
|
|
755
|
+
// `html.length > 0` write gate — `bodyHash: null` is explicit
|
|
756
|
+
// rather than relying on that gate to make an omitted field
|
|
757
|
+
// harmless, so this stays correct if that ever changes.
|
|
758
|
+
void this.emit('page', {
|
|
759
|
+
result: pageResult,
|
|
760
|
+
source: pageSource,
|
|
761
|
+
bodyHash: null,
|
|
762
|
+
});
|
|
744
763
|
}
|
|
745
764
|
}
|
|
746
765
|
void this.emit('error', {
|
|
@@ -906,7 +925,7 @@ class Crawler extends EventEmitter {
|
|
|
906
925
|
}
|
|
907
926
|
const concurrency = this.#options.parallels
|
|
908
927
|
? Math.max(this.#options.parallels, 1)
|
|
909
|
-
:
|
|
928
|
+
: Crawler.MAX_PROCESS_LENGTH;
|
|
910
929
|
await deal(initialUrls, (url, update, _index, setLineHeader, push, unshift) => {
|
|
911
930
|
const matchedScope = findScopeEntry(url, this.#scope, this.#options);
|
|
912
931
|
const isExternal = matchedScope === null;
|
|
@@ -961,10 +980,12 @@ class Crawler extends EventEmitter {
|
|
|
961
980
|
const markBrowserScrape = () => {
|
|
962
981
|
renderedInBrowser = true;
|
|
963
982
|
};
|
|
964
|
-
// Set
|
|
965
|
-
//
|
|
966
|
-
//
|
|
967
|
-
//
|
|
983
|
+
// Set below for every internal page with a rendered HTML body
|
|
984
|
+
// (not just predicted ones — see the computation site's comment),
|
|
985
|
+
// so both `#handleResult`'s dedupe-cap observation and the `page`
|
|
986
|
+
// event's `bodyHash` payload (ultimately consumed by
|
|
987
|
+
// `update-page.ts`'s `page_meta.body_hash` write) reuse this one
|
|
988
|
+
// value instead of each hashing the same html again.
|
|
968
989
|
let precomputedBodyHash = null;
|
|
969
990
|
try {
|
|
970
991
|
const robotsAllowed = await this.#robotsChecker.isAllowed(url);
|
|
@@ -1104,30 +1125,41 @@ class Crawler extends EventEmitter {
|
|
|
1104
1125
|
log(c.dim('Predicted (discarded)'));
|
|
1105
1126
|
return;
|
|
1106
1127
|
}
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
//
|
|
1111
|
-
//
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
|
|
1116
|
-
|
|
1128
|
+
// Compute this page's body hash once, up front, for every
|
|
1129
|
+
// internal page with a rendered HTML body — not just predicted
|
|
1130
|
+
// ones. This condition intentionally mirrors `update-page.ts`'s
|
|
1131
|
+
// `writeHtml && page.html.length > 0` write gate (internal pages
|
|
1132
|
+
// are exactly the ones `setPage` — as opposed to
|
|
1133
|
+
// `setExternalPage` — writes a body through), so the value
|
|
1134
|
+
// computed here can be forwarded through the `page` event all
|
|
1135
|
+
// the way to that write and reused there instead of hashing the
|
|
1136
|
+
// same html a second time.
|
|
1137
|
+
if (result.type === 'success' &&
|
|
1117
1138
|
result.pageData &&
|
|
1139
|
+
!result.pageData.isExternal &&
|
|
1118
1140
|
result.pageData.html.length > 0) {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1141
|
+
precomputedBodyHash = computeBodyHash(result.pageData.html);
|
|
1142
|
+
// Discard a predicted URL whose rendered body is a
|
|
1143
|
+
// byte-for-byte duplicate of the previous predicted page of
|
|
1144
|
+
// the same shape, and stop generating further predictions for
|
|
1145
|
+
// that shape (checked above, in the pagination-pattern
|
|
1146
|
+
// branch). This is the always-on backstop against a site
|
|
1147
|
+
// that returns 2xx for any extrapolated token but ignores it
|
|
1148
|
+
// entirely (e.g. always serving the same "no results"
|
|
1149
|
+
// template) — `shouldDiscardPredicted` alone cannot see
|
|
1150
|
+
// this, since it only inspects HTTP status.
|
|
1151
|
+
if (isPredicted) {
|
|
1152
|
+
const shapeKey = computeShapeKey(url.withoutHashAndAuth);
|
|
1153
|
+
if (shapeKey) {
|
|
1154
|
+
const lastBodyHash = this.#predictedShapeBodyHashes.get(shapeKey) ?? null;
|
|
1155
|
+
if (isPredictedContentDuplicate(precomputedBodyHash, lastBodyHash)) {
|
|
1156
|
+
this.#predictedShapeStopped.add(shapeKey);
|
|
1157
|
+
handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
|
|
1158
|
+
log(c.dim('Predicted (content duplicate, discarded)'));
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
this.#predictedShapeBodyHashes.set(shapeKey, precomputedBodyHash);
|
|
1129
1162
|
}
|
|
1130
|
-
this.#predictedShapeBodyHashes.set(shapeKey, bodyHash);
|
|
1131
1163
|
}
|
|
1132
1164
|
}
|
|
1133
1165
|
// Count only after discard check: rendered HTML pages that
|
|
@@ -1875,17 +1907,42 @@ class Crawler extends EventEmitter {
|
|
|
1875
1907
|
// `image_items.dom_path_text_id` resolution at write time; a
|
|
1876
1908
|
// capture failure (or a page with no images) falls back to the
|
|
1877
1909
|
// synthetic `unknown/<n>` markers, so this stays best-effort.
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1910
|
+
// Image dom-path capture and custom-element (Web Component)
|
|
1911
|
+
// capture are two independent `page.evaluate()` round-trips over
|
|
1912
|
+
// the same still-alive `page` — neither reads the other's
|
|
1913
|
+
// output, so they run concurrently instead of paying both
|
|
1914
|
+
// round-trips' latency in sequence. Each keeps its own
|
|
1915
|
+
// pre-existing gate (image dom-paths on a non-empty imageList,
|
|
1916
|
+
// custom elements on a resolved main-content region — unrelated
|
|
1917
|
+
// to each other, so neither gate is relaxed by running together).
|
|
1918
|
+
// Custom-element capture exists because beholder's
|
|
1919
|
+
// `MainContentsData` has no `customElements` category, so
|
|
1920
|
+
// nitpicker captures it itself.
|
|
1921
|
+
const shouldCaptureImageDomPaths = result.type === 'success' &&
|
|
1922
|
+
result.pageData !== undefined &&
|
|
1923
|
+
result.pageData.imageList.length > 0;
|
|
1924
|
+
const shouldCaptureCustomElements = result.type === 'success' &&
|
|
1925
|
+
result.pageData !== undefined &&
|
|
1926
|
+
result.pageData.mainContents !== null;
|
|
1927
|
+
const [imageDomPaths, mainContentCustomElements] = await Promise.all([
|
|
1928
|
+
shouldCaptureImageDomPaths ? captureImageDomPaths(page) : undefined,
|
|
1929
|
+
shouldCaptureCustomElements
|
|
1930
|
+
? captureCustomElements(page, this.#options.mainContentSelector)
|
|
1931
|
+
: undefined,
|
|
1932
|
+
]);
|
|
1933
|
+
if (imageDomPaths !== undefined && result.pageData) {
|
|
1934
|
+
const withDomPaths = {
|
|
1935
|
+
...result.pageData,
|
|
1936
|
+
imageDomPaths,
|
|
1937
|
+
};
|
|
1938
|
+
result.pageData = withDomPaths;
|
|
1939
|
+
}
|
|
1940
|
+
if (mainContentCustomElements !== undefined && result.pageData) {
|
|
1941
|
+
const withCustomElements = {
|
|
1942
|
+
...result.pageData,
|
|
1943
|
+
mainContentCustomElements,
|
|
1944
|
+
};
|
|
1945
|
+
result.pageData = withCustomElements;
|
|
1889
1946
|
}
|
|
1890
1947
|
update('Closing browser%dots%');
|
|
1891
1948
|
// JS-redirect rescue capture: when `scrapeStart` catches a
|
|
@@ -1970,8 +2027,6 @@ class Crawler extends EventEmitter {
|
|
|
1970
2027
|
*/
|
|
1971
2028
|
static MAX_PROCESS_LENGTH = 10;
|
|
1972
2029
|
}
|
|
1973
|
-
_a = Crawler;
|
|
1974
|
-
export default Crawler;
|
|
1975
2030
|
/**
|
|
1976
2031
|
* Colorize an HTTP status code string for terminal display.
|
|
1977
2032
|
*
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { delay } from '@d-zero/shared/delay';
|
|
2
|
+
import { raceWithTimeout } from '@d-zero/shared/race-with-timeout';
|
|
2
3
|
import redirects from 'follow-redirects';
|
|
3
4
|
import { decodeAuthCredential } from './decode-auth-credential.js';
|
|
4
5
|
import { destinationCache } from './destination-cache.js';
|
|
@@ -34,19 +35,11 @@ export async function fetchDestination(params) {
|
|
|
34
35
|
}
|
|
35
36
|
const effectiveMethod = titleBytesLimit == null ? method : 'GET';
|
|
36
37
|
const raceTimeoutMs = timeout ?? DEFAULT_HEAD_TIMEOUT_MS;
|
|
37
|
-
// Race the fetch against the requested timeout
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
const result =
|
|
42
|
-
_fetchHead(url, isExternal, effectiveMethod, titleBytesLimit, userAgent, timeout).catch((error) => error instanceof Error ? error : new Error(String(error))),
|
|
43
|
-
new Promise((resolve) => {
|
|
44
|
-
timeoutHandle = setTimeout(() => resolve(new NetTimeoutError(url.href)), raceTimeoutMs);
|
|
45
|
-
}),
|
|
46
|
-
]).finally(() => {
|
|
47
|
-
if (timeoutHandle)
|
|
48
|
-
clearTimeout(timeoutHandle);
|
|
49
|
-
});
|
|
38
|
+
// Race the fetch against the requested timeout via `raceWithTimeout`, which
|
|
39
|
+
// clears the losing timer internally so it never keeps the event loop
|
|
40
|
+
// alive after the race settles.
|
|
41
|
+
const { result: challengeResult, timeout: timedOut } = await raceWithTimeout(() => _fetchHead(url, isExternal, effectiveMethod, titleBytesLimit, userAgent, timeout).catch((error) => error instanceof Error ? error : new Error(String(error))), raceTimeoutMs);
|
|
42
|
+
const result = timedOut ? new NetTimeoutError(url.href) : challengeResult;
|
|
50
43
|
// HEAD failure fallback: a WAF / middlebox that silently drops HEAD will
|
|
51
44
|
// surface as NetTimeoutError / parse-error / connection-reset here even
|
|
52
45
|
// though the same URL serves a normal GET response. Try GET once (using
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locates `packageName`'s directory the way Node's module resolution would —
|
|
3
|
+
* walking `node_modules` upward from `fromDir` — without going through the
|
|
4
|
+
* package's own `exports` map.
|
|
5
|
+
*
|
|
6
|
+
* A plain `require.resolve()` on an ESM-only package (`exports` declaring
|
|
7
|
+
* only the `"import"` condition) fails from a CJS `createRequire` context,
|
|
8
|
+
* and there is no stable public API to run Node's ESM resolution algorithm
|
|
9
|
+
* from an arbitrary base directory. Walking `node_modules` directly
|
|
10
|
+
* sidesteps the `exports` map entirely — it only needs the package's
|
|
11
|
+
* directory, not its resolved entry file.
|
|
12
|
+
* @param fromDir - Directory to start walking upward from.
|
|
13
|
+
* @param packageName - Package name to locate (e.g. `puppeteer`,
|
|
14
|
+
* `@d-zero/beholder`).
|
|
15
|
+
* @throws {Error} When no `node_modules/<packageName>` directory is found
|
|
16
|
+
* between `fromDir` and the filesystem root.
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* import { findPackageDir } from './find-package-dir.js';
|
|
20
|
+
*
|
|
21
|
+
* const puppeteerDir = findPackageDir(import.meta.dirname, 'puppeteer');
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare function findPackageDir(fromDir: string, packageName: string): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Locates `packageName`'s directory the way Node's module resolution would —
|
|
5
|
+
* walking `node_modules` upward from `fromDir` — without going through the
|
|
6
|
+
* package's own `exports` map.
|
|
7
|
+
*
|
|
8
|
+
* A plain `require.resolve()` on an ESM-only package (`exports` declaring
|
|
9
|
+
* only the `"import"` condition) fails from a CJS `createRequire` context,
|
|
10
|
+
* and there is no stable public API to run Node's ESM resolution algorithm
|
|
11
|
+
* from an arbitrary base directory. Walking `node_modules` directly
|
|
12
|
+
* sidesteps the `exports` map entirely — it only needs the package's
|
|
13
|
+
* directory, not its resolved entry file.
|
|
14
|
+
* @param fromDir - Directory to start walking upward from.
|
|
15
|
+
* @param packageName - Package name to locate (e.g. `puppeteer`,
|
|
16
|
+
* `@d-zero/beholder`).
|
|
17
|
+
* @throws {Error} When no `node_modules/<packageName>` directory is found
|
|
18
|
+
* between `fromDir` and the filesystem root.
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { findPackageDir } from './find-package-dir.js';
|
|
22
|
+
*
|
|
23
|
+
* const puppeteerDir = findPackageDir(import.meta.dirname, 'puppeteer');
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function findPackageDir(fromDir, packageName) {
|
|
27
|
+
let dir = fromDir;
|
|
28
|
+
for (;;) {
|
|
29
|
+
const candidate = path.join(dir, 'node_modules', packageName);
|
|
30
|
+
if (existsSync(path.join(candidate, 'package.json'))) {
|
|
31
|
+
return candidate;
|
|
32
|
+
}
|
|
33
|
+
const parent = path.dirname(dir);
|
|
34
|
+
if (parent === dir) {
|
|
35
|
+
throw new Error(`Could not locate "${packageName}" from ${fromDir}`);
|
|
36
|
+
}
|
|
37
|
+
dir = parent;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { TechnologySignalPartial } from '../archive/meta/technologies/types.js';
|
|
2
|
+
/** Options for {@link scanJsResourceForLicenseComment}. */
|
|
3
|
+
export interface ScanJsResourceForLicenseCommentOptions {
|
|
4
|
+
/** Byte cap on the response body read. Defaults to {@link DEFAULT_BYTE_LIMIT}. */
|
|
5
|
+
byteLimit?: number;
|
|
6
|
+
/** Network timeout in milliseconds. Defaults to {@link DEFAULT_TIMEOUT_MS}. */
|
|
7
|
+
timeout?: number;
|
|
8
|
+
/** User-Agent header to send. */
|
|
9
|
+
userAgent?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Reads up to `byteLimit` bytes from a JS resource's leading bytes and tests
|
|
13
|
+
* them against {@link TECHNOLOGY_SIGNAL_DEFINITIONS}' `js-license-comment`
|
|
14
|
+
* patterns (e.g. Vue's leading license-banner comment). Best-effort: any network
|
|
15
|
+
* failure, timeout, or non-2xx response resolves `null` rather than
|
|
16
|
+
* throwing — a single unreachable JS resource must not abort the enrichment
|
|
17
|
+
* pass over the rest of the archive's resources (see
|
|
18
|
+
* `scanJsResourcesForTechnologySignals`, this function's only caller).
|
|
19
|
+
*
|
|
20
|
+
* Not cached and not routed through `destinationCache` (unlike
|
|
21
|
+
* `fetchDestination`): callers are expected to persist the outcome in
|
|
22
|
+
* `technology_js_scan_cache`, keyed by `resourceId`, so a resource is never
|
|
23
|
+
* scanned twice across the archive's lifetime.
|
|
24
|
+
* @param url - The JS resource's absolute URL.
|
|
25
|
+
* @param options - Byte cap, timeout, and User-Agent overrides.
|
|
26
|
+
* @returns The first matching signal, or `null` when nothing matched (or
|
|
27
|
+
* the fetch failed).
|
|
28
|
+
* @example
|
|
29
|
+
* const signal = await scanJsResourceForLicenseComment('https://example.com/_astro/app.js');
|
|
30
|
+
* // { technology: 'Vue', signalType: 'js-license-comment', evidence: 'Vue.js license banner text', weight: 55, category: 'JavaScript frameworks' }
|
|
31
|
+
*/
|
|
32
|
+
export declare function scanJsResourceForLicenseComment(url: string, options?: ScanJsResourceForLicenseCommentOptions): Promise<TechnologySignalPartial | null>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import redirects from 'follow-redirects';
|
|
2
|
+
import { TECHNOLOGY_SIGNAL_DEFINITIONS } from '../archive/meta/technologies/technology-signal-definitions.js';
|
|
3
|
+
/** Default cap on bytes read from a JS resource before giving up on a match. */
|
|
4
|
+
const DEFAULT_BYTE_LIMIT = 8192;
|
|
5
|
+
/** Default network timeout, shorter than `fetchDestination`'s HEAD budget — this is a best-effort enrichment pass, not the crawl's critical path. */
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 8000;
|
|
7
|
+
const EVIDENCE_MAX_LENGTH = 200;
|
|
8
|
+
/** Definitions this scan tests against — only the `js-license-comment` signal type applies to JS resource bodies (the rest match HTML). */
|
|
9
|
+
const JS_LICENSE_COMMENT_DEFINITIONS = TECHNOLOGY_SIGNAL_DEFINITIONS.filter((def) => def.signalType === 'js-license-comment');
|
|
10
|
+
/**
|
|
11
|
+
* Reads up to `byteLimit` bytes from a JS resource's leading bytes and tests
|
|
12
|
+
* them against {@link TECHNOLOGY_SIGNAL_DEFINITIONS}' `js-license-comment`
|
|
13
|
+
* patterns (e.g. Vue's leading license-banner comment). Best-effort: any network
|
|
14
|
+
* failure, timeout, or non-2xx response resolves `null` rather than
|
|
15
|
+
* throwing — a single unreachable JS resource must not abort the enrichment
|
|
16
|
+
* pass over the rest of the archive's resources (see
|
|
17
|
+
* `scanJsResourcesForTechnologySignals`, this function's only caller).
|
|
18
|
+
*
|
|
19
|
+
* Not cached and not routed through `destinationCache` (unlike
|
|
20
|
+
* `fetchDestination`): callers are expected to persist the outcome in
|
|
21
|
+
* `technology_js_scan_cache`, keyed by `resourceId`, so a resource is never
|
|
22
|
+
* scanned twice across the archive's lifetime.
|
|
23
|
+
* @param url - The JS resource's absolute URL.
|
|
24
|
+
* @param options - Byte cap, timeout, and User-Agent overrides.
|
|
25
|
+
* @returns The first matching signal, or `null` when nothing matched (or
|
|
26
|
+
* the fetch failed).
|
|
27
|
+
* @example
|
|
28
|
+
* const signal = await scanJsResourceForLicenseComment('https://example.com/_astro/app.js');
|
|
29
|
+
* // { technology: 'Vue', signalType: 'js-license-comment', evidence: 'Vue.js license banner text', weight: 55, category: 'JavaScript frameworks' }
|
|
30
|
+
*/
|
|
31
|
+
export async function scanJsResourceForLicenseComment(url, options = {}) {
|
|
32
|
+
const byteLimit = options.byteLimit ?? DEFAULT_BYTE_LIMIT;
|
|
33
|
+
const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
34
|
+
const body = await readLeadingBytes(url, byteLimit, timeout, options.userAgent).catch(() => null);
|
|
35
|
+
if (body == null)
|
|
36
|
+
return null;
|
|
37
|
+
for (const def of JS_LICENSE_COMMENT_DEFINITIONS) {
|
|
38
|
+
const match = def.pattern.exec(body);
|
|
39
|
+
if (match) {
|
|
40
|
+
return {
|
|
41
|
+
technology: def.technology,
|
|
42
|
+
signalType: def.signalType,
|
|
43
|
+
evidence: match[0].slice(0, EVIDENCE_MAX_LENGTH),
|
|
44
|
+
weight: def.weight,
|
|
45
|
+
category: def.category ?? null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Streams up to `byteLimit` bytes of a GET response body, destroying the
|
|
53
|
+
* connection once the cap is reached (or the stream ends first). Mirrors
|
|
54
|
+
* `fetchDestination`'s `titleBytesLimit` byte-cap technique (same
|
|
55
|
+
* `follow-redirects` + manual chunk accumulation + `req.destroy()` shape),
|
|
56
|
+
* but generic over "leading bytes as text" rather than title extraction —
|
|
57
|
+
* this module has no `PageData` to build.
|
|
58
|
+
* @param url - The absolute URL to fetch.
|
|
59
|
+
* @param byteLimit - Byte cap on the accumulated body.
|
|
60
|
+
* @param timeout - Milliseconds before the request is aborted.
|
|
61
|
+
* @param userAgent - Optional User-Agent header.
|
|
62
|
+
* @returns The accumulated body text, or `null` on a non-2xx response.
|
|
63
|
+
*/
|
|
64
|
+
async function readLeadingBytes(url, byteLimit, timeout, userAgent) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const parsed = new URL(url);
|
|
67
|
+
const request = {
|
|
68
|
+
protocol: parsed.protocol,
|
|
69
|
+
hostname: parsed.hostname,
|
|
70
|
+
port: parsed.port || undefined,
|
|
71
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
72
|
+
method: 'GET',
|
|
73
|
+
trackRedirects: true,
|
|
74
|
+
headers: {
|
|
75
|
+
host: parsed.host,
|
|
76
|
+
...(userAgent ? { 'User-Agent': userAgent } : {}),
|
|
77
|
+
Accept: '*/*',
|
|
78
|
+
'Accept-Encoding': 'identity',
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
let req;
|
|
82
|
+
let destroyed = false;
|
|
83
|
+
let settled = false;
|
|
84
|
+
const timeoutHandle = setTimeout(() => {
|
|
85
|
+
if (settled)
|
|
86
|
+
return;
|
|
87
|
+
settled = true;
|
|
88
|
+
destroyed = true;
|
|
89
|
+
req.destroy();
|
|
90
|
+
resolve(null);
|
|
91
|
+
}, timeout);
|
|
92
|
+
const response = (res) => {
|
|
93
|
+
const status = res.statusCode ?? 0;
|
|
94
|
+
if (status < 200 || status >= 300) {
|
|
95
|
+
settled = true;
|
|
96
|
+
clearTimeout(timeoutHandle);
|
|
97
|
+
destroyed = true;
|
|
98
|
+
req.destroy();
|
|
99
|
+
resolve(null);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const chunks = [];
|
|
103
|
+
let totalBytes = 0;
|
|
104
|
+
res.on('data', (chunk) => {
|
|
105
|
+
if (settled)
|
|
106
|
+
return;
|
|
107
|
+
chunks.push(chunk);
|
|
108
|
+
totalBytes += chunk.length;
|
|
109
|
+
if (totalBytes >= byteLimit) {
|
|
110
|
+
settled = true;
|
|
111
|
+
clearTimeout(timeoutHandle);
|
|
112
|
+
destroyed = true;
|
|
113
|
+
req.destroy();
|
|
114
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
res.on('end', () => {
|
|
118
|
+
if (settled)
|
|
119
|
+
return;
|
|
120
|
+
settled = true;
|
|
121
|
+
clearTimeout(timeoutHandle);
|
|
122
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
if (parsed.protocol === 'https:') {
|
|
126
|
+
req = redirects.https.request(request, response);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
req = redirects.http.request(request, response);
|
|
130
|
+
}
|
|
131
|
+
req.on('error', (error) => {
|
|
132
|
+
clearTimeout(timeoutHandle);
|
|
133
|
+
// Ignore errors caused by our own intentional req.destroy() above.
|
|
134
|
+
if (destroyed)
|
|
135
|
+
return;
|
|
136
|
+
reject(error);
|
|
137
|
+
});
|
|
138
|
+
req.end();
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ArchiveAccessor } from '../archive/archive-accessor.js';
|
|
2
|
+
/** Options for {@link scanJsResourcesForTechnologySignals}. */
|
|
3
|
+
export interface ScanJsResourcesForTechnologySignalsOptions {
|
|
4
|
+
/** Maximum concurrent network fetches. Defaults to {@link DEFAULT_CONCURRENCY}. */
|
|
5
|
+
concurrency?: number;
|
|
6
|
+
/** Forwarded to `scanJsResourceForLicenseComment`. */
|
|
7
|
+
byteLimit?: number;
|
|
8
|
+
/** Forwarded to `scanJsResourceForLicenseComment`. */
|
|
9
|
+
timeout?: number;
|
|
10
|
+
/** Forwarded to `scanJsResourceForLicenseComment`. */
|
|
11
|
+
userAgent?: string;
|
|
12
|
+
/** Called after each resource finishes scanning (matched or not). */
|
|
13
|
+
onProgress?: (done: number, total: number) => void;
|
|
14
|
+
}
|
|
15
|
+
/** Outcome counters returned by {@link scanJsResourcesForTechnologySignals}. */
|
|
16
|
+
export interface ScanJsResourcesForTechnologySignalsResult {
|
|
17
|
+
/** JS resources eligible for scanning (internal, not yet cached). */
|
|
18
|
+
candidateCount: number;
|
|
19
|
+
/** Resources actually scanned this run (equals `candidateCount` barring a mid-run crash). */
|
|
20
|
+
scannedCount: number;
|
|
21
|
+
/** Resources whose leading bytes matched a known license comment. */
|
|
22
|
+
matchedCount: number;
|
|
23
|
+
/** Distinct pages whose `technology_signals` / `page_technologies` were updated as a result. */
|
|
24
|
+
pagesUpdatedCount: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Post-crawl network enrichment (distinct from crawl-time extraction and
|
|
28
|
+
* from read-model-time backfill — see ARCHITECTURE.md): re-fetches the
|
|
29
|
+
* leading bytes of every not-yet-scanned internal JS resource, tests them
|
|
30
|
+
* for a known technology's license comment, and folds any match into the
|
|
31
|
+
* referencing pages' `technology_signals` / `page_technologies`.
|
|
32
|
+
*
|
|
33
|
+
* Each resource is scanned at most once ever, across the archive's whole
|
|
34
|
+
* lifetime — outcomes (including non-matches) are recorded in
|
|
35
|
+
* `technology_js_scan_cache` keyed by `resourceId`, so a later
|
|
36
|
+
* `--append`/`--retry-failed` run only pays the network cost for resources
|
|
37
|
+
* discovered since the last run.
|
|
38
|
+
*
|
|
39
|
+
* A single resource can be referenced by many pages (a shared bundle); a
|
|
40
|
+
* match is applied to every one of them independently. Network scanning
|
|
41
|
+
* runs at bounded concurrency; the per-page DB recombination that follows
|
|
42
|
+
* runs after every scan has settled, never concurrently for the same page,
|
|
43
|
+
* so two resources that both resolve to the same page cannot race each
|
|
44
|
+
* other's read-modify-write.
|
|
45
|
+
*
|
|
46
|
+
* Best-effort like `scanJsResourceForLicenseComment`: an unreachable
|
|
47
|
+
* resource is recorded as a non-match (cached as scanned, `technology:
|
|
48
|
+
* null`) rather than retried or surfaced as an error — a flaky CDN must not
|
|
49
|
+
* block the rest of the archive's enrichment, and the resource will not be
|
|
50
|
+
* retried until `technology_js_scan_cache` itself is cleared.
|
|
51
|
+
* @param accessor - The archive to enrich.
|
|
52
|
+
* @param options - Concurrency, byte-cap, timeout, and progress overrides.
|
|
53
|
+
* @returns Counters describing what was scanned, matched, and updated.
|
|
54
|
+
* @example
|
|
55
|
+
* const result = await scanJsResourcesForTechnologySignals(archive);
|
|
56
|
+
* // { candidateCount: 42, scannedCount: 42, matchedCount: 3, pagesUpdatedCount: 57 }
|
|
57
|
+
*/
|
|
58
|
+
export declare function scanJsResourcesForTechnologySignals(accessor: ArchiveAccessor, options?: ScanJsResourcesForTechnologySignalsOptions): Promise<ScanJsResourcesForTechnologySignalsResult>;
|