website-to-markdown 1.1.1 → 2.0.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 (85) hide show
  1. package/README.md +135 -5
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +3 -1
  4. package/dist/cli.js.map +1 -1
  5. package/dist/content-type.d.ts.map +1 -1
  6. package/dist/content-type.js +9 -8
  7. package/dist/content-type.js.map +1 -1
  8. package/dist/crawl-snapshot.d.ts +2 -1
  9. package/dist/crawl-snapshot.d.ts.map +1 -1
  10. package/dist/crawl-snapshot.js +15 -8
  11. package/dist/crawl-snapshot.js.map +1 -1
  12. package/dist/fetch-page.d.ts +38 -22
  13. package/dist/fetch-page.d.ts.map +1 -1
  14. package/dist/fetch-page.js +209 -167
  15. package/dist/fetch-page.js.map +1 -1
  16. package/dist/hash.d.ts +3 -7
  17. package/dist/hash.d.ts.map +1 -1
  18. package/dist/hash.js +3 -7
  19. package/dist/hash.js.map +1 -1
  20. package/dist/html-metadata.d.ts.map +1 -1
  21. package/dist/html-metadata.js +16 -20
  22. package/dist/html-metadata.js.map +1 -1
  23. package/dist/index.d.ts +3 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/limit.d.ts +1 -1
  28. package/dist/limit.js +1 -1
  29. package/dist/limit.js.map +1 -1
  30. package/dist/markdown-refs.d.ts +8 -0
  31. package/dist/markdown-refs.d.ts.map +1 -1
  32. package/dist/markdown-refs.js +104 -59
  33. package/dist/markdown-refs.js.map +1 -1
  34. package/dist/memory-limit.d.ts +1 -1
  35. package/dist/memory-limit.js +1 -1
  36. package/dist/memory-limit.js.map +1 -1
  37. package/dist/net-guard.d.ts +38 -2
  38. package/dist/net-guard.d.ts.map +1 -1
  39. package/dist/net-guard.js +88 -17
  40. package/dist/net-guard.js.map +1 -1
  41. package/dist/rate-limit-retry.d.ts.map +1 -1
  42. package/dist/rate-limit-retry.js +12 -21
  43. package/dist/rate-limit-retry.js.map +1 -1
  44. package/dist/read-body.d.ts +12 -0
  45. package/dist/read-body.d.ts.map +1 -0
  46. package/dist/read-body.js +40 -0
  47. package/dist/read-body.js.map +1 -0
  48. package/dist/retry-after.d.ts.map +1 -1
  49. package/dist/retry-after.js +8 -12
  50. package/dist/retry-after.js.map +1 -1
  51. package/dist/schema/index.d.ts +9 -0
  52. package/dist/schema/index.d.ts.map +1 -1
  53. package/dist/schema/index.js +28 -0
  54. package/dist/schema/index.js.map +1 -1
  55. package/dist/sitemap/index.d.ts +13 -4
  56. package/dist/sitemap/index.d.ts.map +1 -1
  57. package/dist/sitemap/index.js +58 -86
  58. package/dist/sitemap/index.js.map +1 -1
  59. package/dist/testing/index.d.ts +16 -0
  60. package/dist/testing/index.d.ts.map +1 -0
  61. package/dist/testing/index.js +54 -0
  62. package/dist/testing/index.js.map +1 -0
  63. package/dist/unchanged-page.d.ts +9 -0
  64. package/dist/unchanged-page.d.ts.map +1 -0
  65. package/dist/unchanged-page.js +31 -0
  66. package/dist/unchanged-page.js.map +1 -0
  67. package/package.json +12 -7
  68. package/src/cli.ts +3 -1
  69. package/src/content-type.ts +11 -9
  70. package/src/crawl-snapshot.ts +21 -8
  71. package/src/fetch-page.ts +304 -221
  72. package/src/hash.ts +3 -7
  73. package/src/html-metadata.ts +20 -18
  74. package/src/index.ts +7 -0
  75. package/src/limit.ts +1 -1
  76. package/src/markdown-refs.ts +129 -57
  77. package/src/memory-limit.ts +1 -1
  78. package/src/net-guard.ts +126 -16
  79. package/src/rate-limit-retry.ts +20 -20
  80. package/src/read-body.ts +38 -0
  81. package/src/retry-after.ts +11 -18
  82. package/src/schema/index.ts +34 -0
  83. package/src/sitemap/index.ts +83 -86
  84. package/src/testing/index.ts +60 -0
  85. package/src/unchanged-page.ts +37 -0
@@ -1,4 +1,4 @@
1
- import { parseRetryAfter } from 'hanzio';
1
+ import { exponentialBackoff, fileExtension, getUniqueValues, invariant, isOneOf, isRecord, must, parseRetryAfter, startTimer, timeoutSignal, typedSwitch, typedSwitchGroup } from 'hanzio';
2
2
  import { CloudflareRateLimitedError, createCloudflareClient } from './client.js';
3
3
  import { classifyContentType, convertibleDocumentType } from './content-type.js';
4
4
  import { currentCrawlSnapshot, throughCrawlSnapshot, withCrawlSnapshot } from './crawl-snapshot.js';
@@ -7,40 +7,50 @@ import { sha256Hex } from './hash.js';
7
7
  import { extractHtmlMetadata } from './html-metadata.js';
8
8
  import { createHostLimits } from './limit.js';
9
9
  import { errorLogAttributes, silentLogger } from './logger.js';
10
- import { htmlImages, htmlLinks, markdownRefs, stripDocumentPreamble, stripFrontMatter, titleFromMarkdown } from './markdown-refs.js';
10
+ import { cleanConvertedMarkdown, htmlImages, htmlLinks, markdownRefs, stripDocumentPreamble, stripFrontMatter, titleFromMarkdown } from './markdown-refs.js';
11
11
  import { createMemoryConcurrencyLimit } from './memory-limit.js';
12
- import { isHostAllowed, isLocalhostHostname } from './net-guard.js';
12
+ import { followRedirects, guardedFetch, HostNotAllowedError, isHostAllowed, isLocalhostHostname } from './net-guard.js';
13
13
  import { currentOperationSignal } from './operation-signal.js';
14
+ import { decodeText, readBody } from './read-body.js';
15
+ import { asUnchanged, isUnchangedFor } from './unchanged-page.js';
14
16
  import { BROWSER_USER_AGENT } from './user-agent.js';
15
17
  import { preserveXlsxZeros } from './xlsx-zeros.js';
16
18
  const FETCH_TIMEOUT_MS = 30_000;
17
19
  /** Browser Rendering once returned a 6 MB markdown page from a 6 MB upload. */
18
20
  const MAX_HTML_BYTES = 2 * 1024 * 1024;
21
+ /**
22
+ * Read cap for a textual body. Above `MAX_HTML_BYTES` a page is not converted
23
+ * but still yields its metadata and links; above this it is not downloaded.
24
+ */
25
+ const MAX_TEXT_READ_BYTES = 2 * MAX_HTML_BYTES;
19
26
  const MAX_DOCUMENT_BYTES = 2 * 1024 * 1024;
20
27
  const MAX_PDF_BYTES = 12 * 1024 * 1024;
21
28
  const MAX_PDF_PAGES = 60;
22
29
  /** Stored markdown ceiling; a page over this is a listing or an upload, not knowledge. */
23
30
  export const MAX_MARKDOWN_CHARS = 300_000;
31
+ /**
32
+ * A NUL byte is never text, and Postgres refuses a string that holds one: a
33
+ * single page with one (a Wix site's conversion) failed a whole import.
34
+ * Composed Unicode, because string comparisons downstream assume it: a site
35
+ * that wrote "Gård" as "a" plus a combining ring read as "Ga" to anything
36
+ * matching names in the text.
37
+ */
38
+ function cleanText(text) {
39
+ return text.replaceAll('\u0000', '').normalize('NFC');
40
+ }
24
41
  /**
25
42
  * The one place markdown, front-matter and HTML metadata are reconciled: the
26
43
  * HTML `<title>` wins, the Workers AI front-matter is the fallback for pages we
27
44
  * only ever saw as markdown, and links/images are resolved against `finalUrl`.
28
45
  */
29
46
  function buildPage(base, rawBody, logger) {
30
- // A NUL byte is never text, and Postgres refuses a string that holds one:
31
- // a single page with one (a Wix site's conversion) failed the whole import.
32
- // Composed Unicode, because every comparison downstream assumes it: a site
33
- // that wrote "Gård" as "a" plus a combining ring gave the fact guard the
34
- // name "Broges Ga", which no draft could contain, and the document shipped
35
- // as its verbatim source.
36
- const clean = (text) => text?.replaceAll('\u0000', '').normalize('NFC');
37
47
  const body = {
38
48
  ...rawBody,
39
- markdown: clean(rawBody.markdown),
40
- html: clean(rawBody.html)
49
+ markdown: rawBody.markdown ? cleanText(rawBody.markdown) : null,
50
+ html: rawBody.html ? cleanText(rawBody.html) : null
41
51
  };
42
52
  const frontMatter = stripFrontMatter(body.markdown ?? '');
43
- let markdown = frontMatter.markdown;
53
+ let markdown = cleanConvertedMarkdown(frontMatter.markdown);
44
54
  if (markdown.length > MAX_MARKDOWN_CHARS) {
45
55
  logger.warn('url-to-markdown.truncated', {
46
56
  url: base.finalUrl,
@@ -56,7 +66,7 @@ function buildPage(base, rawBody, logger) {
56
66
  // navigation (image-wrapped anchors, folded menus), so when we hold the
57
67
  // HTML its anchors decide what discovery can still reach.
58
68
  const links = body.html
59
- ? [...new Set([...refs.links, ...htmlLinks(body.html, base.finalUrl)])]
69
+ ? getUniqueValues([...refs.links, ...htmlLinks(body.html, base.finalUrl)])
60
70
  : refs.links;
61
71
  return {
62
72
  url: base.url,
@@ -76,25 +86,25 @@ function buildPage(base, rawBody, logger) {
76
86
  metaDescription: metadata?.metaDescription ?? frontMatter.description ?? null,
77
87
  jsonLd: metadata?.jsonLd ?? [],
78
88
  links,
79
- images: [
80
- ...new Set([
81
- ...refs.images,
82
- ...(body.html ? htmlImages(body.html, base.finalUrl) : [])
83
- ])
84
- ],
89
+ images: getUniqueValues([
90
+ ...refs.images,
91
+ ...(body.html ? htmlImages(body.html, base.finalUrl) : [])
92
+ ]),
85
93
  captureIssue: frontMatter.markdown.length > MAX_MARKDOWN_CHARS
86
94
  ? 'markdown_limit'
87
95
  : (body.captureIssue ?? null),
88
- durationMs: Math.round(performance.now() - base.startedAt),
96
+ durationMs: Math.round(base.elapsedMs()),
97
+ // Over the cleaned HTML, as `convertTextual` compares it.
89
98
  htmlHash: body.html ? htmlHash(body.html) : null
90
99
  };
91
100
  }
92
101
  /**
93
102
  * `http://` is upgraded rather than rejected: two carpenter sites in the probe
94
103
  * link internally with `http://` and the https-only guard threw away most of
95
- * their pages. Localhost http stays reachable outside production.
104
+ * their pages. Localhost http stays http, and is reachable only with
105
+ * `allowLocalhost`.
96
106
  */
97
- function prepareRequestUrl(raw) {
107
+ function prepareRequestUrl(raw, allowLocalhost) {
98
108
  let parsed;
99
109
  try {
100
110
  parsed = new URL(raw);
@@ -107,7 +117,7 @@ function prepareRequestUrl(raw) {
107
117
  if (parsed.protocol !== 'http:')
108
118
  return null;
109
119
  if (isLocalhostHostname(parsed.hostname)) {
110
- return process.env.NODE_ENV === 'production' ? null : parsed;
120
+ return allowLocalhost ? parsed : null;
111
121
  }
112
122
  parsed.protocol = 'https:';
113
123
  if (parsed.port === '80')
@@ -130,7 +140,7 @@ function wwwFallbackUrl(url) {
130
140
  }
131
141
  /**
132
142
  * A 429 is an origin asking us to slow down, not a bot wall. Shopify answered
133
- * 117 of Good Habit's 244 reads with it; sent on to Browser Rendering they
143
+ * 117 of one store's 244 reads with it; sent on to Browser Rendering they
134
144
  * tripped Cloudflare's own limit (79 times, 25-30 s each) and 7 came back.
135
145
  * So the host is narrowed and paused (`coolDownHost`), the URL is asked again,
136
146
  * and a page still refused after that is `transient`: its stored copy stands
@@ -141,7 +151,12 @@ const MAX_RATE_LIMIT_WAIT_MS = 30_000;
141
151
  /** The origin's `Retry-After` when it sent one, else 1 s, 2 s, 4 s. */
142
152
  function rateLimitWaitMs(response, retry) {
143
153
  const askedMs = parseRetryAfter(response.headers.get('retry-after'));
144
- return Math.min(MAX_RATE_LIMIT_WAIT_MS, askedMs ?? 1_000 * 2 ** (retry - 1));
154
+ return Math.min(MAX_RATE_LIMIT_WAIT_MS, askedMs ??
155
+ exponentialBackoff(retry, {
156
+ baseMs: 1_000,
157
+ maxMs: MAX_RATE_LIMIT_WAIT_MS,
158
+ jitter: false
159
+ }));
145
160
  }
146
161
  /**
147
162
  * Bump whenever conversion changes what the same HTML becomes (`withoutHiddenUi`,
@@ -149,7 +164,7 @@ function rateLimitWaitMs(response, retry) {
149
164
  * matches, and every page is converted again once instead of keeping the
150
165
  * markdown the old conversion made.
151
166
  */
152
- const CONVERSION_VERSION = 1;
167
+ const CONVERSION_VERSION = 2;
153
168
  function htmlHash(html) {
154
169
  return sha256Hex(`${CONVERSION_VERSION}\n${html}`);
155
170
  }
@@ -173,11 +188,10 @@ function parseFinalUrl(responseUrl, requestUrl) {
173
188
  }
174
189
  }
175
190
  function isAbortError(error) {
176
- const name = typeof error === 'object' && error && 'name' in error
177
- ? String(error.name)
178
- : '';
179
- return name === 'AbortError' || name === 'TimeoutError';
191
+ return isRecord(error) && isOneOf(['AbortError', 'TimeoutError'], error.name);
180
192
  }
193
+ /** The content kinds whose body is read as text; the rest are documents or assets. */
194
+ const TEXTUAL_KINDS = ['markdown', 'html', 'text'];
181
195
  /** Reading the body is optional; an unsupported type is dropped at the headers. */
182
196
  function discardBody(response) {
183
197
  void response.body?.cancel().catch(() => { });
@@ -195,75 +209,67 @@ async function readOriginBody(response, finalUrl, logger) {
195
209
  }
196
210
  const contentType = response.headers.get('content-type');
197
211
  const kind = classifyContentType(contentType);
198
- if (kind === 'asset') {
199
- discardBody(response);
200
- return { kind: 'none' };
201
- }
202
- if (kind === 'document') {
203
- const type = convertibleDocumentType(contentType);
204
- if (!type) {
212
+ return typedSwitch(kind, {
213
+ asset: () => {
205
214
  discardBody(response);
206
215
  return { kind: 'none' };
207
- }
208
- const limit = type.extension === 'pdf' ? MAX_PDF_BYTES : MAX_DOCUMENT_BYTES;
209
- const declared = Number(response.headers.get('content-length'));
210
- if (Number.isFinite(declared) && declared > limit) {
211
- discardBody(response);
212
- logger.info('url-to-markdown.document_oversized', {
213
- url: finalUrl.href,
214
- bytes: declared
215
- });
216
- return { kind: 'none', captureIssue: 'document_byte_limit' };
217
- }
218
- const bytes = await readBoundedBody(response, limit);
219
- return bytes
220
- ? { kind: 'bytes', bytes }
221
- : { kind: 'none', captureIssue: 'document_byte_limit' };
222
- }
223
- return { kind: 'text', text: await response.text() };
216
+ },
217
+ document: async () => {
218
+ const type = convertibleDocumentType(contentType);
219
+ if (!type) {
220
+ discardBody(response);
221
+ return { kind: 'none' };
222
+ }
223
+ const limit = type.extension === 'pdf' ? MAX_PDF_BYTES : MAX_DOCUMENT_BYTES;
224
+ const declared = Number(response.headers.get('content-length'));
225
+ if (Number.isFinite(declared) && declared > limit) {
226
+ discardBody(response);
227
+ logger.info('url-to-markdown.document_oversized', {
228
+ url: finalUrl.href,
229
+ bytes: declared
230
+ });
231
+ return { kind: 'none', captureIssue: 'document_byte_limit' };
232
+ }
233
+ const bytes = await readBoundedBody(response, limit);
234
+ return bytes
235
+ ? { kind: 'bytes', bytes }
236
+ : { kind: 'none', captureIssue: 'document_byte_limit' };
237
+ },
238
+ ...typedSwitchGroup(kind, TEXTUAL_KINDS, async () => {
239
+ const declared = Number(response.headers.get('content-length'));
240
+ const read = Number.isFinite(declared) && declared > MAX_TEXT_READ_BYTES
241
+ ? null
242
+ : await readBody(response, MAX_TEXT_READ_BYTES);
243
+ if (!read || read.truncated) {
244
+ if (!read)
245
+ discardBody(response);
246
+ logger.info('url-to-markdown.html_oversized', {
247
+ url: finalUrl.href,
248
+ bytes: read ? MAX_TEXT_READ_BYTES : declared
249
+ });
250
+ return { kind: 'none', captureIssue: 'html_byte_limit' };
251
+ }
252
+ return { kind: 'text', text: decodeText(read.bytes) };
253
+ })
254
+ });
224
255
  }
225
256
  function documentFilename(finalUrl, extension) {
226
257
  const segment = finalUrl.split(/[?#]/)[0]?.split('/').pop() || 'document';
227
258
  const name = segment.slice(0, 80);
228
- return name.toLowerCase().endsWith(`.${extension}`)
229
- ? name
230
- : `${name}.${extension}`;
259
+ return fileExtension(name) === extension ? name : `${name}.${extension}`;
231
260
  }
232
- /** Streaming limits bound chunked bodies as well as declared Content-Length. */
233
261
  async function readBoundedBody(response, maxBytes) {
234
- const reader = response.body?.getReader();
235
- if (!reader)
236
- return new ArrayBuffer(0);
237
- const chunks = [];
238
- let size = 0;
239
- while (true) {
240
- const next = await reader.read();
241
- if (next.done)
242
- break;
243
- size += next.value.byteLength;
244
- if (size > maxBytes) {
245
- await reader.cancel();
246
- return null;
247
- }
248
- chunks.push(next.value);
249
- }
250
- const bytes = new Uint8Array(size);
251
- let offset = 0;
252
- for (const chunk of chunks) {
253
- bytes.set(chunk, offset);
254
- offset += chunk.byteLength;
255
- }
256
- return bytes.buffer;
262
+ const read = await readBody(response, maxBytes);
263
+ return read.truncated ? null : read.bytes.buffer;
257
264
  }
258
265
  /**
259
266
  * Markup a visitor never sees until they open it: `<template>` content and
260
267
  * closed `<dialog>`s. A Shopify theme ships its cart drawer ("Your cart is
261
268
  * empty") and search overlay (a product list with prices) in every page this
262
- * way; converted, they reached 70 of one store's 89 pages and a price table
263
- * in seven of its documents (Good Habit audit §3.4). An open dialog is shown
264
- * and stays. A contact or newsletter form (Shopify posts both to `/contact`)
265
- * is labels and a button, and "Telefon Kommentar Send us a note" read as the
266
- * name `Kommentar Send` the fact guard then demanded of the document.
269
+ * way; converted, they reached 70 of one store's 89 pages. An open dialog is
270
+ * shown and stays. A contact or newsletter form (Shopify posts both to
271
+ * `/contact`) is labels and a button, and "Telefon Kommentar Send us a note"
272
+ * read as if `Kommentar Send` were a name on the page.
267
273
  *
268
274
  * Plus the form fields a visitor cannot see: an anti-spam honeypot inside a
269
275
  * `display:none` block renamed its label ("Navn" / "Url") on every request,
@@ -288,12 +294,7 @@ function withoutHiddenUi(html) {
288
294
  .transform(html);
289
295
  }
290
296
  /** A real browser passes these; every other 4xx has nothing for it to find. */
291
- const BOT_WALL_STATUSES = new Set([401, 403]);
292
- /** `AbortSignal.timeout` and the caller's signal, whichever fires first. */
293
- function timeoutSignal(ms, signal) {
294
- const timeout = AbortSignal.timeout(ms);
295
- return signal ? AbortSignal.any([timeout, signal]) : timeout;
296
- }
297
+ const BOT_WALL_STATUSES = [401, 403];
297
298
  /**
298
299
  * One converter: its Cloudflare client, pools and per-host politeness limits.
299
300
  * Create it once per process; every call shares the pools.
@@ -309,14 +310,13 @@ export function createWebsiteToMarkdown(options) {
309
310
  logger
310
311
  });
311
312
  const hostLimits = createHostLimits(createLimit);
313
+ const allowLocalhost = options.allowLocalhost === true;
312
314
  const allowHost = options.isHostAllowed ??
313
- ((hostname) => isHostAllowed(hostname, logger));
315
+ ((hostname) => isHostAllowed(hostname, { logger, allowLocalhost }));
316
+ const guard = { isHostAllowed: allowHost, fetch: options.fetch };
314
317
  async function requestOrigin(url, headers, signal) {
315
318
  if (signal?.aborted)
316
319
  return { kind: 'failed', retryable: false };
317
- if (!(await allowHost(url.hostname))) {
318
- return { kind: 'refused' };
319
- }
320
320
  try {
321
321
  // The politeness slot spans the connection *and* the body: freed at the
322
322
  // response headers it would bound connections only, and the whole 80-wide
@@ -326,12 +326,9 @@ export function createWebsiteToMarkdown(options) {
326
326
  // our upstreams with their own pools, and holding a customer's slot
327
327
  // across a 60s render would stall the crawl for no politeness gain.
328
328
  const read = await hostLimits.withHostLimit(url.hostname, async () => {
329
- const response = await fetch(url, {
330
- redirect: 'follow',
331
- signal: timeoutSignal(FETCH_TIMEOUT_MS, signal),
332
- headers
333
- });
334
- const finalUrl = parseFinalUrl(response.url, url);
329
+ const hop = await followRedirects(url, { signal: timeoutSignal(FETCH_TIMEOUT_MS, signal), headers }, guard);
330
+ const response = hop.response;
331
+ const finalUrl = parseFinalUrl(response.url, hop.url);
335
332
  return {
336
333
  response,
337
334
  finalUrl,
@@ -341,6 +338,9 @@ export function createWebsiteToMarkdown(options) {
341
338
  return { kind: 'read', read };
342
339
  }
343
340
  catch (error) {
341
+ // The guard refused the URL or a redirect: policy, not a network blip.
342
+ if (error instanceof HostNotAllowedError)
343
+ return { kind: 'refused' };
344
344
  logger.warn('url-to-markdown.origin_failed', {
345
345
  url: url.href,
346
346
  ...errorLogAttributes(error)
@@ -395,9 +395,8 @@ export function createWebsiteToMarkdown(options) {
395
395
  if (input.signal?.aborted)
396
396
  return { outcome: 'transient', source: null };
397
397
  try {
398
- if (!options.parsePdf)
399
- throw new Error('No PDF parser configured');
400
- const parsed = await options.parsePdf({
398
+ const parsePdf = must(options.parsePdf, 'No PDF parser configured');
399
+ const parsed = await parsePdf({
401
400
  bytes,
402
401
  filename: input.filename,
403
402
  mimeType: input.mimeType,
@@ -435,7 +434,11 @@ export function createWebsiteToMarkdown(options) {
435
434
  return {
436
435
  outcome: 'unsupported',
437
436
  source: null,
438
- captureIssue: input.body.kind === 'none' ? input.body.captureIssue : null
437
+ captureIssue: typedSwitch(input.body, 'kind', {
438
+ none: (body) => body.captureIssue,
439
+ text: () => null,
440
+ bytes: () => null
441
+ })
439
442
  };
440
443
  }
441
444
  return convertDocumentBytes({
@@ -451,15 +454,20 @@ export function createWebsiteToMarkdown(options) {
451
454
  // The `Content-Type` header is authoritative - sniffing for a leading
452
455
  // `<html` misses every page that opens with a comment or a CDN banner - but
453
456
  // a `text/plain` body that is plainly HTML still needs the HTML tier.
454
- const isHtml = input.kind === 'html' ||
455
- (input.kind !== 'markdown' && looksLikeHtml(body));
457
+ const isHtml = typedSwitch(input.kind, {
458
+ html: () => true,
459
+ markdown: () => false,
460
+ text: () => looksLikeHtml(body)
461
+ });
456
462
  if (!isHtml && !isBlankMarkdown(body)) {
457
463
  return { outcome: 'ok', source: 'content-negotiation', markdown: body };
458
464
  }
459
465
  const html = isHtml ? body : null;
460
466
  // Most origins send no validators, so without this every page of a quiet
461
467
  // site went through Workers AI again every night to learn nothing moved.
462
- if (html && input.htmlHash && htmlHash(html) === input.htmlHash) {
468
+ if (html &&
469
+ input.htmlHash &&
470
+ htmlHash(cleanText(html)) === input.htmlHash) {
463
471
  return { outcome: 'unchanged', source: 'not-modified' };
464
472
  }
465
473
  if (html && Buffer.byteLength(html) > MAX_HTML_BYTES) {
@@ -470,10 +478,16 @@ export function createWebsiteToMarkdown(options) {
470
478
  url: input.finalUrl.href,
471
479
  bytes: Buffer.byteLength(html)
472
480
  });
473
- return { outcome: 'unsupported', source: null, html };
481
+ return {
482
+ outcome: 'unsupported',
483
+ source: null,
484
+ html,
485
+ captureIssue: 'html_byte_limit'
486
+ };
474
487
  }
475
488
  let shell = false;
476
489
  let workersAiRateLimited = false;
490
+ let renderRateLimited = false;
477
491
  if (html) {
478
492
  const visible = withoutHiddenUi(html);
479
493
  let converted = null;
@@ -511,7 +525,7 @@ export function createWebsiteToMarkdown(options) {
511
525
  catch (error) {
512
526
  if (!(error instanceof CloudflareRateLimitedError))
513
527
  throw error;
514
- workersAiRateLimited = true;
528
+ renderRateLimited = true;
515
529
  }
516
530
  if (rendered &&
517
531
  !isBlankMarkdown(rendered) &&
@@ -532,7 +546,9 @@ export function createWebsiteToMarkdown(options) {
532
546
  ? 'render_incomplete'
533
547
  : workersAiRateLimited
534
548
  ? 'workers_ai_rate_limited'
535
- : null
549
+ : renderRateLimited
550
+ ? 'browser_rendering_rate_limited'
551
+ : null
536
552
  };
537
553
  }
538
554
  /** Runs after the origin's slot is released: every tier it reaches is ours. */
@@ -542,7 +558,7 @@ export function createWebsiteToMarkdown(options) {
542
558
  const contentType = response.headers.get('content-type');
543
559
  if (status === 304)
544
560
  return { outcome: 'unchanged', source: 'not-modified' };
545
- if (status === 404 || status === 410) {
561
+ if (isOneOf([404, 410], status)) {
546
562
  return { outcome: 'gone', source: null };
547
563
  }
548
564
  // Still refused after the host's pauses: rendering would only be refused
@@ -550,10 +566,23 @@ export function createWebsiteToMarkdown(options) {
550
566
  if (status >= 500 || status === 429)
551
567
  return { outcome: 'transient', source: null };
552
568
  if (status >= 400) {
553
- if (!input.render || !BOT_WALL_STATUSES.has(status)) {
569
+ if (!input.render || !isOneOf(BOT_WALL_STATUSES, status)) {
554
570
  return { outcome: 'blocked', source: null };
555
571
  }
556
- const rendered = await client.renderUrlToMarkdown(input.finalUrl.href, input.signal);
572
+ let rendered;
573
+ try {
574
+ rendered = await client.renderUrlToMarkdown(input.finalUrl.href, input.signal);
575
+ }
576
+ catch (error) {
577
+ if (!(error instanceof CloudflareRateLimitedError))
578
+ throw error;
579
+ // The wall may well let a browser through on the next run.
580
+ return {
581
+ outcome: 'transient',
582
+ source: null,
583
+ captureIssue: 'browser_rendering_rate_limited'
584
+ };
585
+ }
557
586
  return rendered
558
587
  ? { outcome: 'ok', source: 'browser-rendering', markdown: rendered }
559
588
  : { outcome: 'blocked', source: null };
@@ -562,25 +591,28 @@ export function createWebsiteToMarkdown(options) {
562
591
  return { outcome: 'transient', source: null };
563
592
  }
564
593
  const kind = classifyContentType(contentType);
565
- if (kind === 'asset')
566
- return { outcome: 'unsupported', source: null };
567
- if (kind === 'document') {
568
- return convertDocument({
594
+ return typedSwitch(kind, {
595
+ asset: () => ({ outcome: 'unsupported', source: null }),
596
+ document: () => convertDocument({
569
597
  body,
570
598
  contentType,
571
599
  finalUrl: input.finalUrl,
572
600
  signal: input.signal
573
- });
574
- }
575
- if (body.kind !== 'text')
576
- return { outcome: 'unsupported', source: null };
577
- return convertTextual({
578
- text: body.text,
579
- kind,
580
- finalUrl: input.finalUrl,
581
- render: input.render,
582
- htmlHash: input.htmlHash,
583
- signal: input.signal
601
+ }),
602
+ ...typedSwitchGroup(kind, TEXTUAL_KINDS, async (textualKind) => body.kind === 'text'
603
+ ? convertTextual({
604
+ text: body.text,
605
+ kind: textualKind,
606
+ finalUrl: input.finalUrl,
607
+ render: input.render,
608
+ htmlHash: input.htmlHash,
609
+ signal: input.signal
610
+ })
611
+ : {
612
+ outcome: 'unsupported',
613
+ source: null,
614
+ captureIssue: body.kind === 'none' ? body.captureIssue : null
615
+ })
584
616
  });
585
617
  }
586
618
  /**
@@ -590,10 +622,33 @@ export function createWebsiteToMarkdown(options) {
590
622
  * target, validators, HTML metadata and absolute links.
591
623
  *
592
624
  * Never throws: a page that cannot be read is an outcome, not an exception,
593
- * because the caller is crawling hundreds of URLs at a time. A knowledge bench
594
- * run reads through its crawl snapshot (`crawl-snapshot.ts`).
625
+ * because the caller is crawling hundreds of URLs at a time. Anything
626
+ * unexpected (a failing shared limiter, a corrupt snapshot) is logged and
627
+ * comes back `transient`. Inside `withCrawlSnapshot` it reads through the
628
+ * snapshot.
595
629
  */
596
630
  async function fetchPageAsMarkdown(input) {
631
+ const elapsedMs = startTimer();
632
+ try {
633
+ return await readPage(input);
634
+ }
635
+ catch (error) {
636
+ logger.warn('url-to-markdown.failed', {
637
+ url: input.url,
638
+ ...errorLogAttributes(error)
639
+ });
640
+ return buildPage({
641
+ url: input.url,
642
+ finalUrl: input.url,
643
+ status: 0,
644
+ contentType: null,
645
+ etag: null,
646
+ lastModified: null,
647
+ elapsedMs
648
+ }, { outcome: 'transient', source: null }, logger);
649
+ }
650
+ }
651
+ async function readPage(input) {
597
652
  if (!currentCrawlSnapshot())
598
653
  return fetchPageLive(input);
599
654
  // The snapshot holds the whole page, read without validators; whether it is
@@ -604,34 +659,13 @@ export function createWebsiteToMarkdown(options) {
604
659
  render: input.render,
605
660
  signal: input.signal
606
661
  }));
607
- const unchanged = page.outcome === 'ok' &&
608
- ((input.htmlHash && page.htmlHash === input.htmlHash) ||
609
- (input.etag && page.etag === input.etag));
610
- if (!unchanged)
611
- return page;
612
- return {
613
- ...page,
614
- outcome: 'unchanged',
615
- source: 'not-modified',
616
- markdown: '',
617
- title: null,
618
- lang: null,
619
- canonical: null,
620
- alternates: [],
621
- alternateLanguages: {},
622
- metaDescription: null,
623
- jsonLd: [],
624
- links: [],
625
- images: [],
626
- captureIssue: null,
627
- htmlHash: null
628
- };
662
+ return isUnchangedFor(page, input) ? asUnchanged(page) : page;
629
663
  }
630
664
  async function fetchPageLive(input) {
631
665
  const signal = input.signal ?? currentOperationSignal();
632
- const startedAt = performance.now();
666
+ const elapsedMs = startTimer();
633
667
  const render = input.render ?? true;
634
- const prepared = prepareRequestUrl(input.url);
668
+ const prepared = prepareRequestUrl(input.url, allowLocalhost);
635
669
  if (!prepared) {
636
670
  return buildPage({
637
671
  url: input.url,
@@ -640,7 +674,7 @@ export function createWebsiteToMarkdown(options) {
640
674
  contentType: null,
641
675
  etag: null,
642
676
  lastModified: null,
643
- startedAt
677
+ elapsedMs
644
678
  }, { outcome: 'unsupported', source: null }, logger);
645
679
  }
646
680
  const headers = {
@@ -686,11 +720,13 @@ export function createWebsiteToMarkdown(options) {
686
720
  contentType: null,
687
721
  etag: null,
688
722
  lastModified: null,
689
- startedAt
690
- },
691
- // A refused host is a policy decision, not a retryable network blip.
692
- {
693
- outcome: attempt.kind === 'refused' ? 'blocked' : 'transient',
723
+ elapsedMs
724
+ }, {
725
+ outcome: typedSwitch(attempt, 'kind', {
726
+ // A refused host is a policy decision, not a retryable network blip.
727
+ refused: () => 'blocked',
728
+ failed: () => 'transient'
729
+ }),
694
730
  source: null
695
731
  }, logger);
696
732
  }
@@ -713,7 +749,7 @@ export function createWebsiteToMarkdown(options) {
713
749
  etag: response.headers.get('etag') ?? notModified(input.etag),
714
750
  lastModified: response.headers.get('last-modified') ??
715
751
  notModified(input.lastModified),
716
- startedAt
752
+ elapsedMs
717
753
  }, body, logger);
718
754
  logger.info('url-to-markdown.completed', {
719
755
  url: page.finalUrl,
@@ -742,13 +778,19 @@ export function createWebsiteToMarkdown(options) {
742
778
  renderUrlToMarkdown: client.renderUrlToMarkdown,
743
779
  /** The SSRF guard the converter runs, bound to its `logger`. */
744
780
  isHostAllowed: allowHost,
781
+ /**
782
+ * `fetch` behind the converter's guard, run on the URL and on every
783
+ * redirect. Throws `HostNotAllowedError` when one is refused. Use it for
784
+ * any other request to a customer-supplied URL.
785
+ */
786
+ guardedFetch(input, init, fetchOptions) {
787
+ return guardedFetch(input, init, { ...guard, ...fetchOptions });
788
+ },
745
789
  /** `withCrawlSnapshot` bound to the `crawlSnapshot` option. */
746
790
  withCrawlSnapshot(input, run) {
747
791
  if (!input)
748
792
  return run();
749
- if (!options.crawlSnapshot) {
750
- throw new Error('Set crawlSnapshot.root to use crawl snapshots');
751
- }
793
+ invariant(options.crawlSnapshot, 'Set crawlSnapshot.root to use crawl snapshots');
752
794
  return withCrawlSnapshot(input, run, {
753
795
  root: options.crawlSnapshot.root,
754
796
  logger: options.crawlSnapshot.logger ?? logger