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/retry-after.ts
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import { parseRetryAfter } from 'hanzio'
|
|
1
|
+
import { clamp, isFunction, isRecord, parseRetryAfter } from 'hanzio'
|
|
2
2
|
|
|
3
3
|
const DEFAULT_RETRY_AFTER_MS = 1_000
|
|
4
4
|
const MAX_RETRY_AFTER_MS = 5 * 60 * 1_000
|
|
5
5
|
|
|
6
6
|
function headerGet(headers: unknown, name: string): string | null {
|
|
7
|
-
if (!headers
|
|
8
|
-
if (
|
|
9
|
-
return (headers as { get: (key: string) => string | null }).get(name)
|
|
10
|
-
}
|
|
7
|
+
if (!isRecord(headers)) return null
|
|
8
|
+
if (isFunction(headers.get)) return headers.get(name)
|
|
11
9
|
const record = headers as Record<string, string>
|
|
12
10
|
return record[name] ?? record[name.toLowerCase()] ?? null
|
|
13
11
|
}
|
|
@@ -20,23 +18,17 @@ function headerGet(headers: unknown, name: string): string | null {
|
|
|
20
18
|
export function rateLimitFromError(
|
|
21
19
|
error: unknown
|
|
22
20
|
): { retryAfterMs: number | null } | null {
|
|
23
|
-
if (
|
|
24
|
-
const status =
|
|
25
|
-
'status' in error && typeof error.status === 'number' ? error.status : null
|
|
26
|
-
if (status !== 429) return null
|
|
21
|
+
if (!isRecord(error) || error.status !== 429) return null
|
|
27
22
|
|
|
28
23
|
const raw =
|
|
29
|
-
headerGet(
|
|
30
|
-
headerGet(
|
|
31
|
-
'responseHeaders' in error ? error.responseHeaders : null,
|
|
32
|
-
'retry-after'
|
|
33
|
-
)
|
|
24
|
+
headerGet(error.headers, 'retry-after') ??
|
|
25
|
+
headerGet(error.responseHeaders, 'retry-after')
|
|
34
26
|
const parsed = parseRetryAfter(raw)
|
|
35
27
|
return {
|
|
36
28
|
retryAfterMs:
|
|
37
29
|
parsed === undefined
|
|
38
30
|
? null
|
|
39
|
-
:
|
|
31
|
+
: clamp(Math.ceil(parsed), 0, MAX_RETRY_AFTER_MS)
|
|
40
32
|
}
|
|
41
33
|
}
|
|
42
34
|
|
|
@@ -44,8 +36,9 @@ export function rateLimitFromError(
|
|
|
44
36
|
export function retryAfterMsFromError(error: unknown) {
|
|
45
37
|
const info = rateLimitFromError(error)
|
|
46
38
|
if (!info) return null
|
|
47
|
-
return
|
|
48
|
-
|
|
49
|
-
|
|
39
|
+
return clamp(
|
|
40
|
+
Math.ceil(info.retryAfterMs ?? DEFAULT_RETRY_AFTER_MS),
|
|
41
|
+
0,
|
|
42
|
+
MAX_RETRY_AFTER_MS
|
|
50
43
|
)
|
|
51
44
|
}
|
package/src/schema/index.ts
CHANGED
|
@@ -12,3 +12,37 @@ export type CrawlSnapshotInput = {
|
|
|
12
12
|
/** `<snapshot>/<host>`: the bench's snapshot label, then the site. */
|
|
13
13
|
name: string
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
/** Every `FetchedPage.outcome`, for narrowing a value read back from storage. */
|
|
17
|
+
export const FETCH_OUTCOMES = [
|
|
18
|
+
'ok',
|
|
19
|
+
'unchanged',
|
|
20
|
+
'gone',
|
|
21
|
+
'blocked',
|
|
22
|
+
'transient',
|
|
23
|
+
'unsupported'
|
|
24
|
+
] as const
|
|
25
|
+
export type FetchOutcome = (typeof FETCH_OUTCOMES)[number]
|
|
26
|
+
|
|
27
|
+
/** Every `FetchedPage.source`. `firecrawl-parse` is the `parsePdf` tier, whatever parser it is. */
|
|
28
|
+
export const FETCH_SOURCES = [
|
|
29
|
+
'content-negotiation',
|
|
30
|
+
'workers-ai',
|
|
31
|
+
'browser-rendering',
|
|
32
|
+
'not-modified',
|
|
33
|
+
'firecrawl-parse'
|
|
34
|
+
] as const
|
|
35
|
+
export type FetchSource = (typeof FETCH_SOURCES)[number]
|
|
36
|
+
|
|
37
|
+
/** Every `FetchedPage.captureIssue`: why a page came back incomplete or not at all. */
|
|
38
|
+
export const CAPTURE_ISSUES = [
|
|
39
|
+
'markdown_limit',
|
|
40
|
+
'html_byte_limit',
|
|
41
|
+
'document_byte_limit',
|
|
42
|
+
'workers_ai_rate_limited',
|
|
43
|
+
'browser_rendering_rate_limited',
|
|
44
|
+
'render_incomplete',
|
|
45
|
+
'pdf_page_limit_unverified',
|
|
46
|
+
'pdf_extraction_failed'
|
|
47
|
+
] as const
|
|
48
|
+
export type CaptureIssue = (typeof CAPTURE_ISSUES)[number]
|
package/src/sitemap/index.ts
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getUniqueValues, isOneOf, trimTrailingSlash } from 'hanzio'
|
|
2
|
+
import { isAllowedByRobots, parseRobotsTxt } from 'hanzio/sitemap'
|
|
2
3
|
import { throughCrawlSnapshot } from '../crawl-snapshot.js'
|
|
4
|
+
import { type Fetch, guardedFetch } from '../net-guard.js'
|
|
5
|
+
import { decodeText, readBody } from '../read-body.js'
|
|
6
|
+
import { BROWSER_USER_AGENT } from '../user-agent.js'
|
|
7
|
+
|
|
8
|
+
export type { Fetch }
|
|
3
9
|
|
|
4
10
|
const FETCH_TIMEOUT_MS = 10_000
|
|
5
11
|
const MAX_SITEMAP_FILES = 20
|
|
6
12
|
const MAX_URLS = 20_000
|
|
13
|
+
/** Google reads the first 500 KiB of a robots.txt and ignores the rest. */
|
|
14
|
+
const MAX_ROBOTS_BYTES = 500 * 1024
|
|
15
|
+
/**
|
|
16
|
+
* Read cap per sitemap file. 20,000 URLs fit in a few MB; a longer file is
|
|
17
|
+
* read up to here and its complete entries used.
|
|
18
|
+
*/
|
|
19
|
+
const MAX_SITEMAP_BYTES = 10 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
export type SitemapOptions = {
|
|
22
|
+
/**
|
|
23
|
+
* The `fetch` robots.txt and sitemaps go through. Default
|
|
24
|
+
* {@link guardedFetch} with the default guard (public addresses only, every
|
|
25
|
+
* redirect checked); pass `converter.guardedFetch` to use the converter's.
|
|
26
|
+
*/
|
|
27
|
+
fetch?: Fetch | undefined
|
|
28
|
+
}
|
|
7
29
|
|
|
8
30
|
/** Conventional sitemap locations, tried in order when robots.txt names none. */
|
|
9
31
|
const SITEMAP_ROOT_PATHS = [
|
|
@@ -64,28 +86,41 @@ export function parseSitemapXml(xml: string) {
|
|
|
64
86
|
return { pages, nested }
|
|
65
87
|
}
|
|
66
88
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
89
|
+
type TextRequest = { accept: string; maxBytes: number; fetch: Fetch }
|
|
90
|
+
|
|
91
|
+
function fetchText(url: string, request: TextRequest) {
|
|
92
|
+
return throughCrawlSnapshot(`text ${request.accept} ${url}`, () =>
|
|
93
|
+
fetchTextLive(url, request)
|
|
70
94
|
)
|
|
71
95
|
}
|
|
72
96
|
|
|
73
|
-
async function fetchTextLive(url: string,
|
|
97
|
+
async function fetchTextLive(url: string, request: TextRequest) {
|
|
74
98
|
try {
|
|
75
|
-
const response = await fetch(url, {
|
|
99
|
+
const response = await request.fetch(url, {
|
|
76
100
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
77
|
-
headers: { accept }
|
|
101
|
+
headers: { accept: request.accept, 'user-agent': BROWSER_USER_AGENT }
|
|
78
102
|
})
|
|
79
|
-
if (!response.ok) return null
|
|
80
103
|
const type = response.headers.get('content-type') ?? ''
|
|
81
104
|
// A CMS that renders its 404 page for a missing file answers 200 with HTML.
|
|
82
|
-
if (type.includes('text/html'))
|
|
83
|
-
|
|
105
|
+
if (!response.ok || type.includes('text/html')) {
|
|
106
|
+
void response.body?.cancel().catch(() => {})
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
return decodeText((await readBody(response, request.maxBytes)).bytes)
|
|
84
110
|
} catch {
|
|
85
111
|
return null
|
|
86
112
|
}
|
|
87
113
|
}
|
|
88
114
|
|
|
115
|
+
const ROBOTS_REQUEST = {
|
|
116
|
+
accept: 'text/plain,*/*;q=0.8',
|
|
117
|
+
maxBytes: MAX_ROBOTS_BYTES
|
|
118
|
+
}
|
|
119
|
+
const SITEMAP_REQUEST = {
|
|
120
|
+
accept: 'application/xml,text/xml;q=0.9,*/*;q=0.8',
|
|
121
|
+
maxBytes: MAX_SITEMAP_BYTES
|
|
122
|
+
}
|
|
123
|
+
|
|
89
124
|
function originOf(websiteUrl: string) {
|
|
90
125
|
try {
|
|
91
126
|
return new URL(websiteUrl).origin
|
|
@@ -100,12 +135,18 @@ function originOf(websiteUrl: string) {
|
|
|
100
135
|
* here), and its rules. An HTML body or any non-200 means "no robots.txt";
|
|
101
136
|
* never throws.
|
|
102
137
|
*/
|
|
103
|
-
export async function fetchRobots(
|
|
138
|
+
export async function fetchRobots(
|
|
139
|
+
websiteUrl: string,
|
|
140
|
+
options: SitemapOptions = {}
|
|
141
|
+
) {
|
|
104
142
|
const sitemaps: string[] = []
|
|
105
143
|
const origin = originOf(websiteUrl)
|
|
106
144
|
if (!origin) return { sitemaps, rules: [] }
|
|
107
145
|
const robotsUrl = `${origin}/robots.txt`
|
|
108
|
-
const body = await fetchText(robotsUrl,
|
|
146
|
+
const body = await fetchText(robotsUrl, {
|
|
147
|
+
...ROBOTS_REQUEST,
|
|
148
|
+
fetch: options.fetch ?? guardedFetch
|
|
149
|
+
})
|
|
109
150
|
if (!body) return { sitemaps, rules: [] }
|
|
110
151
|
|
|
111
152
|
for (const match of body.matchAll(ROBOTS_SITEMAP_RE)) {
|
|
@@ -114,15 +155,16 @@ export async function fetchRobots(websiteUrl: string) {
|
|
|
114
155
|
try {
|
|
115
156
|
const absolute = new URL(value, robotsUrl)
|
|
116
157
|
// Only what we could actually GET - `ftp:` and friends are noise.
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
const href = absolute.toString()
|
|
120
|
-
if (!sitemaps.includes(href)) sitemaps.push(href)
|
|
158
|
+
if (!isOneOf(['http:', 'https:'], absolute.protocol)) continue
|
|
159
|
+
sitemaps.push(absolute.toString())
|
|
121
160
|
} catch {
|
|
122
161
|
// A malformed Sitemap: line must not cost us the other lines.
|
|
123
162
|
}
|
|
124
163
|
}
|
|
125
|
-
return {
|
|
164
|
+
return {
|
|
165
|
+
sitemaps: getUniqueValues(sitemaps),
|
|
166
|
+
rules: parseRobotsRules(body)
|
|
167
|
+
}
|
|
126
168
|
}
|
|
127
169
|
|
|
128
170
|
export type RobotsRule = { allow: boolean; pattern: string }
|
|
@@ -133,75 +175,27 @@ export type RobotsRule = { allow: boolean; pattern: string }
|
|
|
133
175
|
* the wildcard group is what the site asks of everyone. An empty `Disallow:`
|
|
134
176
|
* allows everything and adds no rule.
|
|
135
177
|
*/
|
|
136
|
-
export function parseRobotsRules(body: string) {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const match = /^\s*([a-z-]+)\s*:\s*(.*?)\s*$/i.exec(raw.replace(/#.*$/, ''))
|
|
143
|
-
if (!match) continue
|
|
144
|
-
const field = (match[1] ?? '').toLowerCase()
|
|
145
|
-
const value = match[2] ?? ''
|
|
146
|
-
|
|
147
|
-
if (field === 'user-agent') {
|
|
148
|
-
// A user-agent line after rules opens a new group.
|
|
149
|
-
if (inRules) agents = []
|
|
150
|
-
inRules = false
|
|
151
|
-
agents.push(value.toLowerCase())
|
|
152
|
-
continue
|
|
153
|
-
}
|
|
154
|
-
if (field !== 'allow' && field !== 'disallow') continue
|
|
155
|
-
|
|
156
|
-
inRules = true
|
|
157
|
-
if (agents.includes('*') && value !== '')
|
|
158
|
-
rules.push({ allow: field === 'allow', pattern: value })
|
|
159
|
-
}
|
|
160
|
-
return rules
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Compiled once per pattern: the crawl asks for every link of every page. */
|
|
164
|
-
const robotsPatternCache = new Map<string, RegExp>()
|
|
165
|
-
|
|
166
|
-
function robotsPatternRegex(pattern: string) {
|
|
167
|
-
const cached = robotsPatternCache.get(pattern)
|
|
168
|
-
if (cached) return cached
|
|
169
|
-
|
|
170
|
-
const anchored = pattern.endsWith('$')
|
|
171
|
-
const body = (anchored ? pattern.slice(0, -1) : pattern)
|
|
172
|
-
.split('*')
|
|
173
|
-
.map((part) => escapeRegExp(part))
|
|
174
|
-
.join('.*')
|
|
175
|
-
const regex = new RegExp(`^${body}${anchored ? '$' : ''}`)
|
|
176
|
-
// Every site's patterns land here; a long-lived worker starts over rather than grow.
|
|
177
|
-
if (robotsPatternCache.size >= 5_000) robotsPatternCache.clear()
|
|
178
|
-
robotsPatternCache.set(pattern, regex)
|
|
179
|
-
return regex
|
|
178
|
+
export function parseRobotsRules(body: string): RobotsRule[] {
|
|
179
|
+
const { allow, disallow } = parseRobotsTxt(body)
|
|
180
|
+
return [
|
|
181
|
+
...allow.map((pattern) => ({ allow: true, pattern })),
|
|
182
|
+
...disallow.map((pattern) => ({ allow: false, pattern }))
|
|
183
|
+
]
|
|
180
184
|
}
|
|
181
185
|
|
|
182
186
|
/**
|
|
183
187
|
* Whether robots.txt lets us read this URL: the longest matching rule wins and
|
|
184
188
|
* `Allow` wins a tie, as Google reads it. Shopify disallows `/cart/`, and the
|
|
185
|
-
* crawler had fetched `/cart/add?id=…&quantity=1` - a GET that fills a cart
|
|
186
|
-
* (Good Habit audit §3.5).
|
|
189
|
+
* crawler had fetched `/cart/add?id=…&quantity=1` - a GET that fills a cart.
|
|
187
190
|
*/
|
|
188
191
|
export function robotsAllows(rules: RobotsRule[], url: string) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
let best: RobotsRule | null = null
|
|
198
|
-
for (const rule of rules) {
|
|
199
|
-
if (!robotsPatternRegex(rule.pattern).test(path)) continue
|
|
200
|
-
if (!best || rule.pattern.length > best.pattern.length) best = rule
|
|
201
|
-
else if (rule.pattern.length === best.pattern.length && rule.allow)
|
|
202
|
-
best = rule
|
|
203
|
-
}
|
|
204
|
-
return best?.allow ?? true
|
|
192
|
+
return isAllowedByRobots(
|
|
193
|
+
{
|
|
194
|
+
allow: rules.filter((rule) => rule.allow).map((rule) => rule.pattern),
|
|
195
|
+
disallow: rules.filter((rule) => !rule.allow).map((rule) => rule.pattern)
|
|
196
|
+
},
|
|
197
|
+
url
|
|
198
|
+
)
|
|
205
199
|
}
|
|
206
200
|
|
|
207
201
|
/**
|
|
@@ -212,7 +206,8 @@ export function robotsAllows(rules: RobotsRule[], url: string) {
|
|
|
212
206
|
async function readSitemapTree(
|
|
213
207
|
roots: string[],
|
|
214
208
|
found: Map<string, { url: string; lastmod: string | null }>,
|
|
215
|
-
seen: Set<string
|
|
209
|
+
seen: Set<string>,
|
|
210
|
+
fetch: Fetch
|
|
216
211
|
) {
|
|
217
212
|
let readable = false
|
|
218
213
|
let level = [...roots]
|
|
@@ -230,7 +225,7 @@ async function readSitemapTree(
|
|
|
230
225
|
}
|
|
231
226
|
const bodies = await Promise.all(
|
|
232
227
|
batch.map((sitemapUrl) =>
|
|
233
|
-
fetchText(sitemapUrl,
|
|
228
|
+
fetchText(sitemapUrl, { ...SITEMAP_REQUEST, fetch })
|
|
234
229
|
)
|
|
235
230
|
)
|
|
236
231
|
const next: string[] = []
|
|
@@ -263,10 +258,12 @@ async function readSitemapTree(
|
|
|
263
258
|
*/
|
|
264
259
|
export async function discoverSitemapUrls(
|
|
265
260
|
websiteUrl: string,
|
|
266
|
-
robotsSitemaps: string[]
|
|
261
|
+
robotsSitemaps: string[],
|
|
262
|
+
options: SitemapOptions = {}
|
|
267
263
|
) {
|
|
268
264
|
const origin = originOf(websiteUrl)
|
|
269
265
|
if (!origin) return []
|
|
266
|
+
const fetch = options.fetch ?? guardedFetch
|
|
270
267
|
const found = new Map<string, { url: string; lastmod: string | null }>()
|
|
271
268
|
// Shared across roots so a broken sitemap index cannot spend the file budget
|
|
272
269
|
// several times over.
|
|
@@ -276,12 +273,12 @@ export async function discoverSitemapUrls(
|
|
|
276
273
|
// so all of them are read together - only the conventional roots compete.
|
|
277
274
|
if (
|
|
278
275
|
robotsSitemaps.length > 0 &&
|
|
279
|
-
(await readSitemapTree(robotsSitemaps, found, seen))
|
|
276
|
+
(await readSitemapTree(robotsSitemaps, found, seen, fetch))
|
|
280
277
|
) {
|
|
281
278
|
return [...found.values()]
|
|
282
279
|
}
|
|
283
280
|
for (const path of SITEMAP_ROOT_PATHS) {
|
|
284
|
-
if (await readSitemapTree([`${origin}${path}`], found, seen)) break
|
|
281
|
+
if (await readSitemapTree([`${origin}${path}`], found, seen, fetch)) break
|
|
285
282
|
}
|
|
286
283
|
return [...found.values()]
|
|
287
284
|
}
|
|
@@ -291,8 +288,8 @@ export function normalizeSitemapUrl(url: string) {
|
|
|
291
288
|
try {
|
|
292
289
|
const parsed = new URL(url)
|
|
293
290
|
parsed.hash = ''
|
|
294
|
-
return parsed.toString()
|
|
291
|
+
return trimTrailingSlash(parsed.toString())
|
|
295
292
|
} catch {
|
|
296
|
-
return url
|
|
293
|
+
return trimTrailingSlash(url)
|
|
297
294
|
}
|
|
298
295
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { FetchedPage, FetchPageInput } from '../fetch-page.js'
|
|
2
|
+
import { asUnchanged, isUnchangedFor } from '../unchanged-page.js'
|
|
3
|
+
|
|
4
|
+
/** A complete `FetchedPage`: an `ok` HTML read of `url` with nothing on it, plus `page`. */
|
|
5
|
+
export function fakePage(
|
|
6
|
+
page: Partial<FetchedPage> & { url: string }
|
|
7
|
+
): FetchedPage {
|
|
8
|
+
return {
|
|
9
|
+
finalUrl: page.url,
|
|
10
|
+
status: 200,
|
|
11
|
+
outcome: 'ok',
|
|
12
|
+
source: 'workers-ai',
|
|
13
|
+
contentType: 'text/html; charset=utf-8',
|
|
14
|
+
etag: null,
|
|
15
|
+
lastModified: null,
|
|
16
|
+
markdown: '',
|
|
17
|
+
title: null,
|
|
18
|
+
lang: null,
|
|
19
|
+
canonical: null,
|
|
20
|
+
alternates: [],
|
|
21
|
+
alternateLanguages: {},
|
|
22
|
+
metaDescription: null,
|
|
23
|
+
jsonLd: [],
|
|
24
|
+
links: [],
|
|
25
|
+
images: [],
|
|
26
|
+
durationMs: 0,
|
|
27
|
+
captureIssue: null,
|
|
28
|
+
htmlHash: null,
|
|
29
|
+
...page
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A site in memory with the converter's `fetchPageAsMarkdown`: the pages
|
|
35
|
+
* given by URL, `gone` (404) for any other, `unchanged` (304) when the input's
|
|
36
|
+
* `etag`, `lastModified` or `htmlHash` matches the page's. `calls` lists every
|
|
37
|
+
* input in order.
|
|
38
|
+
*/
|
|
39
|
+
export function fakeSite(
|
|
40
|
+
pages: Record<string, Omit<Partial<FetchedPage>, 'url'>>
|
|
41
|
+
) {
|
|
42
|
+
const calls: FetchPageInput[] = []
|
|
43
|
+
async function fetchPageAsMarkdown(input: FetchPageInput) {
|
|
44
|
+
calls.push(input)
|
|
45
|
+
const known = pages[input.url]
|
|
46
|
+
if (!known) {
|
|
47
|
+
return fakePage({
|
|
48
|
+
url: input.url,
|
|
49
|
+
status: 404,
|
|
50
|
+
outcome: 'gone',
|
|
51
|
+
source: null
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
const page = fakePage({ ...known, url: input.url })
|
|
55
|
+
return isUnchangedFor(page, input)
|
|
56
|
+
? { ...asUnchanged(page), status: 304 }
|
|
57
|
+
: page
|
|
58
|
+
}
|
|
59
|
+
return { fetchPageAsMarkdown, calls }
|
|
60
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { FetchedPage, FetchPageInput } from './fetch-page.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a full page read without validators is unchanged for a caller that
|
|
5
|
+
* holds these validators, decided the way the origin would have.
|
|
6
|
+
*/
|
|
7
|
+
export function isUnchangedFor(page: FetchedPage, input: FetchPageInput) {
|
|
8
|
+
return (
|
|
9
|
+
page.outcome === 'ok' &&
|
|
10
|
+
Boolean(
|
|
11
|
+
(input.htmlHash && page.htmlHash === input.htmlHash) ||
|
|
12
|
+
(input.etag && page.etag === input.etag) ||
|
|
13
|
+
(input.lastModified && page.lastModified === input.lastModified)
|
|
14
|
+
)
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `page` as the `unchanged` answer: validators and URLs kept, content dropped. */
|
|
19
|
+
export function asUnchanged(page: FetchedPage): FetchedPage {
|
|
20
|
+
return {
|
|
21
|
+
...page,
|
|
22
|
+
outcome: 'unchanged',
|
|
23
|
+
source: 'not-modified',
|
|
24
|
+
markdown: '',
|
|
25
|
+
title: null,
|
|
26
|
+
lang: null,
|
|
27
|
+
canonical: null,
|
|
28
|
+
alternates: [],
|
|
29
|
+
alternateLanguages: {},
|
|
30
|
+
metaDescription: null,
|
|
31
|
+
jsonLd: [],
|
|
32
|
+
links: [],
|
|
33
|
+
images: [],
|
|
34
|
+
captureIssue: null,
|
|
35
|
+
htmlHash: null
|
|
36
|
+
}
|
|
37
|
+
}
|