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/dist/fetch-page.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseRetryAfter } from 'hanzio';
|
|
1
|
+
import { exponentialBackoff, fileExtension, getUniqueValues, invariant, isOneOf, isRecord, must, parseRetryAfter, startTimer, timeoutSignal, typedSwitch, typedSwitchGroup } from 'hanzio';
|
|
2
2
|
import { CloudflareRateLimitedError, createCloudflareClient } from './client.js';
|
|
3
3
|
import { classifyContentType, convertibleDocumentType } from './content-type.js';
|
|
4
4
|
import { currentCrawlSnapshot, throughCrawlSnapshot, withCrawlSnapshot } from './crawl-snapshot.js';
|
|
@@ -7,40 +7,50 @@ import { sha256Hex } from './hash.js';
|
|
|
7
7
|
import { extractHtmlMetadata } from './html-metadata.js';
|
|
8
8
|
import { createHostLimits } from './limit.js';
|
|
9
9
|
import { errorLogAttributes, silentLogger } from './logger.js';
|
|
10
|
-
import { htmlImages, htmlLinks, markdownRefs, stripDocumentPreamble, stripFrontMatter, titleFromMarkdown } from './markdown-refs.js';
|
|
10
|
+
import { cleanConvertedMarkdown, htmlImages, htmlLinks, markdownRefs, stripDocumentPreamble, stripFrontMatter, titleFromMarkdown } from './markdown-refs.js';
|
|
11
11
|
import { createMemoryConcurrencyLimit } from './memory-limit.js';
|
|
12
|
-
import { isHostAllowed, isLocalhostHostname } from './net-guard.js';
|
|
12
|
+
import { followRedirects, guardedFetch, HostNotAllowedError, isHostAllowed, isLocalhostHostname } from './net-guard.js';
|
|
13
13
|
import { currentOperationSignal } from './operation-signal.js';
|
|
14
|
+
import { decodeText, readBody } from './read-body.js';
|
|
15
|
+
import { asUnchanged, isUnchangedFor } from './unchanged-page.js';
|
|
14
16
|
import { BROWSER_USER_AGENT } from './user-agent.js';
|
|
15
17
|
import { preserveXlsxZeros } from './xlsx-zeros.js';
|
|
16
18
|
const FETCH_TIMEOUT_MS = 30_000;
|
|
17
19
|
/** Browser Rendering once returned a 6 MB markdown page from a 6 MB upload. */
|
|
18
20
|
const MAX_HTML_BYTES = 2 * 1024 * 1024;
|
|
21
|
+
/**
|
|
22
|
+
* Read cap for a textual body. Above `MAX_HTML_BYTES` a page is not converted
|
|
23
|
+
* but still yields its metadata and links; above this it is not downloaded.
|
|
24
|
+
*/
|
|
25
|
+
const MAX_TEXT_READ_BYTES = 2 * MAX_HTML_BYTES;
|
|
19
26
|
const MAX_DOCUMENT_BYTES = 2 * 1024 * 1024;
|
|
20
27
|
const MAX_PDF_BYTES = 12 * 1024 * 1024;
|
|
21
28
|
const MAX_PDF_PAGES = 60;
|
|
22
29
|
/** Stored markdown ceiling; a page over this is a listing or an upload, not knowledge. */
|
|
23
30
|
export const MAX_MARKDOWN_CHARS = 300_000;
|
|
31
|
+
/**
|
|
32
|
+
* A NUL byte is never text, and Postgres refuses a string that holds one: a
|
|
33
|
+
* single page with one (a Wix site's conversion) failed a whole import.
|
|
34
|
+
* Composed Unicode, because string comparisons downstream assume it: a site
|
|
35
|
+
* that wrote "Gård" as "a" plus a combining ring read as "Ga" to anything
|
|
36
|
+
* matching names in the text.
|
|
37
|
+
*/
|
|
38
|
+
function cleanText(text) {
|
|
39
|
+
return text.replaceAll('\u0000', '').normalize('NFC');
|
|
40
|
+
}
|
|
24
41
|
/**
|
|
25
42
|
* The one place markdown, front-matter and HTML metadata are reconciled: the
|
|
26
43
|
* HTML `<title>` wins, the Workers AI front-matter is the fallback for pages we
|
|
27
44
|
* only ever saw as markdown, and links/images are resolved against `finalUrl`.
|
|
28
45
|
*/
|
|
29
46
|
function buildPage(base, rawBody, logger) {
|
|
30
|
-
// A NUL byte is never text, and Postgres refuses a string that holds one:
|
|
31
|
-
// a single page with one (a Wix site's conversion) failed the whole import.
|
|
32
|
-
// Composed Unicode, because every comparison downstream assumes it: a site
|
|
33
|
-
// that wrote "Gård" as "a" plus a combining ring gave the fact guard the
|
|
34
|
-
// name "Broges Ga", which no draft could contain, and the document shipped
|
|
35
|
-
// as its verbatim source.
|
|
36
|
-
const clean = (text) => text?.replaceAll('\u0000', '').normalize('NFC');
|
|
37
47
|
const body = {
|
|
38
48
|
...rawBody,
|
|
39
|
-
markdown:
|
|
40
|
-
html:
|
|
49
|
+
markdown: rawBody.markdown ? cleanText(rawBody.markdown) : null,
|
|
50
|
+
html: rawBody.html ? cleanText(rawBody.html) : null
|
|
41
51
|
};
|
|
42
52
|
const frontMatter = stripFrontMatter(body.markdown ?? '');
|
|
43
|
-
let markdown = frontMatter.markdown;
|
|
53
|
+
let markdown = cleanConvertedMarkdown(frontMatter.markdown);
|
|
44
54
|
if (markdown.length > MAX_MARKDOWN_CHARS) {
|
|
45
55
|
logger.warn('url-to-markdown.truncated', {
|
|
46
56
|
url: base.finalUrl,
|
|
@@ -56,7 +66,7 @@ function buildPage(base, rawBody, logger) {
|
|
|
56
66
|
// navigation (image-wrapped anchors, folded menus), so when we hold the
|
|
57
67
|
// HTML its anchors decide what discovery can still reach.
|
|
58
68
|
const links = body.html
|
|
59
|
-
?
|
|
69
|
+
? getUniqueValues([...refs.links, ...htmlLinks(body.html, base.finalUrl)])
|
|
60
70
|
: refs.links;
|
|
61
71
|
return {
|
|
62
72
|
url: base.url,
|
|
@@ -76,25 +86,25 @@ function buildPage(base, rawBody, logger) {
|
|
|
76
86
|
metaDescription: metadata?.metaDescription ?? frontMatter.description ?? null,
|
|
77
87
|
jsonLd: metadata?.jsonLd ?? [],
|
|
78
88
|
links,
|
|
79
|
-
images: [
|
|
80
|
-
...
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
])
|
|
84
|
-
],
|
|
89
|
+
images: getUniqueValues([
|
|
90
|
+
...refs.images,
|
|
91
|
+
...(body.html ? htmlImages(body.html, base.finalUrl) : [])
|
|
92
|
+
]),
|
|
85
93
|
captureIssue: frontMatter.markdown.length > MAX_MARKDOWN_CHARS
|
|
86
94
|
? 'markdown_limit'
|
|
87
95
|
: (body.captureIssue ?? null),
|
|
88
|
-
durationMs: Math.round(
|
|
96
|
+
durationMs: Math.round(base.elapsedMs()),
|
|
97
|
+
// Over the cleaned HTML, as `convertTextual` compares it.
|
|
89
98
|
htmlHash: body.html ? htmlHash(body.html) : null
|
|
90
99
|
};
|
|
91
100
|
}
|
|
92
101
|
/**
|
|
93
102
|
* `http://` is upgraded rather than rejected: two carpenter sites in the probe
|
|
94
103
|
* link internally with `http://` and the https-only guard threw away most of
|
|
95
|
-
* their pages. Localhost http stays reachable
|
|
104
|
+
* their pages. Localhost http stays http, and is reachable only with
|
|
105
|
+
* `allowLocalhost`.
|
|
96
106
|
*/
|
|
97
|
-
function prepareRequestUrl(raw) {
|
|
107
|
+
function prepareRequestUrl(raw, allowLocalhost) {
|
|
98
108
|
let parsed;
|
|
99
109
|
try {
|
|
100
110
|
parsed = new URL(raw);
|
|
@@ -107,7 +117,7 @@ function prepareRequestUrl(raw) {
|
|
|
107
117
|
if (parsed.protocol !== 'http:')
|
|
108
118
|
return null;
|
|
109
119
|
if (isLocalhostHostname(parsed.hostname)) {
|
|
110
|
-
return
|
|
120
|
+
return allowLocalhost ? parsed : null;
|
|
111
121
|
}
|
|
112
122
|
parsed.protocol = 'https:';
|
|
113
123
|
if (parsed.port === '80')
|
|
@@ -130,7 +140,7 @@ function wwwFallbackUrl(url) {
|
|
|
130
140
|
}
|
|
131
141
|
/**
|
|
132
142
|
* A 429 is an origin asking us to slow down, not a bot wall. Shopify answered
|
|
133
|
-
* 117 of
|
|
143
|
+
* 117 of one store's 244 reads with it; sent on to Browser Rendering they
|
|
134
144
|
* tripped Cloudflare's own limit (79 times, 25-30 s each) and 7 came back.
|
|
135
145
|
* So the host is narrowed and paused (`coolDownHost`), the URL is asked again,
|
|
136
146
|
* and a page still refused after that is `transient`: its stored copy stands
|
|
@@ -141,7 +151,12 @@ const MAX_RATE_LIMIT_WAIT_MS = 30_000;
|
|
|
141
151
|
/** The origin's `Retry-After` when it sent one, else 1 s, 2 s, 4 s. */
|
|
142
152
|
function rateLimitWaitMs(response, retry) {
|
|
143
153
|
const askedMs = parseRetryAfter(response.headers.get('retry-after'));
|
|
144
|
-
return Math.min(MAX_RATE_LIMIT_WAIT_MS, askedMs ??
|
|
154
|
+
return Math.min(MAX_RATE_LIMIT_WAIT_MS, askedMs ??
|
|
155
|
+
exponentialBackoff(retry, {
|
|
156
|
+
baseMs: 1_000,
|
|
157
|
+
maxMs: MAX_RATE_LIMIT_WAIT_MS,
|
|
158
|
+
jitter: false
|
|
159
|
+
}));
|
|
145
160
|
}
|
|
146
161
|
/**
|
|
147
162
|
* Bump whenever conversion changes what the same HTML becomes (`withoutHiddenUi`,
|
|
@@ -149,7 +164,7 @@ function rateLimitWaitMs(response, retry) {
|
|
|
149
164
|
* matches, and every page is converted again once instead of keeping the
|
|
150
165
|
* markdown the old conversion made.
|
|
151
166
|
*/
|
|
152
|
-
const CONVERSION_VERSION =
|
|
167
|
+
const CONVERSION_VERSION = 2;
|
|
153
168
|
function htmlHash(html) {
|
|
154
169
|
return sha256Hex(`${CONVERSION_VERSION}\n${html}`);
|
|
155
170
|
}
|
|
@@ -173,11 +188,10 @@ function parseFinalUrl(responseUrl, requestUrl) {
|
|
|
173
188
|
}
|
|
174
189
|
}
|
|
175
190
|
function isAbortError(error) {
|
|
176
|
-
|
|
177
|
-
? String(error.name)
|
|
178
|
-
: '';
|
|
179
|
-
return name === 'AbortError' || name === 'TimeoutError';
|
|
191
|
+
return isRecord(error) && isOneOf(['AbortError', 'TimeoutError'], error.name);
|
|
180
192
|
}
|
|
193
|
+
/** The content kinds whose body is read as text; the rest are documents or assets. */
|
|
194
|
+
const TEXTUAL_KINDS = ['markdown', 'html', 'text'];
|
|
181
195
|
/** Reading the body is optional; an unsupported type is dropped at the headers. */
|
|
182
196
|
function discardBody(response) {
|
|
183
197
|
void response.body?.cancel().catch(() => { });
|
|
@@ -195,75 +209,67 @@ async function readOriginBody(response, finalUrl, logger) {
|
|
|
195
209
|
}
|
|
196
210
|
const contentType = response.headers.get('content-type');
|
|
197
211
|
const kind = classifyContentType(contentType);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return { kind: 'none' };
|
|
201
|
-
}
|
|
202
|
-
if (kind === 'document') {
|
|
203
|
-
const type = convertibleDocumentType(contentType);
|
|
204
|
-
if (!type) {
|
|
212
|
+
return typedSwitch(kind, {
|
|
213
|
+
asset: () => {
|
|
205
214
|
discardBody(response);
|
|
206
215
|
return { kind: 'none' };
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
216
|
+
},
|
|
217
|
+
document: async () => {
|
|
218
|
+
const type = convertibleDocumentType(contentType);
|
|
219
|
+
if (!type) {
|
|
220
|
+
discardBody(response);
|
|
221
|
+
return { kind: 'none' };
|
|
222
|
+
}
|
|
223
|
+
const limit = type.extension === 'pdf' ? MAX_PDF_BYTES : MAX_DOCUMENT_BYTES;
|
|
224
|
+
const declared = Number(response.headers.get('content-length'));
|
|
225
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
226
|
+
discardBody(response);
|
|
227
|
+
logger.info('url-to-markdown.document_oversized', {
|
|
228
|
+
url: finalUrl.href,
|
|
229
|
+
bytes: declared
|
|
230
|
+
});
|
|
231
|
+
return { kind: 'none', captureIssue: 'document_byte_limit' };
|
|
232
|
+
}
|
|
233
|
+
const bytes = await readBoundedBody(response, limit);
|
|
234
|
+
return bytes
|
|
235
|
+
? { kind: 'bytes', bytes }
|
|
236
|
+
: { kind: 'none', captureIssue: 'document_byte_limit' };
|
|
237
|
+
},
|
|
238
|
+
...typedSwitchGroup(kind, TEXTUAL_KINDS, async () => {
|
|
239
|
+
const declared = Number(response.headers.get('content-length'));
|
|
240
|
+
const read = Number.isFinite(declared) && declared > MAX_TEXT_READ_BYTES
|
|
241
|
+
? null
|
|
242
|
+
: await readBody(response, MAX_TEXT_READ_BYTES);
|
|
243
|
+
if (!read || read.truncated) {
|
|
244
|
+
if (!read)
|
|
245
|
+
discardBody(response);
|
|
246
|
+
logger.info('url-to-markdown.html_oversized', {
|
|
247
|
+
url: finalUrl.href,
|
|
248
|
+
bytes: read ? MAX_TEXT_READ_BYTES : declared
|
|
249
|
+
});
|
|
250
|
+
return { kind: 'none', captureIssue: 'html_byte_limit' };
|
|
251
|
+
}
|
|
252
|
+
return { kind: 'text', text: decodeText(read.bytes) };
|
|
253
|
+
})
|
|
254
|
+
});
|
|
224
255
|
}
|
|
225
256
|
function documentFilename(finalUrl, extension) {
|
|
226
257
|
const segment = finalUrl.split(/[?#]/)[0]?.split('/').pop() || 'document';
|
|
227
258
|
const name = segment.slice(0, 80);
|
|
228
|
-
return name
|
|
229
|
-
? name
|
|
230
|
-
: `${name}.${extension}`;
|
|
259
|
+
return fileExtension(name) === extension ? name : `${name}.${extension}`;
|
|
231
260
|
}
|
|
232
|
-
/** Streaming limits bound chunked bodies as well as declared Content-Length. */
|
|
233
261
|
async function readBoundedBody(response, maxBytes) {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
return new ArrayBuffer(0);
|
|
237
|
-
const chunks = [];
|
|
238
|
-
let size = 0;
|
|
239
|
-
while (true) {
|
|
240
|
-
const next = await reader.read();
|
|
241
|
-
if (next.done)
|
|
242
|
-
break;
|
|
243
|
-
size += next.value.byteLength;
|
|
244
|
-
if (size > maxBytes) {
|
|
245
|
-
await reader.cancel();
|
|
246
|
-
return null;
|
|
247
|
-
}
|
|
248
|
-
chunks.push(next.value);
|
|
249
|
-
}
|
|
250
|
-
const bytes = new Uint8Array(size);
|
|
251
|
-
let offset = 0;
|
|
252
|
-
for (const chunk of chunks) {
|
|
253
|
-
bytes.set(chunk, offset);
|
|
254
|
-
offset += chunk.byteLength;
|
|
255
|
-
}
|
|
256
|
-
return bytes.buffer;
|
|
262
|
+
const read = await readBody(response, maxBytes);
|
|
263
|
+
return read.truncated ? null : read.bytes.buffer;
|
|
257
264
|
}
|
|
258
265
|
/**
|
|
259
266
|
* Markup a visitor never sees until they open it: `<template>` content and
|
|
260
267
|
* closed `<dialog>`s. A Shopify theme ships its cart drawer ("Your cart is
|
|
261
268
|
* empty") and search overlay (a product list with prices) in every page this
|
|
262
|
-
* way; converted, they reached 70 of one store's 89 pages
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
* name `Kommentar Send` the fact guard then demanded of the document.
|
|
269
|
+
* way; converted, they reached 70 of one store's 89 pages. An open dialog is
|
|
270
|
+
* shown and stays. A contact or newsletter form (Shopify posts both to
|
|
271
|
+
* `/contact`) is labels and a button, and "Telefon Kommentar Send us a note"
|
|
272
|
+
* read as if `Kommentar Send` were a name on the page.
|
|
267
273
|
*
|
|
268
274
|
* Plus the form fields a visitor cannot see: an anti-spam honeypot inside a
|
|
269
275
|
* `display:none` block renamed its label ("Navn" / "Url") on every request,
|
|
@@ -288,12 +294,7 @@ function withoutHiddenUi(html) {
|
|
|
288
294
|
.transform(html);
|
|
289
295
|
}
|
|
290
296
|
/** A real browser passes these; every other 4xx has nothing for it to find. */
|
|
291
|
-
const BOT_WALL_STATUSES =
|
|
292
|
-
/** `AbortSignal.timeout` and the caller's signal, whichever fires first. */
|
|
293
|
-
function timeoutSignal(ms, signal) {
|
|
294
|
-
const timeout = AbortSignal.timeout(ms);
|
|
295
|
-
return signal ? AbortSignal.any([timeout, signal]) : timeout;
|
|
296
|
-
}
|
|
297
|
+
const BOT_WALL_STATUSES = [401, 403];
|
|
297
298
|
/**
|
|
298
299
|
* One converter: its Cloudflare client, pools and per-host politeness limits.
|
|
299
300
|
* Create it once per process; every call shares the pools.
|
|
@@ -309,14 +310,13 @@ export function createWebsiteToMarkdown(options) {
|
|
|
309
310
|
logger
|
|
310
311
|
});
|
|
311
312
|
const hostLimits = createHostLimits(createLimit);
|
|
313
|
+
const allowLocalhost = options.allowLocalhost === true;
|
|
312
314
|
const allowHost = options.isHostAllowed ??
|
|
313
|
-
((hostname) => isHostAllowed(hostname, logger));
|
|
315
|
+
((hostname) => isHostAllowed(hostname, { logger, allowLocalhost }));
|
|
316
|
+
const guard = { isHostAllowed: allowHost, fetch: options.fetch };
|
|
314
317
|
async function requestOrigin(url, headers, signal) {
|
|
315
318
|
if (signal?.aborted)
|
|
316
319
|
return { kind: 'failed', retryable: false };
|
|
317
|
-
if (!(await allowHost(url.hostname))) {
|
|
318
|
-
return { kind: 'refused' };
|
|
319
|
-
}
|
|
320
320
|
try {
|
|
321
321
|
// The politeness slot spans the connection *and* the body: freed at the
|
|
322
322
|
// response headers it would bound connections only, and the whole 80-wide
|
|
@@ -326,12 +326,9 @@ export function createWebsiteToMarkdown(options) {
|
|
|
326
326
|
// our upstreams with their own pools, and holding a customer's slot
|
|
327
327
|
// across a 60s render would stall the crawl for no politeness gain.
|
|
328
328
|
const read = await hostLimits.withHostLimit(url.hostname, async () => {
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
headers
|
|
333
|
-
});
|
|
334
|
-
const finalUrl = parseFinalUrl(response.url, url);
|
|
329
|
+
const hop = await followRedirects(url, { signal: timeoutSignal(FETCH_TIMEOUT_MS, signal), headers }, guard);
|
|
330
|
+
const response = hop.response;
|
|
331
|
+
const finalUrl = parseFinalUrl(response.url, hop.url);
|
|
335
332
|
return {
|
|
336
333
|
response,
|
|
337
334
|
finalUrl,
|
|
@@ -341,6 +338,9 @@ export function createWebsiteToMarkdown(options) {
|
|
|
341
338
|
return { kind: 'read', read };
|
|
342
339
|
}
|
|
343
340
|
catch (error) {
|
|
341
|
+
// The guard refused the URL or a redirect: policy, not a network blip.
|
|
342
|
+
if (error instanceof HostNotAllowedError)
|
|
343
|
+
return { kind: 'refused' };
|
|
344
344
|
logger.warn('url-to-markdown.origin_failed', {
|
|
345
345
|
url: url.href,
|
|
346
346
|
...errorLogAttributes(error)
|
|
@@ -395,9 +395,8 @@ export function createWebsiteToMarkdown(options) {
|
|
|
395
395
|
if (input.signal?.aborted)
|
|
396
396
|
return { outcome: 'transient', source: null };
|
|
397
397
|
try {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const parsed = await options.parsePdf({
|
|
398
|
+
const parsePdf = must(options.parsePdf, 'No PDF parser configured');
|
|
399
|
+
const parsed = await parsePdf({
|
|
401
400
|
bytes,
|
|
402
401
|
filename: input.filename,
|
|
403
402
|
mimeType: input.mimeType,
|
|
@@ -435,7 +434,11 @@ export function createWebsiteToMarkdown(options) {
|
|
|
435
434
|
return {
|
|
436
435
|
outcome: 'unsupported',
|
|
437
436
|
source: null,
|
|
438
|
-
captureIssue: input.body
|
|
437
|
+
captureIssue: typedSwitch(input.body, 'kind', {
|
|
438
|
+
none: (body) => body.captureIssue,
|
|
439
|
+
text: () => null,
|
|
440
|
+
bytes: () => null
|
|
441
|
+
})
|
|
439
442
|
};
|
|
440
443
|
}
|
|
441
444
|
return convertDocumentBytes({
|
|
@@ -451,15 +454,20 @@ export function createWebsiteToMarkdown(options) {
|
|
|
451
454
|
// The `Content-Type` header is authoritative - sniffing for a leading
|
|
452
455
|
// `<html` misses every page that opens with a comment or a CDN banner - but
|
|
453
456
|
// a `text/plain` body that is plainly HTML still needs the HTML tier.
|
|
454
|
-
const isHtml = input.kind
|
|
455
|
-
(
|
|
457
|
+
const isHtml = typedSwitch(input.kind, {
|
|
458
|
+
html: () => true,
|
|
459
|
+
markdown: () => false,
|
|
460
|
+
text: () => looksLikeHtml(body)
|
|
461
|
+
});
|
|
456
462
|
if (!isHtml && !isBlankMarkdown(body)) {
|
|
457
463
|
return { outcome: 'ok', source: 'content-negotiation', markdown: body };
|
|
458
464
|
}
|
|
459
465
|
const html = isHtml ? body : null;
|
|
460
466
|
// Most origins send no validators, so without this every page of a quiet
|
|
461
467
|
// site went through Workers AI again every night to learn nothing moved.
|
|
462
|
-
if (html &&
|
|
468
|
+
if (html &&
|
|
469
|
+
input.htmlHash &&
|
|
470
|
+
htmlHash(cleanText(html)) === input.htmlHash) {
|
|
463
471
|
return { outcome: 'unchanged', source: 'not-modified' };
|
|
464
472
|
}
|
|
465
473
|
if (html && Buffer.byteLength(html) > MAX_HTML_BYTES) {
|
|
@@ -470,10 +478,16 @@ export function createWebsiteToMarkdown(options) {
|
|
|
470
478
|
url: input.finalUrl.href,
|
|
471
479
|
bytes: Buffer.byteLength(html)
|
|
472
480
|
});
|
|
473
|
-
return {
|
|
481
|
+
return {
|
|
482
|
+
outcome: 'unsupported',
|
|
483
|
+
source: null,
|
|
484
|
+
html,
|
|
485
|
+
captureIssue: 'html_byte_limit'
|
|
486
|
+
};
|
|
474
487
|
}
|
|
475
488
|
let shell = false;
|
|
476
489
|
let workersAiRateLimited = false;
|
|
490
|
+
let renderRateLimited = false;
|
|
477
491
|
if (html) {
|
|
478
492
|
const visible = withoutHiddenUi(html);
|
|
479
493
|
let converted = null;
|
|
@@ -511,7 +525,7 @@ export function createWebsiteToMarkdown(options) {
|
|
|
511
525
|
catch (error) {
|
|
512
526
|
if (!(error instanceof CloudflareRateLimitedError))
|
|
513
527
|
throw error;
|
|
514
|
-
|
|
528
|
+
renderRateLimited = true;
|
|
515
529
|
}
|
|
516
530
|
if (rendered &&
|
|
517
531
|
!isBlankMarkdown(rendered) &&
|
|
@@ -532,7 +546,9 @@ export function createWebsiteToMarkdown(options) {
|
|
|
532
546
|
? 'render_incomplete'
|
|
533
547
|
: workersAiRateLimited
|
|
534
548
|
? 'workers_ai_rate_limited'
|
|
535
|
-
:
|
|
549
|
+
: renderRateLimited
|
|
550
|
+
? 'browser_rendering_rate_limited'
|
|
551
|
+
: null
|
|
536
552
|
};
|
|
537
553
|
}
|
|
538
554
|
/** Runs after the origin's slot is released: every tier it reaches is ours. */
|
|
@@ -542,7 +558,7 @@ export function createWebsiteToMarkdown(options) {
|
|
|
542
558
|
const contentType = response.headers.get('content-type');
|
|
543
559
|
if (status === 304)
|
|
544
560
|
return { outcome: 'unchanged', source: 'not-modified' };
|
|
545
|
-
if (
|
|
561
|
+
if (isOneOf([404, 410], status)) {
|
|
546
562
|
return { outcome: 'gone', source: null };
|
|
547
563
|
}
|
|
548
564
|
// Still refused after the host's pauses: rendering would only be refused
|
|
@@ -550,10 +566,23 @@ export function createWebsiteToMarkdown(options) {
|
|
|
550
566
|
if (status >= 500 || status === 429)
|
|
551
567
|
return { outcome: 'transient', source: null };
|
|
552
568
|
if (status >= 400) {
|
|
553
|
-
if (!input.render || !BOT_WALL_STATUSES
|
|
569
|
+
if (!input.render || !isOneOf(BOT_WALL_STATUSES, status)) {
|
|
554
570
|
return { outcome: 'blocked', source: null };
|
|
555
571
|
}
|
|
556
|
-
|
|
572
|
+
let rendered;
|
|
573
|
+
try {
|
|
574
|
+
rendered = await client.renderUrlToMarkdown(input.finalUrl.href, input.signal);
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
if (!(error instanceof CloudflareRateLimitedError))
|
|
578
|
+
throw error;
|
|
579
|
+
// The wall may well let a browser through on the next run.
|
|
580
|
+
return {
|
|
581
|
+
outcome: 'transient',
|
|
582
|
+
source: null,
|
|
583
|
+
captureIssue: 'browser_rendering_rate_limited'
|
|
584
|
+
};
|
|
585
|
+
}
|
|
557
586
|
return rendered
|
|
558
587
|
? { outcome: 'ok', source: 'browser-rendering', markdown: rendered }
|
|
559
588
|
: { outcome: 'blocked', source: null };
|
|
@@ -562,25 +591,28 @@ export function createWebsiteToMarkdown(options) {
|
|
|
562
591
|
return { outcome: 'transient', source: null };
|
|
563
592
|
}
|
|
564
593
|
const kind = classifyContentType(contentType);
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
return convertDocument({
|
|
594
|
+
return typedSwitch(kind, {
|
|
595
|
+
asset: () => ({ outcome: 'unsupported', source: null }),
|
|
596
|
+
document: () => convertDocument({
|
|
569
597
|
body,
|
|
570
598
|
contentType,
|
|
571
599
|
finalUrl: input.finalUrl,
|
|
572
600
|
signal: input.signal
|
|
573
|
-
})
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
601
|
+
}),
|
|
602
|
+
...typedSwitchGroup(kind, TEXTUAL_KINDS, async (textualKind) => body.kind === 'text'
|
|
603
|
+
? convertTextual({
|
|
604
|
+
text: body.text,
|
|
605
|
+
kind: textualKind,
|
|
606
|
+
finalUrl: input.finalUrl,
|
|
607
|
+
render: input.render,
|
|
608
|
+
htmlHash: input.htmlHash,
|
|
609
|
+
signal: input.signal
|
|
610
|
+
})
|
|
611
|
+
: {
|
|
612
|
+
outcome: 'unsupported',
|
|
613
|
+
source: null,
|
|
614
|
+
captureIssue: body.kind === 'none' ? body.captureIssue : null
|
|
615
|
+
})
|
|
584
616
|
});
|
|
585
617
|
}
|
|
586
618
|
/**
|
|
@@ -590,10 +622,33 @@ export function createWebsiteToMarkdown(options) {
|
|
|
590
622
|
* target, validators, HTML metadata and absolute links.
|
|
591
623
|
*
|
|
592
624
|
* Never throws: a page that cannot be read is an outcome, not an exception,
|
|
593
|
-
* because the caller is crawling hundreds of URLs at a time.
|
|
594
|
-
*
|
|
625
|
+
* because the caller is crawling hundreds of URLs at a time. Anything
|
|
626
|
+
* unexpected (a failing shared limiter, a corrupt snapshot) is logged and
|
|
627
|
+
* comes back `transient`. Inside `withCrawlSnapshot` it reads through the
|
|
628
|
+
* snapshot.
|
|
595
629
|
*/
|
|
596
630
|
async function fetchPageAsMarkdown(input) {
|
|
631
|
+
const elapsedMs = startTimer();
|
|
632
|
+
try {
|
|
633
|
+
return await readPage(input);
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
logger.warn('url-to-markdown.failed', {
|
|
637
|
+
url: input.url,
|
|
638
|
+
...errorLogAttributes(error)
|
|
639
|
+
});
|
|
640
|
+
return buildPage({
|
|
641
|
+
url: input.url,
|
|
642
|
+
finalUrl: input.url,
|
|
643
|
+
status: 0,
|
|
644
|
+
contentType: null,
|
|
645
|
+
etag: null,
|
|
646
|
+
lastModified: null,
|
|
647
|
+
elapsedMs
|
|
648
|
+
}, { outcome: 'transient', source: null }, logger);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
async function readPage(input) {
|
|
597
652
|
if (!currentCrawlSnapshot())
|
|
598
653
|
return fetchPageLive(input);
|
|
599
654
|
// The snapshot holds the whole page, read without validators; whether it is
|
|
@@ -604,34 +659,13 @@ export function createWebsiteToMarkdown(options) {
|
|
|
604
659
|
render: input.render,
|
|
605
660
|
signal: input.signal
|
|
606
661
|
}));
|
|
607
|
-
|
|
608
|
-
((input.htmlHash && page.htmlHash === input.htmlHash) ||
|
|
609
|
-
(input.etag && page.etag === input.etag));
|
|
610
|
-
if (!unchanged)
|
|
611
|
-
return page;
|
|
612
|
-
return {
|
|
613
|
-
...page,
|
|
614
|
-
outcome: 'unchanged',
|
|
615
|
-
source: 'not-modified',
|
|
616
|
-
markdown: '',
|
|
617
|
-
title: null,
|
|
618
|
-
lang: null,
|
|
619
|
-
canonical: null,
|
|
620
|
-
alternates: [],
|
|
621
|
-
alternateLanguages: {},
|
|
622
|
-
metaDescription: null,
|
|
623
|
-
jsonLd: [],
|
|
624
|
-
links: [],
|
|
625
|
-
images: [],
|
|
626
|
-
captureIssue: null,
|
|
627
|
-
htmlHash: null
|
|
628
|
-
};
|
|
662
|
+
return isUnchangedFor(page, input) ? asUnchanged(page) : page;
|
|
629
663
|
}
|
|
630
664
|
async function fetchPageLive(input) {
|
|
631
665
|
const signal = input.signal ?? currentOperationSignal();
|
|
632
|
-
const
|
|
666
|
+
const elapsedMs = startTimer();
|
|
633
667
|
const render = input.render ?? true;
|
|
634
|
-
const prepared = prepareRequestUrl(input.url);
|
|
668
|
+
const prepared = prepareRequestUrl(input.url, allowLocalhost);
|
|
635
669
|
if (!prepared) {
|
|
636
670
|
return buildPage({
|
|
637
671
|
url: input.url,
|
|
@@ -640,7 +674,7 @@ export function createWebsiteToMarkdown(options) {
|
|
|
640
674
|
contentType: null,
|
|
641
675
|
etag: null,
|
|
642
676
|
lastModified: null,
|
|
643
|
-
|
|
677
|
+
elapsedMs
|
|
644
678
|
}, { outcome: 'unsupported', source: null }, logger);
|
|
645
679
|
}
|
|
646
680
|
const headers = {
|
|
@@ -686,11 +720,13 @@ export function createWebsiteToMarkdown(options) {
|
|
|
686
720
|
contentType: null,
|
|
687
721
|
etag: null,
|
|
688
722
|
lastModified: null,
|
|
689
|
-
|
|
690
|
-
},
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
723
|
+
elapsedMs
|
|
724
|
+
}, {
|
|
725
|
+
outcome: typedSwitch(attempt, 'kind', {
|
|
726
|
+
// A refused host is a policy decision, not a retryable network blip.
|
|
727
|
+
refused: () => 'blocked',
|
|
728
|
+
failed: () => 'transient'
|
|
729
|
+
}),
|
|
694
730
|
source: null
|
|
695
731
|
}, logger);
|
|
696
732
|
}
|
|
@@ -713,7 +749,7 @@ export function createWebsiteToMarkdown(options) {
|
|
|
713
749
|
etag: response.headers.get('etag') ?? notModified(input.etag),
|
|
714
750
|
lastModified: response.headers.get('last-modified') ??
|
|
715
751
|
notModified(input.lastModified),
|
|
716
|
-
|
|
752
|
+
elapsedMs
|
|
717
753
|
}, body, logger);
|
|
718
754
|
logger.info('url-to-markdown.completed', {
|
|
719
755
|
url: page.finalUrl,
|
|
@@ -742,13 +778,19 @@ export function createWebsiteToMarkdown(options) {
|
|
|
742
778
|
renderUrlToMarkdown: client.renderUrlToMarkdown,
|
|
743
779
|
/** The SSRF guard the converter runs, bound to its `logger`. */
|
|
744
780
|
isHostAllowed: allowHost,
|
|
781
|
+
/**
|
|
782
|
+
* `fetch` behind the converter's guard, run on the URL and on every
|
|
783
|
+
* redirect. Throws `HostNotAllowedError` when one is refused. Use it for
|
|
784
|
+
* any other request to a customer-supplied URL.
|
|
785
|
+
*/
|
|
786
|
+
guardedFetch(input, init, fetchOptions) {
|
|
787
|
+
return guardedFetch(input, init, { ...guard, ...fetchOptions });
|
|
788
|
+
},
|
|
745
789
|
/** `withCrawlSnapshot` bound to the `crawlSnapshot` option. */
|
|
746
790
|
withCrawlSnapshot(input, run) {
|
|
747
791
|
if (!input)
|
|
748
792
|
return run();
|
|
749
|
-
|
|
750
|
-
throw new Error('Set crawlSnapshot.root to use crawl snapshots');
|
|
751
|
-
}
|
|
793
|
+
invariant(options.crawlSnapshot, 'Set crawlSnapshot.root to use crawl snapshots');
|
|
752
794
|
return withCrawlSnapshot(input, run, {
|
|
753
795
|
root: options.crawlSnapshot.root,
|
|
754
796
|
logger: options.crawlSnapshot.logger ?? logger
|