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/README.md +53 -1
- package/dist/cli.d.ts +4 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +73 -57
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +7 -8
- package/dist/client.js.map +1 -1
- package/dist/crawl-snapshot.d.ts +6 -24
- package/dist/crawl-snapshot.d.ts.map +1 -1
- package/dist/crawl-snapshot.js +2 -2
- package/dist/crawl-snapshot.js.map +1 -1
- package/dist/fetch-page.d.ts +16 -0
- package/dist/fetch-page.d.ts.map +1 -1
- package/dist/fetch-page.js +17 -2
- package/dist/fetch-page.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/limit.d.ts +3 -2
- package/dist/limit.d.ts.map +1 -1
- package/dist/limit.js.map +1 -1
- package/dist/report-api-error.d.ts +9 -0
- package/dist/report-api-error.d.ts.map +1 -0
- package/dist/report-api-error.js +13 -0
- package/dist/report-api-error.js.map +1 -0
- package/dist/schema/index.d.ts +12 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +7 -0
- package/dist/schema/index.js.map +1 -0
- package/package.json +10 -5
- package/src/cli.ts +84 -62
- package/src/client.ts +9 -11
- package/src/crawl-snapshot.ts +14 -16
- package/src/fetch-page.ts +36 -2
- package/src/index.ts +1 -0
- package/src/limit.ts +3 -2
- package/src/report-api-error.ts +16 -0
- package/src/schema/index.ts +14 -0
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
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
44
|
+
const { fetchPageAsMarkdown } = createWebsiteToMarkdown({
|
|
45
|
+
cloudflare: {
|
|
46
|
+
apiToken: CLOUDFLARE_API_TOKEN,
|
|
47
|
+
accountId: CLOUDFLARE_ACCOUNT_ID
|
|
48
|
+
}
|
|
49
|
+
})
|
|
13
50
|
|
|
14
|
-
|
|
15
|
-
--no-render Skip the Browser Rendering tier (HTML tiers only).
|
|
51
|
+
ctx.stderr.write(`Fetching ${url}…\n`)
|
|
16
52
|
|
|
17
|
-
|
|
18
|
-
CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID
|
|
53
|
+
const page = await fetchPageAsMarkdown({ url, render })
|
|
19
54
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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:
|
|
80
|
+
onError: report,
|
|
83
81
|
endpoints: {
|
|
84
82
|
toMarkdown
|
|
85
83
|
}
|
package/src/crawl-snapshot.ts
CHANGED
|
@@ -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 {
|
|
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
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.
|
|
5
|
-
*
|
|
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
|
+
}
|