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