@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.
Files changed (141) hide show
  1. package/lib/append-retry-failed-common-setup-phases.d.ts +15 -0
  2. package/lib/append-retry-failed-common-setup-phases.js +21 -0
  3. package/lib/append-setup-phases.d.ts +17 -0
  4. package/lib/append-setup-phases.js +22 -0
  5. package/lib/archive/archive-accessor.d.ts +31 -5
  6. package/lib/archive/archive-accessor.js +46 -24
  7. package/lib/archive/archive.d.ts +115 -37
  8. package/lib/archive/archive.js +118 -52
  9. package/lib/archive/cache/extract-archive-to-cache.d.ts +10 -1
  10. package/lib/archive/cache/extract-archive-to-cache.js +14 -4
  11. package/lib/archive/create-adjunct-tables.d.ts +12 -2
  12. package/lib/archive/create-adjunct-tables.js +91 -29
  13. package/lib/archive/create-entity-tables.d.ts +5 -1
  14. package/lib/archive/create-entity-tables.js +6 -1
  15. package/lib/archive/database.d.ts +39 -9
  16. package/lib/archive/database.js +63 -18
  17. package/lib/archive/db-ops/lifecycle/init.d.ts +14 -2
  18. package/lib/archive/db-ops/lifecycle/init.js +29 -11
  19. package/lib/archive/db-ops/meta/get-custom-elements-of-page.d.ts +11 -0
  20. package/lib/archive/db-ops/meta/get-custom-elements-of-page.js +15 -0
  21. package/lib/archive/db-ops/meta/get-page-technologies-of-page.d.ts +11 -0
  22. package/lib/archive/db-ops/meta/get-page-technologies-of-page.js +15 -0
  23. package/lib/archive/db-ops/meta/get-technology-signals-of-page.d.ts +11 -0
  24. package/lib/archive/db-ops/meta/get-technology-signals-of-page.js +15 -0
  25. package/lib/archive/db-ops/pages/order/set-url-order.d.ts +6 -1
  26. package/lib/archive/db-ops/pages/order/set-url-order.js +7 -1
  27. package/lib/archive/db-ops/pages/read/build-page-query.js +1 -1
  28. package/lib/archive/db-ops/pages/reset/repromote-external-pages.d.ts +6 -1
  29. package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +12 -3
  30. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +6 -1
  31. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +10 -2
  32. package/lib/archive/db-ops/pages/write/insert-custom-elements.d.ts +27 -0
  33. package/lib/archive/db-ops/pages/write/insert-custom-elements.js +40 -0
  34. package/lib/archive/db-ops/pages/write/insert-page.d.ts +4 -4
  35. package/lib/archive/db-ops/pages/write/insert-page.js +3 -2
  36. package/lib/archive/db-ops/pages/write/insert-technologies.d.ts +25 -0
  37. package/lib/archive/db-ops/pages/write/insert-technologies.js +45 -0
  38. package/lib/archive/db-ops/pages/write/update-page.d.ts +4 -1
  39. package/lib/archive/db-ops/pages/write/update-page.js +38 -16
  40. package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +12 -1
  41. package/lib/archive/db-ops/resources/get-resource-url-list.js +43 -5
  42. package/lib/archive/filesystem/copy-file-with-progress.d.ts +20 -0
  43. package/lib/archive/filesystem/copy-file-with-progress.js +34 -0
  44. package/lib/archive/filesystem/parse-pax-path.d.ts +18 -0
  45. package/lib/archive/filesystem/parse-pax-path.js +47 -0
  46. package/lib/archive/filesystem/parse-tar-size-field.d.ts +13 -0
  47. package/lib/archive/filesystem/parse-tar-size-field.js +40 -0
  48. package/lib/archive/filesystem/peek-tar-top-dir.d.ts +10 -3
  49. package/lib/archive/filesystem/peek-tar-top-dir.js +166 -16
  50. package/lib/archive/filesystem/tar.d.ts +13 -1
  51. package/lib/archive/filesystem/tar.js +63 -7
  52. package/lib/archive/filesystem/untar.d.ts +13 -0
  53. package/lib/archive/filesystem/untar.js +68 -8
  54. package/lib/archive/init-schema.d.ts +1 -1
  55. package/lib/archive/init-schema.js +6 -4
  56. package/lib/archive/meta/compute-main-contents-denormalized.d.ts +13 -2
  57. package/lib/archive/meta/compute-main-contents-denormalized.js +15 -2
  58. package/lib/archive/meta/technologies/combine-technology-confidence.d.ts +46 -0
  59. package/lib/archive/meta/technologies/combine-technology-confidence.js +93 -0
  60. package/lib/archive/meta/technologies/convert-legacy-page-tags-to-inserts.d.ts +45 -0
  61. package/lib/archive/meta/technologies/convert-legacy-page-tags-to-inserts.js +53 -0
  62. package/lib/archive/meta/technologies/convert-tag-row-to-wappalyzer-signal.d.ts +33 -0
  63. package/lib/archive/meta/technologies/convert-tag-row-to-wappalyzer-signal.js +46 -0
  64. package/lib/archive/meta/technologies/extract-technologies-for-archive.d.ts +30 -0
  65. package/lib/archive/meta/technologies/extract-technologies-for-archive.js +27 -0
  66. package/lib/archive/meta/technologies/match-structural-technology-signals.d.ts +13 -0
  67. package/lib/archive/meta/technologies/match-structural-technology-signals.js +44 -0
  68. package/lib/archive/meta/technologies/normalize-wappalyzer-entries.d.ts +25 -0
  69. package/lib/archive/meta/technologies/normalize-wappalyzer-entries.js +35 -0
  70. package/lib/archive/meta/technologies/technology-signal-definitions.d.ts +43 -0
  71. package/lib/archive/meta/technologies/technology-signal-definitions.js +193 -0
  72. package/lib/archive/meta/technologies/types.d.ts +68 -0
  73. package/lib/archive/meta/technologies/types.js +13 -0
  74. package/lib/archive/meta/types.d.ts +97 -30
  75. package/lib/archive/migrate-content-items-alias-of-id.d.ts +6 -1
  76. package/lib/archive/migrate-content-items-alias-of-id.js +14 -3
  77. package/lib/archive/migrate-content-items-dedupe-cap-event-id.d.ts +6 -1
  78. package/lib/archive/migrate-content-items-dedupe-cap-event-id.js +14 -3
  79. package/lib/archive/migrate-info-main-content-selector.d.ts +6 -1
  80. package/lib/archive/migrate-info-main-content-selector.js +14 -3
  81. package/lib/archive/migrate-info-roots.d.ts +6 -1
  82. package/lib/archive/migrate-info-roots.js +14 -3
  83. package/lib/archive/migrate-inventory-runs-exclude-skipped.d.ts +6 -1
  84. package/lib/archive/migrate-inventory-runs-exclude-skipped.js +14 -3
  85. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +6 -1
  86. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +14 -3
  87. package/lib/archive/migrate-main-contents-columns.d.ts +6 -1
  88. package/lib/archive/migrate-main-contents-columns.js +14 -3
  89. package/lib/archive/migrate-page-meta-body-hash.d.ts +6 -1
  90. package/lib/archive/migrate-page-meta-body-hash.js +14 -3
  91. package/lib/archive/migrate-page-meta-console-error-count.d.ts +6 -1
  92. package/lib/archive/migrate-page-meta-console-error-count.js +14 -3
  93. package/lib/archive/migrate-page-meta-custom-element-count.d.ts +33 -0
  94. package/lib/archive/migrate-page-meta-custom-element-count.js +51 -0
  95. package/lib/archive/migrate-page-tags-to-page-technologies.d.ts +39 -0
  96. package/lib/archive/migrate-page-tags-to-page-technologies.js +79 -0
  97. package/lib/archive/page.d.ts +37 -13
  98. package/lib/archive/page.js +45 -17
  99. package/lib/archive/retarget-legacy-fk-tables.d.ts +27 -14
  100. package/lib/archive/retarget-legacy-fk-tables.js +61 -15
  101. package/lib/archive/types.d.ts +18 -0
  102. package/lib/crawler/assert-puppeteer-shared-with-beholder.d.ts +24 -0
  103. package/lib/crawler/assert-puppeteer-shared-with-beholder.js +36 -0
  104. package/lib/crawler/capture-custom-elements.d.ts +33 -0
  105. package/lib/crawler/capture-custom-elements.js +39 -0
  106. package/lib/crawler/close-browser-safely.d.ts +3 -3
  107. package/lib/crawler/close-browser-safely.js +8 -17
  108. package/lib/crawler/collect-custom-elements.d.ts +33 -0
  109. package/lib/crawler/collect-custom-elements.js +110 -0
  110. package/lib/crawler/crawler.js +140 -56
  111. package/lib/crawler/fetch-destination.js +6 -13
  112. package/lib/crawler/find-package-dir.d.ts +24 -0
  113. package/lib/crawler/find-package-dir.js +39 -0
  114. package/lib/crawler/handle-scrape-end.js +19 -10
  115. package/lib/crawler/resolve-result-went-off-host.d.ts +34 -0
  116. package/lib/crawler/resolve-result-went-off-host.js +39 -0
  117. package/lib/crawler/scan-js-resource-for-license-comment.d.ts +32 -0
  118. package/lib/crawler/scan-js-resource-for-license-comment.js +140 -0
  119. package/lib/crawler/scan-js-resources-for-technology-signals.d.ts +58 -0
  120. package/lib/crawler/scan-js-resources-for-technology-signals.js +196 -0
  121. package/lib/crawler/types.d.ts +34 -1
  122. package/lib/crawler-orchestrator.d.ts +53 -9
  123. package/lib/crawler-orchestrator.js +223 -46
  124. package/lib/crawler.d.ts +11 -1
  125. package/lib/crawler.js +9 -0
  126. package/lib/inventory-setup-phases.d.ts +32 -0
  127. package/lib/inventory-setup-phases.js +45 -0
  128. package/lib/resume-setup-phases.d.ts +16 -0
  129. package/lib/resume-setup-phases.js +24 -0
  130. package/lib/retry-failed-setup-phases.d.ts +19 -0
  131. package/lib/retry-failed-setup-phases.js +24 -0
  132. package/lib/setup-recovery-phase-labels.d.ts +21 -0
  133. package/lib/setup-recovery-phase-labels.js +24 -0
  134. package/lib/types.d.ts +145 -0
  135. package/package.json +11 -8
  136. package/lib/archive/db-ops/meta/get-tags-of-page.d.ts +0 -12
  137. package/lib/archive/db-ops/meta/get-tags-of-page.js +0 -28
  138. package/lib/archive/db-ops/pages/write/insert-tags.d.ts +0 -16
  139. package/lib/archive/db-ops/pages/write/insert-tags.js +0 -34
  140. package/lib/archive/meta/summarize-tags.d.ts +0 -16
  141. package/lib/archive/meta/summarize-tags.js +0 -33
@@ -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, if the caller already
560
- * computed it (the predicted-content-duplicate check, A-3, computes it for
561
- * every predicted page regardless of `--dedupe-cap`) — reused for the
562
- * dedupe-cap observation below instead of hashing the same html twice.
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`, `handle-scrape-end.ts` marks EVERY anchor
626
- // metadata-only, internal or not, so excluding them here would
627
- // silently disable `--dedupe-cap` for anchor discovery whenever
628
- // `--recursive=false` is set while gate 2 (the JS-redirect
629
- // direct enqueue below) has no such exclusion and would still
630
- // cap the very same shape, an inconsistency between the two
631
- // discovery paths.
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
- void this.emit('page', { result: pageResult, source: pageSource });
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
- : _a.MAX_PROCESS_LENGTH;
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 by the predicted-content-duplicate check below (A-3) when it
965
- // computes this page's body hash, so `#handleResult`'s dedupe-cap
966
- // observation (also gated on this page's html) can reuse it instead
967
- // of hashing the same html a second time.
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
- // Discard a predicted URL whose rendered body is a
1108
- // byte-for-byte duplicate of the previous predicted page of the
1109
- // same shape, and stop generating further predictions for that
1110
- // shape (checked above, in the pagination-pattern branch). This
1111
- // is the always-on backstop against a site that returns 2xx for
1112
- // any extrapolated token but ignores it entirely (e.g. always
1113
- // serving the same "no results" template) `shouldDiscardPredicted`
1114
- // alone cannot see this, since it only inspects HTTP status.
1115
- if (isPredicted &&
1116
- result.type === 'success' &&
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
- const shapeKey = computeShapeKey(url.withoutHashAndAuth);
1120
- if (shapeKey) {
1121
- const bodyHash = computeBodyHash(result.pageData.html);
1122
- precomputedBodyHash = bodyHash;
1123
- const lastBodyHash = this.#predictedShapeBodyHashes.get(shapeKey) ?? null;
1124
- if (isPredictedContentDuplicate(bodyHash, lastBodyHash)) {
1125
- this.#predictedShapeStopped.add(shapeKey);
1126
- handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
1127
- log(c.dim('Predicted (content duplicate, discarded)'));
1128
- return;
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
- const parentSource = await this.#resolveParentSource(url);
1143
- this.#handleResources(result.resources, parentSource);
1144
- this.#handleConsoleLogs(result.consoleLogs, url, result.pageData?.redirectPaths ?? []);
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
- if (result.type === 'success' &&
1879
- result.pageData &&
1880
- result.pageData.imageList.length > 0) {
1881
- const imageDomPaths = await captureImageDomPaths(page);
1882
- if (imageDomPaths !== undefined) {
1883
- const withDomPaths = {
1884
- ...result.pageData,
1885
- imageDomPaths,
1886
- };
1887
- result.pageData = withDomPaths;
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. The losing timer is cleared
38
- // explicitly so it never keeps the event loop alive after the race settles
39
- // (a plain `delay()` in `Promise.race` would leak the timer until it fires).
40
- let timeoutHandle;
41
- const result = await Promise.race([
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. In recursive mode: enqueues internal anchors for full scraping, and
44
- * external anchors for metadata-only scraping (when `fetchExternal` is on).
45
- * 4. In non-recursive mode: enqueues every anchor for metadata-only scraping.
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
- if (options.recursive) {
73
- if (matchedScope) {
74
- addUrl(anchor.href);
75
- }
76
- else if (options.fetchExternal) {
77
- addUrl(anchor.href, { metadataOnly: true });
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>;