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.
- package/LICENSE +21 -0
- package/README.md +78 -2
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +66 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +164 -0
- package/dist/client.js.map +1 -0
- package/dist/content-type.d.ts +9 -0
- package/dist/content-type.d.ts.map +1 -0
- package/dist/content-type.js +71 -0
- package/dist/content-type.js.map +1 -0
- package/dist/crawl-snapshot.d.ts +38 -0
- package/dist/crawl-snapshot.d.ts.map +1 -0
- package/dist/crawl-snapshot.js +51 -0
- package/dist/crawl-snapshot.js.map +1 -0
- package/dist/detect-markdown.d.ts +12 -0
- package/dist/detect-markdown.d.ts.map +1 -0
- package/dist/detect-markdown.js +37 -0
- package/dist/detect-markdown.js.map +1 -0
- package/dist/fetch-page.d.ts +133 -0
- package/dist/fetch-page.d.ts.map +1 -0
- package/dist/fetch-page.js +744 -0
- package/dist/fetch-page.js.map +1 -0
- package/dist/hash.d.ts +11 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +16 -0
- package/dist/hash.js.map +1 -0
- package/dist/html-entities.d.ts +3 -0
- package/dist/html-entities.d.ts.map +1 -0
- package/dist/html-entities.js +97 -0
- package/dist/html-entities.js.map +1 -0
- package/dist/html-metadata.d.ts +15 -0
- package/dist/html-metadata.d.ts.map +1 -0
- package/dist/html-metadata.js +125 -0
- package/dist/html-metadata.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/limit.d.ts +85 -0
- package/dist/limit.d.ts.map +1 -0
- package/dist/limit.js +157 -0
- package/dist/limit.js.map +1 -0
- package/dist/logger.d.ts +10 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +7 -0
- package/dist/logger.js.map +1 -0
- package/dist/markdown-refs.d.ts +31 -0
- package/dist/markdown-refs.d.ts.map +1 -0
- package/dist/markdown-refs.js +183 -0
- package/dist/markdown-refs.js.map +1 -0
- package/dist/memory-limit.d.ts +10 -0
- package/dist/memory-limit.d.ts.map +1 -0
- package/dist/memory-limit.js +75 -0
- package/dist/memory-limit.js.map +1 -0
- package/dist/net-guard.d.ts +15 -0
- package/dist/net-guard.d.ts.map +1 -0
- package/dist/net-guard.js +165 -0
- package/dist/net-guard.js.map +1 -0
- package/dist/operation-signal.d.ts +16 -0
- package/dist/operation-signal.d.ts.map +1 -0
- package/dist/operation-signal.js +18 -0
- package/dist/operation-signal.js.map +1 -0
- package/dist/rate-limit-retry.d.ts +48 -0
- package/dist/rate-limit-retry.d.ts.map +1 -0
- package/dist/rate-limit-retry.js +96 -0
- package/dist/rate-limit-retry.js.map +1 -0
- package/dist/retry-after.d.ts +11 -0
- package/dist/retry-after.d.ts.map +1 -0
- package/dist/retry-after.js +40 -0
- package/dist/retry-after.js.map +1 -0
- package/dist/sitemap/index.d.ts +50 -0
- package/dist/sitemap/index.d.ts.map +1 -0
- package/dist/sitemap/index.js +281 -0
- package/dist/sitemap/index.js.map +1 -0
- package/dist/to-markdown.d.ts +30 -0
- package/dist/to-markdown.d.ts.map +1 -0
- package/dist/to-markdown.js +30 -0
- package/dist/to-markdown.js.map +1 -0
- package/dist/user-agent.d.ts +8 -0
- package/dist/user-agent.d.ts.map +1 -0
- package/dist/user-agent.js +8 -0
- package/dist/user-agent.js.map +1 -0
- package/dist/xlsx-zeros.d.ts +7 -0
- package/dist/xlsx-zeros.d.ts.map +1 -0
- package/dist/xlsx-zeros.js +45 -0
- package/dist/xlsx-zeros.js.map +1 -0
- package/package.json +80 -5
- package/src/cli.ts +78 -0
- package/src/client.ts +219 -0
- package/src/content-type.ts +77 -0
- package/src/crawl-snapshot.ts +98 -0
- package/src/detect-markdown.ts +47 -0
- package/src/fetch-page.ts +1010 -0
- package/src/hash.ts +16 -0
- package/src/html-entities.ts +100 -0
- package/src/html-metadata.ts +126 -0
- package/src/index.ts +58 -0
- package/src/limit.ts +207 -0
- package/src/logger.ts +16 -0
- package/src/markdown-refs.ts +197 -0
- package/src/memory-limit.ts +80 -0
- package/src/net-guard.ts +170 -0
- package/src/operation-signal.ts +31 -0
- package/src/rate-limit-retry.ts +132 -0
- package/src/retry-after.ts +51 -0
- package/src/sitemap/index.ts +298 -0
- package/src/to-markdown.ts +33 -0
- package/src/user-agent.ts +8 -0
- 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
|
-
#
|
|
1
|
+
# website-to-markdown
|
|
2
2
|
|
|
3
|
-
|
|
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 @@
|
|
|
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
|
package/dist/cli.js.map
ADDED
|
@@ -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"]}
|
package/dist/client.d.ts
ADDED
|
@@ -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"}
|