website-to-markdown 0.0.0-stage → 1.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 (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +78 -2
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +66 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/client.d.ts +40 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +164 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/content-type.d.ts +9 -0
  12. package/dist/content-type.d.ts.map +1 -0
  13. package/dist/content-type.js +71 -0
  14. package/dist/content-type.js.map +1 -0
  15. package/dist/crawl-snapshot.d.ts +38 -0
  16. package/dist/crawl-snapshot.d.ts.map +1 -0
  17. package/dist/crawl-snapshot.js +51 -0
  18. package/dist/crawl-snapshot.js.map +1 -0
  19. package/dist/detect-markdown.d.ts +12 -0
  20. package/dist/detect-markdown.d.ts.map +1 -0
  21. package/dist/detect-markdown.js +37 -0
  22. package/dist/detect-markdown.js.map +1 -0
  23. package/dist/fetch-page.d.ts +133 -0
  24. package/dist/fetch-page.d.ts.map +1 -0
  25. package/dist/fetch-page.js +744 -0
  26. package/dist/fetch-page.js.map +1 -0
  27. package/dist/hash.d.ts +11 -0
  28. package/dist/hash.d.ts.map +1 -0
  29. package/dist/hash.js +16 -0
  30. package/dist/hash.js.map +1 -0
  31. package/dist/html-entities.d.ts +3 -0
  32. package/dist/html-entities.d.ts.map +1 -0
  33. package/dist/html-entities.js +97 -0
  34. package/dist/html-entities.js.map +1 -0
  35. package/dist/html-metadata.d.ts +15 -0
  36. package/dist/html-metadata.d.ts.map +1 -0
  37. package/dist/html-metadata.js +125 -0
  38. package/dist/html-metadata.js.map +1 -0
  39. package/dist/index.d.ts +14 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +13 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/limit.d.ts +85 -0
  44. package/dist/limit.d.ts.map +1 -0
  45. package/dist/limit.js +157 -0
  46. package/dist/limit.js.map +1 -0
  47. package/dist/logger.d.ts +10 -0
  48. package/dist/logger.d.ts.map +1 -0
  49. package/dist/logger.js +7 -0
  50. package/dist/logger.js.map +1 -0
  51. package/dist/markdown-refs.d.ts +31 -0
  52. package/dist/markdown-refs.d.ts.map +1 -0
  53. package/dist/markdown-refs.js +183 -0
  54. package/dist/markdown-refs.js.map +1 -0
  55. package/dist/memory-limit.d.ts +10 -0
  56. package/dist/memory-limit.d.ts.map +1 -0
  57. package/dist/memory-limit.js +75 -0
  58. package/dist/memory-limit.js.map +1 -0
  59. package/dist/net-guard.d.ts +15 -0
  60. package/dist/net-guard.d.ts.map +1 -0
  61. package/dist/net-guard.js +165 -0
  62. package/dist/net-guard.js.map +1 -0
  63. package/dist/operation-signal.d.ts +16 -0
  64. package/dist/operation-signal.d.ts.map +1 -0
  65. package/dist/operation-signal.js +18 -0
  66. package/dist/operation-signal.js.map +1 -0
  67. package/dist/rate-limit-retry.d.ts +48 -0
  68. package/dist/rate-limit-retry.d.ts.map +1 -0
  69. package/dist/rate-limit-retry.js +96 -0
  70. package/dist/rate-limit-retry.js.map +1 -0
  71. package/dist/retry-after.d.ts +11 -0
  72. package/dist/retry-after.d.ts.map +1 -0
  73. package/dist/retry-after.js +40 -0
  74. package/dist/retry-after.js.map +1 -0
  75. package/dist/sitemap/index.d.ts +50 -0
  76. package/dist/sitemap/index.d.ts.map +1 -0
  77. package/dist/sitemap/index.js +281 -0
  78. package/dist/sitemap/index.js.map +1 -0
  79. package/dist/to-markdown.d.ts +30 -0
  80. package/dist/to-markdown.d.ts.map +1 -0
  81. package/dist/to-markdown.js +30 -0
  82. package/dist/to-markdown.js.map +1 -0
  83. package/dist/user-agent.d.ts +8 -0
  84. package/dist/user-agent.d.ts.map +1 -0
  85. package/dist/user-agent.js +8 -0
  86. package/dist/user-agent.js.map +1 -0
  87. package/dist/xlsx-zeros.d.ts +7 -0
  88. package/dist/xlsx-zeros.d.ts.map +1 -0
  89. package/dist/xlsx-zeros.js +45 -0
  90. package/dist/xlsx-zeros.js.map +1 -0
  91. package/package.json +80 -5
  92. package/src/cli.ts +78 -0
  93. package/src/client.ts +219 -0
  94. package/src/content-type.ts +77 -0
  95. package/src/crawl-snapshot.ts +98 -0
  96. package/src/detect-markdown.ts +47 -0
  97. package/src/fetch-page.ts +1010 -0
  98. package/src/hash.ts +16 -0
  99. package/src/html-entities.ts +100 -0
  100. package/src/html-metadata.ts +126 -0
  101. package/src/index.ts +58 -0
  102. package/src/limit.ts +207 -0
  103. package/src/logger.ts +16 -0
  104. package/src/markdown-refs.ts +197 -0
  105. package/src/memory-limit.ts +80 -0
  106. package/src/net-guard.ts +170 -0
  107. package/src/operation-signal.ts +31 -0
  108. package/src/rate-limit-retry.ts +132 -0
  109. package/src/retry-after.ts +51 -0
  110. package/src/sitemap/index.ts +298 -0
  111. package/src/to-markdown.ts +33 -0
  112. package/src/user-agent.ts +8 -0
  113. package/src/xlsx-zeros.ts +58 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hans Kristoffer Hansen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,79 @@
1
- # Temporary Holding Version
1
+ # website-to-markdown
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Turn URLs, HTML and files into markdown through Cloudflare's tiers (content
4
+ negotiation, Workers AI `toMarkdown`, Browser Rendering), with an SSRF guard,
5
+ per-host politeness limits, `Retry-After` handling, crawl snapshots for
6
+ benchmarks, and robots.txt + sitemap discovery. Runs on Bun (it uses
7
+ `HTMLRewriter`).
8
+
9
+ ```sh
10
+ bun add website-to-markdown hanzio zod
11
+ ```
12
+
13
+ ## Convert
14
+
15
+ ```ts
16
+ import { createWebsiteToMarkdown } from 'website-to-markdown'
17
+
18
+ const converter = createWebsiteToMarkdown({
19
+ // Read on first use, so getters over a secret store work.
20
+ cloudflare: { apiToken: '…', accountId: '…' }
21
+ })
22
+
23
+ const page = await converter.fetchPageAsMarkdown({ url: 'https://example.com' })
24
+ if (page.outcome === 'ok') console.log(page.title, page.markdown, page.links)
25
+ ```
26
+
27
+ `fetchPageAsMarkdown` never throws: a page that cannot be read comes back as
28
+ an `outcome` (`unchanged`, `gone`, `blocked`, `transient`, `unsupported`).
29
+ Pass the last read's `etag`, `lastModified` or `htmlHash` to get `unchanged`
30
+ without converting again. `convertDocumentBytes` converts an uploaded file;
31
+ `urlToMarkdown` is the one-shot form that throws.
32
+
33
+ Create one converter per process: it owns the Cloudflare pools and the
34
+ per-host limits.
35
+
36
+ ### Options
37
+
38
+ | Option | Default | |
39
+ |---|---|---|
40
+ | `cloudflare` | required | `{ apiToken, accountId }` |
41
+ | `createLimit` | `createMemoryConcurrencyLimit` | Builds every pool. Pass a shared (Redis) limiter when several processes share the Cloudflare quota. |
42
+ | `onApiError` | none | Workers AI client errors (hanzio `ApiError`) other than 429s. |
43
+ | `logger` | silent | `{ info, warn }` |
44
+ | `parsePdf` | none | OCR tier for a PDF Workers AI returned nothing for. |
45
+ | `isHostAllowed` | public addresses only | SSRF guard run before every origin request. |
46
+
47
+ A limiter is a function `run => Promise` with `tripCooldown(ms)` and
48
+ `dispose()`:
49
+
50
+ ```ts
51
+ import type { CreateConcurrencyLimit } from 'website-to-markdown'
52
+ ```
53
+
54
+ ### Cancellation
55
+
56
+ Pass `signal`, or run inside `withOperationSignal(signal, run)` so every read
57
+ in a job inherits it.
58
+
59
+ ### Crawl snapshots
60
+
61
+ `withCrawlSnapshot({ mode: 'record' | 'replay', name: 'label/host' }, run, { root })`
62
+ records every page and sitemap read under `root` and replays them, so two
63
+ benchmark runs read the same site. Never in production.
64
+
65
+ ## Sitemaps
66
+
67
+ ```ts
68
+ import { discoverSitemapUrls, fetchRobots, robotsAllows } from 'website-to-markdown/sitemap'
69
+
70
+ const robots = await fetchRobots('https://example.com')
71
+ const pages = await discoverSitemapUrls('https://example.com', robots.sitemaps)
72
+ const readable = pages.filter((page) => robotsAllows(robots.rules, page.url))
73
+ ```
74
+
75
+ ## CLI
76
+
77
+ ```sh
78
+ CLOUDFLARE_API_TOKEN=… CLOUDFLARE_ACCOUNT_ID=… bunx website-to-markdown https://example.com [--no-render]
79
+ ```
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Fetch a public URL as markdown and print the result. Page metadata goes to
4
+ * stderr; markdown goes to stdout.
5
+ */
6
+ import { createWebsiteToMarkdown } from './fetch-page.js';
7
+ const HELP = `
8
+ Fetch a public URL as markdown.
9
+
10
+ Usage:
11
+ website-to-markdown <url> [--no-render]
12
+
13
+ Options:
14
+ --no-render Skip the Browser Rendering tier (HTML tiers only).
15
+
16
+ Environment:
17
+ CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID
18
+
19
+ Example:
20
+ website-to-markdown https://example.com/about
21
+ `.trim();
22
+ const args = Bun.argv.slice(2);
23
+ const url = args.find((arg) => !arg.startsWith('-'));
24
+ if (!url || args.includes('-h') || args.includes('--help')) {
25
+ console.log(HELP);
26
+ process.exit(url ? 0 : 1);
27
+ }
28
+ const { CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID } = process.env;
29
+ if (!CLOUDFLARE_API_TOKEN || !CLOUDFLARE_ACCOUNT_ID) {
30
+ console.error('Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.');
31
+ process.exit(1);
32
+ }
33
+ const { fetchPageAsMarkdown } = createWebsiteToMarkdown({
34
+ cloudflare: {
35
+ apiToken: CLOUDFLARE_API_TOKEN,
36
+ accountId: CLOUDFLARE_ACCOUNT_ID
37
+ }
38
+ });
39
+ console.error(`Fetching ${url}…`);
40
+ const page = await fetchPageAsMarkdown({
41
+ url,
42
+ render: !args.includes('--no-render')
43
+ });
44
+ console.error(JSON.stringify({
45
+ outcome: page.outcome,
46
+ status: page.status,
47
+ source: page.source,
48
+ url: page.url,
49
+ finalUrl: page.finalUrl,
50
+ contentType: page.contentType,
51
+ etag: page.etag,
52
+ lastModified: page.lastModified,
53
+ title: page.title,
54
+ lang: page.lang,
55
+ canonical: page.canonical,
56
+ metaDescription: page.metaDescription,
57
+ jsonLdBlocks: page.jsonLd.length,
58
+ links: page.links.length,
59
+ images: page.images.length,
60
+ markdownChars: page.markdown.length,
61
+ durationMs: page.durationMs
62
+ }, null, 2));
63
+ console.log(page.markdown);
64
+ if (page.outcome !== 'ok')
65
+ process.exit(1);
66
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA;;;GAGG;AACH,OAAO,EAAE,uBAAuB,EAAE,MAAM,iBAAiB,CAAA;AAEzD,MAAM,IAAI,GAAG;;;;;;;;;;;;;;CAcZ,CAAC,IAAI,EAAE,CAAA;AAER,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;AAC9B,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAA;AACpD,IAAI,CAAC,GAAG,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;IAC5D,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;IACjB,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;AAC1B,CAAC;AAED,MAAM,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,GAAG,OAAO,CAAC,GAAG,CAAA;AACnE,IAAI,CAAC,oBAAoB,IAAI,CAAC,qBAAqB,EAAE,CAAC;IACrD,OAAO,CAAC,KAAK,CAAC,qDAAqD,CAAC,CAAA;IACpE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AAChB,CAAC;AAED,MAAM,EAAE,mBAAmB,EAAE,GAAG,uBAAuB,CAAC;IACvD,UAAU,EAAE;QACX,QAAQ,EAAE,oBAAoB;QAC9B,SAAS,EAAE,qBAAqB;KAChC;CACD,CAAC,CAAA;AAEF,OAAO,CAAC,KAAK,CAAC,YAAY,GAAG,GAAG,CAAC,CAAA;AAEjC,MAAM,IAAI,GAAG,MAAM,mBAAmB,CAAC;IACtC,GAAG;IACH,MAAM,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,aAAa,CAAC;CACrC,CAAC,CAAA;AAEF,OAAO,CAAC,KAAK,CACZ,IAAI,CAAC,SAAS,CACb;IACC,OAAO,EAAE,IAAI,CAAC,OAAO;IACrB,MAAM,EAAE,IAAI,CAAC,MAAM;IACnB,MAAM,EAAE,IAAI,CAAC,MAAM;IACnB,GAAG,EAAE,IAAI,CAAC,GAAG;IACb,QAAQ,EAAE,IAAI,CAAC,QAAQ;IACvB,WAAW,EAAE,IAAI,CAAC,WAAW;IAC7B,IAAI,EAAE,IAAI,CAAC,IAAI;IACf,YAAY,EAAE,IAAI,CAAC,YAAY;IAC/B,KAAK,EAAE,IAAI,CAAC,KAAK;IACjB,IAAI,EAAE,IAAI,CAAC,IAAI;IACf,SAAS,EAAE,IAAI,CAAC,SAAS;IACzB,eAAe,EAAE,IAAI,CAAC,eAAe;IACrC,YAAY,EAAE,IAAI,CAAC,MAAM,CAAC,MAAM;IAChC,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,MAAM;IACxB,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,MAAM;IAC1B,aAAa,EAAE,IAAI,CAAC,QAAQ,CAAC,MAAM;IACnC,UAAU,EAAE,IAAI,CAAC,UAAU;CAC3B,EACD,IAAI,EACJ,CAAC,CACD,CACD,CAAA;AAED,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;AAC1B,IAAI,IAAI,CAAC,OAAO,KAAK,IAAI;IAAE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA","sourcesContent":["#!/usr/bin/env bun\n/**\n * Fetch a public URL as markdown and print the result. Page metadata goes to\n * stderr; markdown goes to stdout.\n */\nimport { createWebsiteToMarkdown } from './fetch-page.js'\n\nconst HELP = `\nFetch a public URL as markdown.\n\nUsage:\n website-to-markdown <url> [--no-render]\n\nOptions:\n --no-render Skip the Browser Rendering tier (HTML tiers only).\n\nEnvironment:\n CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID\n\nExample:\n website-to-markdown https://example.com/about\n`.trim()\n\nconst args = Bun.argv.slice(2)\nconst url = args.find((arg) => !arg.startsWith('-'))\nif (!url || args.includes('-h') || args.includes('--help')) {\n\tconsole.log(HELP)\n\tprocess.exit(url ? 0 : 1)\n}\n\nconst { CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID } = process.env\nif (!CLOUDFLARE_API_TOKEN || !CLOUDFLARE_ACCOUNT_ID) {\n\tconsole.error('Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.')\n\tprocess.exit(1)\n}\n\nconst { fetchPageAsMarkdown } = createWebsiteToMarkdown({\n\tcloudflare: {\n\t\tapiToken: CLOUDFLARE_API_TOKEN,\n\t\taccountId: CLOUDFLARE_ACCOUNT_ID\n\t}\n})\n\nconsole.error(`Fetching ${url}…`)\n\nconst page = await fetchPageAsMarkdown({\n\turl,\n\trender: !args.includes('--no-render')\n})\n\nconsole.error(\n\tJSON.stringify(\n\t\t{\n\t\t\toutcome: page.outcome,\n\t\t\tstatus: page.status,\n\t\t\tsource: page.source,\n\t\t\turl: page.url,\n\t\t\tfinalUrl: page.finalUrl,\n\t\t\tcontentType: page.contentType,\n\t\t\tetag: page.etag,\n\t\t\tlastModified: page.lastModified,\n\t\t\ttitle: page.title,\n\t\t\tlang: page.lang,\n\t\t\tcanonical: page.canonical,\n\t\t\tmetaDescription: page.metaDescription,\n\t\t\tjsonLdBlocks: page.jsonLd.length,\n\t\t\tlinks: page.links.length,\n\t\t\timages: page.images.length,\n\t\t\tmarkdownChars: page.markdown.length,\n\t\t\tdurationMs: page.durationMs\n\t\t},\n\t\tnull,\n\t\t2\n\t)\n)\n\nconsole.log(page.markdown)\nif (page.outcome !== 'ok') process.exit(1)\n"]}
@@ -0,0 +1,40 @@
1
+ import { type ApiError } from 'hanzio/api-wrapper';
2
+ import { type CreateConcurrencyLimit } from './limit.js';
3
+ import { type Logger } from './logger.js';
4
+ import { CloudflareRateLimitedError } from './rate-limit-retry.js';
5
+ export { CloudflareRateLimitedError };
6
+ /** Read on first use, so getters over a secret store work. */
7
+ export type CloudflareCredentials = {
8
+ apiToken: string;
9
+ accountId: string;
10
+ };
11
+ export type ConvertFileInput = {
12
+ signal?: AbortSignal | undefined;
13
+ body: string | ArrayBuffer;
14
+ /** Cloudflare picks its converter from the extension. */
15
+ filename: string;
16
+ mimeType: string;
17
+ /** HTML only: base host for relative links. */
18
+ hostname?: string | undefined;
19
+ };
20
+ /**
21
+ * The two Cloudflare tiers behind their pools. Rate-limit retries live in
22
+ * `withCloudflareRateLimitRetry` so a 429 releases the slot and trips the
23
+ * shared cooldown before the next try.
24
+ */
25
+ export declare function createCloudflareClient(options: {
26
+ credentials: CloudflareCredentials;
27
+ createLimit: CreateConcurrencyLimit;
28
+ /** Every Workers AI client error except a 429, which is retried instead. */
29
+ onApiError?: ((error: ApiError) => void) | undefined;
30
+ logger: Logger;
31
+ }): {
32
+ convertFileWithWorkersAi: (input: ConvertFileInput) => Promise<string | null>;
33
+ convertHtmlWithWorkersAi: (input: {
34
+ html: string;
35
+ hostname: string;
36
+ signal?: AbortSignal | undefined;
37
+ }) => Promise<string | null>;
38
+ renderUrlToMarkdown: (url: string, signal?: AbortSignal) => Promise<string | null>;
39
+ };
40
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EACN,KAAK,QAAQ,EAGb,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAEN,KAAK,sBAAsB,EAE3B,MAAM,YAAY,CAAA;AACnB,OAAO,EAAsB,KAAK,MAAM,EAAE,MAAM,aAAa,CAAA;AAC7D,OAAO,EACN,0BAA0B,EAE1B,MAAM,uBAAuB,CAAA;AAK9B,OAAO,EAAE,0BAA0B,EAAE,CAAA;AAErC,8DAA8D;AAC9D,MAAM,MAAM,qBAAqB,GAAG;IACnC,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;CACjB,CAAA;AASD,MAAM,MAAM,gBAAgB,GAAG;IAC9B,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAA;IAChC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IAC1B,yDAAyD;IACzD,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;IAChB,+CAA+C;IAC/C,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAC7B,CAAA;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE;IAC/C,WAAW,EAAE,qBAAqB,CAAA;IAClC,WAAW,EAAE,sBAAsB,CAAA;IACnC,4EAA4E;IAC5E,UAAU,CAAC,EAAE,CAAC,CAAC,KAAK,EAAE,QAAQ,KAAK,IAAI,CAAC,GAAG,SAAS,CAAA;IACpD,MAAM,EAAE,MAAM,CAAA;CACd;sCAkF+C,gBAAgB;sCAmCtB;QACxC,IAAI,EAAE,MAAM,CAAA;QACZ,QAAQ,EAAE,MAAM,CAAA;QAChB,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAA;KAChC;+BAUuC,MAAM,WAAW,WAAW;EA+BpE"}
package/dist/client.js ADDED
@@ -0,0 +1,164 @@
1
+ import Cloudflare from 'cloudflare';
2
+ import { createApiClient, HttpResponseError } from 'hanzio/api-wrapper';
3
+ import { createCloudflareLimits } from './limit.js';
4
+ import { errorLogAttributes } from './logger.js';
5
+ import { CloudflareRateLimitedError, withCloudflareRateLimitRetry } from './rate-limit-retry.js';
6
+ import { rateLimitFromError } from './retry-after.js';
7
+ import { toMarkdown } from './to-markdown.js';
8
+ import { BROWSER_USER_AGENT } from './user-agent.js';
9
+ export { CloudflareRateLimitedError };
10
+ const BROWSER_RENDERING_TIMEOUT_MS = 60_000;
11
+ /**
12
+ * `networkidle0` never settles on Squarespace commerce pages and burned 30s
13
+ * timeouts in the probe; `load` plus a short settle gets the same markdown.
14
+ */
15
+ const BROWSER_RENDERING_SETTLE_MS = 1_500;
16
+ /**
17
+ * The two Cloudflare tiers behind their pools. Rate-limit retries live in
18
+ * `withCloudflareRateLimitRetry` so a 429 releases the slot and trips the
19
+ * shared cooldown before the next try.
20
+ */
21
+ export function createCloudflareClient(options) {
22
+ const { credentials, logger } = options;
23
+ const limits = createCloudflareLimits(options.createLimit);
24
+ function reportUnlessRateLimited(error) {
25
+ // Expected transient throttles: we retry and trip the shared cooldown.
26
+ // Reporting every 429 floods the error tracker without adding signal.
27
+ if (error instanceof HttpResponseError && error.status === 429)
28
+ return;
29
+ options.onApiError?.(error);
30
+ }
31
+ let cloudflareSdk;
32
+ let toMarkdownApi;
33
+ function createToMarkdownApi() {
34
+ return createApiClient({
35
+ name: 'cloudflareToMarkdownApi',
36
+ baseApiUrls: {
37
+ default: 'https://api.cloudflare.com/client/v4'
38
+ },
39
+ defaultHeaders: {
40
+ Authorization: `Bearer ${credentials.apiToken}`
41
+ },
42
+ timeoutMs: 30_000,
43
+ retries: 0,
44
+ onError: reportUnlessRateLimited,
45
+ endpoints: {
46
+ toMarkdown
47
+ }
48
+ });
49
+ }
50
+ function getSdk() {
51
+ cloudflareSdk ??= new Cloudflare({ apiToken: credentials.apiToken });
52
+ return cloudflareSdk;
53
+ }
54
+ async function transform(file) {
55
+ toMarkdownApi ??= createToMarkdownApi();
56
+ const res = await toMarkdownApi.toMarkdown({
57
+ ...(file.signal && { signal: file.signal }),
58
+ reqParams: { accountId: credentials.accountId },
59
+ reqBody: {
60
+ files: new File([file.body], file.filename, {
61
+ type: file.mimeType
62
+ }),
63
+ // Only the HTML converter takes options; it uses the hostname to
64
+ // resolve relative links while it walks the document.
65
+ conversionOptions: file.hostname
66
+ ? JSON.stringify({ html: { hostname: file.hostname } })
67
+ : undefined
68
+ }
69
+ });
70
+ return res.data;
71
+ }
72
+ async function runWithCloudflareRateLimit(input) {
73
+ return withCloudflareRateLimitRetry({
74
+ event: input.event,
75
+ signal: input.signal,
76
+ rateLimitFromError,
77
+ tripCooldown: (waitMs) => input.limit.tripCooldown(waitMs),
78
+ onRateLimit: ({ attempt, waitMs }) => {
79
+ logger.warn(`${input.event}.rate_limited`, {
80
+ attempt,
81
+ wait_ms: waitMs
82
+ });
83
+ },
84
+ run: () => input.limit(input.run)
85
+ });
86
+ }
87
+ /**
88
+ * Workers AI `toMarkdown` over any file it accepts - HTML, PDF, docx, xlsx,
89
+ * pptx. Returns null for a soft tier miss. Throws `CloudflareRateLimitedError`
90
+ * when 429 retries are exhausted so callers can surface a clear capture issue.
91
+ */
92
+ async function convertFileWithWorkersAi(input) {
93
+ try {
94
+ const envelope = await runWithCloudflareRateLimit({
95
+ event: 'workers-ai.to-markdown',
96
+ limit: limits.workersAi,
97
+ signal: input.signal,
98
+ run: () => {
99
+ input.signal?.throwIfAborted();
100
+ return transform(input);
101
+ }
102
+ });
103
+ input.signal?.throwIfAborted();
104
+ const first = envelope.result[0];
105
+ if (!envelope.success ||
106
+ !first ||
107
+ first.format === 'error' ||
108
+ !first.data?.trim()) {
109
+ logger.warn('workers-ai.to-markdown.unusable', {
110
+ success: envelope.success,
111
+ format: first?.format ?? '',
112
+ mime_type: input.mimeType,
113
+ has_error: Boolean(first?.error)
114
+ });
115
+ return null;
116
+ }
117
+ return first.data;
118
+ }
119
+ catch (error) {
120
+ if (error instanceof CloudflareRateLimitedError)
121
+ throw error;
122
+ logger.warn('workers-ai.to-markdown.failed', errorLogAttributes(error));
123
+ return null;
124
+ }
125
+ }
126
+ function convertHtmlWithWorkersAi(input) {
127
+ return convertFileWithWorkersAi({
128
+ body: input.html,
129
+ filename: 'page.html',
130
+ mimeType: 'text/html',
131
+ hostname: input.hostname,
132
+ signal: input.signal
133
+ });
134
+ }
135
+ async function renderUrlToMarkdown(url, signal) {
136
+ try {
137
+ const markdown = await runWithCloudflareRateLimit({
138
+ event: 'browser-rendering',
139
+ limit: limits.browserRendering,
140
+ signal,
141
+ run: () => getSdk().browserRendering.markdown.create({
142
+ account_id: credentials.accountId,
143
+ url,
144
+ userAgent: BROWSER_USER_AGENT,
145
+ gotoOptions: { waitUntil: 'load' },
146
+ waitForTimeout: BROWSER_RENDERING_SETTLE_MS
147
+ }, { timeout: BROWSER_RENDERING_TIMEOUT_MS, signal })
148
+ });
149
+ return markdown.trim() || null;
150
+ }
151
+ catch (error) {
152
+ if (error instanceof CloudflareRateLimitedError)
153
+ throw error;
154
+ logger.warn('browser-rendering.failed', errorLogAttributes(error));
155
+ return null;
156
+ }
157
+ }
158
+ return {
159
+ convertFileWithWorkersAi,
160
+ convertHtmlWithWorkersAi,
161
+ renderUrlToMarkdown
162
+ };
163
+ }
164
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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","sourcesContent":["import Cloudflare from 'cloudflare'\nimport {\n\ttype ApiError,\n\tcreateApiClient,\n\tHttpResponseError\n} from 'hanzio/api-wrapper'\nimport {\n\ttype ConcurrencyLimit,\n\ttype CreateConcurrencyLimit,\n\tcreateCloudflareLimits\n} from './limit.js'\nimport { errorLogAttributes, type Logger } from './logger.js'\nimport {\n\tCloudflareRateLimitedError,\n\twithCloudflareRateLimitRetry\n} from './rate-limit-retry.js'\nimport { rateLimitFromError } from './retry-after.js'\nimport { toMarkdown } from './to-markdown.js'\nimport { BROWSER_USER_AGENT } from './user-agent.js'\n\nexport { CloudflareRateLimitedError }\n\n/** Read on first use, so getters over a secret store work. */\nexport type CloudflareCredentials = {\n\tapiToken: string\n\taccountId: string\n}\n\nconst BROWSER_RENDERING_TIMEOUT_MS = 60_000\n/**\n * `networkidle0` never settles on Squarespace commerce pages and burned 30s\n * timeouts in the probe; `load` plus a short settle gets the same markdown.\n */\nconst BROWSER_RENDERING_SETTLE_MS = 1_500\n\nexport type ConvertFileInput = {\n\tsignal?: AbortSignal | undefined\n\tbody: string | ArrayBuffer\n\t/** Cloudflare picks its converter from the extension. */\n\tfilename: string\n\tmimeType: string\n\t/** HTML only: base host for relative links. */\n\thostname?: string | undefined\n}\n\n/**\n * The two Cloudflare tiers behind their pools. Rate-limit retries live in\n * `withCloudflareRateLimitRetry` so a 429 releases the slot and trips the\n * shared cooldown before the next try.\n */\nexport function createCloudflareClient(options: {\n\tcredentials: CloudflareCredentials\n\tcreateLimit: CreateConcurrencyLimit\n\t/** Every Workers AI client error except a 429, which is retried instead. */\n\tonApiError?: ((error: ApiError) => void) | undefined\n\tlogger: Logger\n}) {\n\tconst { credentials, logger } = options\n\tconst limits = createCloudflareLimits(options.createLimit)\n\n\tfunction reportUnlessRateLimited(error: ApiError) {\n\t\t// Expected transient throttles: we retry and trip the shared cooldown.\n\t\t// Reporting every 429 floods the error tracker without adding signal.\n\t\tif (error instanceof HttpResponseError && error.status === 429) return\n\t\toptions.onApiError?.(error)\n\t}\n\n\tlet cloudflareSdk: Cloudflare | undefined\n\tlet toMarkdownApi: ReturnType<typeof createToMarkdownApi> | undefined\n\n\tfunction createToMarkdownApi() {\n\t\treturn createApiClient({\n\t\t\tname: 'cloudflareToMarkdownApi',\n\t\t\tbaseApiUrls: {\n\t\t\t\tdefault: 'https://api.cloudflare.com/client/v4'\n\t\t\t},\n\t\t\tdefaultHeaders: {\n\t\t\t\tAuthorization: `Bearer ${credentials.apiToken}`\n\t\t\t},\n\t\t\ttimeoutMs: 30_000,\n\t\t\tretries: 0,\n\t\t\tonError: reportUnlessRateLimited,\n\t\t\tendpoints: {\n\t\t\t\ttoMarkdown\n\t\t\t}\n\t\t})\n\t}\n\n\tfunction getSdk() {\n\t\tcloudflareSdk ??= new Cloudflare({ apiToken: credentials.apiToken })\n\t\treturn cloudflareSdk\n\t}\n\n\tasync function transform(file: ConvertFileInput) {\n\t\ttoMarkdownApi ??= createToMarkdownApi()\n\t\tconst res = await toMarkdownApi.toMarkdown({\n\t\t\t...(file.signal && { signal: file.signal }),\n\t\t\treqParams: { accountId: credentials.accountId },\n\t\t\treqBody: {\n\t\t\t\tfiles: new File([file.body], file.filename, {\n\t\t\t\t\ttype: file.mimeType\n\t\t\t\t}),\n\t\t\t\t// Only the HTML converter takes options; it uses the hostname to\n\t\t\t\t// resolve relative links while it walks the document.\n\t\t\t\tconversionOptions: file.hostname\n\t\t\t\t\t? JSON.stringify({ html: { hostname: file.hostname } })\n\t\t\t\t\t: undefined\n\t\t\t}\n\t\t})\n\t\treturn res.data\n\t}\n\n\tasync function runWithCloudflareRateLimit<T>(input: {\n\t\tevent: string\n\t\tlimit: ConcurrencyLimit\n\t\tsignal?: AbortSignal | undefined\n\t\trun: () => Promise<T>\n\t}) {\n\t\treturn withCloudflareRateLimitRetry({\n\t\t\tevent: input.event,\n\t\t\tsignal: input.signal,\n\t\t\trateLimitFromError,\n\t\t\ttripCooldown: (waitMs) => input.limit.tripCooldown(waitMs),\n\t\t\tonRateLimit: ({ attempt, waitMs }) => {\n\t\t\t\tlogger.warn(`${input.event}.rate_limited`, {\n\t\t\t\t\tattempt,\n\t\t\t\t\twait_ms: waitMs\n\t\t\t\t})\n\t\t\t},\n\t\t\trun: () => input.limit(input.run)\n\t\t})\n\t}\n\n\t/**\n\t * Workers AI `toMarkdown` over any file it accepts - HTML, PDF, docx, xlsx,\n\t * pptx. Returns null for a soft tier miss. Throws `CloudflareRateLimitedError`\n\t * when 429 retries are exhausted so callers can surface a clear capture issue.\n\t */\n\tasync function convertFileWithWorkersAi(input: ConvertFileInput) {\n\t\ttry {\n\t\t\tconst envelope = await runWithCloudflareRateLimit({\n\t\t\t\tevent: 'workers-ai.to-markdown',\n\t\t\t\tlimit: limits.workersAi,\n\t\t\t\tsignal: input.signal,\n\t\t\t\trun: () => {\n\t\t\t\t\tinput.signal?.throwIfAborted()\n\t\t\t\t\treturn transform(input)\n\t\t\t\t}\n\t\t\t})\n\t\t\tinput.signal?.throwIfAborted()\n\t\t\tconst first = envelope.result[0]\n\t\t\tif (\n\t\t\t\t!envelope.success ||\n\t\t\t\t!first ||\n\t\t\t\tfirst.format === 'error' ||\n\t\t\t\t!first.data?.trim()\n\t\t\t) {\n\t\t\t\tlogger.warn('workers-ai.to-markdown.unusable', {\n\t\t\t\t\tsuccess: envelope.success,\n\t\t\t\t\tformat: first?.format ?? '',\n\t\t\t\t\tmime_type: input.mimeType,\n\t\t\t\t\thas_error: Boolean(first?.error)\n\t\t\t\t})\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn first.data\n\t\t} catch (error) {\n\t\t\tif (error instanceof CloudflareRateLimitedError) throw error\n\t\t\tlogger.warn('workers-ai.to-markdown.failed', errorLogAttributes(error))\n\t\t\treturn null\n\t\t}\n\t}\n\n\tfunction convertHtmlWithWorkersAi(input: {\n\t\thtml: string\n\t\thostname: string\n\t\tsignal?: AbortSignal | undefined\n\t}) {\n\t\treturn convertFileWithWorkersAi({\n\t\t\tbody: input.html,\n\t\t\tfilename: 'page.html',\n\t\t\tmimeType: 'text/html',\n\t\t\thostname: input.hostname,\n\t\t\tsignal: input.signal\n\t\t})\n\t}\n\n\tasync function renderUrlToMarkdown(url: string, signal?: AbortSignal) {\n\t\ttry {\n\t\t\tconst markdown = await runWithCloudflareRateLimit({\n\t\t\t\tevent: 'browser-rendering',\n\t\t\t\tlimit: limits.browserRendering,\n\t\t\t\tsignal,\n\t\t\t\trun: () =>\n\t\t\t\t\tgetSdk().browserRendering.markdown.create(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\taccount_id: credentials.accountId,\n\t\t\t\t\t\t\turl,\n\t\t\t\t\t\t\tuserAgent: BROWSER_USER_AGENT,\n\t\t\t\t\t\t\tgotoOptions: { waitUntil: 'load' },\n\t\t\t\t\t\t\twaitForTimeout: BROWSER_RENDERING_SETTLE_MS\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ timeout: BROWSER_RENDERING_TIMEOUT_MS, signal }\n\t\t\t\t\t)\n\t\t\t})\n\t\t\treturn markdown.trim() || null\n\t\t} catch (error) {\n\t\t\tif (error instanceof CloudflareRateLimitedError) throw error\n\t\t\tlogger.warn('browser-rendering.failed', errorLogAttributes(error))\n\t\t\treturn null\n\t\t}\n\t}\n\n\treturn {\n\t\tconvertFileWithWorkersAi,\n\t\tconvertHtmlWithWorkersAi,\n\t\trenderUrlToMarkdown\n\t}\n}\n"]}
@@ -0,0 +1,9 @@
1
+ /** The upload descriptor for a binary we can convert, or null for text/assets. */
2
+ export declare function convertibleDocumentType(contentType: string | null): {
3
+ extension: string;
4
+ mimeType: string;
5
+ } | null;
6
+ export type ContentKind = 'markdown' | 'html' | 'text' | 'document' | 'asset';
7
+ /** What the fetch layer should do with a body, decided from the header alone. */
8
+ export declare function classifyContentType(contentType: string | null): ContentKind;
9
+ //# sourceMappingURL=content-type.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-type.d.ts","sourceRoot":"","sources":["../src/content-type.ts"],"names":[],"mappings":"AA2DA,kFAAkF;AAClF,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI;;;SAIjE;AAED,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,MAAM,GAAG,MAAM,GAAG,UAAU,GAAG,OAAO,CAAA;AAE7E,iFAAiF;AACjF,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,WAAW,CAO3E"}
@@ -0,0 +1,71 @@
1
+ const MARKDOWN_CONTENT_TYPES = new Set([
2
+ 'text/markdown',
3
+ 'text/x-markdown',
4
+ 'application/markdown'
5
+ ]);
6
+ const HTML_CONTENT_TYPES = new Set(['text/html', 'application/xhtml+xml']);
7
+ const TEXTUAL_CONTENT_TYPES = new Set([
8
+ 'application/xml',
9
+ 'application/json',
10
+ 'application/rss+xml',
11
+ 'application/atom+xml'
12
+ ]);
13
+ /**
14
+ * The formats Workers AI `toMarkdown` accepts as documents (its `/supported`
15
+ * list, read 2026-09-08). Images are accepted there too but are deliberately
16
+ * absent: this list decides what the CRAWLER downloads and converts, and a
17
+ * site's gallery must stay an asset. Uploads accept images on their own list.
18
+ */
19
+ const CONVERTIBLE_DOCUMENT_EXTENSIONS = new Map([
20
+ ['application/pdf', 'pdf'],
21
+ [
22
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
23
+ 'docx'
24
+ ],
25
+ ['application/vnd.oasis.opendocument.text', 'odt'],
26
+ ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'xlsx'],
27
+ ['application/vnd.ms-excel.sheet.macroenabled.12', 'xlsm'],
28
+ ['application/vnd.ms-excel.sheet.binary.macroenabled.12', 'xlsb'],
29
+ ['application/vnd.ms-excel', 'xls'],
30
+ ['application/vnd.oasis.opendocument.spreadsheet', 'ods'],
31
+ [
32
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
33
+ 'pptx'
34
+ ],
35
+ [
36
+ 'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
37
+ 'ppsx'
38
+ ],
39
+ ['application/vnd.oasis.opendocument.presentation', 'odp']
40
+ ]);
41
+ function normalizeContentType(contentType) {
42
+ return contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
43
+ }
44
+ function isTextualContentType(type) {
45
+ return (type === '' ||
46
+ type.startsWith('text/') ||
47
+ TEXTUAL_CONTENT_TYPES.has(type) ||
48
+ // `application/ld+json`, `application/rdf+xml`, … but not `image/svg+xml`.
49
+ (type.startsWith('application/') &&
50
+ (type.endsWith('+json') || type.endsWith('+xml'))));
51
+ }
52
+ /** The upload descriptor for a binary we can convert, or null for text/assets. */
53
+ export function convertibleDocumentType(contentType) {
54
+ const mimeType = normalizeContentType(contentType);
55
+ const extension = CONVERTIBLE_DOCUMENT_EXTENSIONS.get(mimeType);
56
+ return extension ? { extension, mimeType } : null;
57
+ }
58
+ /** What the fetch layer should do with a body, decided from the header alone. */
59
+ export function classifyContentType(contentType) {
60
+ const type = normalizeContentType(contentType);
61
+ if (MARKDOWN_CONTENT_TYPES.has(type))
62
+ return 'markdown';
63
+ if (HTML_CONTENT_TYPES.has(type))
64
+ return 'html';
65
+ if (CONVERTIBLE_DOCUMENT_EXTENSIONS.has(type))
66
+ return 'document';
67
+ if (isTextualContentType(type))
68
+ return 'text';
69
+ return 'asset';
70
+ }
71
+ //# sourceMappingURL=content-type.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-type.js","sourceRoot":"","sources":["../src/content-type.ts"],"names":[],"mappings":"AAAA,MAAM,sBAAsB,GAAG,IAAI,GAAG,CAAC;IACtC,eAAe;IACf,iBAAiB;IACjB,sBAAsB;CACtB,CAAC,CAAA;AAEF,MAAM,kBAAkB,GAAG,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,uBAAuB,CAAC,CAAC,CAAA;AAE1E,MAAM,qBAAqB,GAAG,IAAI,GAAG,CAAC;IACrC,iBAAiB;IACjB,kBAAkB;IAClB,qBAAqB;IACrB,sBAAsB;CACtB,CAAC,CAAA;AAEF;;;;;GAKG;AACH,MAAM,+BAA+B,GAAG,IAAI,GAAG,CAAC;IAC/C,CAAC,iBAAiB,EAAE,KAAK,CAAC;IAC1B;QACC,yEAAyE;QACzE,MAAM;KACN;IACD,CAAC,yCAAyC,EAAE,KAAK,CAAC;IAClD,CAAC,mEAAmE,EAAE,MAAM,CAAC;IAC7E,CAAC,gDAAgD,EAAE,MAAM,CAAC;IAC1D,CAAC,uDAAuD,EAAE,MAAM,CAAC;IACjE,CAAC,0BAA0B,EAAE,KAAK,CAAC;IACnC,CAAC,gDAAgD,EAAE,KAAK,CAAC;IACzD;QACC,2EAA2E;QAC3E,MAAM;KACN;IACD;QACC,wEAAwE;QACxE,MAAM;KACN;IACD,CAAC,iDAAiD,EAAE,KAAK,CAAC;CAC1D,CAAC,CAAA;AAEF,SAAS,oBAAoB,CAAC,WAAsC;IACnE,OAAO,WAAW,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAA;AAC9D,CAAC;AAED,SAAS,oBAAoB,CAAC,IAAY;IACzC,OAAO,CACN,IAAI,KAAK,EAAE;QACX,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC;QACxB,qBAAqB,CAAC,GAAG,CAAC,IAAI,CAAC;QAC/B,2EAA2E;QAC3E,CAAC,IAAI,CAAC,UAAU,CAAC,cAAc,CAAC;YAC/B,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CACnD,CAAA;AACF,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,uBAAuB,CAAC,WAA0B;IACjE,MAAM,QAAQ,GAAG,oBAAoB,CAAC,WAAW,CAAC,CAAA;IAClD,MAAM,SAAS,GAAG,+BAA+B,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IAC/D,OAAO,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,IAAI,CAAA;AAClD,CAAC;AAID,iFAAiF;AACjF,MAAM,UAAU,mBAAmB,CAAC,WAA0B;IAC7D,MAAM,IAAI,GAAG,oBAAoB,CAAC,WAAW,CAAC,CAAA;IAC9C,IAAI,sBAAsB,CAAC,GAAG,CAAC,IAAI,CAAC;QAAE,OAAO,UAAU,CAAA;IACvD,IAAI,kBAAkB,CAAC,GAAG,CAAC,IAAI,CAAC;QAAE,OAAO,MAAM,CAAA;IAC/C,IAAI,+BAA+B,CAAC,GAAG,CAAC,IAAI,CAAC;QAAE,OAAO,UAAU,CAAA;IAChE,IAAI,oBAAoB,CAAC,IAAI,CAAC;QAAE,OAAO,MAAM,CAAA;IAC7C,OAAO,OAAO,CAAA;AACf,CAAC","sourcesContent":["const MARKDOWN_CONTENT_TYPES = new Set([\n\t'text/markdown',\n\t'text/x-markdown',\n\t'application/markdown'\n])\n\nconst HTML_CONTENT_TYPES = new Set(['text/html', 'application/xhtml+xml'])\n\nconst TEXTUAL_CONTENT_TYPES = new Set([\n\t'application/xml',\n\t'application/json',\n\t'application/rss+xml',\n\t'application/atom+xml'\n])\n\n/**\n * The formats Workers AI `toMarkdown` accepts as documents (its `/supported`\n * list, read 2026-09-08). Images are accepted there too but are deliberately\n * absent: this list decides what the CRAWLER downloads and converts, and a\n * site's gallery must stay an asset. Uploads accept images on their own list.\n */\nconst CONVERTIBLE_DOCUMENT_EXTENSIONS = new Map([\n\t['application/pdf', 'pdf'],\n\t[\n\t\t'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n\t\t'docx'\n\t],\n\t['application/vnd.oasis.opendocument.text', 'odt'],\n\t['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'xlsx'],\n\t['application/vnd.ms-excel.sheet.macroenabled.12', 'xlsm'],\n\t['application/vnd.ms-excel.sheet.binary.macroenabled.12', 'xlsb'],\n\t['application/vnd.ms-excel', 'xls'],\n\t['application/vnd.oasis.opendocument.spreadsheet', 'ods'],\n\t[\n\t\t'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n\t\t'pptx'\n\t],\n\t[\n\t\t'application/vnd.openxmlformats-officedocument.presentationml.slideshow',\n\t\t'ppsx'\n\t],\n\t['application/vnd.oasis.opendocument.presentation', 'odp']\n])\n\nfunction normalizeContentType(contentType: string | null | undefined) {\n\treturn contentType?.split(';')[0]?.trim().toLowerCase() ?? ''\n}\n\nfunction isTextualContentType(type: string) {\n\treturn (\n\t\ttype === '' ||\n\t\ttype.startsWith('text/') ||\n\t\tTEXTUAL_CONTENT_TYPES.has(type) ||\n\t\t// `application/ld+json`, `application/rdf+xml`, … but not `image/svg+xml`.\n\t\t(type.startsWith('application/') &&\n\t\t\t(type.endsWith('+json') || type.endsWith('+xml')))\n\t)\n}\n\n/** The upload descriptor for a binary we can convert, or null for text/assets. */\nexport function convertibleDocumentType(contentType: string | null) {\n\tconst mimeType = normalizeContentType(contentType)\n\tconst extension = CONVERTIBLE_DOCUMENT_EXTENSIONS.get(mimeType)\n\treturn extension ? { extension, mimeType } : null\n}\n\nexport type ContentKind = 'markdown' | 'html' | 'text' | 'document' | 'asset'\n\n/** What the fetch layer should do with a body, decided from the header alone. */\nexport function classifyContentType(contentType: string | null): ContentKind {\n\tconst type = normalizeContentType(contentType)\n\tif (MARKDOWN_CONTENT_TYPES.has(type)) return 'markdown'\n\tif (HTML_CONTENT_TYPES.has(type)) return 'html'\n\tif (CONVERTIBLE_DOCUMENT_EXTENSIONS.has(type)) return 'document'\n\tif (isTextualContentType(type)) return 'text'\n\treturn 'asset'\n}\n"]}
@@ -0,0 +1,38 @@
1
+ import type { Logger } from './logger.js';
2
+ /**
3
+ * A crawl recorded once and replayed, for benchmarks. Two runs of a pipeline
4
+ * then read the same pages, so a difference between them is the code: not a
5
+ * site that published a page in between, a 429, or a host that timed out on
6
+ * one run and not the other.
7
+ *
8
+ * Recorded: the network reads - pages as `fetchPageAsMarkdown` returns them,
9
+ * robots.txt and sitemaps - plus anything the caller routes through
10
+ * `throughCrawlSnapshot`. Everything else runs live on every replay, which is
11
+ * the point.
12
+ *
13
+ * Set per job through `withCrawlSnapshot`, never globally: a worker drains
14
+ * other tenants' jobs on the same lane. Never in production.
15
+ */
16
+ export type CrawlSnapshotInput = {
17
+ mode: 'record' | 'replay';
18
+ /** `<snapshot>/<host>`: the bench's snapshot label, then the site. */
19
+ name: string;
20
+ };
21
+ /** The name becomes a path, so it may not climb out of `root`. */
22
+ export declare const CRAWL_SNAPSHOT_NAME_PATTERN: RegExp;
23
+ export declare function withCrawlSnapshot<T>(input: CrawlSnapshotInput | null | undefined, run: () => Promise<T>, options: {
24
+ /** Directory the snapshots live under. */
25
+ root: string;
26
+ /** Told about replay misses. */
27
+ logger?: Logger;
28
+ }): Promise<T>;
29
+ /**
30
+ * `read` through the active snapshot. Recording stores what the network
31
+ * returned; replaying returns what was stored. A read the recording never made
32
+ * (a code change that discovers a new URL) goes to the network and is added,
33
+ * with a warning, so the snapshot stays complete without hiding the drift.
34
+ */
35
+ export declare function throughCrawlSnapshot<T>(key: string, read: () => Promise<T>): Promise<T>;
36
+ /** The snapshot this job reads or writes, to hand on to the job it queues. */
37
+ export declare function currentCrawlSnapshot(): CrawlSnapshotInput | null;
38
+ //# sourceMappingURL=crawl-snapshot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crawl-snapshot.d.ts","sourceRoot":"","sources":["../src/crawl-snapshot.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAEzC;;;;;;;;;;;;;GAaG;AAEH,MAAM,MAAM,kBAAkB,GAAG;IAChC,IAAI,EAAE,QAAQ,GAAG,QAAQ,CAAA;IACzB,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAA;CACZ,CAAA;AAED,kEAAkE;AAClE,eAAO,MAAM,2BAA2B,QACE,CAAA;AAS1C,wBAAgB,iBAAiB,CAAC,CAAC,EAClC,KAAK,EAAE,kBAAkB,GAAG,IAAI,GAAG,SAAS,EAC5C,GAAG,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACrB,OAAO,EAAE;IACR,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAA;IACZ,gCAAgC;IAChC,MAAM,CAAC,EAAE,MAAM,CAAA;CACf,cAmBD;AAED;;;;;GAKG;AACH,wBAAsB,oBAAoB,CAAC,CAAC,EAC3C,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACpB,OAAO,CAAC,CAAC,CAAC,CAeZ;AAED,8EAA8E;AAC9E,wBAAgB,oBAAoB,IAAI,kBAAkB,GAAG,IAAI,CAGhE"}
@@ -0,0 +1,51 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { sha256Hex } from './hash.js';
5
+ /** The name becomes a path, so it may not climb out of `root`. */
6
+ export const CRAWL_SNAPSHOT_NAME_PATTERN = /^[a-z0-9._-]{1,80}\/[a-z0-9._-]{1,120}$/;
7
+ const snapshot = new AsyncLocalStorage();
8
+ export function withCrawlSnapshot(input, run, options) {
9
+ if (!input)
10
+ return run();
11
+ if (process.env.NODE_ENV === 'production') {
12
+ throw new Error('A crawl snapshot is a local bench tool');
13
+ }
14
+ if (!CRAWL_SNAPSHOT_NAME_PATTERN.test(input.name)) {
15
+ throw new Error(`Invalid crawl snapshot name: ${input.name}`);
16
+ }
17
+ return snapshot.run({
18
+ mode: input.mode,
19
+ name: input.name,
20
+ directory: resolve(options.root, input.name),
21
+ logger: options.logger
22
+ }, run);
23
+ }
24
+ /**
25
+ * `read` through the active snapshot. Recording stores what the network
26
+ * returned; replaying returns what was stored. A read the recording never made
27
+ * (a code change that discovers a new URL) goes to the network and is added,
28
+ * with a warning, so the snapshot stays complete without hiding the drift.
29
+ */
30
+ export async function throughCrawlSnapshot(key, read) {
31
+ const active = snapshot.getStore();
32
+ if (!active)
33
+ return read();
34
+ const file = resolve(active.directory, `${sha256Hex(key).slice(0, 32)}.json`);
35
+ if (active.mode === 'replay') {
36
+ const stored = await readFile(file, 'utf8').catch(() => null);
37
+ if (stored !== null)
38
+ return JSON.parse(stored).value;
39
+ active.logger?.warn('crawl-snapshot.miss', { key });
40
+ }
41
+ const value = await read();
42
+ await mkdir(active.directory, { recursive: true });
43
+ await writeFile(file, JSON.stringify({ key, value }));
44
+ return value;
45
+ }
46
+ /** The snapshot this job reads or writes, to hand on to the job it queues. */
47
+ export function currentCrawlSnapshot() {
48
+ const active = snapshot.getStore();
49
+ return active ? { mode: active.mode, name: active.name } : null;
50
+ }
51
+ //# sourceMappingURL=crawl-snapshot.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crawl-snapshot.js","sourceRoot":"","sources":["../src/crawl-snapshot.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAC7D,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AACnC,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAwBrC,kEAAkE;AAClE,MAAM,CAAC,MAAM,2BAA2B,GACvC,yCAAyC,CAAA;AAO1C,MAAM,QAAQ,GAAG,IAAI,iBAAiB,EAAiB,CAAA;AAEvD,MAAM,UAAU,iBAAiB,CAChC,KAA4C,EAC5C,GAAqB,EACrB,OAKC;IAED,IAAI,CAAC,KAAK;QAAE,OAAO,GAAG,EAAE,CAAA;IACxB,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;QAC3C,MAAM,IAAI,KAAK,CAAC,wCAAwC,CAAC,CAAA;IAC1D,CAAC;IACD,IAAI,CAAC,2BAA2B,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACnD,MAAM,IAAI,KAAK,CAAC,gCAAgC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAA;IAC9D,CAAC;IAED,OAAO,QAAQ,CAAC,GAAG,CAClB;QACC,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,SAAS,EAAE,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC;QAC5C,MAAM,EAAE,OAAO,CAAC,MAAM;KACtB,EACD,GAAG,CACH,CAAA;AACF,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACzC,GAAW,EACX,IAAsB;IAEtB,MAAM,MAAM,GAAG,QAAQ,CAAC,QAAQ,EAAE,CAAA;IAClC,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,EAAE,CAAA;IAE1B,MAAM,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,SAAS,EAAE,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,CAAA;IAC7E,IAAI,MAAM,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;QAC9B,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAA;QAC7D,IAAI,MAAM,KAAK,IAAI;YAAE,OAAQ,IAAI,CAAC,KAAK,CAAC,MAAM,CAAkB,CAAC,KAAK,CAAA;QACtE,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,qBAAqB,EAAE,EAAE,GAAG,EAAE,CAAC,CAAA;IACpD,CAAC;IAED,MAAM,KAAK,GAAG,MAAM,IAAI,EAAE,CAAA;IAC1B,MAAM,KAAK,CAAC,MAAM,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAClD,MAAM,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC,CAAA;IACrD,OAAO,KAAK,CAAA;AACb,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,oBAAoB;IACnC,MAAM,MAAM,GAAG,QAAQ,CAAC,QAAQ,EAAE,CAAA;IAClC,OAAO,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAA;AAChE,CAAC","sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks'\nimport { mkdir, readFile, writeFile } from 'node:fs/promises'\nimport { resolve } from 'node:path'\nimport { sha256Hex } from './hash.js'\nimport type { Logger } from './logger.js'\n\n/**\n * A crawl recorded once and replayed, for benchmarks. Two runs of a pipeline\n * then read the same pages, so a difference between them is the code: not a\n * site that published a page in between, a 429, or a host that timed out on\n * one run and not the other.\n *\n * Recorded: the network reads - pages as `fetchPageAsMarkdown` returns them,\n * robots.txt and sitemaps - plus anything the caller routes through\n * `throughCrawlSnapshot`. Everything else runs live on every replay, which is\n * the point.\n *\n * Set per job through `withCrawlSnapshot`, never globally: a worker drains\n * other tenants' jobs on the same lane. Never in production.\n */\n\nexport type CrawlSnapshotInput = {\n\tmode: 'record' | 'replay'\n\t/** `<snapshot>/<host>`: the bench's snapshot label, then the site. */\n\tname: string\n}\n\n/** The name becomes a path, so it may not climb out of `root`. */\nexport const CRAWL_SNAPSHOT_NAME_PATTERN =\n\t/^[a-z0-9._-]{1,80}\\/[a-z0-9._-]{1,120}$/\n\ntype CrawlSnapshot = CrawlSnapshotInput & {\n\tdirectory: string\n\tlogger: Logger | undefined\n}\n\nconst snapshot = new AsyncLocalStorage<CrawlSnapshot>()\n\nexport function withCrawlSnapshot<T>(\n\tinput: CrawlSnapshotInput | null | undefined,\n\trun: () => Promise<T>,\n\toptions: {\n\t\t/** Directory the snapshots live under. */\n\t\troot: string\n\t\t/** Told about replay misses. */\n\t\tlogger?: Logger\n\t}\n) {\n\tif (!input) return run()\n\tif (process.env.NODE_ENV === 'production') {\n\t\tthrow new Error('A crawl snapshot is a local bench tool')\n\t}\n\tif (!CRAWL_SNAPSHOT_NAME_PATTERN.test(input.name)) {\n\t\tthrow new Error(`Invalid crawl snapshot name: ${input.name}`)\n\t}\n\n\treturn snapshot.run(\n\t\t{\n\t\t\tmode: input.mode,\n\t\t\tname: input.name,\n\t\t\tdirectory: resolve(options.root, input.name),\n\t\t\tlogger: options.logger\n\t\t},\n\t\trun\n\t)\n}\n\n/**\n * `read` through the active snapshot. Recording stores what the network\n * returned; replaying returns what was stored. A read the recording never made\n * (a code change that discovers a new URL) goes to the network and is added,\n * with a warning, so the snapshot stays complete without hiding the drift.\n */\nexport async function throughCrawlSnapshot<T>(\n\tkey: string,\n\tread: () => Promise<T>\n): Promise<T> {\n\tconst active = snapshot.getStore()\n\tif (!active) return read()\n\n\tconst file = resolve(active.directory, `${sha256Hex(key).slice(0, 32)}.json`)\n\tif (active.mode === 'replay') {\n\t\tconst stored = await readFile(file, 'utf8').catch(() => null)\n\t\tif (stored !== null) return (JSON.parse(stored) as { value: T }).value\n\t\tactive.logger?.warn('crawl-snapshot.miss', { key })\n\t}\n\n\tconst value = await read()\n\tawait mkdir(active.directory, { recursive: true })\n\tawait writeFile(file, JSON.stringify({ key, value }))\n\treturn value\n}\n\n/** The snapshot this job reads or writes, to hand on to the job it queues. */\nexport function currentCrawlSnapshot(): CrawlSnapshotInput | null {\n\tconst active = snapshot.getStore()\n\treturn active ? { mode: active.mode, name: active.name } : null\n}\n"]}
@@ -0,0 +1,12 @@
1
+ /** A server that mislabels HTML as `text/plain` still needs the HTML tier. */
2
+ export declare function looksLikeHtml(body: string): boolean;
3
+ /**
4
+ * Deliberately "empty", not "short": a real 300-character opening-hours page
5
+ * must not be promoted to Browser Rendering, which is the slow, rate-limited
6
+ * tier and the only one that ever timed out in the probe.
7
+ */
8
+ export declare function isBlankMarkdown(markdown: string): boolean;
9
+ /** Require a JS bootstrap signal AND missing meaningful prose before rendering.
10
+ * Short opening-hours pages remain on the fast path. */
11
+ export declare function isApplicationShell(html: string, markdown: string): boolean;
12
+ //# sourceMappingURL=detect-markdown.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detect-markdown.d.ts","sourceRoot":"","sources":["../src/detect-markdown.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,WAEzC;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,WAE/C;AAOD;wDACwD;AACxD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,WAyBhE"}