@nitpicker/crawler 0.18.1 → 0.20.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 +140 -56
- 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/handle-scrape-end.js +19 -10
- package/lib/crawler/resolve-result-went-off-host.d.ts +34 -0
- package/lib/crawler/resolve-result-went-off-host.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 +34 -1
- 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';
|
|
@@ -55,6 +55,7 @@ import { PreloadShortCircuitError } from './preload-short-circuit-error.js';
|
|
|
55
55
|
import { probeNetwork } from './probe-network.js';
|
|
56
56
|
import { protocolAgnosticKey } from './protocol-agnostic-key.js';
|
|
57
57
|
import { redirectDestKey } from './redirect-dest-key.js';
|
|
58
|
+
import { resolveResultWentOffHost } from './resolve-result-went-off-host.js';
|
|
58
59
|
import { resourceToPageData } from './resource-to-page-data.js';
|
|
59
60
|
import { RobotsChecker } from './robots-checker.js';
|
|
60
61
|
import { shouldBurnHost } from './should-burn-host.js';
|
|
@@ -92,7 +93,7 @@ const DEFAULT_DEDUPE_MAP_CAP = 100_000;
|
|
|
92
93
|
* Crawling is performed concurrently using the dealer pattern, with
|
|
93
94
|
* configurable parallelism up to {@link Crawler.MAX_PROCESS_LENGTH}.
|
|
94
95
|
*/
|
|
95
|
-
class Crawler extends EventEmitter {
|
|
96
|
+
export default class Crawler extends EventEmitter {
|
|
96
97
|
/** Controller used to cancel the deal-based crawl via its AbortSignal. */
|
|
97
98
|
#abortController = new AbortController();
|
|
98
99
|
/**
|
|
@@ -556,10 +557,11 @@ class Crawler extends EventEmitter {
|
|
|
556
557
|
* queue, prioritising likely-HTML URLs to the front (see {@link partitionUrlsByHtml}).
|
|
557
558
|
* Accepts a batch so a group of URLs (e.g. predicted pagination) keeps its order.
|
|
558
559
|
* @param concurrency - Current concurrency level, used to determine predicted URL count
|
|
559
|
-
* @param precomputedBodyHash - This page's body hash,
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
560
|
+
* @param precomputedBodyHash - This page's body hash, computed once by the
|
|
561
|
+
* caller for every internal page with a rendered HTML body (see the
|
|
562
|
+
* computation site's comment) — reused here for the dedupe-cap
|
|
563
|
+
* observation, and forwarded via the `page` event so `update-page.ts`
|
|
564
|
+
* does not hash the same html again.
|
|
563
565
|
*/
|
|
564
566
|
#handleResult(result, url, enqueue, concurrency, precomputedBodyHash) {
|
|
565
567
|
switch (result.type) {
|
|
@@ -595,6 +597,14 @@ class Crawler extends EventEmitter {
|
|
|
595
597
|
const shapeKey = computeShapeKey(result.pageData.url.withoutHashAndAuth);
|
|
596
598
|
const metaSig = computeMetaSignature(result.pageData.meta);
|
|
597
599
|
if (shapeKey && metaSig) {
|
|
600
|
+
// `precomputedBodyHash` is non-null here in every reachable
|
|
601
|
+
// case: this branch's guard (`!isExternal && html.length > 0`,
|
|
602
|
+
// modulo the `isMetadataOnly` exclusion which only narrows it)
|
|
603
|
+
// is a subset of the unconditional computation site's condition
|
|
604
|
+
// above. The `??` fallback is not expected to ever fire — it is
|
|
605
|
+
// kept only as a defensive backstop against a future edit to
|
|
606
|
+
// either condition silently breaking that invariant, favouring
|
|
607
|
+
// a slow-but-correct recomputation over a crash.
|
|
598
608
|
const bodyHash = precomputedBodyHash ?? computeBodyHash(result.pageData.html);
|
|
599
609
|
const ogUrlMismatch = resolveOgUrlMismatch(result.pageData.meta, result.pageData.url.href);
|
|
600
610
|
const event = this.#dedupeCapTracker.observe({
|
|
@@ -622,13 +632,16 @@ class Crawler extends EventEmitter {
|
|
|
622
632
|
// below. Deliberately NOT also excluding `opts?.metadataOnly`
|
|
623
633
|
// (unlike the tracker's observation side, which does skip
|
|
624
634
|
// metadata-only pages — they carry no reliable signature): with
|
|
625
|
-
// `--recursive=false`,
|
|
626
|
-
// metadata-only
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
// discovery
|
|
635
|
+
// `--recursive=false`, every INTERNAL anchor that reaches this
|
|
636
|
+
// closure is metadata-only (`handle-scrape-end.ts` never issues
|
|
637
|
+
// a full-scrape `addUrl` call outside recursive mode; external
|
|
638
|
+
// anchors reach it too when `--fetch-external` is on, but those
|
|
639
|
+
// are already filtered by the scope check below), so excluding
|
|
640
|
+
// `opts?.metadataOnly` here would silently disable
|
|
641
|
+
// `--dedupe-cap` for anchor discovery whenever `--recursive=false`
|
|
642
|
+
// is set — while gate 2 (the JS-redirect direct enqueue below)
|
|
643
|
+
// has no such exclusion and would still cap the very same
|
|
644
|
+
// shape, an inconsistency between the two discovery paths.
|
|
632
645
|
if (this.#options.dedupeCap !== null &&
|
|
633
646
|
findScopeEntry(newUrl, this.#scope, this.#options) !== null) {
|
|
634
647
|
const gateShapeKey = computeShapeKey(newUrl.withoutHashAndAuth);
|
|
@@ -702,6 +715,7 @@ class Crawler extends EventEmitter {
|
|
|
702
715
|
void this.emit('page', {
|
|
703
716
|
result: result.pageData,
|
|
704
717
|
source: pageSource,
|
|
718
|
+
bodyHash: precomputedBodyHash,
|
|
705
719
|
});
|
|
706
720
|
}
|
|
707
721
|
}
|
|
@@ -740,7 +754,16 @@ class Crawler extends EventEmitter {
|
|
|
740
754
|
});
|
|
741
755
|
}
|
|
742
756
|
else {
|
|
743
|
-
|
|
757
|
+
// `pageResult` here always carries `html: ''` (`linkToPageData`'s
|
|
758
|
+
// error-fallback shape), so it never reaches `update-page.ts`'s
|
|
759
|
+
// `html.length > 0` write gate — `bodyHash: null` is explicit
|
|
760
|
+
// rather than relying on that gate to make an omitted field
|
|
761
|
+
// harmless, so this stays correct if that ever changes.
|
|
762
|
+
void this.emit('page', {
|
|
763
|
+
result: pageResult,
|
|
764
|
+
source: pageSource,
|
|
765
|
+
bodyHash: null,
|
|
766
|
+
});
|
|
744
767
|
}
|
|
745
768
|
}
|
|
746
769
|
void this.emit('error', {
|
|
@@ -906,7 +929,7 @@ class Crawler extends EventEmitter {
|
|
|
906
929
|
}
|
|
907
930
|
const concurrency = this.#options.parallels
|
|
908
931
|
? Math.max(this.#options.parallels, 1)
|
|
909
|
-
:
|
|
932
|
+
: Crawler.MAX_PROCESS_LENGTH;
|
|
910
933
|
await deal(initialUrls, (url, update, _index, setLineHeader, push, unshift) => {
|
|
911
934
|
const matchedScope = findScopeEntry(url, this.#scope, this.#options);
|
|
912
935
|
const isExternal = matchedScope === null;
|
|
@@ -961,10 +984,12 @@ class Crawler extends EventEmitter {
|
|
|
961
984
|
const markBrowserScrape = () => {
|
|
962
985
|
renderedInBrowser = true;
|
|
963
986
|
};
|
|
964
|
-
// Set
|
|
965
|
-
//
|
|
966
|
-
//
|
|
967
|
-
//
|
|
987
|
+
// Set below for every internal page with a rendered HTML body
|
|
988
|
+
// (not just predicted ones — see the computation site's comment),
|
|
989
|
+
// so both `#handleResult`'s dedupe-cap observation and the `page`
|
|
990
|
+
// event's `bodyHash` payload (ultimately consumed by
|
|
991
|
+
// `update-page.ts`'s `page_meta.body_hash` write) reuse this one
|
|
992
|
+
// value instead of each hashing the same html again.
|
|
968
993
|
let precomputedBodyHash = null;
|
|
969
994
|
try {
|
|
970
995
|
const robotsAllowed = await this.#robotsChecker.isAllowed(url);
|
|
@@ -1104,30 +1129,41 @@ class Crawler extends EventEmitter {
|
|
|
1104
1129
|
log(c.dim('Predicted (discarded)'));
|
|
1105
1130
|
return;
|
|
1106
1131
|
}
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
//
|
|
1111
|
-
//
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
|
|
1116
|
-
|
|
1132
|
+
// Compute this page's body hash once, up front, for every
|
|
1133
|
+
// internal page with a rendered HTML body — not just predicted
|
|
1134
|
+
// ones. This condition intentionally mirrors `update-page.ts`'s
|
|
1135
|
+
// `writeHtml && page.html.length > 0` write gate (internal pages
|
|
1136
|
+
// are exactly the ones `setPage` — as opposed to
|
|
1137
|
+
// `setExternalPage` — writes a body through), so the value
|
|
1138
|
+
// computed here can be forwarded through the `page` event all
|
|
1139
|
+
// the way to that write and reused there instead of hashing the
|
|
1140
|
+
// same html a second time.
|
|
1141
|
+
if (result.type === 'success' &&
|
|
1117
1142
|
result.pageData &&
|
|
1143
|
+
!result.pageData.isExternal &&
|
|
1118
1144
|
result.pageData.html.length > 0) {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1145
|
+
precomputedBodyHash = computeBodyHash(result.pageData.html);
|
|
1146
|
+
// Discard a predicted URL whose rendered body is a
|
|
1147
|
+
// byte-for-byte duplicate of the previous predicted page of
|
|
1148
|
+
// the same shape, and stop generating further predictions for
|
|
1149
|
+
// that shape (checked above, in the pagination-pattern
|
|
1150
|
+
// branch). This is the always-on backstop against a site
|
|
1151
|
+
// that returns 2xx for any extrapolated token but ignores it
|
|
1152
|
+
// entirely (e.g. always serving the same "no results"
|
|
1153
|
+
// template) — `shouldDiscardPredicted` alone cannot see
|
|
1154
|
+
// this, since it only inspects HTTP status.
|
|
1155
|
+
if (isPredicted) {
|
|
1156
|
+
const shapeKey = computeShapeKey(url.withoutHashAndAuth);
|
|
1157
|
+
if (shapeKey) {
|
|
1158
|
+
const lastBodyHash = this.#predictedShapeBodyHashes.get(shapeKey) ?? null;
|
|
1159
|
+
if (isPredictedContentDuplicate(precomputedBodyHash, lastBodyHash)) {
|
|
1160
|
+
this.#predictedShapeStopped.add(shapeKey);
|
|
1161
|
+
handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
|
|
1162
|
+
log(c.dim('Predicted (content duplicate, discarded)'));
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
this.#predictedShapeBodyHashes.set(shapeKey, precomputedBodyHash);
|
|
1129
1166
|
}
|
|
1130
|
-
this.#predictedShapeBodyHashes.set(shapeKey, bodyHash);
|
|
1131
1167
|
}
|
|
1132
1168
|
}
|
|
1133
1169
|
// Count only after discard check: rendered HTML pages that
|
|
@@ -1139,9 +1175,34 @@ class Crawler extends EventEmitter {
|
|
|
1139
1175
|
}
|
|
1140
1176
|
log('Saving results%dots%');
|
|
1141
1177
|
this.#handleResult(result, url, enqueue, concurrency, precomputedBodyHash);
|
|
1142
|
-
|
|
1143
|
-
this
|
|
1144
|
-
|
|
1178
|
+
// Skip sub-resources / console logs for a result that turned out
|
|
1179
|
+
// external — NOT the same as this worker's own `isExternal`
|
|
1180
|
+
// (computed from `url` before navigation). Beholder decides
|
|
1181
|
+
// `isExternal: false` before navigating and only flips it to
|
|
1182
|
+
// `true` after seeing the destination's hostname, so a
|
|
1183
|
+
// same-host source that redirects cross-host still has its
|
|
1184
|
+
// request/response/console listeners attached under the
|
|
1185
|
+
// pre-navigation `isExternal: false` for the whole trip. Those
|
|
1186
|
+
// listeners keep capturing the destination's sub-resources and
|
|
1187
|
+
// console output even after the flip, so without this guard a
|
|
1188
|
+
// cross-host redirect leaks the OFF-SCOPE destination's data
|
|
1189
|
+
// into this archive: its console output would be recorded as
|
|
1190
|
+
// this page's quality signal, and its resources would leave a
|
|
1191
|
+
// `resource_ref_edges` row on the (now content-less) redirect
|
|
1192
|
+
// SOURCE — `linkRedirectSources` deletes that source's
|
|
1193
|
+
// `anchor_edges` / `image_items` but not `resource_ref_edges`.
|
|
1194
|
+
// A genuinely external URL never reaches this branch with
|
|
1195
|
+
// non-empty `resources` / `consoleLogs` in the first place —
|
|
1196
|
+
// beholder never attaches these listeners for one, per the
|
|
1197
|
+
// `isExternal` gate in `#fetchData` — so this guard is a no-op
|
|
1198
|
+
// outside the cross-host-redirect case. See
|
|
1199
|
+
// `resolveResultWentOffHost`'s JSDoc for how it answers this for
|
|
1200
|
+
// a `type: 'error'` result, which has no `pageData` to read.
|
|
1201
|
+
if (!resolveResultWentOffHost(result, url)) {
|
|
1202
|
+
const parentSource = await this.#resolveParentSource(url);
|
|
1203
|
+
this.#handleResources(result.resources, parentSource);
|
|
1204
|
+
this.#handleConsoleLogs(result.consoleLogs, url, result.pageData?.redirectPaths ?? []);
|
|
1205
|
+
}
|
|
1145
1206
|
log(formatResultSummary(result));
|
|
1146
1207
|
// Phase errors must be emitted AFTER 'page' / 'externalPage'
|
|
1147
1208
|
// so the orchestrator's WriteQueue sees `setPage` before
|
|
@@ -1875,17 +1936,42 @@ class Crawler extends EventEmitter {
|
|
|
1875
1936
|
// `image_items.dom_path_text_id` resolution at write time; a
|
|
1876
1937
|
// capture failure (or a page with no images) falls back to the
|
|
1877
1938
|
// synthetic `unknown/<n>` markers, so this stays best-effort.
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1939
|
+
// Image dom-path capture and custom-element (Web Component)
|
|
1940
|
+
// capture are two independent `page.evaluate()` round-trips over
|
|
1941
|
+
// the same still-alive `page` — neither reads the other's
|
|
1942
|
+
// output, so they run concurrently instead of paying both
|
|
1943
|
+
// round-trips' latency in sequence. Each keeps its own
|
|
1944
|
+
// pre-existing gate (image dom-paths on a non-empty imageList,
|
|
1945
|
+
// custom elements on a resolved main-content region — unrelated
|
|
1946
|
+
// to each other, so neither gate is relaxed by running together).
|
|
1947
|
+
// Custom-element capture exists because beholder's
|
|
1948
|
+
// `MainContentsData` has no `customElements` category, so
|
|
1949
|
+
// nitpicker captures it itself.
|
|
1950
|
+
const shouldCaptureImageDomPaths = result.type === 'success' &&
|
|
1951
|
+
result.pageData !== undefined &&
|
|
1952
|
+
result.pageData.imageList.length > 0;
|
|
1953
|
+
const shouldCaptureCustomElements = result.type === 'success' &&
|
|
1954
|
+
result.pageData !== undefined &&
|
|
1955
|
+
result.pageData.mainContents !== null;
|
|
1956
|
+
const [imageDomPaths, mainContentCustomElements] = await Promise.all([
|
|
1957
|
+
shouldCaptureImageDomPaths ? captureImageDomPaths(page) : undefined,
|
|
1958
|
+
shouldCaptureCustomElements
|
|
1959
|
+
? captureCustomElements(page, this.#options.mainContentSelector)
|
|
1960
|
+
: undefined,
|
|
1961
|
+
]);
|
|
1962
|
+
if (imageDomPaths !== undefined && result.pageData) {
|
|
1963
|
+
const withDomPaths = {
|
|
1964
|
+
...result.pageData,
|
|
1965
|
+
imageDomPaths,
|
|
1966
|
+
};
|
|
1967
|
+
result.pageData = withDomPaths;
|
|
1968
|
+
}
|
|
1969
|
+
if (mainContentCustomElements !== undefined && result.pageData) {
|
|
1970
|
+
const withCustomElements = {
|
|
1971
|
+
...result.pageData,
|
|
1972
|
+
mainContentCustomElements,
|
|
1973
|
+
};
|
|
1974
|
+
result.pageData = withCustomElements;
|
|
1889
1975
|
}
|
|
1890
1976
|
update('Closing browser%dots%');
|
|
1891
1977
|
// JS-redirect rescue capture: when `scrapeStart` catches a
|
|
@@ -1970,8 +2056,6 @@ class Crawler extends EventEmitter {
|
|
|
1970
2056
|
*/
|
|
1971
2057
|
static MAX_PROCESS_LENGTH = 10;
|
|
1972
2058
|
}
|
|
1973
|
-
_a = Crawler;
|
|
1974
|
-
export default Crawler;
|
|
1975
2059
|
/**
|
|
1976
2060
|
* Colorize an HTTP status code string for terminal display.
|
|
1977
2061
|
*
|
|
@@ -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
|
+
}
|
|
@@ -40,9 +40,12 @@ export function handleScrapeEnd(result, linkList, scope, options, addUrl) {
|
|
|
40
40
|
* deepest matching scope.
|
|
41
41
|
* 2. For internal anchors without credentials, inherits auth from the matched
|
|
42
42
|
* scope and rebuilds `withoutHash` with the injected auth.
|
|
43
|
-
* 3.
|
|
44
|
-
*
|
|
45
|
-
* 4. In
|
|
43
|
+
* 3. External anchors are skipped entirely when `fetchExternal` is off — this
|
|
44
|
+
* gate applies in both recursive and non-recursive mode.
|
|
45
|
+
* 4. In recursive mode: enqueues internal anchors for full scraping, and
|
|
46
|
+
* external anchors (when not skipped by 3) for metadata-only scraping.
|
|
47
|
+
* 5. In non-recursive mode: enqueues every anchor that survives gate 3 for
|
|
48
|
+
* metadata-only scraping, internal or not.
|
|
46
49
|
* @param anchors - The list of anchor data extracted from the page.
|
|
47
50
|
* @param scope - Map of hostnames to their scope URLs.
|
|
48
51
|
* @param options - Crawler configuration options.
|
|
@@ -69,13 +72,19 @@ function processAnchors(anchors, scope, options, addUrl) {
|
|
|
69
72
|
const withoutHash = `${anchor.href.protocol}//${auth}${host}${body ? `/${body}` : ''}`;
|
|
70
73
|
anchor.href.withoutHash = withoutHash;
|
|
71
74
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
// `fetchExternal` gates external anchors the same way in both modes —
|
|
76
|
+
// checked once, ahead of the recursive/non-recursive split below,
|
|
77
|
+
// rather than only inside the recursive branch. Non-recursive
|
|
78
|
+
// discovery has no other point that enforces `fetchExternal`: every
|
|
79
|
+
// anchor that reaches the split falls straight into the unconditional
|
|
80
|
+
// metadata-only `addUrl` call, so scoping the check to the recursive
|
|
81
|
+
// branch alone would make `--list` / `--single` / `--no-recursive`
|
|
82
|
+
// unable to honour `--no-fetch-external` at all.
|
|
83
|
+
if (!matchedScope && !options.fetchExternal) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (options.recursive && matchedScope) {
|
|
87
|
+
addUrl(anchor.href);
|
|
79
88
|
continue;
|
|
80
89
|
}
|
|
81
90
|
addUrl(anchor.href, { metadataOnly: true });
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { BrowserScrapeResult } from './types.js';
|
|
2
|
+
import type { ExURL } from '@d-zero/shared/parse-url';
|
|
3
|
+
/**
|
|
4
|
+
* Determines whether a scrape result's actual destination ended up on a
|
|
5
|
+
* different host than the URL that was requested.
|
|
6
|
+
*
|
|
7
|
+
* Exists for the cross-host-redirect leak guard in `Crawler`'s worker body:
|
|
8
|
+
* beholder decides `isExternal: false` before navigating and only flips it
|
|
9
|
+
* to `true` after seeing the destination's hostname, so a same-host source
|
|
10
|
+
* that redirects cross-host still has its sub-resource / console listeners
|
|
11
|
+
* attached under the pre-navigation `isExternal: false` for the whole trip
|
|
12
|
+
* — the caller uses this function's answer to decide whether to discard
|
|
13
|
+
* what those listeners captured.
|
|
14
|
+
*
|
|
15
|
+
* `pageData` (present on `type: 'success'`) carries the authoritative
|
|
16
|
+
* post-navigation `isExternal` and is used directly. A `type: 'error'`
|
|
17
|
+
* result has no `pageData`, so `postNavigationUrl` — the puppeteer-side
|
|
18
|
+
* `page.url()` captured by the JS-redirect rescue (see `BrowserScrapeResult`
|
|
19
|
+
* JSDoc) — is used as the fallback signal instead. When neither is
|
|
20
|
+
* available (e.g. the browser context died before `page.url()` could be
|
|
21
|
+
* read, or the result is `type: 'skipped'`, which carries neither field),
|
|
22
|
+
* this returns `false` — the alternative would discard legitimate
|
|
23
|
+
* console/resource data for the overwhelmingly common same-host case on
|
|
24
|
+
* the mere possibility of a leak this function has no evidence for.
|
|
25
|
+
* @param result - The non-redirect-edge scrape outcome.
|
|
26
|
+
* @param url - The originally-requested URL.
|
|
27
|
+
* @returns `true` when the browser is known to have ended up off-host.
|
|
28
|
+
* @example
|
|
29
|
+
* const wentOffHost = resolveResultWentOffHost(result, url);
|
|
30
|
+
* if (!wentOffHost) {
|
|
31
|
+
* handleResources(result.resources, parentSource);
|
|
32
|
+
* }
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveResultWentOffHost(result: BrowserScrapeResult, url: ExURL): boolean;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
|
|
2
|
+
/**
|
|
3
|
+
* Determines whether a scrape result's actual destination ended up on a
|
|
4
|
+
* different host than the URL that was requested.
|
|
5
|
+
*
|
|
6
|
+
* Exists for the cross-host-redirect leak guard in `Crawler`'s worker body:
|
|
7
|
+
* beholder decides `isExternal: false` before navigating and only flips it
|
|
8
|
+
* to `true` after seeing the destination's hostname, so a same-host source
|
|
9
|
+
* that redirects cross-host still has its sub-resource / console listeners
|
|
10
|
+
* attached under the pre-navigation `isExternal: false` for the whole trip
|
|
11
|
+
* — the caller uses this function's answer to decide whether to discard
|
|
12
|
+
* what those listeners captured.
|
|
13
|
+
*
|
|
14
|
+
* `pageData` (present on `type: 'success'`) carries the authoritative
|
|
15
|
+
* post-navigation `isExternal` and is used directly. A `type: 'error'`
|
|
16
|
+
* result has no `pageData`, so `postNavigationUrl` — the puppeteer-side
|
|
17
|
+
* `page.url()` captured by the JS-redirect rescue (see `BrowserScrapeResult`
|
|
18
|
+
* JSDoc) — is used as the fallback signal instead. When neither is
|
|
19
|
+
* available (e.g. the browser context died before `page.url()` could be
|
|
20
|
+
* read, or the result is `type: 'skipped'`, which carries neither field),
|
|
21
|
+
* this returns `false` — the alternative would discard legitimate
|
|
22
|
+
* console/resource data for the overwhelmingly common same-host case on
|
|
23
|
+
* the mere possibility of a leak this function has no evidence for.
|
|
24
|
+
* @param result - The non-redirect-edge scrape outcome.
|
|
25
|
+
* @param url - The originally-requested URL.
|
|
26
|
+
* @returns `true` when the browser is known to have ended up off-host.
|
|
27
|
+
* @example
|
|
28
|
+
* const wentOffHost = resolveResultWentOffHost(result, url);
|
|
29
|
+
* if (!wentOffHost) {
|
|
30
|
+
* handleResources(result.resources, parentSource);
|
|
31
|
+
* }
|
|
32
|
+
*/
|
|
33
|
+
export function resolveResultWentOffHost(result, url) {
|
|
34
|
+
if (result.pageData !== undefined) {
|
|
35
|
+
return result.pageData.isExternal;
|
|
36
|
+
}
|
|
37
|
+
return (result.postNavigationUrl !== undefined &&
|
|
38
|
+
parseUrl(result.postNavigationUrl)?.hostname !== url.hostname);
|
|
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>;
|