website-to-markdown 1.1.0 → 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 (86) hide show
  1. package/README.md +135 -5
  2. package/dist/cli.d.ts +1 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +17 -7
  5. package/dist/cli.js.map +1 -1
  6. package/dist/content-type.d.ts.map +1 -1
  7. package/dist/content-type.js +9 -8
  8. package/dist/content-type.js.map +1 -1
  9. package/dist/crawl-snapshot.d.ts +2 -1
  10. package/dist/crawl-snapshot.d.ts.map +1 -1
  11. package/dist/crawl-snapshot.js +15 -8
  12. package/dist/crawl-snapshot.js.map +1 -1
  13. package/dist/fetch-page.d.ts +38 -22
  14. package/dist/fetch-page.d.ts.map +1 -1
  15. package/dist/fetch-page.js +209 -167
  16. package/dist/fetch-page.js.map +1 -1
  17. package/dist/hash.d.ts +3 -7
  18. package/dist/hash.d.ts.map +1 -1
  19. package/dist/hash.js +3 -7
  20. package/dist/hash.js.map +1 -1
  21. package/dist/html-metadata.d.ts.map +1 -1
  22. package/dist/html-metadata.js +16 -20
  23. package/dist/html-metadata.js.map +1 -1
  24. package/dist/index.d.ts +3 -3
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/limit.d.ts +1 -1
  29. package/dist/limit.js +1 -1
  30. package/dist/limit.js.map +1 -1
  31. package/dist/markdown-refs.d.ts +8 -0
  32. package/dist/markdown-refs.d.ts.map +1 -1
  33. package/dist/markdown-refs.js +104 -59
  34. package/dist/markdown-refs.js.map +1 -1
  35. package/dist/memory-limit.d.ts +1 -1
  36. package/dist/memory-limit.js +1 -1
  37. package/dist/memory-limit.js.map +1 -1
  38. package/dist/net-guard.d.ts +38 -2
  39. package/dist/net-guard.d.ts.map +1 -1
  40. package/dist/net-guard.js +88 -17
  41. package/dist/net-guard.js.map +1 -1
  42. package/dist/rate-limit-retry.d.ts.map +1 -1
  43. package/dist/rate-limit-retry.js +12 -21
  44. package/dist/rate-limit-retry.js.map +1 -1
  45. package/dist/read-body.d.ts +12 -0
  46. package/dist/read-body.d.ts.map +1 -0
  47. package/dist/read-body.js +40 -0
  48. package/dist/read-body.js.map +1 -0
  49. package/dist/retry-after.d.ts.map +1 -1
  50. package/dist/retry-after.js +8 -12
  51. package/dist/retry-after.js.map +1 -1
  52. package/dist/schema/index.d.ts +9 -0
  53. package/dist/schema/index.d.ts.map +1 -1
  54. package/dist/schema/index.js +28 -0
  55. package/dist/schema/index.js.map +1 -1
  56. package/dist/sitemap/index.d.ts +13 -4
  57. package/dist/sitemap/index.d.ts.map +1 -1
  58. package/dist/sitemap/index.js +58 -86
  59. package/dist/sitemap/index.js.map +1 -1
  60. package/dist/testing/index.d.ts +16 -0
  61. package/dist/testing/index.d.ts.map +1 -0
  62. package/dist/testing/index.js +54 -0
  63. package/dist/testing/index.js.map +1 -0
  64. package/dist/unchanged-page.d.ts +9 -0
  65. package/dist/unchanged-page.d.ts.map +1 -0
  66. package/dist/unchanged-page.js +31 -0
  67. package/dist/unchanged-page.js.map +1 -0
  68. package/package.json +12 -7
  69. package/src/cli.ts +25 -6
  70. package/src/content-type.ts +11 -9
  71. package/src/crawl-snapshot.ts +21 -8
  72. package/src/fetch-page.ts +304 -221
  73. package/src/hash.ts +3 -7
  74. package/src/html-metadata.ts +20 -18
  75. package/src/index.ts +7 -0
  76. package/src/limit.ts +1 -1
  77. package/src/markdown-refs.ts +129 -57
  78. package/src/memory-limit.ts +1 -1
  79. package/src/net-guard.ts +126 -16
  80. package/src/rate-limit-retry.ts +20 -20
  81. package/src/read-body.ts +38 -0
  82. package/src/retry-after.ts +11 -18
  83. package/src/schema/index.ts +34 -0
  84. package/src/sitemap/index.ts +83 -86
  85. package/src/testing/index.ts +60 -0
  86. package/src/unchanged-page.ts +37 -0
package/src/fetch-page.ts CHANGED
@@ -1,15 +1,26 @@
1
+ import {
2
+ type ElementOf,
3
+ exponentialBackoff,
4
+ fileExtension,
5
+ getUniqueValues,
6
+ invariant,
7
+ isOneOf,
8
+ isRecord,
9
+ must,
10
+ type Nullable,
11
+ parseRetryAfter,
12
+ startTimer,
13
+ timeoutSignal,
14
+ typedSwitch,
15
+ typedSwitchGroup
16
+ } from 'hanzio'
1
17
  import type { ApiError } from 'hanzio/api-wrapper'
2
- import { parseRetryAfter } from 'hanzio'
3
18
  import {
4
19
  type CloudflareCredentials,
5
20
  CloudflareRateLimitedError,
6
21
  createCloudflareClient
7
22
  } from './client.js'
8
- import {
9
- classifyContentType,
10
- type ContentKind,
11
- convertibleDocumentType
12
- } from './content-type.js'
23
+ import { classifyContentType, convertibleDocumentType } from './content-type.js'
13
24
  import {
14
25
  type CrawlSnapshotInput,
15
26
  type CrawlSnapshotOptions,
@@ -27,6 +38,7 @@ import { extractHtmlMetadata } from './html-metadata.js'
27
38
  import { type CreateConcurrencyLimit, createHostLimits } from './limit.js'
28
39
  import { errorLogAttributes, type Logger, silentLogger } from './logger.js'
29
40
  import {
41
+ cleanConvertedMarkdown,
30
42
  htmlImages,
31
43
  htmlLinks,
32
44
  markdownRefs,
@@ -35,14 +47,32 @@ import {
35
47
  titleFromMarkdown
36
48
  } from './markdown-refs.js'
37
49
  import { createMemoryConcurrencyLimit } from './memory-limit.js'
38
- import { isHostAllowed, isLocalhostHostname } from './net-guard.js'
50
+ import {
51
+ type Fetch,
52
+ followRedirects,
53
+ type GuardedFetchOptions,
54
+ guardedFetch,
55
+ HostNotAllowedError,
56
+ isHostAllowed,
57
+ isLocalhostHostname
58
+ } from './net-guard.js'
39
59
  import { currentOperationSignal } from './operation-signal.js'
60
+ import { decodeText, readBody } from './read-body.js'
61
+ import type { CaptureIssue, FetchOutcome, FetchSource } from './schema/index.js'
62
+ import { asUnchanged, isUnchangedFor } from './unchanged-page.js'
40
63
  import { BROWSER_USER_AGENT } from './user-agent.js'
41
64
  import { preserveXlsxZeros } from './xlsx-zeros.js'
42
65
 
66
+ export type { CaptureIssue, FetchOutcome, FetchSource }
67
+
43
68
  const FETCH_TIMEOUT_MS = 30_000
44
69
  /** Browser Rendering once returned a 6 MB markdown page from a 6 MB upload. */
45
70
  const MAX_HTML_BYTES = 2 * 1024 * 1024
71
+ /**
72
+ * Read cap for a textual body. Above `MAX_HTML_BYTES` a page is not converted
73
+ * but still yields its metadata and links; above this it is not downloaded.
74
+ */
75
+ const MAX_TEXT_READ_BYTES = 2 * MAX_HTML_BYTES
46
76
  const MAX_DOCUMENT_BYTES = 2 * 1024 * 1024
47
77
  const MAX_PDF_BYTES = 12 * 1024 * 1024
48
78
  const MAX_PDF_PAGES = 60
@@ -50,20 +80,6 @@ const MAX_PDF_PAGES = 60
50
80
  /** Stored markdown ceiling; a page over this is a listing or an upload, not knowledge. */
51
81
  export const MAX_MARKDOWN_CHARS = 300_000
52
82
 
53
- export type FetchOutcome =
54
- | 'ok'
55
- | 'unchanged'
56
- | 'gone'
57
- | 'blocked'
58
- | 'transient'
59
- | 'unsupported'
60
- export type FetchSource =
61
- | 'content-negotiation'
62
- | 'workers-ai'
63
- | 'browser-rendering'
64
- | 'not-modified'
65
- | 'firecrawl-parse'
66
-
67
83
  export type FetchedPage = {
68
84
  /** The URL we requested, after http->https upgrade and www fallback. */
69
85
  url: string
@@ -84,13 +100,13 @@ export type FetchedPage = {
84
100
  lang: string | null
85
101
  /** <link rel="canonical">, absolute. */
86
102
  canonical: string | null
87
- /** `<link rel="alternate" hreflang>` targets, absolute, `x-default` excluded. Optional: many callers build a page from parts. */
88
- alternates?: string[]
103
+ /** `<link rel="alternate" hreflang>` targets, absolute, `x-default` excluded. */
104
+ alternates: string[]
89
105
  /** `alternates` by language: URL -> ISO 639-1 (`da` of `da-DK`). */
90
- alternateLanguages?: Record<string, string>
106
+ alternateLanguages: Record<string, string>
91
107
  /** <meta name="description"> or the og: equivalent. */
92
108
  metaDescription: string | null
93
- /** Parsed application/ld+json blocks (any shape; the profile layer reads LocalBusiness/OpeningHoursSpecification). */
109
+ /** Parsed application/ld+json blocks, any shape. */
94
110
  jsonLd: unknown[]
95
111
  /**
96
112
  * Absolute, deduped link targets: the markdown's, unioned with every
@@ -101,13 +117,14 @@ export type FetchedPage = {
101
117
  /** Absolute, deduped image URLs found in the markdown. */
102
118
  images: string[]
103
119
  durationMs: number
104
- captureIssue?: string | null | undefined
120
+ /** Why the page is incomplete or missing, when the library knows. */
121
+ captureIssue: CaptureIssue | null
105
122
  /**
106
123
  * Hash of the HTML the origin served, when an origin tier read HTML. Given
107
124
  * back on the next read (`fetchPageAsMarkdown({ htmlHash })`), identical HTML
108
125
  * is answered as `unchanged` without converting it again.
109
126
  */
110
- htmlHash?: string | null | undefined
127
+ htmlHash: string | null
111
128
  }
112
129
 
113
130
  type PageBase = {
@@ -117,16 +134,27 @@ type PageBase = {
117
134
  contentType: string | null
118
135
  etag: string | null
119
136
  lastModified: string | null
120
- startedAt: number
137
+ elapsedMs: () => number
121
138
  }
122
139
 
123
140
  export type PageBody = {
124
- captureIssue?: string | null | undefined
141
+ captureIssue?: Nullable<CaptureIssue>
125
142
  outcome: FetchOutcome
126
143
  source: FetchSource | null
127
- markdown?: string | null | undefined
144
+ markdown?: Nullable<string>
128
145
  /** The HTML an origin tier read, when one did - the only source of metadata. */
129
- html?: string | null | undefined
146
+ html?: Nullable<string>
147
+ }
148
+
149
+ /**
150
+ * A NUL byte is never text, and Postgres refuses a string that holds one: a
151
+ * single page with one (a Wix site's conversion) failed a whole import.
152
+ * Composed Unicode, because string comparisons downstream assume it: a site
153
+ * that wrote "Gård" as "a" plus a combining ring read as "Ga" to anything
154
+ * matching names in the text.
155
+ */
156
+ function cleanText(text: string) {
157
+ return text.replaceAll('\u0000', '').normalize('NFC')
130
158
  }
131
159
 
132
160
  /**
@@ -139,21 +167,13 @@ function buildPage(
139
167
  rawBody: PageBody,
140
168
  logger: Logger
141
169
  ): FetchedPage {
142
- // A NUL byte is never text, and Postgres refuses a string that holds one:
143
- // a single page with one (a Wix site's conversion) failed the whole import.
144
- // Composed Unicode, because every comparison downstream assumes it: a site
145
- // that wrote "Gård" as "a" plus a combining ring gave the fact guard the
146
- // name "Broges Ga", which no draft could contain, and the document shipped
147
- // as its verbatim source.
148
- const clean = (text: string | null | undefined) =>
149
- text?.replaceAll('\u0000', '').normalize('NFC')
150
170
  const body = {
151
171
  ...rawBody,
152
- markdown: clean(rawBody.markdown),
153
- html: clean(rawBody.html)
172
+ markdown: rawBody.markdown ? cleanText(rawBody.markdown) : null,
173
+ html: rawBody.html ? cleanText(rawBody.html) : null
154
174
  }
155
175
  const frontMatter = stripFrontMatter(body.markdown ?? '')
156
- let markdown = frontMatter.markdown
176
+ let markdown = cleanConvertedMarkdown(frontMatter.markdown)
157
177
  if (markdown.length > MAX_MARKDOWN_CHARS) {
158
178
  logger.warn('url-to-markdown.truncated', {
159
179
  url: base.finalUrl,
@@ -170,7 +190,7 @@ function buildPage(
170
190
  // navigation (image-wrapped anchors, folded menus), so when we hold the
171
191
  // HTML its anchors decide what discovery can still reach.
172
192
  const links = body.html
173
- ? [...new Set([...refs.links, ...htmlLinks(body.html, base.finalUrl)])]
193
+ ? getUniqueValues([...refs.links, ...htmlLinks(body.html, base.finalUrl)])
174
194
  : refs.links
175
195
 
176
196
  return {
@@ -192,17 +212,16 @@ function buildPage(
192
212
  metadata?.metaDescription ?? frontMatter.description ?? null,
193
213
  jsonLd: metadata?.jsonLd ?? [],
194
214
  links,
195
- images: [
196
- ...new Set([
197
- ...refs.images,
198
- ...(body.html ? htmlImages(body.html, base.finalUrl) : [])
199
- ])
200
- ],
215
+ images: getUniqueValues([
216
+ ...refs.images,
217
+ ...(body.html ? htmlImages(body.html, base.finalUrl) : [])
218
+ ]),
201
219
  captureIssue:
202
220
  frontMatter.markdown.length > MAX_MARKDOWN_CHARS
203
221
  ? 'markdown_limit'
204
222
  : (body.captureIssue ?? null),
205
- durationMs: Math.round(performance.now() - base.startedAt),
223
+ durationMs: Math.round(base.elapsedMs()),
224
+ // Over the cleaned HTML, as `convertTextual` compares it.
206
225
  htmlHash: body.html ? htmlHash(body.html) : null
207
226
  }
208
227
  }
@@ -210,9 +229,10 @@ function buildPage(
210
229
  /**
211
230
  * `http://` is upgraded rather than rejected: two carpenter sites in the probe
212
231
  * link internally with `http://` and the https-only guard threw away most of
213
- * their pages. Localhost http stays reachable outside production.
232
+ * their pages. Localhost http stays http, and is reachable only with
233
+ * `allowLocalhost`.
214
234
  */
215
- function prepareRequestUrl(raw: string) {
235
+ function prepareRequestUrl(raw: string, allowLocalhost: boolean) {
216
236
  let parsed: URL
217
237
  try {
218
238
  parsed = new URL(raw)
@@ -222,7 +242,7 @@ function prepareRequestUrl(raw: string) {
222
242
  if (parsed.protocol === 'https:') return parsed
223
243
  if (parsed.protocol !== 'http:') return null
224
244
  if (isLocalhostHostname(parsed.hostname)) {
225
- return process.env.NODE_ENV === 'production' ? null : parsed
245
+ return allowLocalhost ? parsed : null
226
246
  }
227
247
  parsed.protocol = 'https:'
228
248
  if (parsed.port === '80') parsed.port = ''
@@ -244,7 +264,7 @@ function wwwFallbackUrl(url: URL) {
244
264
 
245
265
  /**
246
266
  * A 429 is an origin asking us to slow down, not a bot wall. Shopify answered
247
- * 117 of Good Habit's 244 reads with it; sent on to Browser Rendering they
267
+ * 117 of one store's 244 reads with it; sent on to Browser Rendering they
248
268
  * tripped Cloudflare's own limit (79 times, 25-30 s each) and 7 came back.
249
269
  * So the host is narrowed and paused (`coolDownHost`), the URL is asked again,
250
270
  * and a page still refused after that is `transient`: its stored copy stands
@@ -256,7 +276,15 @@ const MAX_RATE_LIMIT_WAIT_MS = 30_000
256
276
  /** The origin's `Retry-After` when it sent one, else 1 s, 2 s, 4 s. */
257
277
  function rateLimitWaitMs(response: Response, retry: number) {
258
278
  const askedMs = parseRetryAfter(response.headers.get('retry-after'))
259
- return Math.min(MAX_RATE_LIMIT_WAIT_MS, askedMs ?? 1_000 * 2 ** (retry - 1))
279
+ return Math.min(
280
+ MAX_RATE_LIMIT_WAIT_MS,
281
+ askedMs ??
282
+ exponentialBackoff(retry, {
283
+ baseMs: 1_000,
284
+ maxMs: MAX_RATE_LIMIT_WAIT_MS,
285
+ jitter: false
286
+ })
287
+ )
260
288
  }
261
289
 
262
290
  /**
@@ -265,14 +293,14 @@ function rateLimitWaitMs(response: Response, retry: number) {
265
293
  * matches, and every page is converted again once instead of keeping the
266
294
  * markdown the old conversion made.
267
295
  */
268
- const CONVERSION_VERSION = 1
296
+ const CONVERSION_VERSION = 2
269
297
 
270
298
  function htmlHash(html: string) {
271
299
  return sha256Hex(`${CONVERSION_VERSION}\n${html}`)
272
300
  }
273
301
 
274
302
  /** The request URL with the market pinned; the URL itself when there is none. */
275
- function inMarket(url: URL, country: string | null | undefined) {
303
+ function inMarket(url: URL, country: Nullable<string>) {
276
304
  if (!country || url.searchParams.has('country')) return url
277
305
  const pinned = new URL(url.href)
278
306
  pinned.searchParams.set('country', country)
@@ -290,16 +318,16 @@ function parseFinalUrl(responseUrl: string, requestUrl: URL) {
290
318
  }
291
319
 
292
320
  function isAbortError(error: unknown) {
293
- const name =
294
- typeof error === 'object' && error && 'name' in error
295
- ? String(error.name)
296
- : ''
297
- return name === 'AbortError' || name === 'TimeoutError'
321
+ return isRecord(error) && isOneOf(['AbortError', 'TimeoutError'], error.name)
298
322
  }
299
323
 
324
+ /** The content kinds whose body is read as text; the rest are documents or assets. */
325
+ const TEXTUAL_KINDS = ['markdown', 'html', 'text'] as const
326
+ type TextualKind = ElementOf<typeof TEXTUAL_KINDS>
327
+
300
328
  /** What the origin gave us, already downloaded under the host's politeness slot. */
301
329
  type OriginBody =
302
- | { kind: 'none'; captureIssue?: string }
330
+ | { kind: 'none'; captureIssue?: CaptureIssue }
303
331
  | { kind: 'text'; text: string }
304
332
  | { kind: 'bytes'; bytes: ArrayBuffer }
305
333
 
@@ -333,79 +361,71 @@ async function readOriginBody(
333
361
 
334
362
  const contentType = response.headers.get('content-type')
335
363
  const kind = classifyContentType(contentType)
336
- if (kind === 'asset') {
337
- discardBody(response)
338
- return { kind: 'none' }
339
- }
340
- if (kind === 'document') {
341
- const type = convertibleDocumentType(contentType)
342
- if (!type) {
364
+ return typedSwitch(kind, {
365
+ asset: () => {
343
366
  discardBody(response)
344
367
  return { kind: 'none' }
345
- }
346
- const limit = type.extension === 'pdf' ? MAX_PDF_BYTES : MAX_DOCUMENT_BYTES
347
- const declared = Number(response.headers.get('content-length'))
348
- if (Number.isFinite(declared) && declared > limit) {
349
- discardBody(response)
350
- logger.info('url-to-markdown.document_oversized', {
351
- url: finalUrl.href,
352
- bytes: declared
353
- })
354
- return { kind: 'none', captureIssue: 'document_byte_limit' }
355
- }
356
- const bytes = await readBoundedBody(response, limit)
357
- return bytes
358
- ? { kind: 'bytes', bytes }
359
- : { kind: 'none', captureIssue: 'document_byte_limit' }
360
- }
361
- return { kind: 'text', text: await response.text() }
368
+ },
369
+ document: async () => {
370
+ const type = convertibleDocumentType(contentType)
371
+ if (!type) {
372
+ discardBody(response)
373
+ return { kind: 'none' }
374
+ }
375
+ const limit =
376
+ type.extension === 'pdf' ? MAX_PDF_BYTES : MAX_DOCUMENT_BYTES
377
+ const declared = Number(response.headers.get('content-length'))
378
+ if (Number.isFinite(declared) && declared > limit) {
379
+ discardBody(response)
380
+ logger.info('url-to-markdown.document_oversized', {
381
+ url: finalUrl.href,
382
+ bytes: declared
383
+ })
384
+ return { kind: 'none', captureIssue: 'document_byte_limit' }
385
+ }
386
+ const bytes = await readBoundedBody(response, limit)
387
+ return bytes
388
+ ? { kind: 'bytes', bytes }
389
+ : { kind: 'none', captureIssue: 'document_byte_limit' }
390
+ },
391
+ ...typedSwitchGroup(kind, TEXTUAL_KINDS, async (): Promise<OriginBody> => {
392
+ const declared = Number(response.headers.get('content-length'))
393
+ const read =
394
+ Number.isFinite(declared) && declared > MAX_TEXT_READ_BYTES
395
+ ? null
396
+ : await readBody(response, MAX_TEXT_READ_BYTES)
397
+ if (!read || read.truncated) {
398
+ if (!read) discardBody(response)
399
+ logger.info('url-to-markdown.html_oversized', {
400
+ url: finalUrl.href,
401
+ bytes: read ? MAX_TEXT_READ_BYTES : declared
402
+ })
403
+ return { kind: 'none', captureIssue: 'html_byte_limit' }
404
+ }
405
+ return { kind: 'text', text: decodeText(read.bytes) }
406
+ })
407
+ })
362
408
  }
363
409
 
364
410
  function documentFilename(finalUrl: string, extension: string) {
365
411
  const segment = finalUrl.split(/[?#]/)[0]?.split('/').pop() || 'document'
366
412
  const name = segment.slice(0, 80)
367
- return name.toLowerCase().endsWith(`.${extension}`)
368
- ? name
369
- : `${name}.${extension}`
413
+ return fileExtension(name) === extension ? name : `${name}.${extension}`
370
414
  }
371
415
 
372
- /** Streaming limits bound chunked bodies as well as declared Content-Length. */
373
- async function readBoundedBody(
374
- response: Response,
375
- maxBytes: number
376
- ): Promise<ArrayBuffer | null> {
377
- const reader = response.body?.getReader()
378
- if (!reader) return new ArrayBuffer(0)
379
- const chunks: Uint8Array[] = []
380
- let size = 0
381
- while (true) {
382
- const next = await reader.read()
383
- if (next.done) break
384
- size += next.value.byteLength
385
- if (size > maxBytes) {
386
- await reader.cancel()
387
- return null
388
- }
389
- chunks.push(next.value)
390
- }
391
- const bytes = new Uint8Array(size)
392
- let offset = 0
393
- for (const chunk of chunks) {
394
- bytes.set(chunk, offset)
395
- offset += chunk.byteLength
396
- }
397
- return bytes.buffer
416
+ async function readBoundedBody(response: Response, maxBytes: number) {
417
+ const read = await readBody(response, maxBytes)
418
+ return read.truncated ? null : read.bytes.buffer
398
419
  }
399
420
 
400
421
  /**
401
422
  * Markup a visitor never sees until they open it: `<template>` content and
402
423
  * closed `<dialog>`s. A Shopify theme ships its cart drawer ("Your cart is
403
424
  * empty") and search overlay (a product list with prices) in every page this
404
- * way; converted, they reached 70 of one store's 89 pages and a price table
405
- * in seven of its documents (Good Habit audit §3.4). An open dialog is shown
406
- * and stays. A contact or newsletter form (Shopify posts both to `/contact`)
407
- * is labels and a button, and "Telefon Kommentar Send us a note" read as the
408
- * name `Kommentar Send` the fact guard then demanded of the document.
425
+ * way; converted, they reached 70 of one store's 89 pages. An open dialog is
426
+ * shown and stays. A contact or newsletter form (Shopify posts both to
427
+ * `/contact`) is labels and a button, and "Telefon Kommentar Send us a note"
428
+ * read as if `Kommentar Send` were a name on the page.
409
429
  *
410
430
  * Plus the form fields a visitor cannot see: an anti-spam honeypot inside a
411
431
  * `display:none` block renamed its label ("Navn" / "Url") on every request,
@@ -432,30 +452,24 @@ function withoutHiddenUi(html: string) {
432
452
  }
433
453
 
434
454
  /** A real browser passes these; every other 4xx has nothing for it to find. */
435
- const BOT_WALL_STATUSES = new Set([401, 403])
436
-
437
- /** `AbortSignal.timeout` and the caller's signal, whichever fires first. */
438
- function timeoutSignal(ms: number, signal: AbortSignal | undefined) {
439
- const timeout = AbortSignal.timeout(ms)
440
- return signal ? AbortSignal.any([timeout, signal]) : timeout
441
- }
455
+ const BOT_WALL_STATUSES = [401, 403] as const
442
456
 
443
457
  export type FetchPageInput = {
444
458
  url: string
445
459
  /** Stored validators from the last run; a 304 returns outcome 'unchanged'. */
446
- etag?: string | null | undefined
447
- lastModified?: string | null | undefined
460
+ etag?: Nullable<string>
461
+ lastModified?: Nullable<string>
448
462
  /** Allow the Browser Rendering tier. Default true. */
449
463
  render?: boolean | undefined
450
464
  /**
451
465
  * ISO country of the market to read the page in. A Shopify store redirects
452
- * by the visitor's IP - our worker reads a Danish shop as its EU market in
453
- * euros and English (Good Habit audit §3.2) - and `?country=DK` pins it.
454
- * Applied to the request only: the page keeps the URL it was asked for.
466
+ * by the visitor's IP - a worker abroad reads a Danish shop as its EU market
467
+ * in euros and English - and `?country=DK` pins it. Applied to the request
468
+ * only: the page keeps the URL it was asked for.
455
469
  */
456
- country?: string | null | undefined
470
+ country?: Nullable<string>
457
471
  /** The last read's `htmlHash`; identical HTML comes back `unchanged`. */
458
- htmlHash?: string | null | undefined
472
+ htmlHash?: Nullable<string>
459
473
  signal?: AbortSignal | undefined
460
474
  }
461
475
 
@@ -494,10 +508,17 @@ export type WebsiteToMarkdownOptions = {
494
508
  /** The OCR tier for a PDF Workers AI returned nothing for. */
495
509
  parsePdf?: PdfParser
496
510
  /**
497
- * SSRF guard run before every origin request. Defaults to
498
- * {@link isHostAllowed}: public addresses only, localhost outside production.
511
+ * SSRF guard run before every origin request and every redirect it
512
+ * follows. Defaults to {@link isHostAllowed}: public addresses only.
499
513
  */
500
514
  isHostAllowed?: (hostname: string) => Promise<boolean>
515
+ /**
516
+ * Let the default guard and `http://` through to `localhost`, `127.0.0.1`
517
+ * and `::1`, e.g. outside production for a dev server. Default false.
518
+ */
519
+ allowLocalhost?: boolean
520
+ /** The `fetch` origin requests go through, behind the guard. Default the global. */
521
+ fetch?: Fetch
501
522
  }
502
523
 
503
524
  /**
@@ -515,9 +536,11 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
515
536
  logger
516
537
  })
517
538
  const hostLimits = createHostLimits(createLimit)
539
+ const allowLocalhost = options.allowLocalhost === true
518
540
  const allowHost =
519
541
  options.isHostAllowed ??
520
- ((hostname: string) => isHostAllowed(hostname, logger))
542
+ ((hostname: string) => isHostAllowed(hostname, { logger, allowLocalhost }))
543
+ const guard = { isHostAllowed: allowHost, fetch: options.fetch }
521
544
 
522
545
  async function requestOrigin(
523
546
  url: URL,
@@ -525,9 +548,6 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
525
548
  signal?: AbortSignal | undefined
526
549
  ): Promise<OriginAttempt> {
527
550
  if (signal?.aborted) return { kind: 'failed', retryable: false }
528
- if (!(await allowHost(url.hostname))) {
529
- return { kind: 'refused' }
530
- }
531
551
  try {
532
552
  // The politeness slot spans the connection *and* the body: freed at the
533
553
  // response headers it would bound connections only, and the whole 80-wide
@@ -537,12 +557,13 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
537
557
  // our upstreams with their own pools, and holding a customer's slot
538
558
  // across a 60s render would stall the crawl for no politeness gain.
539
559
  const read = await hostLimits.withHostLimit(url.hostname, async () => {
540
- const response = await fetch(url, {
541
- redirect: 'follow',
542
- signal: timeoutSignal(FETCH_TIMEOUT_MS, signal),
543
- headers
544
- })
545
- const finalUrl = parseFinalUrl(response.url, url)
560
+ const hop = await followRedirects(
561
+ url,
562
+ { signal: timeoutSignal(FETCH_TIMEOUT_MS, signal), headers },
563
+ guard
564
+ )
565
+ const response = hop.response
566
+ const finalUrl = parseFinalUrl(response.url, hop.url)
546
567
  return {
547
568
  response,
548
569
  finalUrl,
@@ -551,6 +572,8 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
551
572
  })
552
573
  return { kind: 'read', read }
553
574
  } catch (error) {
575
+ // The guard refused the URL or a redirect: policy, not a network blip.
576
+ if (error instanceof HostNotAllowedError) return { kind: 'refused' }
554
577
  logger.warn('url-to-markdown.origin_failed', {
555
578
  url: url.href,
556
579
  ...errorLogAttributes(error)
@@ -608,8 +631,8 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
608
631
  }
609
632
  if (input.signal?.aborted) return { outcome: 'transient', source: null }
610
633
  try {
611
- if (!options.parsePdf) throw new Error('No PDF parser configured')
612
- const parsed = await options.parsePdf({
634
+ const parsePdf = must(options.parsePdf, 'No PDF parser configured')
635
+ const parsed = await parsePdf({
613
636
  bytes,
614
637
  filename: input.filename,
615
638
  mimeType: input.mimeType,
@@ -653,8 +676,11 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
653
676
  return {
654
677
  outcome: 'unsupported',
655
678
  source: null,
656
- captureIssue:
657
- input.body.kind === 'none' ? input.body.captureIssue : null
679
+ captureIssue: typedSwitch(input.body, 'kind', {
680
+ none: (body) => body.captureIssue,
681
+ text: () => null,
682
+ bytes: () => null
683
+ })
658
684
  }
659
685
  }
660
686
  return convertDocumentBytes({
@@ -668,20 +694,22 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
668
694
 
669
695
  async function convertTextual(input: {
670
696
  text: string
671
- kind: ContentKind
697
+ kind: TextualKind
672
698
  finalUrl: URL
673
699
  render: boolean
674
700
  /** The hash of the HTML last read; see `FetchedPage.htmlHash`. */
675
- htmlHash?: string | null | undefined
701
+ htmlHash?: Nullable<string>
676
702
  signal?: AbortSignal | undefined
677
703
  }): Promise<PageBody> {
678
704
  const body = input.text
679
705
  // The `Content-Type` header is authoritative - sniffing for a leading
680
706
  // `<html` misses every page that opens with a comment or a CDN banner - but
681
707
  // a `text/plain` body that is plainly HTML still needs the HTML tier.
682
- const isHtml =
683
- input.kind === 'html' ||
684
- (input.kind !== 'markdown' && looksLikeHtml(body))
708
+ const isHtml = typedSwitch(input.kind, {
709
+ html: () => true,
710
+ markdown: () => false,
711
+ text: () => looksLikeHtml(body)
712
+ })
685
713
 
686
714
  if (!isHtml && !isBlankMarkdown(body)) {
687
715
  return { outcome: 'ok', source: 'content-negotiation', markdown: body }
@@ -690,7 +718,11 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
690
718
  const html = isHtml ? body : null
691
719
  // Most origins send no validators, so without this every page of a quiet
692
720
  // site went through Workers AI again every night to learn nothing moved.
693
- if (html && input.htmlHash && htmlHash(html) === input.htmlHash) {
721
+ if (
722
+ html &&
723
+ input.htmlHash &&
724
+ htmlHash(cleanText(html)) === input.htmlHash
725
+ ) {
694
726
  return { outcome: 'unchanged', source: 'not-modified' }
695
727
  }
696
728
  if (html && Buffer.byteLength(html) > MAX_HTML_BYTES) {
@@ -701,11 +733,17 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
701
733
  url: input.finalUrl.href,
702
734
  bytes: Buffer.byteLength(html)
703
735
  })
704
- return { outcome: 'unsupported', source: null, html }
736
+ return {
737
+ outcome: 'unsupported',
738
+ source: null,
739
+ html,
740
+ captureIssue: 'html_byte_limit'
741
+ }
705
742
  }
706
743
 
707
744
  let shell = false
708
745
  let workersAiRateLimited = false
746
+ let renderRateLimited = false
709
747
  if (html) {
710
748
  const visible = withoutHiddenUi(html)
711
749
  let converted: string | null = null
@@ -743,7 +781,7 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
743
781
  )
744
782
  } catch (error) {
745
783
  if (!(error instanceof CloudflareRateLimitedError)) throw error
746
- workersAiRateLimited = true
784
+ renderRateLimited = true
747
785
  }
748
786
  if (
749
787
  rendered &&
@@ -766,7 +804,9 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
766
804
  ? 'render_incomplete'
767
805
  : workersAiRateLimited
768
806
  ? 'workers_ai_rate_limited'
769
- : null
807
+ : renderRateLimited
808
+ ? 'browser_rendering_rate_limited'
809
+ : null
770
810
  }
771
811
  }
772
812
 
@@ -774,7 +814,7 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
774
814
  async function readResponse(
775
815
  input: OriginRead & {
776
816
  render: boolean
777
- htmlHash?: string | null | undefined
817
+ htmlHash?: Nullable<string>
778
818
  signal?: AbortSignal | undefined
779
819
  }
780
820
  ): Promise<PageBody> {
@@ -783,7 +823,7 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
783
823
  const contentType = response.headers.get('content-type')
784
824
 
785
825
  if (status === 304) return { outcome: 'unchanged', source: 'not-modified' }
786
- if (status === 404 || status === 410) {
826
+ if (isOneOf([404, 410], status)) {
787
827
  return { outcome: 'gone', source: null }
788
828
  }
789
829
  // Still refused after the host's pauses: rendering would only be refused
@@ -791,13 +831,24 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
791
831
  if (status >= 500 || status === 429)
792
832
  return { outcome: 'transient', source: null }
793
833
  if (status >= 400) {
794
- if (!input.render || !BOT_WALL_STATUSES.has(status)) {
834
+ if (!input.render || !isOneOf(BOT_WALL_STATUSES, status)) {
795
835
  return { outcome: 'blocked', source: null }
796
836
  }
797
- const rendered = await client.renderUrlToMarkdown(
798
- input.finalUrl.href,
799
- input.signal
800
- )
837
+ let rendered: string | null
838
+ try {
839
+ rendered = await client.renderUrlToMarkdown(
840
+ input.finalUrl.href,
841
+ input.signal
842
+ )
843
+ } catch (error) {
844
+ if (!(error instanceof CloudflareRateLimitedError)) throw error
845
+ // The wall may well let a browser through on the next run.
846
+ return {
847
+ outcome: 'transient',
848
+ source: null,
849
+ captureIssue: 'browser_rendering_rate_limited'
850
+ }
851
+ }
801
852
  return rendered
802
853
  ? { outcome: 'ok', source: 'browser-rendering', markdown: rendered }
803
854
  : { outcome: 'blocked', source: null }
@@ -807,23 +858,34 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
807
858
  }
808
859
 
809
860
  const kind = classifyContentType(contentType)
810
- if (kind === 'asset') return { outcome: 'unsupported', source: null }
811
- if (kind === 'document') {
812
- return convertDocument({
813
- body,
814
- contentType,
815
- finalUrl: input.finalUrl,
816
- signal: input.signal
817
- })
818
- }
819
- if (body.kind !== 'text') return { outcome: 'unsupported', source: null }
820
- return convertTextual({
821
- text: body.text,
822
- kind,
823
- finalUrl: input.finalUrl,
824
- render: input.render,
825
- htmlHash: input.htmlHash,
826
- signal: input.signal
861
+ return typedSwitch(kind, {
862
+ asset: () => ({ outcome: 'unsupported', source: null }),
863
+ document: () =>
864
+ convertDocument({
865
+ body,
866
+ contentType,
867
+ finalUrl: input.finalUrl,
868
+ signal: input.signal
869
+ }),
870
+ ...typedSwitchGroup(
871
+ kind,
872
+ TEXTUAL_KINDS,
873
+ async (textualKind): Promise<PageBody> =>
874
+ body.kind === 'text'
875
+ ? convertTextual({
876
+ text: body.text,
877
+ kind: textualKind,
878
+ finalUrl: input.finalUrl,
879
+ render: input.render,
880
+ htmlHash: input.htmlHash,
881
+ signal: input.signal
882
+ })
883
+ : {
884
+ outcome: 'unsupported',
885
+ source: null,
886
+ captureIssue: body.kind === 'none' ? body.captureIssue : null
887
+ }
888
+ )
827
889
  })
828
890
  }
829
891
 
@@ -834,12 +896,39 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
834
896
  * target, validators, HTML metadata and absolute links.
835
897
  *
836
898
  * Never throws: a page that cannot be read is an outcome, not an exception,
837
- * because the caller is crawling hundreds of URLs at a time. A knowledge bench
838
- * run reads through its crawl snapshot (`crawl-snapshot.ts`).
899
+ * because the caller is crawling hundreds of URLs at a time. Anything
900
+ * unexpected (a failing shared limiter, a corrupt snapshot) is logged and
901
+ * comes back `transient`. Inside `withCrawlSnapshot` it reads through the
902
+ * snapshot.
839
903
  */
840
904
  async function fetchPageAsMarkdown(
841
905
  input: FetchPageInput
842
906
  ): Promise<FetchedPage> {
907
+ const elapsedMs = startTimer()
908
+ try {
909
+ return await readPage(input)
910
+ } catch (error) {
911
+ logger.warn('url-to-markdown.failed', {
912
+ url: input.url,
913
+ ...errorLogAttributes(error)
914
+ })
915
+ return buildPage(
916
+ {
917
+ url: input.url,
918
+ finalUrl: input.url,
919
+ status: 0,
920
+ contentType: null,
921
+ etag: null,
922
+ lastModified: null,
923
+ elapsedMs
924
+ },
925
+ { outcome: 'transient', source: null },
926
+ logger
927
+ )
928
+ }
929
+ }
930
+
931
+ async function readPage(input: FetchPageInput): Promise<FetchedPage> {
843
932
  if (!currentCrawlSnapshot()) return fetchPageLive(input)
844
933
 
845
934
  // The snapshot holds the whole page, read without validators; whether it is
@@ -854,37 +943,15 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
854
943
  signal: input.signal
855
944
  })
856
945
  )
857
- const unchanged =
858
- page.outcome === 'ok' &&
859
- ((input.htmlHash && page.htmlHash === input.htmlHash) ||
860
- (input.etag && page.etag === input.etag))
861
- if (!unchanged) return page
862
-
863
- return {
864
- ...page,
865
- outcome: 'unchanged',
866
- source: 'not-modified',
867
- markdown: '',
868
- title: null,
869
- lang: null,
870
- canonical: null,
871
- alternates: [],
872
- alternateLanguages: {},
873
- metaDescription: null,
874
- jsonLd: [],
875
- links: [],
876
- images: [],
877
- captureIssue: null,
878
- htmlHash: null
879
- }
946
+ return isUnchangedFor(page, input) ? asUnchanged(page) : page
880
947
  }
881
948
 
882
949
  async function fetchPageLive(input: FetchPageInput): Promise<FetchedPage> {
883
950
  const signal = input.signal ?? currentOperationSignal()
884
- const startedAt = performance.now()
951
+ const elapsedMs = startTimer()
885
952
  const render = input.render ?? true
886
953
 
887
- const prepared = prepareRequestUrl(input.url)
954
+ const prepared = prepareRequestUrl(input.url, allowLocalhost)
888
955
  if (!prepared) {
889
956
  return buildPage(
890
957
  {
@@ -894,7 +961,7 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
894
961
  contentType: null,
895
962
  etag: null,
896
963
  lastModified: null,
897
- startedAt
964
+ elapsedMs
898
965
  },
899
966
  { outcome: 'unsupported', source: null },
900
967
  logger
@@ -953,11 +1020,14 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
953
1020
  contentType: null,
954
1021
  etag: null,
955
1022
  lastModified: null,
956
- startedAt
1023
+ elapsedMs
957
1024
  },
958
- // A refused host is a policy decision, not a retryable network blip.
959
1025
  {
960
- outcome: attempt.kind === 'refused' ? 'blocked' : 'transient',
1026
+ outcome: typedSwitch(attempt, 'kind', {
1027
+ // A refused host is a policy decision, not a retryable network blip.
1028
+ refused: () => 'blocked',
1029
+ failed: () => 'transient'
1030
+ }),
961
1031
  source: null
962
1032
  },
963
1033
  logger
@@ -965,7 +1035,7 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
965
1035
  }
966
1036
 
967
1037
  const { response, finalUrl } = attempt.read
968
- const notModified = (stored: string | null | undefined) =>
1038
+ const notModified = (stored: Nullable<string>) =>
969
1039
  response.status === 304 ? (stored ?? null) : null
970
1040
  const body = await readResponse({
971
1041
  ...attempt.read,
@@ -987,7 +1057,7 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
987
1057
  lastModified:
988
1058
  response.headers.get('last-modified') ??
989
1059
  notModified(input.lastModified),
990
- startedAt
1060
+ elapsedMs
991
1061
  },
992
1062
  body,
993
1063
  logger
@@ -1024,15 +1094,28 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
1024
1094
  renderUrlToMarkdown: client.renderUrlToMarkdown,
1025
1095
  /** The SSRF guard the converter runs, bound to its `logger`. */
1026
1096
  isHostAllowed: allowHost,
1097
+ /**
1098
+ * `fetch` behind the converter's guard, run on the URL and on every
1099
+ * redirect. Throws `HostNotAllowedError` when one is refused. Use it for
1100
+ * any other request to a customer-supplied URL.
1101
+ */
1102
+ guardedFetch(
1103
+ input: string | URL,
1104
+ init?: RequestInit,
1105
+ fetchOptions?: Pick<GuardedFetchOptions, 'maxRedirects'>
1106
+ ) {
1107
+ return guardedFetch(input, init, { ...guard, ...fetchOptions })
1108
+ },
1027
1109
  /** `withCrawlSnapshot` bound to the `crawlSnapshot` option. */
1028
1110
  withCrawlSnapshot<T>(
1029
- input: CrawlSnapshotInput | null | undefined,
1111
+ input: Nullable<CrawlSnapshotInput>,
1030
1112
  run: () => Promise<T>
1031
1113
  ) {
1032
1114
  if (!input) return run()
1033
- if (!options.crawlSnapshot) {
1034
- throw new Error('Set crawlSnapshot.root to use crawl snapshots')
1035
- }
1115
+ invariant(
1116
+ options.crawlSnapshot,
1117
+ 'Set crawlSnapshot.root to use crawl snapshots'
1118
+ )
1036
1119
  return withCrawlSnapshot(input, run, {
1037
1120
  root: options.crawlSnapshot.root,
1038
1121
  logger: options.crawlSnapshot.logger ?? logger