website-to-markdown 1.1.0 → 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 +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +17 -7
- 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 +25 -6
- 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/hash.ts
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Hex SHA-256 of a UTF-8 string or of raw bytes
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Used as a change detector (has this text moved since we last derived from
|
|
8
|
-
* it?), as an API-secret digest, and to fingerprint fetched binaries. The value
|
|
9
|
-
* is a stored identity in several tables, so it must stay byte-for-byte what it
|
|
10
|
-
* has always been.
|
|
4
|
+
* Hex SHA-256 of a UTF-8 string or of raw bytes. `htmlHash` and snapshot file
|
|
5
|
+
* names are built on it, and callers store them, so it must stay byte-for-byte
|
|
6
|
+
* what it has always been.
|
|
11
7
|
*/
|
|
12
8
|
export function sha256Hex(data: string | Uint8Array): string {
|
|
13
9
|
return typeof data === 'string'
|
package/src/html-metadata.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getUniqueValues,
|
|
3
|
+
isOneOf,
|
|
4
|
+
normalizeWhitespace,
|
|
5
|
+
safeJsonParse
|
|
6
|
+
} from 'hanzio'
|
|
1
7
|
import { decodeHtmlEntities } from './html-entities.js'
|
|
2
8
|
|
|
3
9
|
const TITLE_RE = /<title[^>]*>([\s\S]*?)<\/title>/i
|
|
@@ -29,17 +35,15 @@ function attributesOf(tag: string) {
|
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
function cleanText(value: string) {
|
|
32
|
-
return
|
|
33
|
-
.replace(
|
|
34
|
-
|
|
38
|
+
return normalizeWhitespace(
|
|
39
|
+
decodeHtmlEntities(value.replace(HTML_TAG_RE, ' '))
|
|
40
|
+
)
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
function absolute(url: string, baseUrl: string) {
|
|
38
44
|
try {
|
|
39
45
|
const resolved = new URL(url, baseUrl)
|
|
40
|
-
if (
|
|
41
|
-
return null
|
|
42
|
-
}
|
|
46
|
+
if (!isOneOf(['https:', 'http:'], resolved.protocol)) return null
|
|
43
47
|
return resolved.href
|
|
44
48
|
} catch {
|
|
45
49
|
return null
|
|
@@ -51,15 +55,13 @@ function parseJsonLd(html: string) {
|
|
|
51
55
|
for (const match of html.matchAll(JSON_LD_RE)) {
|
|
52
56
|
const body = match[1]?.trim()
|
|
53
57
|
if (!body) continue
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// Hand-written JSON-LD with a trailing comma is common; skip the block.
|
|
62
|
-
}
|
|
58
|
+
const parsed = safeJsonParse(body)
|
|
59
|
+
// Hand-written JSON-LD with a trailing comma is common; skip the block.
|
|
60
|
+
if (!parsed.ok) continue
|
|
61
|
+
// A single script may hold an array of nodes; flatten so the profile
|
|
62
|
+
// layer can look for `@type` without walking two shapes.
|
|
63
|
+
if (Array.isArray(parsed.data)) blocks.push(...parsed.data)
|
|
64
|
+
else blocks.push(parsed.data)
|
|
63
65
|
}
|
|
64
66
|
return blocks
|
|
65
67
|
}
|
|
@@ -82,7 +84,7 @@ export function extractHtmlMetadata(html: string, baseUrl: string) {
|
|
|
82
84
|
// `<link rel="alternate" hreflang>`: the site's own declaration of which
|
|
83
85
|
// URLs are translations of this page. `x-default` is a language picker,
|
|
84
86
|
// not a translation.
|
|
85
|
-
const alternates =
|
|
87
|
+
const alternates: string[] = []
|
|
86
88
|
/** Each alternate's language (`da` of `da-DK`); what lets a crawl skip a mirror. */
|
|
87
89
|
const alternateLanguages: Record<string, string> = {}
|
|
88
90
|
for (const match of html.matchAll(LINK_TAG_RE)) {
|
|
@@ -97,7 +99,7 @@ export function extractHtmlMetadata(html: string, baseUrl: string) {
|
|
|
97
99
|
if (rel.includes('alternate') && hreflang && hreflang !== 'x-default') {
|
|
98
100
|
const href = absolute(attributes.href, baseUrl)
|
|
99
101
|
const language = hreflang.split('-')[0] ?? ''
|
|
100
|
-
if (href) alternates.
|
|
102
|
+
if (href) alternates.push(href)
|
|
101
103
|
if (href && /^[a-z]{2}$/.test(language))
|
|
102
104
|
alternateLanguages[href] = language
|
|
103
105
|
}
|
|
@@ -118,7 +120,7 @@ export function extractHtmlMetadata(html: string, baseUrl: string) {
|
|
|
118
120
|
title,
|
|
119
121
|
lang,
|
|
120
122
|
canonical,
|
|
121
|
-
alternates:
|
|
123
|
+
alternates: getUniqueValues(alternates),
|
|
122
124
|
alternateLanguages,
|
|
123
125
|
metaDescription: metaDescription || ogDescription || null,
|
|
124
126
|
jsonLd: parseJsonLd(html)
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export {
|
|
|
17
17
|
withCrawlSnapshot
|
|
18
18
|
} from './crawl-snapshot.js'
|
|
19
19
|
export {
|
|
20
|
+
type CaptureIssue,
|
|
20
21
|
createWebsiteToMarkdown,
|
|
21
22
|
type FetchedPage,
|
|
22
23
|
type FetchOutcome,
|
|
@@ -38,6 +39,7 @@ export {
|
|
|
38
39
|
} from './limit.js'
|
|
39
40
|
export type { LogAttributes, Logger } from './logger.js'
|
|
40
41
|
export {
|
|
42
|
+
cleanConvertedMarkdown,
|
|
41
43
|
htmlImages,
|
|
42
44
|
htmlLinks,
|
|
43
45
|
markdownRefs,
|
|
@@ -47,6 +49,11 @@ export {
|
|
|
47
49
|
} from './markdown-refs.js'
|
|
48
50
|
export { createMemoryConcurrencyLimit } from './memory-limit.js'
|
|
49
51
|
export {
|
|
52
|
+
type Fetch,
|
|
53
|
+
type GuardedFetchOptions,
|
|
54
|
+
guardedFetch,
|
|
55
|
+
type HostGuardOptions,
|
|
56
|
+
HostNotAllowedError,
|
|
50
57
|
isBlockedAddress,
|
|
51
58
|
isHostAllowed,
|
|
52
59
|
isLocalhostHostname
|
package/src/limit.ts
CHANGED
|
@@ -90,7 +90,7 @@ export const URL_TO_MARKDOWN_HOST_MAX_CONCURRENCY = 16
|
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
92
|
* A host that answered 429 is read this narrowly from then on. At 16 in
|
|
93
|
-
* flight Shopify refused 117 of
|
|
93
|
+
* flight Shopify refused 117 of one store's 244 reads even after each had
|
|
94
94
|
* waited out its pause: the pause only delayed the next burst. Calibration
|
|
95
95
|
* knobs - set them from the crawl log's `rate_limited`, not by theory.
|
|
96
96
|
*/
|
package/src/markdown-refs.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { compact, getUniqueValues, isOneOf } from 'hanzio'
|
|
1
2
|
import { decodeHtmlEntities } from './html-entities.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -31,10 +32,9 @@ export function stripFrontMatter(markdown: string) {
|
|
|
31
32
|
* Workers AI `toMarkdown` opens every converted PDF and office file with the
|
|
32
33
|
* file name as an H1, a `## Metadata` list (creation dates, uuids,
|
|
33
34
|
* `PDFFormatVersion=1.7`) and a `## Contents` heading, then labels every page
|
|
34
|
-
* `### Page N`. None of it is what the document says
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* the PDF metadata inside.
|
|
35
|
+
* `### Page N`. None of it is what the document says: a hotel's terms PDF
|
|
36
|
+
* came back as 13,000 characters with its creation dates and uuids inside,
|
|
37
|
+
* and a reader of the markdown takes those for facts of the document.
|
|
38
38
|
*/
|
|
39
39
|
const DOCUMENT_TITLE_RE =
|
|
40
40
|
/^#\s+[^\n]*\.(?:pdf|docx?|xlsx?|pptx?|odt|rtf)\s*\r?\n/i
|
|
@@ -60,6 +60,94 @@ export function stripDocumentPreamble(markdown: string): string {
|
|
|
60
60
|
.trim()
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Cloudflare's e-mail obfuscation rewrites every address it finds into
|
|
65
|
+
* `[\[email protected\]](/cdn-cgi/l/email-protection#<hex>)`, so a converted
|
|
66
|
+
* page behind it shows no readable address at all. The hex is the address
|
|
67
|
+
* XOR-ed with its first byte; decoded only when the result is shaped like an
|
|
68
|
+
* address.
|
|
69
|
+
*/
|
|
70
|
+
const CF_EMAIL_LINK_RE =
|
|
71
|
+
/\[(?:[^[\]\\]|\\.|\[[^\]]*\])*\]\((?:https?:\/\/[^/)]+)?\/cdn-cgi\/l\/email-protection#([0-9a-fA-F]{8,})\)/g
|
|
72
|
+
|
|
73
|
+
function decodeCloudflareEmail(hex: string) {
|
|
74
|
+
const key = Number.parseInt(hex.slice(0, 2), 16)
|
|
75
|
+
let address = ''
|
|
76
|
+
for (let index = 2; index + 1 < hex.length; index += 2) {
|
|
77
|
+
address += String.fromCharCode(
|
|
78
|
+
Number.parseInt(hex.slice(index, index + 2), 16) ^ key
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@.]+$/.test(address) ? address : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `<script type="application/ld+json">` survives conversion as a fenced code
|
|
86
|
+
* block on Yoast and Squarespace sites. It is never prose, and the page already
|
|
87
|
+
* carries it parsed as `jsonLd`.
|
|
88
|
+
*/
|
|
89
|
+
const FENCED_BLOCK_RE = /^```[^\n]*\n([\s\S]*?)^```[ \t]*$/gm
|
|
90
|
+
const JSON_LD_RE = /schema\.org|["']@context["']\s*:/
|
|
91
|
+
|
|
92
|
+
/** A page that inlines an SVG file gets its prolog back from the converter as text. */
|
|
93
|
+
const MARKUP_DECLARATION_RE = /<\?xml[^>]*\?>|<!DOCTYPE[^>]*>/gi
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A label rendered next to its own value with no space between -
|
|
97
|
+
* `[Saleshello@example.com](mailto:hello@example.com)`,
|
|
98
|
+
* `[Support+45 22 81 82 95](tel:+4522818295)` - reads as an invented address.
|
|
99
|
+
* Matched on the value's shape, so the label's language never matters.
|
|
100
|
+
*/
|
|
101
|
+
const VALUE_LINK_RE = /\[([^\]]+)\]\((mailto|tel):([^)\s]+)\)/gi
|
|
102
|
+
|
|
103
|
+
function emailStart(label: string, target: string) {
|
|
104
|
+
const address = (target.split('?')[0] ?? '').toLowerCase()
|
|
105
|
+
return address && label.toLowerCase().endsWith(address)
|
|
106
|
+
? label.length - address.length
|
|
107
|
+
: -1
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function phoneStart(label: string, target: string) {
|
|
111
|
+
const digits = target.replace(/\D/g, '')
|
|
112
|
+
if (digits.length < 6) return -1
|
|
113
|
+
const first = label.search(/[+\d]/)
|
|
114
|
+
if (first < 0) return -1
|
|
115
|
+
const rest = label.slice(first)
|
|
116
|
+
return /^[+\d\s().-]+$/.test(rest) && rest.replace(/\D/g, '') === digits
|
|
117
|
+
? first
|
|
118
|
+
: -1
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Removes what Cloudflare's conversion leaves behind and is never page text:
|
|
123
|
+
* obfuscated e-mail links are decoded, JSON-LD code fences and markup
|
|
124
|
+
* declarations dropped, and labels glued to their `mailto:`/`tel:` value
|
|
125
|
+
* split. Applied to every fetched page; idempotent, so markdown stored by an
|
|
126
|
+
* older version can be run through it too.
|
|
127
|
+
*/
|
|
128
|
+
export function cleanConvertedMarkdown(markdown: string) {
|
|
129
|
+
return markdown
|
|
130
|
+
.replace(CF_EMAIL_LINK_RE, (link, hex: string) => {
|
|
131
|
+
const address = decodeCloudflareEmail(hex)
|
|
132
|
+
return address ? `[${address}](mailto:${address})` : link
|
|
133
|
+
})
|
|
134
|
+
.replace(FENCED_BLOCK_RE, (block, body: string) =>
|
|
135
|
+
JSON_LD_RE.test(body) ? '' : block
|
|
136
|
+
)
|
|
137
|
+
.replace(MARKUP_DECLARATION_RE, '')
|
|
138
|
+
.replace(
|
|
139
|
+
VALUE_LINK_RE,
|
|
140
|
+
(link, label: string, scheme: string, target: string) => {
|
|
141
|
+
const start =
|
|
142
|
+
scheme.toLowerCase() === 'mailto'
|
|
143
|
+
? emailStart(label, target)
|
|
144
|
+
: phoneStart(label, target)
|
|
145
|
+
if (start <= 0 || !/\p{L}/u.test(label[start - 1] ?? '')) return link
|
|
146
|
+
return `[${label.slice(0, start)} ${label.slice(start)}](${scheme}:${target})`
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
63
151
|
export function titleFromMarkdown(markdown: string) {
|
|
64
152
|
const heading = markdown.match(/^#\s+(.+)$/m)
|
|
65
153
|
return heading?.[1]?.trim() || null
|
|
@@ -92,13 +180,11 @@ function resolveTarget(target: string, baseUrl: string) {
|
|
|
92
180
|
const resolved = new URL(raw, baseUrl)
|
|
93
181
|
// Drops `mailto:`, `tel:`, `javascript:`, `data:` and every other scheme
|
|
94
182
|
// discovery cannot crawl; they stay in the markdown itself.
|
|
95
|
-
if (
|
|
96
|
-
return null
|
|
97
|
-
}
|
|
183
|
+
if (!isOneOf(['https:', 'http:'], resolved.protocol)) return null
|
|
98
184
|
// Cloudflare's reserved prefix is plumbing, never a page: the e-mail
|
|
99
185
|
// obfuscation endpoint under it answers 410, which used to count as a
|
|
100
|
-
// page this site failed to deliver. `
|
|
101
|
-
// out of the link
|
|
186
|
+
// page this site failed to deliver. `cleanConvertedMarkdown` has already
|
|
187
|
+
// decoded the address out of the link.
|
|
102
188
|
if (resolved.pathname.startsWith('/cdn-cgi/')) return null
|
|
103
189
|
return resolved.href
|
|
104
190
|
} catch {
|
|
@@ -106,37 +192,32 @@ function resolveTarget(target: string, baseUrl: string) {
|
|
|
106
192
|
}
|
|
107
193
|
}
|
|
108
194
|
|
|
195
|
+
/** Resolved targets, deduped, in the order they were found. */
|
|
196
|
+
function resolveUnique(targets: (string | undefined)[], baseUrl: string) {
|
|
197
|
+
return getUniqueValues(
|
|
198
|
+
compact(
|
|
199
|
+
targets.map((target) => (target ? resolveTarget(target, baseUrl) : null))
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
109
204
|
/**
|
|
110
205
|
* Absolute, deduped, document-order links and images. Discovery crawls these,
|
|
111
206
|
* so relative targets (`/kontakt`) and CMS-escaped query strings
|
|
112
207
|
* (`?image=x&width=200`) have to come out resolved and decoded.
|
|
113
208
|
*/
|
|
114
209
|
export function markdownRefs(markdown: string, baseUrl: string) {
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
seen.add(resolved)
|
|
126
|
-
;(isImage ? images : links).push(resolved)
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
for (const match of markdown.matchAll(IMAGE_RE)) {
|
|
130
|
-
if (match[1]) push(true, match[1])
|
|
131
|
-
}
|
|
132
|
-
for (const match of markdown.matchAll(LINK_RE)) {
|
|
133
|
-
if (match[1] !== '!' && match[2]) push(false, match[2])
|
|
134
|
-
}
|
|
135
|
-
for (const match of markdown.matchAll(AUTOLINK_RE)) {
|
|
136
|
-
if (match[1]) push(false, match[1])
|
|
210
|
+
const images = [...markdown.matchAll(IMAGE_RE)].map((match) => match[1])
|
|
211
|
+
const links = [
|
|
212
|
+
...[...markdown.matchAll(LINK_RE)].map((match) =>
|
|
213
|
+
match[1] === '!' ? undefined : match[2]
|
|
214
|
+
),
|
|
215
|
+
...[...markdown.matchAll(AUTOLINK_RE)].map((match) => match[1])
|
|
216
|
+
]
|
|
217
|
+
return {
|
|
218
|
+
links: resolveUnique(links, baseUrl),
|
|
219
|
+
images: resolveUnique(images, baseUrl)
|
|
137
220
|
}
|
|
138
|
-
|
|
139
|
-
return { links, images }
|
|
140
221
|
}
|
|
141
222
|
|
|
142
223
|
const ANCHOR_HREF_RE = /<a\b[^>]*?\bhref\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi
|
|
@@ -160,38 +241,29 @@ const NON_MARKUP_RE = /<(script|style|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi
|
|
|
160
241
|
* reachable if ANY representation mentions it.
|
|
161
242
|
*/
|
|
162
243
|
export function htmlLinks(html: string, baseUrl: string): string[] {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
.replace(NON_MARKUP_RE, ' ')
|
|
167
|
-
.matchAll(ANCHOR_HREF_RE)) {
|
|
244
|
+
const targets = [
|
|
245
|
+
...html.replace(NON_MARKUP_RE, ' ').matchAll(ANCHOR_HREF_RE)
|
|
246
|
+
].map((match) => {
|
|
168
247
|
const raw = match[1]
|
|
169
|
-
if (!raw)
|
|
248
|
+
if (!raw) return undefined
|
|
170
249
|
const first = raw[0]
|
|
171
250
|
const target =
|
|
172
251
|
(first === '"' || first === "'") && raw.endsWith(first)
|
|
173
252
|
? raw.slice(1, -1)
|
|
174
253
|
: raw
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
links.push(resolved)
|
|
179
|
-
}
|
|
180
|
-
return links
|
|
254
|
+
return decodeHtmlEntities(target)
|
|
255
|
+
})
|
|
256
|
+
return resolveUnique(targets, baseUrl)
|
|
181
257
|
}
|
|
182
258
|
|
|
183
259
|
/** Conversion may omit an image-only menu; retain original and lazy asset URLs. */
|
|
184
260
|
export function htmlImages(html: string, baseUrl: string): string[] {
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
if (url) images.add(url)
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
return [...images]
|
|
261
|
+
const targets = [...html.matchAll(/<img\b[^>]*>/gi)].flatMap((tag) =>
|
|
262
|
+
[
|
|
263
|
+
...tag[0].matchAll(
|
|
264
|
+
/\b(?:src|data-src|data-original)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi
|
|
265
|
+
)
|
|
266
|
+
].map((attribute) => attribute[1]?.replace(/^["']|["']$/g, ''))
|
|
267
|
+
)
|
|
268
|
+
return resolveUnique(targets, baseUrl)
|
|
197
269
|
}
|
package/src/memory-limit.ts
CHANGED
|
@@ -7,7 +7,7 @@ const START_WINDOW_MS = 1_000
|
|
|
7
7
|
* starts in any second, and a `tripCooldown` pause before the next start.
|
|
8
8
|
*
|
|
9
9
|
* ponytail: per-process only. Several processes sharing one Cloudflare quota
|
|
10
|
-
* need a shared limiter
|
|
10
|
+
* need a shared limiter, e.g. `hanzio/redis`'s `createRedisConcurrencyLimit`.
|
|
11
11
|
*/
|
|
12
12
|
export function createMemoryConcurrencyLimit(
|
|
13
13
|
options: ConcurrencyLimitOptions
|
package/src/net-guard.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { lookup } from 'node:dns/promises'
|
|
2
|
+
import { createCache, isOneOf } from 'hanzio'
|
|
2
3
|
import { errorLogAttributes, type Logger, silentLogger } from './logger.js'
|
|
3
4
|
|
|
4
5
|
/** One crawl hits the same host hundreds of times; DNS is not cached by Node. */
|
|
@@ -116,32 +117,50 @@ export function isBlockedAddress(address: string): boolean {
|
|
|
116
117
|
|
|
117
118
|
export function isLocalhostHostname(hostname: string) {
|
|
118
119
|
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
119
|
-
return
|
|
120
|
+
return isOneOf(['localhost', '127.0.0.1', '::1'], host)
|
|
120
121
|
}
|
|
121
122
|
|
|
122
|
-
const resolutionCache =
|
|
123
|
+
const resolutionCache = createCache({
|
|
124
|
+
defaultTtlMs: RESOLUTION_TTL_MS,
|
|
125
|
+
maxEntries: RESOLUTION_CACHE_MAX
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
export type HostGuardOptions = {
|
|
129
|
+
logger?: Logger | undefined
|
|
130
|
+
/** Allow `localhost`, `127.0.0.1` and `::1`, e.g. for a dev server. Default false. */
|
|
131
|
+
allowLocalhost?: boolean | undefined
|
|
132
|
+
}
|
|
123
133
|
|
|
124
134
|
/**
|
|
125
135
|
* SSRF guard: a customer can put any hostname in the website field, and an
|
|
126
136
|
* internal address behind it would turn the importer into a proxy for our own
|
|
127
|
-
* network.
|
|
137
|
+
* network. An IP literal (`[fd00::1]` included) is judged as written, without
|
|
138
|
+
* a DNS lookup.
|
|
128
139
|
*/
|
|
129
140
|
export async function isHostAllowed(
|
|
130
141
|
hostname: string,
|
|
131
|
-
|
|
142
|
+
options: HostGuardOptions = {}
|
|
132
143
|
) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
144
|
+
const logger = options.logger ?? silentLogger
|
|
145
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
146
|
+
if (isLocalhostHostname(host)) return options.allowLocalhost === true
|
|
136
147
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
148
|
+
// `dns.lookup` fails on a bracketed literal, and a failed lookup is let
|
|
149
|
+
// through below, so a literal never reaches it.
|
|
150
|
+
if (parseIpv4(host) || host.includes(':')) {
|
|
151
|
+
const allowed = !isBlockedAddress(host)
|
|
152
|
+
if (!allowed) {
|
|
153
|
+
logger.warn('net-guard.blocked_address', { hostname, addresses: host })
|
|
154
|
+
}
|
|
155
|
+
return allowed
|
|
140
156
|
}
|
|
141
157
|
|
|
158
|
+
const cached = resolutionCache.get<boolean>(host)
|
|
159
|
+
if (cached !== undefined) return cached
|
|
160
|
+
|
|
142
161
|
let allowed = false
|
|
143
162
|
try {
|
|
144
|
-
const addresses = await lookup(
|
|
163
|
+
const addresses = await lookup(host, { all: true })
|
|
145
164
|
allowed =
|
|
146
165
|
addresses.length > 0 &&
|
|
147
166
|
addresses.every((entry) => !isBlockedAddress(entry.address))
|
|
@@ -161,10 +180,101 @@ export async function isHostAllowed(
|
|
|
161
180
|
return true
|
|
162
181
|
}
|
|
163
182
|
|
|
164
|
-
|
|
165
|
-
const oldest = resolutionCache.keys().next().value
|
|
166
|
-
if (oldest) resolutionCache.delete(oldest)
|
|
167
|
-
}
|
|
168
|
-
resolutionCache.set(hostname, { allowed, atMs: Date.now() })
|
|
183
|
+
resolutionCache.set(host, allowed)
|
|
169
184
|
return allowed
|
|
170
185
|
}
|
|
186
|
+
|
|
187
|
+
/** The `fetch` shape the guard wraps and the library accepts as an option. */
|
|
188
|
+
export type Fetch = (
|
|
189
|
+
input: string | URL,
|
|
190
|
+
init?: RequestInit
|
|
191
|
+
) => Promise<Response>
|
|
192
|
+
|
|
193
|
+
/** A request, or one of its redirects, went to a host the guard refuses. */
|
|
194
|
+
export class HostNotAllowedError extends Error {
|
|
195
|
+
override readonly name = 'HostNotAllowedError'
|
|
196
|
+
|
|
197
|
+
constructor(readonly url: string) {
|
|
198
|
+
super(`Host not allowed: ${url}`)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export type GuardedFetchOptions = {
|
|
203
|
+
/** Run on the URL and on every redirect target. Default {@link isHostAllowed}. */
|
|
204
|
+
isHostAllowed?: ((hostname: string) => Promise<boolean>) | undefined
|
|
205
|
+
/** Default `globalThis.fetch`, read at call time. */
|
|
206
|
+
fetch?: Fetch | undefined
|
|
207
|
+
/** Default 10. */
|
|
208
|
+
maxRedirects?: number | undefined
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const REDIRECT_STATUSES = [301, 302, 303, 307, 308] as const
|
|
212
|
+
const DEFAULT_MAX_REDIRECTS = 10
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* `fetch` that runs the guard on every hop: `redirect: 'follow'` would take a
|
|
216
|
+
* public host's 302 to `http://169.254.169.254/` without asking. Returns the
|
|
217
|
+
* last response and the URL it came from.
|
|
218
|
+
*/
|
|
219
|
+
export async function followRedirects(
|
|
220
|
+
input: string | URL,
|
|
221
|
+
init: RequestInit = {},
|
|
222
|
+
options: GuardedFetchOptions = {}
|
|
223
|
+
): Promise<{ response: Response; url: URL }> {
|
|
224
|
+
const allow =
|
|
225
|
+
options.isHostAllowed ?? ((hostname: string) => isHostAllowed(hostname))
|
|
226
|
+
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS
|
|
227
|
+
const follow = (init.redirect ?? 'follow') === 'follow'
|
|
228
|
+
let url = new URL(input)
|
|
229
|
+
let request: RequestInit = follow ? { ...init, redirect: 'manual' } : init
|
|
230
|
+
|
|
231
|
+
for (let hop = 0; ; hop++) {
|
|
232
|
+
if (
|
|
233
|
+
!isOneOf(['http:', 'https:'], url.protocol) ||
|
|
234
|
+
!(await allow(url.hostname))
|
|
235
|
+
) {
|
|
236
|
+
throw new HostNotAllowedError(url.href)
|
|
237
|
+
}
|
|
238
|
+
const response = await (options.fetch ?? globalThis.fetch)(url, request)
|
|
239
|
+
const location = response.headers.get('location')
|
|
240
|
+
if (!follow || !location || !isOneOf(REDIRECT_STATUSES, response.status)) {
|
|
241
|
+
return { response, url }
|
|
242
|
+
}
|
|
243
|
+
void response.body?.cancel().catch(() => {})
|
|
244
|
+
if (hop >= maxRedirects) {
|
|
245
|
+
throw new TypeError(`Too many redirects from ${String(input)}`)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const next = new URL(location, url)
|
|
249
|
+
// What `fetch` itself does on a redirect: credentials stay with their
|
|
250
|
+
// origin, and a 303 (or a POST answered with 301/302) becomes a GET.
|
|
251
|
+
if (next.origin !== url.origin) {
|
|
252
|
+
const headers = new Headers(request.headers)
|
|
253
|
+
headers.delete('authorization')
|
|
254
|
+
headers.delete('cookie')
|
|
255
|
+
request = { ...request, headers }
|
|
256
|
+
}
|
|
257
|
+
const method = (request.method ?? 'GET').toUpperCase()
|
|
258
|
+
if (
|
|
259
|
+
(response.status === 303 && !isOneOf(['GET', 'HEAD'], method)) ||
|
|
260
|
+
(isOneOf([301, 302], response.status) && method === 'POST')
|
|
261
|
+
) {
|
|
262
|
+
const headers = new Headers(request.headers)
|
|
263
|
+
headers.delete('content-type')
|
|
264
|
+
request = { ...request, method: 'GET', body: null, headers }
|
|
265
|
+
}
|
|
266
|
+
url = next
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* {@link followRedirects} as a drop-in `fetch`. Throws
|
|
272
|
+
* {@link HostNotAllowedError} when the URL or a redirect is refused.
|
|
273
|
+
*/
|
|
274
|
+
export async function guardedFetch(
|
|
275
|
+
input: string | URL,
|
|
276
|
+
init?: RequestInit,
|
|
277
|
+
options?: GuardedFetchOptions
|
|
278
|
+
): Promise<Response> {
|
|
279
|
+
return (await followRedirects(input, init, options)).response
|
|
280
|
+
}
|
package/src/rate-limit-retry.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
exponentialBackoff,
|
|
3
|
+
getAtPath,
|
|
4
|
+
isNumber,
|
|
5
|
+
isRecord,
|
|
6
|
+
isString,
|
|
7
|
+
safeJsonParse
|
|
8
|
+
} from 'hanzio'
|
|
9
|
+
|
|
1
10
|
/**
|
|
2
11
|
* Bounded retries for Cloudflare 429s (Workers AI code 971 / Browser Rendering).
|
|
3
12
|
*
|
|
@@ -27,28 +36,15 @@ const THROTTLE_971_BACKOFF_MS = 5_000
|
|
|
27
36
|
const MAX_BACKOFF_MS = 20_000
|
|
28
37
|
|
|
29
38
|
export function cloudflareErrorCode(error: unknown): number | null {
|
|
30
|
-
if (
|
|
31
|
-
const body =
|
|
32
|
-
'body' in error && typeof error.body === 'string' ? error.body : null
|
|
39
|
+
if (!isRecord(error)) return null
|
|
33
40
|
const bodyJson =
|
|
34
|
-
|
|
41
|
+
error.bodyJson && typeof error.bodyJson === 'object'
|
|
35
42
|
? error.bodyJson
|
|
36
|
-
: body
|
|
37
|
-
?
|
|
43
|
+
: isString(error.body) && error.body
|
|
44
|
+
? safeJsonParse(error.body).data
|
|
38
45
|
: null
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (!Array.isArray(errors) || errors.length === 0) return null
|
|
42
|
-
const code = (errors[0] as { code?: unknown }).code
|
|
43
|
-
return typeof code === 'number' ? code : null
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function safeJson(raw: string) {
|
|
47
|
-
try {
|
|
48
|
-
return JSON.parse(raw) as unknown
|
|
49
|
-
} catch {
|
|
50
|
-
return null
|
|
51
|
-
}
|
|
46
|
+
const code = getAtPath(bodyJson, ['errors', 0, 'code'])
|
|
47
|
+
return isNumber(code) ? code : null
|
|
52
48
|
}
|
|
53
49
|
|
|
54
50
|
/**
|
|
@@ -66,7 +62,11 @@ export function rateLimitBackoffMs(
|
|
|
66
62
|
cloudflareErrorCode(error) === 971
|
|
67
63
|
? THROTTLE_971_BACKOFF_MS
|
|
68
64
|
: DEFAULT_BACKOFF_MS
|
|
69
|
-
const exponential =
|
|
65
|
+
const exponential = exponentialBackoff(attempt, {
|
|
66
|
+
baseMs: floor,
|
|
67
|
+
maxMs: MAX_BACKOFF_MS,
|
|
68
|
+
jitter: false
|
|
69
|
+
})
|
|
70
70
|
const base = Math.min(MAX_BACKOFF_MS, retryAfterMs ?? exponential)
|
|
71
71
|
const jitter = Math.floor(random() * Math.max(250, base * 0.2))
|
|
72
72
|
return base + jitter
|
package/src/read-body.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* At most `maxBytes` of a response body, streamed, so a chunked body is bounded
|
|
3
|
+
* as well as a declared `Content-Length`. `truncated` says the body went on;
|
|
4
|
+
* the rest is cancelled, never downloaded.
|
|
5
|
+
*/
|
|
6
|
+
export async function readBody(response: Response, maxBytes: number) {
|
|
7
|
+
const reader = response.body?.getReader()
|
|
8
|
+
if (!reader) return { bytes: new Uint8Array(0), truncated: false }
|
|
9
|
+
const chunks: Uint8Array[] = []
|
|
10
|
+
let size = 0
|
|
11
|
+
let truncated = false
|
|
12
|
+
while (true) {
|
|
13
|
+
const next = await reader.read()
|
|
14
|
+
if (next.done) break
|
|
15
|
+
const room = maxBytes - size
|
|
16
|
+
if (next.value.byteLength > room) {
|
|
17
|
+
chunks.push(next.value.subarray(0, room))
|
|
18
|
+
size = maxBytes
|
|
19
|
+
truncated = true
|
|
20
|
+
await reader.cancel()
|
|
21
|
+
break
|
|
22
|
+
}
|
|
23
|
+
chunks.push(next.value)
|
|
24
|
+
size += next.value.byteLength
|
|
25
|
+
}
|
|
26
|
+
const bytes = new Uint8Array(size)
|
|
27
|
+
let offset = 0
|
|
28
|
+
for (const chunk of chunks) {
|
|
29
|
+
bytes.set(chunk, offset)
|
|
30
|
+
offset += chunk.byteLength
|
|
31
|
+
}
|
|
32
|
+
return { bytes, truncated }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** UTF-8, as `Response.text()` decodes whatever the charset header says. */
|
|
36
|
+
export function decodeText(bytes: Uint8Array) {
|
|
37
|
+
return new TextDecoder().decode(bytes)
|
|
38
|
+
}
|