website-to-markdown 1.0.0 → 1.1.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/src/cli.ts CHANGED
@@ -3,76 +3,98 @@
3
3
  * Fetch a public URL as markdown and print the result. Page metadata goes to
4
4
  * stderr; markdown goes to stdout.
5
5
  */
6
+ import { readFile } from 'node:fs/promises'
7
+ import { parseEnv } from 'node:util'
8
+ import { type CliIO, CliError, defineCli, defineCommand } from 'hanzio/cli'
9
+ import { z } from 'zod'
6
10
  import { createWebsiteToMarkdown } from './fetch-page.js'
7
11
 
8
- const HELP = `
9
- Fetch a public URL as markdown.
12
+ export const cli = defineCli({
13
+ name: 'website-to-markdown',
14
+ commands: defineCommand({
15
+ description:
16
+ 'Fetch a public URL as markdown. Needs CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.',
17
+ input: z.object({
18
+ url: z
19
+ .string()
20
+ .describe('e.g. https://example.com/about')
21
+ .meta({ positional: true }),
22
+ render: z
23
+ .boolean()
24
+ .default(true)
25
+ .describe(
26
+ 'Use the Browser Rendering tier; --no-render keeps to HTML tiers.'
27
+ ),
28
+ envFile: z
29
+ .string()
30
+ .optional()
31
+ .describe('Read CLOUDFLARE_* from a dotenv file; the environment wins.')
32
+ }),
33
+ run: async ({ url, render, envFile }, ctx) => {
34
+ const file = envFile ? parseEnv(await readEnvFile(envFile)) : {}
35
+ const CLOUDFLARE_API_TOKEN =
36
+ ctx.env.CLOUDFLARE_API_TOKEN || file.CLOUDFLARE_API_TOKEN
37
+ const CLOUDFLARE_ACCOUNT_ID =
38
+ ctx.env.CLOUDFLARE_ACCOUNT_ID || file.CLOUDFLARE_ACCOUNT_ID
39
+ if (!CLOUDFLARE_API_TOKEN || !CLOUDFLARE_ACCOUNT_ID)
40
+ throw new CliError(
41
+ 'Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.'
42
+ )
10
43
 
11
- Usage:
12
- website-to-markdown <url> [--no-render]
44
+ const { fetchPageAsMarkdown } = createWebsiteToMarkdown({
45
+ cloudflare: {
46
+ apiToken: CLOUDFLARE_API_TOKEN,
47
+ accountId: CLOUDFLARE_ACCOUNT_ID
48
+ }
49
+ })
13
50
 
14
- Options:
15
- --no-render Skip the Browser Rendering tier (HTML tiers only).
51
+ ctx.stderr.write(`Fetching ${url}…\n`)
16
52
 
17
- Environment:
18
- CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID
53
+ const page = await fetchPageAsMarkdown({ url, render })
19
54
 
20
- Example:
21
- website-to-markdown https://example.com/about
22
- `.trim()
55
+ ctx.stderr.write(
56
+ `${JSON.stringify(
57
+ {
58
+ outcome: page.outcome,
59
+ status: page.status,
60
+ source: page.source,
61
+ url: page.url,
62
+ finalUrl: page.finalUrl,
63
+ contentType: page.contentType,
64
+ etag: page.etag,
65
+ lastModified: page.lastModified,
66
+ title: page.title,
67
+ lang: page.lang,
68
+ canonical: page.canonical,
69
+ metaDescription: page.metaDescription,
70
+ jsonLdBlocks: page.jsonLd.length,
71
+ links: page.links.length,
72
+ images: page.images.length,
73
+ markdownChars: page.markdown.length,
74
+ durationMs: page.durationMs
75
+ },
76
+ null,
77
+ 2
78
+ )}\n`
79
+ )
23
80
 
24
- const args = Bun.argv.slice(2)
25
- const url = args.find((arg) => !arg.startsWith('-'))
26
- if (!url || args.includes('-h') || args.includes('--help')) {
27
- console.log(HELP)
28
- process.exit(url ? 0 : 1)
29
- }
30
-
31
- const { CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID } = process.env
32
- if (!CLOUDFLARE_API_TOKEN || !CLOUDFLARE_ACCOUNT_ID) {
33
- console.error('Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.')
34
- process.exit(1)
35
- }
36
-
37
- const { fetchPageAsMarkdown } = createWebsiteToMarkdown({
38
- cloudflare: {
39
- apiToken: CLOUDFLARE_API_TOKEN,
40
- accountId: CLOUDFLARE_ACCOUNT_ID
41
- }
81
+ if (page.outcome !== 'ok') ctx.exitCode = 1
82
+ return page.markdown
83
+ }
84
+ })
42
85
  })
43
86
 
44
- console.error(`Fetching ${url}…`)
45
-
46
- const page = await fetchPageAsMarkdown({
47
- url,
48
- render: !args.includes('--no-render')
49
- })
87
+ function readEnvFile(path: string) {
88
+ return readFile(path, 'utf8').catch(() => {
89
+ throw new CliError(`Cannot read --env-file ${path}.`)
90
+ })
91
+ }
50
92
 
51
- console.error(
52
- JSON.stringify(
53
- {
54
- outcome: page.outcome,
55
- status: page.status,
56
- source: page.source,
57
- url: page.url,
58
- finalUrl: page.finalUrl,
59
- contentType: page.contentType,
60
- etag: page.etag,
61
- lastModified: page.lastModified,
62
- title: page.title,
63
- lang: page.lang,
64
- canonical: page.canonical,
65
- metaDescription: page.metaDescription,
66
- jsonLdBlocks: page.jsonLd.length,
67
- links: page.links.length,
68
- images: page.images.length,
69
- markdownChars: page.markdown.length,
70
- durationMs: page.durationMs
71
- },
72
- null,
73
- 2
74
- )
75
- )
93
+ /** The bin: 1.0 printed the help and exited 1 when no URL was given, even with `--help`. */
94
+ export async function main(argv: readonly string[], io?: CliIO) {
95
+ if (argv.some((arg) => !arg.startsWith('-'))) return cli.run(argv, io)
96
+ await cli.run(['--help'], io)
97
+ return 1
98
+ }
76
99
 
77
- console.log(page.markdown)
78
- if (page.outcome !== 'ok') process.exit(1)
100
+ if (import.meta.main) process.exitCode = await main(process.argv.slice(2))
package/src/client.ts CHANGED
@@ -1,9 +1,5 @@
1
1
  import Cloudflare from 'cloudflare'
2
- import {
3
- type ApiError,
4
- createApiClient,
5
- HttpResponseError
6
- } from 'hanzio/api-wrapper'
2
+ import { type ApiError, createApiClient } from 'hanzio/api-wrapper'
7
3
  import {
8
4
  type ConcurrencyLimit,
9
5
  type CreateConcurrencyLimit,
@@ -14,6 +10,7 @@ import {
14
10
  CloudflareRateLimitedError,
15
11
  withCloudflareRateLimitRetry
16
12
  } from './rate-limit-retry.js'
13
+ import { isReportableApiError } from './report-api-error.js'
17
14
  import { rateLimitFromError } from './retry-after.js'
18
15
  import { toMarkdown } from './to-markdown.js'
19
16
  import { BROWSER_USER_AGENT } from './user-agent.js'
@@ -53,16 +50,17 @@ export function createCloudflareClient(options: {
53
50
  createLimit: CreateConcurrencyLimit
54
51
  /** Every Workers AI client error except a 429, which is retried instead. */
55
52
  onApiError?: ((error: ApiError) => void) | undefined
53
+ /** Pass timeouts to `onApiError`; default true. */
54
+ reportTimeouts?: boolean | undefined
56
55
  logger: Logger
57
56
  }) {
58
57
  const { credentials, logger } = options
59
58
  const limits = createCloudflareLimits(options.createLimit)
60
59
 
61
- function reportUnlessRateLimited(error: ApiError) {
62
- // Expected transient throttles: we retry and trip the shared cooldown.
63
- // Reporting every 429 floods the error tracker without adding signal.
64
- if (error instanceof HttpResponseError && error.status === 429) return
65
- options.onApiError?.(error)
60
+ function report(error: ApiError) {
61
+ if (isReportableApiError(error, options.reportTimeouts)) {
62
+ options.onApiError?.(error)
63
+ }
66
64
  }
67
65
 
68
66
  let cloudflareSdk: Cloudflare | undefined
@@ -79,7 +77,7 @@ export function createCloudflareClient(options: {
79
77
  },
80
78
  timeoutMs: 30_000,
81
79
  retries: 0,
82
- onError: reportUnlessRateLimited,
80
+ onError: report,
83
81
  endpoints: {
84
82
  toMarkdown
85
83
  }
@@ -3,6 +3,12 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import { resolve } from 'node:path'
4
4
  import { sha256Hex } from './hash.js'
5
5
  import type { Logger } from './logger.js'
6
+ import {
7
+ CRAWL_SNAPSHOT_NAME_PATTERN,
8
+ type CrawlSnapshotInput
9
+ } from './schema/index.js'
10
+
11
+ export { CRAWL_SNAPSHOT_NAME_PATTERN, type CrawlSnapshotInput }
6
12
 
7
13
  /**
8
14
  * A crawl recorded once and replayed, for benchmarks. Two runs of a pipeline
@@ -19,32 +25,24 @@ import type { Logger } from './logger.js'
19
25
  * other tenants' jobs on the same lane. Never in production.
20
26
  */
21
27
 
22
- export type CrawlSnapshotInput = {
23
- mode: 'record' | 'replay'
24
- /** `<snapshot>/<host>`: the bench's snapshot label, then the site. */
25
- name: string
26
- }
27
-
28
- /** The name becomes a path, so it may not climb out of `root`. */
29
- export const CRAWL_SNAPSHOT_NAME_PATTERN =
30
- /^[a-z0-9._-]{1,80}\/[a-z0-9._-]{1,120}$/
31
-
32
28
  type CrawlSnapshot = CrawlSnapshotInput & {
33
29
  directory: string
34
30
  logger: Logger | undefined
35
31
  }
36
32
 
33
+ export type CrawlSnapshotOptions = {
34
+ /** Directory the snapshots live under. */
35
+ root: string
36
+ /** Told about replay misses. */
37
+ logger?: Logger | undefined
38
+ }
39
+
37
40
  const snapshot = new AsyncLocalStorage<CrawlSnapshot>()
38
41
 
39
42
  export function withCrawlSnapshot<T>(
40
43
  input: CrawlSnapshotInput | null | undefined,
41
44
  run: () => Promise<T>,
42
- options: {
43
- /** Directory the snapshots live under. */
44
- root: string
45
- /** Told about replay misses. */
46
- logger?: Logger
47
- }
45
+ options: CrawlSnapshotOptions
48
46
  ) {
49
47
  if (!input) return run()
50
48
  if (process.env.NODE_ENV === 'production') {
package/src/fetch-page.ts CHANGED
@@ -10,7 +10,13 @@ import {
10
10
  type ContentKind,
11
11
  convertibleDocumentType
12
12
  } from './content-type.js'
13
- import { currentCrawlSnapshot, throughCrawlSnapshot } from './crawl-snapshot.js'
13
+ import {
14
+ type CrawlSnapshotInput,
15
+ type CrawlSnapshotOptions,
16
+ currentCrawlSnapshot,
17
+ throughCrawlSnapshot,
18
+ withCrawlSnapshot
19
+ } from './crawl-snapshot.js'
14
20
  import {
15
21
  isApplicationShell,
16
22
  isBlankMarkdown,
@@ -473,7 +479,18 @@ export type WebsiteToMarkdownOptions = {
473
479
  createLimit?: CreateConcurrencyLimit
474
480
  /** Workers AI client errors other than 429s, for an error tracker. */
475
481
  onApiError?: (error: ApiError) => void
482
+ /**
483
+ * Pass Workers AI timeouts to `onApiError`. A timed-out page falls through
484
+ * to the next tier, so `false` keeps import bursts out of error tracking.
485
+ * Default true.
486
+ */
487
+ reportTimeouts?: boolean
476
488
  logger?: Logger
489
+ /**
490
+ * Where the returned `withCrawlSnapshot` keeps snapshots. Its logger
491
+ * defaults to `logger`.
492
+ */
493
+ crawlSnapshot?: CrawlSnapshotOptions
477
494
  /** The OCR tier for a PDF Workers AI returned nothing for. */
478
495
  parsePdf?: PdfParser
479
496
  /**
@@ -494,6 +511,7 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
494
511
  credentials: options.cloudflare,
495
512
  createLimit,
496
513
  onApiError: options.onApiError,
514
+ reportTimeouts: options.reportTimeouts,
497
515
  logger
498
516
  })
499
517
  const hostLimits = createHostLimits(createLimit)
@@ -1003,7 +1021,23 @@ export function createWebsiteToMarkdown(options: WebsiteToMarkdownOptions) {
1003
1021
  convertDocumentBytes,
1004
1022
  urlToMarkdown,
1005
1023
  convertFileWithWorkersAi: client.convertFileWithWorkersAi,
1006
- renderUrlToMarkdown: client.renderUrlToMarkdown
1024
+ renderUrlToMarkdown: client.renderUrlToMarkdown,
1025
+ /** The SSRF guard the converter runs, bound to its `logger`. */
1026
+ isHostAllowed: allowHost,
1027
+ /** `withCrawlSnapshot` bound to the `crawlSnapshot` option. */
1028
+ withCrawlSnapshot<T>(
1029
+ input: CrawlSnapshotInput | null | undefined,
1030
+ run: () => Promise<T>
1031
+ ) {
1032
+ if (!input) return run()
1033
+ if (!options.crawlSnapshot) {
1034
+ throw new Error('Set crawlSnapshot.root to use crawl snapshots')
1035
+ }
1036
+ return withCrawlSnapshot(input, run, {
1037
+ root: options.crawlSnapshot.root,
1038
+ logger: options.crawlSnapshot.logger ?? logger
1039
+ })
1040
+ }
1007
1041
  }
1008
1042
  }
1009
1043
 
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export {
11
11
  export {
12
12
  CRAWL_SNAPSHOT_NAME_PATTERN,
13
13
  type CrawlSnapshotInput,
14
+ type CrawlSnapshotOptions,
14
15
  currentCrawlSnapshot,
15
16
  throughCrawlSnapshot,
16
17
  withCrawlSnapshot
package/src/limit.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { createMemoryConcurrencyLimit } from './memory-limit.js'
2
2
 
3
3
  /**
4
- * A semaphore: at most `max` callbacks in flight. hellu's Redis Lua limiter
5
- * (`createRedisConcurrencyLimit`) fits this shape as is; the default is
4
+ * A semaphore: at most `max` callbacks in flight. `hanzio/redis`'s
5
+ * `createRedisConcurrencyLimit` returns this shape (plus `queueDepth`, which is
6
+ * not needed here) once `send` is bound; the default is
6
7
  * {@link createMemoryConcurrencyLimit}.
7
8
  */
8
9
  export type ConcurrencyLimit = {
@@ -0,0 +1,16 @@
1
+ import {
2
+ type ApiError,
3
+ HttpResponseError,
4
+ RequestTimeoutError
5
+ } from 'hanzio/api-wrapper'
6
+
7
+ /**
8
+ * Whether `onApiError` hears about a Workers AI client error. A 429 never: it
9
+ * is retried and trips the shared cooldown, and reporting every one floods the
10
+ * error tracker. A timeout only with `reportTimeouts`, since the page falls
11
+ * through to the next tier anyway.
12
+ */
13
+ export function isReportableApiError(error: ApiError, reportTimeouts = true) {
14
+ if (error instanceof HttpResponseError && error.status === 429) return false
15
+ return reportTimeouts || !(error instanceof RequestTimeoutError)
16
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Values a shared schema can import without the runtime: no Node built-ins,
3
+ * no peer libraries.
4
+ */
5
+
6
+ /** A crawl snapshot name, `<snapshot>/<host>`; it becomes a path, so it may not climb out of the root. */
7
+ export const CRAWL_SNAPSHOT_NAME_PATTERN =
8
+ /^[a-z0-9._-]{1,80}\/[a-z0-9._-]{1,120}$/
9
+
10
+ export type CrawlSnapshotInput = {
11
+ mode: 'record' | 'replay'
12
+ /** `<snapshot>/<host>`: the bench's snapshot label, then the site. */
13
+ name: string
14
+ }