@nitpicker/crawler 0.9.0 → 0.12.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 (148) hide show
  1. package/lib/archive/archive-accessor.d.ts +87 -14
  2. package/lib/archive/archive-accessor.js +162 -36
  3. package/lib/archive/archive.d.ts +262 -24
  4. package/lib/archive/archive.js +397 -86
  5. package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
  6. package/lib/archive/cache/compute-archive-cache-key.js +95 -0
  7. package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
  8. package/lib/archive/cache/extract-archive-to-cache.js +309 -0
  9. package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
  10. package/lib/archive/cache/get-archive-cache-root.js +53 -0
  11. package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
  12. package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
  13. package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
  14. package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
  15. package/lib/archive/database.d.ts +411 -26
  16. package/lib/archive/database.js +1881 -540
  17. package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
  18. package/lib/archive/derive-lineage-from-parent.js +42 -0
  19. package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
  20. package/lib/archive/filesystem/peek-tar-top-dir.js +65 -0
  21. package/lib/archive/get-failed-page-messages.d.ts +43 -0
  22. package/lib/archive/get-failed-page-messages.js +131 -0
  23. package/lib/archive/init-schema.d.ts +53 -2
  24. package/lib/archive/init-schema.js +399 -15
  25. package/lib/archive/is-inventory-source.d.ts +21 -0
  26. package/lib/archive/is-inventory-source.js +22 -0
  27. package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
  28. package/lib/archive/meta/assert-compatible-version.js +72 -0
  29. package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
  30. package/lib/archive/meta/classify-jsonld-type.js +43 -0
  31. package/lib/archive/meta/compare-semver.d.ts +23 -0
  32. package/lib/archive/meta/compare-semver.js +51 -0
  33. package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
  34. package/lib/archive/meta/compute-page-denormalized.js +35 -0
  35. package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
  36. package/lib/archive/meta/derive-flat-from-meta.js +158 -0
  37. package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
  38. package/lib/archive/meta/derive-meta-extras.js +23 -0
  39. package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
  40. package/lib/archive/meta/extract-tags-for-archive.js +36 -0
  41. package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
  42. package/lib/archive/meta/summarize-jsonld.js +29 -0
  43. package/lib/archive/meta/summarize-tags.d.ts +16 -0
  44. package/lib/archive/meta/summarize-tags.js +33 -0
  45. package/lib/archive/meta/types.d.ts +207 -0
  46. package/lib/archive/meta/types.js +33 -0
  47. package/lib/archive/migrate-crawl-errors.d.ts +20 -0
  48. package/lib/archive/migrate-crawl-errors.js +38 -0
  49. package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
  50. package/lib/archive/migrate-html-blob-tables.js +53 -0
  51. package/lib/archive/migrate-inventory-runs.d.ts +29 -0
  52. package/lib/archive/migrate-inventory-runs.js +52 -0
  53. package/lib/archive/migrate-page-errors.d.ts +16 -0
  54. package/lib/archive/migrate-page-errors.js +35 -0
  55. package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
  56. package/lib/archive/migrate-pages-resources-source.js +46 -0
  57. package/lib/archive/page.d.ts +187 -49
  58. package/lib/archive/page.js +258 -63
  59. package/lib/archive/peek-archive-lock.d.ts +40 -0
  60. package/lib/archive/peek-archive-lock.js +62 -0
  61. package/lib/archive/resolve-redirect-chain.d.ts +33 -0
  62. package/lib/archive/resolve-redirect-chain.js +27 -0
  63. package/lib/archive/types.d.ts +168 -26
  64. package/lib/classify-error-kind.d.ts +19 -0
  65. package/lib/classify-error-kind.js +122 -0
  66. package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
  67. package/lib/crawler/build-js-redirect-edge.js +57 -0
  68. package/lib/crawler/build-redirect-event.d.ts +24 -0
  69. package/lib/crawler/build-redirect-event.js +28 -0
  70. package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
  71. package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
  72. package/lib/crawler/close-browser-safely.d.ts +64 -0
  73. package/lib/crawler/close-browser-safely.js +73 -0
  74. package/lib/crawler/crawler.d.ts +7 -2
  75. package/lib/crawler/crawler.js +919 -113
  76. package/lib/crawler/create-change-phase-handler.d.ts +54 -0
  77. package/lib/crawler/create-change-phase-handler.js +44 -0
  78. package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
  79. package/lib/crawler/derive-js-redirect-target.js +129 -0
  80. package/lib/crawler/derive-page-source.d.ts +23 -0
  81. package/lib/crawler/derive-page-source.js +28 -0
  82. package/lib/crawler/derive-resource-source.d.ts +33 -0
  83. package/lib/crawler/derive-resource-source.js +37 -0
  84. package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
  85. package/lib/crawler/dns-burned-host-cache.js +25 -0
  86. package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
  87. package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
  88. package/lib/crawler/drain-phase-errors.d.ts +48 -0
  89. package/lib/crawler/drain-phase-errors.js +35 -0
  90. package/lib/crawler/fetch-destination.d.ts +12 -4
  91. package/lib/crawler/fetch-destination.js +132 -18
  92. package/lib/crawler/format-crawl-progress.d.ts +12 -3
  93. package/lib/crawler/format-crawl-progress.js +14 -6
  94. package/lib/crawler/handle-browser-close.d.ts +29 -0
  95. package/lib/crawler/handle-browser-close.js +28 -0
  96. package/lib/crawler/is-html-content-type.d.ts +17 -0
  97. package/lib/crawler/is-html-content-type.js +19 -0
  98. package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
  99. package/lib/crawler/is-js-redirect-error-shape.js +53 -0
  100. package/lib/crawler/is-likely-html-url.d.ts +22 -0
  101. package/lib/crawler/is-likely-html-url.js +65 -0
  102. package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
  103. package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
  104. package/lib/crawler/kill-process-tree.d.ts +94 -0
  105. package/lib/crawler/kill-process-tree.js +178 -0
  106. package/lib/crawler/link-list.d.ts +21 -1
  107. package/lib/crawler/link-list.js +25 -4
  108. package/lib/crawler/link-to-page-data.d.ts +13 -5
  109. package/lib/crawler/link-to-page-data.js +26 -5
  110. package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
  111. package/lib/crawler/log-undrained-phase-errors.js +34 -0
  112. package/lib/crawler/normalize-content-type.d.ts +14 -0
  113. package/lib/crawler/normalize-content-type.js +20 -0
  114. package/lib/crawler/partition-urls-by-html.d.ts +16 -0
  115. package/lib/crawler/partition-urls-by-html.js +23 -0
  116. package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
  117. package/lib/crawler/plan-sub-resource-emits.js +44 -0
  118. package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
  119. package/lib/crawler/preload-short-circuit-error.js +25 -0
  120. package/lib/crawler/redirect-dest-key.d.ts +19 -0
  121. package/lib/crawler/redirect-dest-key.js +27 -0
  122. package/lib/crawler/resource-to-page-data.d.ts +28 -0
  123. package/lib/crawler/resource-to-page-data.js +59 -0
  124. package/lib/crawler/should-burn-host.d.ts +78 -0
  125. package/lib/crawler/should-burn-host.js +61 -0
  126. package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
  127. package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
  128. package/lib/crawler/types.d.ts +229 -1
  129. package/lib/crawler-orchestrator.d.ts +105 -3
  130. package/lib/crawler-orchestrator.js +620 -20
  131. package/lib/crawler.d.ts +8 -2
  132. package/lib/crawler.js +6 -1
  133. package/lib/permanent-error-kinds.d.ts +43 -0
  134. package/lib/permanent-error-kinds.js +48 -0
  135. package/lib/resource-row-to-lookup-result.d.ts +13 -0
  136. package/lib/resource-row-to-lookup-result.js +20 -0
  137. package/lib/types.d.ts +95 -1
  138. package/lib/utils/compute-file-sha256.d.ts +23 -0
  139. package/lib/utils/compute-file-sha256.js +55 -0
  140. package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
  141. package/lib/utils/error/emit-error-with-retry.js +44 -0
  142. package/lib/utils/error/emit-error.d.ts +39 -0
  143. package/lib/utils/error/emit-error.js +41 -0
  144. package/lib/utils/object/parse-response-headers.d.ts +12 -0
  145. package/lib/utils/object/parse-response-headers.js +26 -0
  146. package/package.json +11 -11
  147. package/lib/utils/error/error-emitter.d.ts +0 -18
  148. package/lib/utils/error/error-emitter.js +0 -29
@@ -2,22 +2,26 @@ import { delay } from '@d-zero/shared/delay';
2
2
  import redirects from 'follow-redirects';
3
3
  import { destinationCache } from './destination-cache.js';
4
4
  import NetTimeoutError from './net-timeout-error.js';
5
+ import { shouldGetFallbackOnHeadFailure } from './should-get-fallback-on-head-failure.js';
6
+ /** Default race timeout for the HEAD pre-flight, in milliseconds. */
7
+ const DEFAULT_HEAD_TIMEOUT_MS = 10 * 1000;
5
8
  /**
6
9
  * Fetches the destination metadata for a URL using an HTTP HEAD request (or GET as fallback).
7
10
  *
8
11
  * Results are cached in memory so that repeated calls for the same URL
9
- * (without hash) return immediately. The request races against a 10-second
10
- * timeout; if the server does not respond in time, a {@link NetTimeoutError} is thrown.
12
+ * (without hash) return immediately. The request races against a configurable
13
+ * timeout (defaults to {@link DEFAULT_HEAD_TIMEOUT_MS}, 10 seconds); if the
14
+ * server does not respond in time, a {@link NetTimeoutError} is thrown.
11
15
  *
12
16
  * If the server returns 405 (Method Not Allowed), 501 (Not Implemented), or 503
13
17
  * (Service Unavailable) for a HEAD request, the function automatically retries with GET.
14
- * @param params - Parameters containing URL, external flag, method, options, and optional User-Agent.
18
+ * @param params - Parameters containing URL, external flag, method, options, optional User-Agent, and optional timeout.
15
19
  * @returns The page metadata obtained from the HTTP response.
16
- * @throws {NetTimeoutError} If the request exceeds the 10-second timeout.
20
+ * @throws {NetTimeoutError} If the request exceeds the configured timeout.
17
21
  * @throws {Error} If the HTTP request fails for any other reason.
18
22
  */
19
23
  export async function fetchDestination(params) {
20
- const { url, isExternal, method = 'HEAD', options, userAgent } = params;
24
+ const { url, isExternal, method = 'HEAD', options, userAgent, timeout } = params;
21
25
  const titleBytesLimit = options?.titleBytesLimit;
22
26
  const cacheKey = titleBytesLimit == null ? url.withoutHash : `${url.withoutHash}:title`;
23
27
  if (destinationCache.has(cacheKey)) {
@@ -28,20 +32,65 @@ export async function fetchDestination(params) {
28
32
  return cache;
29
33
  }
30
34
  const effectiveMethod = titleBytesLimit == null ? method : 'GET';
31
- // Race the fetch against a 10-second timeout. The losing timer is cleared
35
+ const raceTimeoutMs = timeout ?? DEFAULT_HEAD_TIMEOUT_MS;
36
+ // Race the fetch against the requested timeout. The losing timer is cleared
32
37
  // explicitly so it never keeps the event loop alive after the race settles
33
38
  // (a plain `delay()` in `Promise.race` would leak the timer until it fires).
34
39
  let timeoutHandle;
35
40
  const result = await Promise.race([
36
- _fetchHead(url, isExternal, effectiveMethod, titleBytesLimit, userAgent).catch((error) => (error instanceof Error ? error : new Error(String(error)))),
41
+ _fetchHead(url, isExternal, effectiveMethod, titleBytesLimit, userAgent, timeout).catch((error) => error instanceof Error ? error : new Error(String(error))),
37
42
  new Promise((resolve) => {
38
- timeoutHandle = setTimeout(() => resolve(new NetTimeoutError(url.href)), 10 * 1000);
43
+ timeoutHandle = setTimeout(() => resolve(new NetTimeoutError(url.href)), raceTimeoutMs);
39
44
  }),
40
45
  ]).finally(() => {
41
46
  if (timeoutHandle)
42
47
  clearTimeout(timeoutHandle);
43
48
  });
44
- destinationCache.set(cacheKey, result);
49
+ // HEAD failure fallback: a WAF / middlebox that silently drops HEAD will
50
+ // surface as NetTimeoutError / parse-error / connection-reset here even
51
+ // though the same URL serves a normal GET response. Try GET once (using
52
+ // the same timeout budget) before giving up on the URL. Only when
53
+ // `method === 'HEAD'` to avoid infinite recursion if the GET itself
54
+ // times out — at that point the server really is unreachable.
55
+ if (method === 'HEAD' &&
56
+ result instanceof Error &&
57
+ shouldGetFallbackOnHeadFailure(result)) {
58
+ try {
59
+ const getResult = await fetchDestination({
60
+ url,
61
+ isExternal,
62
+ method: 'GET',
63
+ userAgent,
64
+ timeout,
65
+ });
66
+ // GET succeeded — that is the canonical answer for this URL, so
67
+ // cache it under the HEAD cacheKey too (same key, since cacheKey
68
+ // only depends on URL + titleBytesLimit, not on method). The
69
+ // inner GET call already wrote to the cache under the same key,
70
+ // but a future caller hitting the HEAD path will find it there.
71
+ return getResult;
72
+ }
73
+ catch {
74
+ // GET fallback failed too; fall through to surface the original
75
+ // HEAD failure so retry / classification / DNS-burned cache see
76
+ // the actual underlying cause.
77
+ }
78
+ }
79
+ // Errors that are RECOVERABLE on retry — NetTimeoutError plus the kinds
80
+ // `shouldGetFallbackOnHeadFailure` already singles out as
81
+ // possibly-recoverable (parse-error, connection-reset) — are
82
+ // intentionally NOT cached. Caching a recoverable failure would freeze
83
+ // the first slow probe as the verdict for every later caller on the
84
+ // same host AND defeat `Crawler.#sendHeadRequest`'s
85
+ // HEAD_TIMEOUT_ESCALATION_MS (the 30s/60s retry would hit the cache and
86
+ // re-throw the stale 10s failure instead of getting the longer
87
+ // budget). DNS / TLS / refused / blocked are persistent within a crawl
88
+ // session so caching them is what keeps a doomed host from re-paying
89
+ // the network cost N times.
90
+ const isRecoverableError = result instanceof Error && shouldGetFallbackOnHeadFailure(result);
91
+ if (!isRecoverableError) {
92
+ destinationCache.set(cacheKey, result);
93
+ }
45
94
  if (result instanceof Error) {
46
95
  throw result;
47
96
  }
@@ -58,17 +107,26 @@ export async function fetchDestination(params) {
58
107
  * @param titleBytesLimit - When set, reads up to this many bytes from the response body
59
108
  * to extract a `<title>` tag, then destroys the connection.
60
109
  * @param userAgent - Optional User-Agent string to send with the request.
110
+ * @param timeout - Optional race timeout in ms, forwarded to GET fallback so the
111
+ * second pass keeps the same budget as the original HEAD attempt.
61
112
  * @returns A promise resolving to {@link PageData} with response metadata.
62
113
  */
63
- async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent) {
114
+ async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent, timeout) {
64
115
  return new Promise((resolve, reject) => {
65
116
  const hostHeader = url.port ? `${url.hostname}:${url.port}` : url.hostname;
117
+ // `trackRedirects` makes follow-redirects populate `res.redirects` with the
118
+ // chain of followed URLs. Without it that array stays empty and the
119
+ // pre-flight cannot tell where a URL lands — required for the redirect
120
+ // chain in `redirectPaths` and for the #73 convergence dedup, which decides
121
+ // whether a redirect destination was already rendered *before* launching
122
+ // the browser.
66
123
  const request = {
67
124
  protocol: url.protocol,
68
125
  hostname: url.hostname,
69
126
  port: url.port || undefined,
70
127
  path: url.pathname,
71
128
  method,
129
+ trackRedirects: true,
72
130
  headers: {
73
131
  host: hostHeader,
74
132
  ...(userAgent ? { 'User-Agent': userAgent } : {}),
@@ -92,7 +150,17 @@ async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent) {
92
150
  let totalBytes = 0;
93
151
  let settled = false;
94
152
  const buildPageData = (title) => {
95
- const redirectPaths = res.redirects.map((r) => r.url);
153
+ // `res.redirects` (populated by trackRedirects) ALWAYS starts with the
154
+ // originally requested URL, then each followed hop. We drop that first
155
+ // entry so `redirectPaths` keeps its established contract: empty when the
156
+ // URL did not redirect, and `[...intermediate, finalDest]` when it did
157
+ // (the original URL is NOT included — callers like `resolveRedirectChain`
158
+ // and `updatePage` re-add it). Keeping the original here would (a) make
159
+ // `redirectPaths` non-empty for every page, so a direct page looks like a
160
+ // self-redirect, and (b) leak the query-stripped request-target (the HEAD
161
+ // request uses `url.pathname`), collapsing query-distinguished pages.
162
+ // Redirect *targets* come from Location headers and keep their query.
163
+ const redirectPaths = res.redirects.map((r) => r.url).slice(1);
96
164
  const _contentLength = Number.parseInt(res.headers['content-length'] || '');
97
165
  const contentLength = Number.isFinite(_contentLength) ? _contentLength : null;
98
166
  return {
@@ -105,7 +173,26 @@ async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent) {
105
173
  contentType: res.headers['content-type']?.split(';')[0] || null,
106
174
  contentLength,
107
175
  responseHeaders: res.headers,
108
- meta: { title },
176
+ // beholder 3.0.0 made jsonLd / speculationRules / tags /
177
+ // others / originTrial required Meta fields. Even this
178
+ // HEAD-only fallback path must populate every slot so
179
+ // downstream insert/derive helpers iterate without crashing.
180
+ meta: {
181
+ title,
182
+ jsonLd: [],
183
+ speculationRules: [],
184
+ tags: { detected: {}, entries: [] },
185
+ others: {
186
+ meta: {},
187
+ property: {},
188
+ httpEquiv: {},
189
+ itemprop: {},
190
+ link: [],
191
+ script: [],
192
+ iframe: [],
193
+ },
194
+ originTrial: [],
195
+ },
109
196
  imageList: [],
110
197
  anchorList: [],
111
198
  html: '',
@@ -118,11 +205,21 @@ async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent) {
118
205
  let rep = buildPageData('');
119
206
  if (rep.status === 405) {
120
207
  if (method === 'GET') {
121
- reject(new Error(`Method Not Allowed: ${url.href} ${rep.statusText}`));
208
+ // GET fallback also returned 405 — the server really does
209
+ // reject both methods. Resolve with the PageData so the
210
+ // archive records `status: 405` instead of the `-1`
211
+ // sentinel a reject would land on (which would erase the
212
+ // only useful diagnostic the server gave us).
213
+ resolve(rep);
122
214
  return;
123
215
  }
124
216
  try {
125
- rep = await fetchDestination({ url, isExternal, method: 'GET' });
217
+ rep = await fetchDestination({
218
+ url,
219
+ isExternal,
220
+ method: 'GET',
221
+ timeout,
222
+ });
126
223
  }
127
224
  catch (error) {
128
225
  reject(error);
@@ -131,12 +228,19 @@ async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent) {
131
228
  }
132
229
  if (rep.status === 501) {
133
230
  if (method === 'GET') {
134
- reject(new Error(`Method Not Implemented: ${url.href} ${rep.statusText}`));
231
+ // GET fallback also returned 501 — preserve the status
232
+ // rather than dropping it into the `-1` bucket.
233
+ resolve(rep);
135
234
  return;
136
235
  }
137
236
  await delay(5 * 1000);
138
237
  try {
139
- rep = await fetchDestination({ url, isExternal, method: 'GET' });
238
+ rep = await fetchDestination({
239
+ url,
240
+ isExternal,
241
+ method: 'GET',
242
+ timeout,
243
+ });
140
244
  }
141
245
  catch (error) {
142
246
  reject(error);
@@ -145,12 +249,22 @@ async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent) {
145
249
  }
146
250
  if (rep.status === 503) {
147
251
  if (method === 'GET') {
148
- reject(new Error(`Retrying failed: ${url.href} ${rep.statusText}`));
252
+ // GET fallback also returned 503 — preserve the status.
253
+ // A second-pass 5xx from a different method is the
254
+ // server's real answer, not a transient HEAD-only quirk,
255
+ // so the archive should remember it as 503 instead of
256
+ // the generic `-1` sentinel.
257
+ resolve(rep);
149
258
  return;
150
259
  }
151
260
  await delay(5 * 1000);
152
261
  try {
153
- rep = await fetchDestination({ url, isExternal, method: 'GET' });
262
+ rep = await fetchDestination({
263
+ url,
264
+ isExternal,
265
+ method: 'GET',
266
+ timeout,
267
+ });
154
268
  }
155
269
  catch (error) {
156
270
  reject(error);
@@ -12,22 +12,31 @@ interface FormatCrawlProgressParams {
12
12
  readonly externalTotal: number;
13
13
  /** Number of external URLs completed */
14
14
  readonly externalDone: number;
15
+ /**
16
+ * Number of HTML pages actually rendered by the browser in the current session.
17
+ * Excludes HEAD-only resources, title-only metadata fetches, and skipped URLs.
18
+ */
19
+ readonly pagesScraped: number;
15
20
  /** Number of parallel workers */
16
21
  readonly limit: number;
17
22
  }
18
23
  /**
19
24
  * Formats the crawl progress header for the deal() progress display.
20
25
  *
21
- * Shows "done / found (remaining)" format instead of "done/total"
22
- * to make it clearer that the total is expected to grow during crawling.
26
+ * Shows "done(pages) / found URLs (remaining)" format instead of "done/total"
27
+ * to make it clearer that the total is expected to grow during crawling,
28
+ * that the counts are processed URLs (not resulting pages), and how many of
29
+ * those URLs were actually rendered by the browser as HTML pages.
30
+ * Counts are formatted with thousands separators (e.g. `1,234,567`).
23
31
  * @param params - The crawl progress parameters.
24
32
  * @param params.done - Number of URLs completed by the deal queue.
25
33
  * @param params.total - Total number of URLs in the deal queue (including completed).
26
34
  * @param params.resumeOffset - Offset from a previous resumed session.
27
35
  * @param params.externalTotal - Number of external URLs discovered.
28
36
  * @param params.externalDone - Number of external URLs completed.
37
+ * @param params.pagesScraped - Number of HTML pages rendered by the browser in this session.
29
38
  * @param params.limit - Number of parallel workers.
30
39
  * @returns The formatted progress string with ANSI color codes.
31
40
  */
32
- export declare function formatCrawlProgress({ done, total, resumeOffset, externalTotal, externalDone, limit, }: FormatCrawlProgressParams): string;
41
+ export declare function formatCrawlProgress({ done, total, resumeOffset, externalTotal, externalDone, pagesScraped, limit, }: FormatCrawlProgressParams): string;
33
42
  export {};
@@ -1,19 +1,27 @@
1
1
  import c from 'ansi-colors';
2
+ /**
3
+ * Number formatter for thousands-separated count display (e.g. `1,234,567`).
4
+ */
5
+ const countFormat = new Intl.NumberFormat('en-US');
2
6
  /**
3
7
  * Formats the crawl progress header for the deal() progress display.
4
8
  *
5
- * Shows "done / found (remaining)" format instead of "done/total"
6
- * to make it clearer that the total is expected to grow during crawling.
9
+ * Shows "done(pages) / found URLs (remaining)" format instead of "done/total"
10
+ * to make it clearer that the total is expected to grow during crawling,
11
+ * that the counts are processed URLs (not resulting pages), and how many of
12
+ * those URLs were actually rendered by the browser as HTML pages.
13
+ * Counts are formatted with thousands separators (e.g. `1,234,567`).
7
14
  * @param params - The crawl progress parameters.
8
15
  * @param params.done - Number of URLs completed by the deal queue.
9
16
  * @param params.total - Total number of URLs in the deal queue (including completed).
10
17
  * @param params.resumeOffset - Offset from a previous resumed session.
11
18
  * @param params.externalTotal - Number of external URLs discovered.
12
19
  * @param params.externalDone - Number of external URLs completed.
20
+ * @param params.pagesScraped - Number of HTML pages rendered by the browser in this session.
13
21
  * @param params.limit - Number of parallel workers.
14
22
  * @returns The formatted progress string with ANSI color codes.
15
23
  */
16
- export function formatCrawlProgress({ done, total, resumeOffset, externalTotal, externalDone, limit, }) {
24
+ export function formatCrawlProgress({ done, total, resumeOffset, externalTotal, externalDone, pagesScraped, limit, }) {
17
25
  const allDone = done + resumeOffset;
18
26
  const allTotal = total + resumeOffset;
19
27
  const internalDone = allDone - externalDone;
@@ -22,8 +30,8 @@ export function formatCrawlProgress({ done, total, resumeOffset, externalTotal,
22
30
  const externalRemaining = externalTotal - externalDone;
23
31
  const totalRemaining = internalRemaining + externalRemaining;
24
32
  const pct = allTotal > 0 ? Math.round((allDone / allTotal) * 100) : 0;
25
- return (c.bold(`Crawling: ${internalDone} done / ${internalTotal} found`) +
26
- c.dim(` (+${externalDone}/${externalTotal} ext)`) +
27
- c.bold(` (${pct}%) [${totalRemaining} remaining]`) +
33
+ return (c.bold(`Crawling: ${countFormat.format(internalDone)}(${countFormat.format(pagesScraped)}) done / ${countFormat.format(internalTotal)} found URLs`) +
34
+ c.dim(` (+${countFormat.format(externalDone)}/${countFormat.format(externalTotal)} ext)`) +
35
+ c.bold(` (${pct}%) [${countFormat.format(totalRemaining)} remaining]`) +
28
36
  c.dim(` [${limit} parallel]`));
29
37
  }
@@ -0,0 +1,29 @@
1
+ import type { ClosableBrowser } from './close-browser-safely.js';
2
+ /**
3
+ * Debug-style logger compatible with the `debug` package's printf-style API.
4
+ *
5
+ * Declared structurally so the function stays unit-testable with a `vi.fn()`
6
+ * stub and free of a `debug` import.
7
+ */
8
+ export type BrowserCloseLogger = (
9
+ /** printf-style format string (e.g. `'%s'`, `'%O'`). */
10
+ formatter: string,
11
+ /** Arguments interpolated into the format string. */
12
+ ...args: readonly unknown[]) => void;
13
+ /**
14
+ * Closes a Puppeteer browser used to scrape a single URL, recording any
15
+ * timeout fallback or unexpected cleanup error to the supplied logger.
16
+ *
17
+ * WHY a dedicated function: the prior inline `finally` in
18
+ * {@link Crawler.#launchBrowserAndScrape} mixed cleanup orchestration with
19
+ * production log formatting. Splitting it out makes the two observable
20
+ * branches (force-kill notice and unexpected-error notice) directly testable
21
+ * without spawning a real browser, and keeps the rule that a finally block
22
+ * never throws: any error from {@link closeBrowserSafely} is logged here,
23
+ * never re-thrown.
24
+ * @param browser - The browser to close.
25
+ * @param urlHref - URL string included in the log messages for diagnostic
26
+ * context.
27
+ * @param log - Logger used to record timeout and error events.
28
+ */
29
+ export declare function handleBrowserClose(browser: ClosableBrowser, urlHref: string, log: BrowserCloseLogger): Promise<void>;
@@ -0,0 +1,28 @@
1
+ import { closeBrowserSafely } from './close-browser-safely.js';
2
+ /**
3
+ * Closes a Puppeteer browser used to scrape a single URL, recording any
4
+ * timeout fallback or unexpected cleanup error to the supplied logger.
5
+ *
6
+ * WHY a dedicated function: the prior inline `finally` in
7
+ * {@link Crawler.#launchBrowserAndScrape} mixed cleanup orchestration with
8
+ * production log formatting. Splitting it out makes the two observable
9
+ * branches (force-kill notice and unexpected-error notice) directly testable
10
+ * without spawning a real browser, and keeps the rule that a finally block
11
+ * never throws: any error from {@link closeBrowserSafely} is logged here,
12
+ * never re-thrown.
13
+ * @param browser - The browser to close.
14
+ * @param urlHref - URL string included in the log messages for diagnostic
15
+ * context.
16
+ * @param log - Logger used to record timeout and error events.
17
+ */
18
+ export async function handleBrowserClose(browser, urlHref, log) {
19
+ try {
20
+ const timedOut = await closeBrowserSafely(browser);
21
+ if (timedOut) {
22
+ log('Force-killed wedged Chromium browser for %s (close() timed out)', urlHref);
23
+ }
24
+ }
25
+ catch (error) {
26
+ log('closeBrowserSafely failed for %s: %O', urlHref, error);
27
+ }
28
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Determine whether a Content-Type media type is HTML.
3
+ *
4
+ * MIME types are case-insensitive (RFC 2045), and values captured from
5
+ * Puppeteer responses preserve the server's original casing, so the
6
+ * comparison must normalize case — `text/HTML` is HTML. Surrounding
7
+ * whitespace (e.g. `text/html ` left over after parameter stripping)
8
+ * is also tolerated.
9
+ *
10
+ * This is the single source of truth for HTML detection — `Page.isPage()`
11
+ * and the link list delegate here so the classification never diverges
12
+ * between code paths.
13
+ * @param contentType - The media type portion of a Content-Type header
14
+ * (parameters already stripped), or `null` when unknown.
15
+ * @returns `true` when the media type is `text/html` in any letter case.
16
+ */
17
+ export declare function isHtmlContentType(contentType: string | null): boolean;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Determine whether a Content-Type media type is HTML.
3
+ *
4
+ * MIME types are case-insensitive (RFC 2045), and values captured from
5
+ * Puppeteer responses preserve the server's original casing, so the
6
+ * comparison must normalize case — `text/HTML` is HTML. Surrounding
7
+ * whitespace (e.g. `text/html ` left over after parameter stripping)
8
+ * is also tolerated.
9
+ *
10
+ * This is the single source of truth for HTML detection — `Page.isPage()`
11
+ * and the link list delegate here so the classification never diverges
12
+ * between code paths.
13
+ * @param contentType - The media type portion of a Content-Type header
14
+ * (parameters already stripped), or `null` when unknown.
15
+ * @returns `true` when the media type is `text/html` in any letter case.
16
+ */
17
+ export function isHtmlContentType(contentType) {
18
+ return contentType !== null && contentType.trim().toLowerCase() === 'text/html';
19
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Decide whether a browser-scrape error message is the specific
3
+ * `Page.goto() returned null` shape that the JS-redirect rescue is
4
+ * designed to recover from.
5
+ *
6
+ * **Why this gate exists:** before the gate, the rescue fired on *any*
7
+ * thrown error from `scraper.scrapeStart` as long as `page.url()` happened
8
+ * to report a different http(s) URL. That made every browser failure
9
+ * (TLS, target-crashed, OOM, navigation timeout, …) that incidentally
10
+ * left the page on a follow-up URL look like a JS redirect, hiding the
11
+ * real failure mode and stamping a phantom `status = 301` on the source.
12
+ *
13
+ * The narrow trigger only fires on the upstream's exact sentinel —
14
+ * `Page.goto returned null` — which beholder's scraper throws *only*
15
+ * when puppeteer's `page.goto()` resolved to `null`. Substring match (not
16
+ * equality) so wrapped variants like `[Retried 3 times] The method
17
+ * Page.goto returned null` (which surface in `crawl_errors` after retry
18
+ * exhaustion at outer layers) still classify, even though the rescue
19
+ * sees the bare form. Case-insensitive on the marker so a future
20
+ * beholder bump that lowercases the message keeps working.
21
+ *
22
+ * The trigger keys off the message *string*, not the message-classifier
23
+ * `kind`, because the rescue runs *before* the kind decision: the kind
24
+ * classifier would already wash this into `protocol`, and `protocol`
25
+ * covers more than just goto-null (Target closed / Session closed /
26
+ * detached Frame …) — none of which leave puppeteer with a meaningful
27
+ * post-navigation URL to recover.
28
+ * @param message - The raw error message from
29
+ * `BrowserScrapeResult.error.message` (or any string that may carry
30
+ * the sentinel inside a wrapper). `null` / `undefined` returns `false`.
31
+ * @returns `true` iff the message carries the `Page.goto returned null`
32
+ * sentinel.
33
+ * @example
34
+ * ```ts
35
+ * isJsRedirectErrorShape('The method Page.goto returned null'); // → true
36
+ * isJsRedirectErrorShape('Navigation timeout of 60000 ms exceeded'); // → false
37
+ * isJsRedirectErrorShape(undefined); // → false
38
+ * ```
39
+ */
40
+ export declare function isJsRedirectErrorShape(message: string | null | undefined): boolean;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Sentinel string emitted by `@d-zero/beholder`'s scraper when
3
+ * `await page.goto(...)` resolves to `null`. Pinned here because the
4
+ * JS-redirect rescue in `Crawler.#scrapePage` keys off the exact text —
5
+ * an upstream rename would silently disable the rescue, but the
6
+ * spec on this helper would also break, surfacing the drift in CI.
7
+ */
8
+ const PAGE_GOTO_NULL_MARKER = 'Page.goto returned null';
9
+ /**
10
+ * Decide whether a browser-scrape error message is the specific
11
+ * `Page.goto() returned null` shape that the JS-redirect rescue is
12
+ * designed to recover from.
13
+ *
14
+ * **Why this gate exists:** before the gate, the rescue fired on *any*
15
+ * thrown error from `scraper.scrapeStart` as long as `page.url()` happened
16
+ * to report a different http(s) URL. That made every browser failure
17
+ * (TLS, target-crashed, OOM, navigation timeout, …) that incidentally
18
+ * left the page on a follow-up URL look like a JS redirect, hiding the
19
+ * real failure mode and stamping a phantom `status = 301` on the source.
20
+ *
21
+ * The narrow trigger only fires on the upstream's exact sentinel —
22
+ * `Page.goto returned null` — which beholder's scraper throws *only*
23
+ * when puppeteer's `page.goto()` resolved to `null`. Substring match (not
24
+ * equality) so wrapped variants like `[Retried 3 times] The method
25
+ * Page.goto returned null` (which surface in `crawl_errors` after retry
26
+ * exhaustion at outer layers) still classify, even though the rescue
27
+ * sees the bare form. Case-insensitive on the marker so a future
28
+ * beholder bump that lowercases the message keeps working.
29
+ *
30
+ * The trigger keys off the message *string*, not the message-classifier
31
+ * `kind`, because the rescue runs *before* the kind decision: the kind
32
+ * classifier would already wash this into `protocol`, and `protocol`
33
+ * covers more than just goto-null (Target closed / Session closed /
34
+ * detached Frame …) — none of which leave puppeteer with a meaningful
35
+ * post-navigation URL to recover.
36
+ * @param message - The raw error message from
37
+ * `BrowserScrapeResult.error.message` (or any string that may carry
38
+ * the sentinel inside a wrapper). `null` / `undefined` returns `false`.
39
+ * @returns `true` iff the message carries the `Page.goto returned null`
40
+ * sentinel.
41
+ * @example
42
+ * ```ts
43
+ * isJsRedirectErrorShape('The method Page.goto returned null'); // → true
44
+ * isJsRedirectErrorShape('Navigation timeout of 60000 ms exceeded'); // → false
45
+ * isJsRedirectErrorShape(undefined); // → false
46
+ * ```
47
+ */
48
+ export function isJsRedirectErrorShape(message) {
49
+ if (typeof message !== 'string' || message === '') {
50
+ return false;
51
+ }
52
+ return message.toLowerCase().includes(PAGE_GOTO_NULL_MARKER.toLowerCase());
53
+ }
@@ -0,0 +1,22 @@
1
+ import type { ExURL } from '@d-zero/shared/parse-url';
2
+ /**
3
+ * Heuristically decide whether a discovered URL is likely to resolve to an
4
+ * HTML page, based solely on the URL itself.
5
+ *
6
+ * WHY URL-only: this runs at enqueue time — before any HEAD/GET — so the actual
7
+ * `Content-Type` is unknown. The crawler uses the result to prioritise the
8
+ * dealer queue (likely-HTML URLs are `unshift`ed to the front so page crawling
9
+ * advances ahead of asset/document fetches), so a heuristic is acceptable: a
10
+ * misclassification only changes fetch order, never correctness.
11
+ *
12
+ * Classification rules:
13
+ * - Non-HTTP URLs (`mailto:`, `tel:`, …) are never HTML pages.
14
+ * - Extensionless / directory-style URLs (`/`, `/about/`), and bare trailing-dot
15
+ * URLs (`/index.`, whose `extname` is `"."`), are treated as HTML — these are
16
+ * the overwhelmingly common shape for navigable pages.
17
+ * - URLs whose extension is in {@link HTML_EXTENSIONS} are HTML; every other
18
+ * extension (`.jpg`, `.pdf`, `.css`, `.js`, …) is treated as non-HTML.
19
+ * @param url - The parsed URL to classify.
20
+ * @returns `true` when the URL is likely an HTML page.
21
+ */
22
+ export declare function isLikelyHtmlUrl(url: ExURL): boolean;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * File extensions (lowercased, leading dot included) that typically denote a
3
+ * document served as `text/html`. Both static pages (`.html`) and the common
4
+ * server-side template / handler extensions (`.php`, `.aspx`, `.jsp`, `.ashx`,
5
+ * `.jsf` …) are listed because they usually serve an HTML page.
6
+ *
7
+ * A few entries (`.cgi`, `.do`, `.action`) are ambiguous — they sometimes
8
+ * return JSON or binary — but are kept here because a misclassification only
9
+ * changes fetch order, never correctness (see {@link isLikelyHtmlUrl}).
10
+ *
11
+ * Keys keep the leading dot so they can be compared directly against
12
+ * `ExURL.extname` (Node `Path.extname` output) without stripping it.
13
+ */
14
+ const HTML_EXTENSIONS = new Set([
15
+ '.html',
16
+ '.htm',
17
+ '.xhtml',
18
+ '.shtml',
19
+ '.mhtml',
20
+ '.php',
21
+ '.php3',
22
+ '.php4',
23
+ '.php5',
24
+ '.phtml',
25
+ '.asp',
26
+ '.aspx',
27
+ '.ashx',
28
+ '.jsp',
29
+ '.jspx',
30
+ '.jsf',
31
+ '.cfm',
32
+ '.cgi',
33
+ '.do',
34
+ '.action',
35
+ ]);
36
+ /**
37
+ * Heuristically decide whether a discovered URL is likely to resolve to an
38
+ * HTML page, based solely on the URL itself.
39
+ *
40
+ * WHY URL-only: this runs at enqueue time — before any HEAD/GET — so the actual
41
+ * `Content-Type` is unknown. The crawler uses the result to prioritise the
42
+ * dealer queue (likely-HTML URLs are `unshift`ed to the front so page crawling
43
+ * advances ahead of asset/document fetches), so a heuristic is acceptable: a
44
+ * misclassification only changes fetch order, never correctness.
45
+ *
46
+ * Classification rules:
47
+ * - Non-HTTP URLs (`mailto:`, `tel:`, …) are never HTML pages.
48
+ * - Extensionless / directory-style URLs (`/`, `/about/`), and bare trailing-dot
49
+ * URLs (`/index.`, whose `extname` is `"."`), are treated as HTML — these are
50
+ * the overwhelmingly common shape for navigable pages.
51
+ * - URLs whose extension is in {@link HTML_EXTENSIONS} are HTML; every other
52
+ * extension (`.jpg`, `.pdf`, `.css`, `.js`, …) is treated as non-HTML.
53
+ * @param url - The parsed URL to classify.
54
+ * @returns `true` when the URL is likely an HTML page.
55
+ */
56
+ export function isLikelyHtmlUrl(url) {
57
+ if (!url.isHTTP) {
58
+ return false;
59
+ }
60
+ const extname = url.extname;
61
+ if (!extname || extname === '.') {
62
+ return true;
63
+ }
64
+ return HTML_EXTENSIONS.has(extname.toLowerCase());
65
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Decide whether a failed HEAD/GET pre-flight error message warrants one
3
+ * puppeteer fallback attempt. Pure and deterministic — the same message
4
+ * always gives the same answer, so the decision can be unit-tested without
5
+ * spinning up a browser.
6
+ * @param message - The pre-flight error message (typically the last rejected
7
+ * `retryCall` attempt).
8
+ * @returns `true` when puppeteer should be tried once, `false` to give up.
9
+ * @example
10
+ * ```ts
11
+ * isPuppeteerFallbackCandidate('Timeout: https://slow.example.org/'); // true
12
+ * isPuppeteerFallbackCandidate('getaddrinfo ENOTFOUND host.invalid'); // false
13
+ * isPuppeteerFallbackCandidate('net::ERR_CERT_DATE_INVALID'); // false
14
+ * ```
15
+ */
16
+ export declare function isPuppeteerFallbackCandidate(message: string): boolean;