@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.
Files changed (138) 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 +101 -46
  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/scan-js-resource-for-license-comment.d.ts +32 -0
  115. package/lib/crawler/scan-js-resource-for-license-comment.js +140 -0
  116. package/lib/crawler/scan-js-resources-for-technology-signals.d.ts +58 -0
  117. package/lib/crawler/scan-js-resources-for-technology-signals.js +196 -0
  118. package/lib/crawler/types.d.ts +23 -0
  119. package/lib/crawler-orchestrator.d.ts +53 -9
  120. package/lib/crawler-orchestrator.js +223 -46
  121. package/lib/crawler.d.ts +11 -1
  122. package/lib/crawler.js +9 -0
  123. package/lib/inventory-setup-phases.d.ts +32 -0
  124. package/lib/inventory-setup-phases.js +45 -0
  125. package/lib/resume-setup-phases.d.ts +16 -0
  126. package/lib/resume-setup-phases.js +24 -0
  127. package/lib/retry-failed-setup-phases.d.ts +19 -0
  128. package/lib/retry-failed-setup-phases.js +24 -0
  129. package/lib/setup-recovery-phase-labels.d.ts +21 -0
  130. package/lib/setup-recovery-phase-labels.js +24 -0
  131. package/lib/types.d.ts +145 -0
  132. package/package.json +11 -8
  133. package/lib/archive/db-ops/meta/get-tags-of-page.d.ts +0 -12
  134. package/lib/archive/db-ops/meta/get-tags-of-page.js +0 -28
  135. package/lib/archive/db-ops/pages/write/insert-tags.d.ts +0 -16
  136. package/lib/archive/db-ops/pages/write/insert-tags.js +0 -34
  137. package/lib/archive/meta/summarize-tags.d.ts +0 -16
  138. 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';
@@ -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, 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.
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
- void this.emit('page', { result: pageResult, source: pageSource });
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
- : _a.MAX_PROCESS_LENGTH;
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 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.
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
- // 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' &&
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
- 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;
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
- 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
- }
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. 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
+ }
@@ -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>;