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/dist/sitemap/index.js
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
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 { guardedFetch } from '../net-guard.js';
|
|
5
|
+
import { decodeText, readBody } from '../read-body.js';
|
|
6
|
+
import { BROWSER_USER_AGENT } from '../user-agent.js';
|
|
3
7
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
4
8
|
const MAX_SITEMAP_FILES = 20;
|
|
5
9
|
const MAX_URLS = 20_000;
|
|
10
|
+
/** Google reads the first 500 KiB of a robots.txt and ignores the rest. */
|
|
11
|
+
const MAX_ROBOTS_BYTES = 500 * 1024;
|
|
12
|
+
/**
|
|
13
|
+
* Read cap per sitemap file. 20,000 URLs fit in a few MB; a longer file is
|
|
14
|
+
* read up to here and its complete entries used.
|
|
15
|
+
*/
|
|
16
|
+
const MAX_SITEMAP_BYTES = 10 * 1024 * 1024;
|
|
6
17
|
/** Conventional sitemap locations, tried in order when robots.txt names none. */
|
|
7
18
|
const SITEMAP_ROOT_PATHS = [
|
|
8
19
|
'/sitemap.xml',
|
|
@@ -55,27 +66,35 @@ export function parseSitemapXml(xml) {
|
|
|
55
66
|
}
|
|
56
67
|
return { pages, nested };
|
|
57
68
|
}
|
|
58
|
-
function fetchText(url,
|
|
59
|
-
return throughCrawlSnapshot(`text ${accept} ${url}`, () => fetchTextLive(url,
|
|
69
|
+
function fetchText(url, request) {
|
|
70
|
+
return throughCrawlSnapshot(`text ${request.accept} ${url}`, () => fetchTextLive(url, request));
|
|
60
71
|
}
|
|
61
|
-
async function fetchTextLive(url,
|
|
72
|
+
async function fetchTextLive(url, request) {
|
|
62
73
|
try {
|
|
63
|
-
const response = await fetch(url, {
|
|
74
|
+
const response = await request.fetch(url, {
|
|
64
75
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
65
|
-
headers: { accept }
|
|
76
|
+
headers: { accept: request.accept, 'user-agent': BROWSER_USER_AGENT }
|
|
66
77
|
});
|
|
67
|
-
if (!response.ok)
|
|
68
|
-
return null;
|
|
69
78
|
const type = response.headers.get('content-type') ?? '';
|
|
70
79
|
// A CMS that renders its 404 page for a missing file answers 200 with HTML.
|
|
71
|
-
if (type.includes('text/html'))
|
|
80
|
+
if (!response.ok || type.includes('text/html')) {
|
|
81
|
+
void response.body?.cancel().catch(() => { });
|
|
72
82
|
return null;
|
|
73
|
-
|
|
83
|
+
}
|
|
84
|
+
return decodeText((await readBody(response, request.maxBytes)).bytes);
|
|
74
85
|
}
|
|
75
86
|
catch {
|
|
76
87
|
return null;
|
|
77
88
|
}
|
|
78
89
|
}
|
|
90
|
+
const ROBOTS_REQUEST = {
|
|
91
|
+
accept: 'text/plain,*/*;q=0.8',
|
|
92
|
+
maxBytes: MAX_ROBOTS_BYTES
|
|
93
|
+
};
|
|
94
|
+
const SITEMAP_REQUEST = {
|
|
95
|
+
accept: 'application/xml,text/xml;q=0.9,*/*;q=0.8',
|
|
96
|
+
maxBytes: MAX_SITEMAP_BYTES
|
|
97
|
+
};
|
|
79
98
|
function originOf(websiteUrl) {
|
|
80
99
|
try {
|
|
81
100
|
return new URL(websiteUrl).origin;
|
|
@@ -90,13 +109,16 @@ function originOf(websiteUrl) {
|
|
|
90
109
|
* here), and its rules. An HTML body or any non-200 means "no robots.txt";
|
|
91
110
|
* never throws.
|
|
92
111
|
*/
|
|
93
|
-
export async function fetchRobots(websiteUrl) {
|
|
112
|
+
export async function fetchRobots(websiteUrl, options = {}) {
|
|
94
113
|
const sitemaps = [];
|
|
95
114
|
const origin = originOf(websiteUrl);
|
|
96
115
|
if (!origin)
|
|
97
116
|
return { sitemaps, rules: [] };
|
|
98
117
|
const robotsUrl = `${origin}/robots.txt`;
|
|
99
|
-
const body = await fetchText(robotsUrl,
|
|
118
|
+
const body = await fetchText(robotsUrl, {
|
|
119
|
+
...ROBOTS_REQUEST,
|
|
120
|
+
fetch: options.fetch ?? guardedFetch
|
|
121
|
+
});
|
|
100
122
|
if (!body)
|
|
101
123
|
return { sitemaps, rules: [] };
|
|
102
124
|
for (const match of body.matchAll(ROBOTS_SITEMAP_RE)) {
|
|
@@ -106,17 +128,18 @@ export async function fetchRobots(websiteUrl) {
|
|
|
106
128
|
try {
|
|
107
129
|
const absolute = new URL(value, robotsUrl);
|
|
108
130
|
// Only what we could actually GET - `ftp:` and friends are noise.
|
|
109
|
-
if (
|
|
131
|
+
if (!isOneOf(['http:', 'https:'], absolute.protocol))
|
|
110
132
|
continue;
|
|
111
|
-
|
|
112
|
-
if (!sitemaps.includes(href))
|
|
113
|
-
sitemaps.push(href);
|
|
133
|
+
sitemaps.push(absolute.toString());
|
|
114
134
|
}
|
|
115
135
|
catch {
|
|
116
136
|
// A malformed Sitemap: line must not cost us the other lines.
|
|
117
137
|
}
|
|
118
138
|
}
|
|
119
|
-
return {
|
|
139
|
+
return {
|
|
140
|
+
sitemaps: getUniqueValues(sitemaps),
|
|
141
|
+
rules: parseRobotsRules(body)
|
|
142
|
+
};
|
|
120
143
|
}
|
|
121
144
|
/**
|
|
122
145
|
* The `Allow`/`Disallow` lines of the `User-agent: *` groups. We read pages
|
|
@@ -125,81 +148,29 @@ export async function fetchRobots(websiteUrl) {
|
|
|
125
148
|
* allows everything and adds no rule.
|
|
126
149
|
*/
|
|
127
150
|
export function parseRobotsRules(body) {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (!match)
|
|
134
|
-
continue;
|
|
135
|
-
const field = (match[1] ?? '').toLowerCase();
|
|
136
|
-
const value = match[2] ?? '';
|
|
137
|
-
if (field === 'user-agent') {
|
|
138
|
-
// A user-agent line after rules opens a new group.
|
|
139
|
-
if (inRules)
|
|
140
|
-
agents = [];
|
|
141
|
-
inRules = false;
|
|
142
|
-
agents.push(value.toLowerCase());
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
if (field !== 'allow' && field !== 'disallow')
|
|
146
|
-
continue;
|
|
147
|
-
inRules = true;
|
|
148
|
-
if (agents.includes('*') && value !== '')
|
|
149
|
-
rules.push({ allow: field === 'allow', pattern: value });
|
|
150
|
-
}
|
|
151
|
-
return rules;
|
|
152
|
-
}
|
|
153
|
-
/** Compiled once per pattern: the crawl asks for every link of every page. */
|
|
154
|
-
const robotsPatternCache = new Map();
|
|
155
|
-
function robotsPatternRegex(pattern) {
|
|
156
|
-
const cached = robotsPatternCache.get(pattern);
|
|
157
|
-
if (cached)
|
|
158
|
-
return cached;
|
|
159
|
-
const anchored = pattern.endsWith('$');
|
|
160
|
-
const body = (anchored ? pattern.slice(0, -1) : pattern)
|
|
161
|
-
.split('*')
|
|
162
|
-
.map((part) => escapeRegExp(part))
|
|
163
|
-
.join('.*');
|
|
164
|
-
const regex = new RegExp(`^${body}${anchored ? '$' : ''}`);
|
|
165
|
-
// Every site's patterns land here; a long-lived worker starts over rather than grow.
|
|
166
|
-
if (robotsPatternCache.size >= 5_000)
|
|
167
|
-
robotsPatternCache.clear();
|
|
168
|
-
robotsPatternCache.set(pattern, regex);
|
|
169
|
-
return regex;
|
|
151
|
+
const { allow, disallow } = parseRobotsTxt(body);
|
|
152
|
+
return [
|
|
153
|
+
...allow.map((pattern) => ({ allow: true, pattern })),
|
|
154
|
+
...disallow.map((pattern) => ({ allow: false, pattern }))
|
|
155
|
+
];
|
|
170
156
|
}
|
|
171
157
|
/**
|
|
172
158
|
* Whether robots.txt lets us read this URL: the longest matching rule wins and
|
|
173
159
|
* `Allow` wins a tie, as Google reads it. Shopify disallows `/cart/`, and the
|
|
174
|
-
* crawler had fetched `/cart/add?id=…&quantity=1` - a GET that fills a cart
|
|
175
|
-
* (Good Habit audit §3.5).
|
|
160
|
+
* crawler had fetched `/cart/add?id=…&quantity=1` - a GET that fills a cart.
|
|
176
161
|
*/
|
|
177
162
|
export function robotsAllows(rules, url) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
catch {
|
|
184
|
-
return true;
|
|
185
|
-
}
|
|
186
|
-
let best = null;
|
|
187
|
-
for (const rule of rules) {
|
|
188
|
-
if (!robotsPatternRegex(rule.pattern).test(path))
|
|
189
|
-
continue;
|
|
190
|
-
if (!best || rule.pattern.length > best.pattern.length)
|
|
191
|
-
best = rule;
|
|
192
|
-
else if (rule.pattern.length === best.pattern.length && rule.allow)
|
|
193
|
-
best = rule;
|
|
194
|
-
}
|
|
195
|
-
return best?.allow ?? true;
|
|
163
|
+
return isAllowedByRobots({
|
|
164
|
+
allow: rules.filter((rule) => rule.allow).map((rule) => rule.pattern),
|
|
165
|
+
disallow: rules.filter((rule) => !rule.allow).map((rule) => rule.pattern)
|
|
166
|
+
}, url);
|
|
196
167
|
}
|
|
197
168
|
/**
|
|
198
169
|
* Reads a set of sitemap roots (following index nesting) into `found`.
|
|
199
170
|
* Returns true when at least one of them yielded page URLs, so the caller can
|
|
200
171
|
* stop trying further roots.
|
|
201
172
|
*/
|
|
202
|
-
async function readSitemapTree(roots, found, seen) {
|
|
173
|
+
async function readSitemapTree(roots, found, seen, fetch) {
|
|
203
174
|
let readable = false;
|
|
204
175
|
let level = [...roots];
|
|
205
176
|
// One level of the index at a time, its files read side by side: a shop
|
|
@@ -214,7 +185,7 @@ async function readSitemapTree(roots, found, seen) {
|
|
|
214
185
|
seen.add(sitemapUrl);
|
|
215
186
|
batch.push(sitemapUrl);
|
|
216
187
|
}
|
|
217
|
-
const bodies = await Promise.all(batch.map((sitemapUrl) => fetchText(sitemapUrl,
|
|
188
|
+
const bodies = await Promise.all(batch.map((sitemapUrl) => fetchText(sitemapUrl, { ...SITEMAP_REQUEST, fetch })));
|
|
218
189
|
const next = [];
|
|
219
190
|
for (const xml of bodies) {
|
|
220
191
|
if (!xml)
|
|
@@ -247,10 +218,11 @@ async function readSitemapTree(roots, found, seen) {
|
|
|
247
218
|
* an error: link-following discovery is the source, sitemaps a supplement.
|
|
248
219
|
* Never throws.
|
|
249
220
|
*/
|
|
250
|
-
export async function discoverSitemapUrls(websiteUrl, robotsSitemaps) {
|
|
221
|
+
export async function discoverSitemapUrls(websiteUrl, robotsSitemaps, options = {}) {
|
|
251
222
|
const origin = originOf(websiteUrl);
|
|
252
223
|
if (!origin)
|
|
253
224
|
return [];
|
|
225
|
+
const fetch = options.fetch ?? guardedFetch;
|
|
254
226
|
const found = new Map();
|
|
255
227
|
// Shared across roots so a broken sitemap index cannot spend the file budget
|
|
256
228
|
// several times over.
|
|
@@ -258,11 +230,11 @@ export async function discoverSitemapUrls(websiteUrl, robotsSitemaps) {
|
|
|
258
230
|
// robots.txt may declare several sitemaps (WordPress splits per post type),
|
|
259
231
|
// so all of them are read together - only the conventional roots compete.
|
|
260
232
|
if (robotsSitemaps.length > 0 &&
|
|
261
|
-
(await readSitemapTree(robotsSitemaps, found, seen))) {
|
|
233
|
+
(await readSitemapTree(robotsSitemaps, found, seen, fetch))) {
|
|
262
234
|
return [...found.values()];
|
|
263
235
|
}
|
|
264
236
|
for (const path of SITEMAP_ROOT_PATHS) {
|
|
265
|
-
if (await readSitemapTree([`${origin}${path}`], found, seen))
|
|
237
|
+
if (await readSitemapTree([`${origin}${path}`], found, seen, fetch))
|
|
266
238
|
break;
|
|
267
239
|
}
|
|
268
240
|
return [...found.values()];
|
|
@@ -272,10 +244,10 @@ export function normalizeSitemapUrl(url) {
|
|
|
272
244
|
try {
|
|
273
245
|
const parsed = new URL(url);
|
|
274
246
|
parsed.hash = '';
|
|
275
|
-
return parsed.toString()
|
|
247
|
+
return trimTrailingSlash(parsed.toString());
|
|
276
248
|
}
|
|
277
249
|
catch {
|
|
278
|
-
return url
|
|
250
|
+
return trimTrailingSlash(url);
|
|
279
251
|
}
|
|
280
252
|
}
|
|
281
253
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/sitemap/index.ts"],"names":[],"mappings":"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","sourcesContent":["import { escapeRegExp } from 'hanzio'\nimport { throughCrawlSnapshot } from '../crawl-snapshot.js'\n\nconst FETCH_TIMEOUT_MS = 10_000\nconst MAX_SITEMAP_FILES = 20\nconst MAX_URLS = 20_000\n\n/** Conventional sitemap locations, tried in order when robots.txt names none. */\nconst SITEMAP_ROOT_PATHS = [\n\t'/sitemap.xml',\n\t'/sitemap_index.xml',\n\t'/wp-sitemap.xml'\n]\n\nconst LOC_RE = /<loc>\\s*([^<]+?)\\s*<\\/loc>/gi\nconst URL_ENTRY_RE = /<url>([\\s\\S]*?)<\\/url>/gi\nconst SITEMAP_ENTRY_RE = /<sitemap>([\\s\\S]*?)<\\/sitemap>/gi\nconst ENTRY_LOC_RE = /<loc>\\s*([^<]+?)\\s*<\\/loc>/i\nconst LASTMOD_RE = /<lastmod>\\s*([^<]+?)\\s*<\\/lastmod>/i\nconst ROBOTS_SITEMAP_RE = /^[^\\S\\r\\n]*sitemap[^\\S\\r\\n]*:[^\\S\\r\\n]*(\\S+)/gim\n\nconst XML_ENTITIES = new Map([\n\t['&', '&'],\n\t['<', '<'],\n\t['>', '>'],\n\t['"', '\"'],\n\t[''', \"'\"],\n\t[''', \"'\"]\n])\n\n/** One pass, so `&lt;` decodes to `<` and not to `<`. */\nfunction decodeXmlEntities(value: string) {\n\treturn value.replace(\n\t\t/&(?:amp|lt|gt|quot|apos|#39);/g,\n\t\t(entity) => XML_ENTITIES.get(entity) ?? entity\n\t)\n}\n\n/** Parses one sitemap document into page entries and nested sitemap URLs. */\nexport function parseSitemapXml(xml: string) {\n\tconst pages: { url: string; lastmod: string | null }[] = []\n\tconst nested: string[] = []\n\n\tfor (const match of xml.matchAll(URL_ENTRY_RE)) {\n\t\tconst entry = match[1] ?? ''\n\t\tconst loc = entry.match(ENTRY_LOC_RE)?.[1]\n\t\tif (!loc) continue\n\t\tpages.push({\n\t\t\turl: decodeXmlEntities(loc),\n\t\t\tlastmod: entry.match(LASTMOD_RE)?.[1]?.trim() ?? null\n\t\t})\n\t}\n\tfor (const match of xml.matchAll(SITEMAP_ENTRY_RE)) {\n\t\tconst loc = match[1]?.match(ENTRY_LOC_RE)?.[1]\n\t\tif (loc) nested.push(decodeXmlEntities(loc))\n\t}\n\t// Plain `<loc>` lists without `<url>` wrappers (rare, but seen in the wild).\n\tif (pages.length === 0 && nested.length === 0) {\n\t\tfor (const match of xml.matchAll(LOC_RE)) {\n\t\t\tif (match[1])\n\t\t\t\tpages.push({ url: decodeXmlEntities(match[1]), lastmod: null })\n\t\t}\n\t}\n\treturn { pages, nested }\n}\n\nfunction fetchText(url: string, accept: string) {\n\treturn throughCrawlSnapshot(`text ${accept} ${url}`, () =>\n\t\tfetchTextLive(url, accept)\n\t)\n}\n\nasync function fetchTextLive(url: string, accept: string) {\n\ttry {\n\t\tconst response = await fetch(url, {\n\t\t\tsignal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n\t\t\theaders: { accept }\n\t\t})\n\t\tif (!response.ok) return null\n\t\tconst type = response.headers.get('content-type') ?? ''\n\t\t// A CMS that renders its 404 page for a missing file answers 200 with HTML.\n\t\tif (type.includes('text/html')) return null\n\t\treturn await response.text()\n\t} catch {\n\t\treturn null\n\t}\n}\n\nfunction originOf(websiteUrl: string) {\n\ttry {\n\t\treturn new URL(websiteUrl).origin\n\t} catch {\n\t\treturn null\n\t}\n}\n\n/**\n * `/robots.txt`, read once: its `Sitemap:` lines, absolute-ised (many CMSs\n * publish their sitemap somewhere other than `/sitemap.xml` and only say so\n * here), and its rules. An HTML body or any non-200 means \"no robots.txt\";\n * never throws.\n */\nexport async function fetchRobots(websiteUrl: string) {\n\tconst sitemaps: string[] = []\n\tconst origin = originOf(websiteUrl)\n\tif (!origin) return { sitemaps, rules: [] }\n\tconst robotsUrl = `${origin}/robots.txt`\n\tconst body = await fetchText(robotsUrl, 'text/plain,*/*;q=0.8')\n\tif (!body) return { sitemaps, rules: [] }\n\n\tfor (const match of body.matchAll(ROBOTS_SITEMAP_RE)) {\n\t\tconst value = match[1]\n\t\tif (!value) continue\n\t\ttry {\n\t\t\tconst absolute = new URL(value, robotsUrl)\n\t\t\t// Only what we could actually GET - `ftp:` and friends are noise.\n\t\t\tif (absolute.protocol !== 'http:' && absolute.protocol !== 'https:')\n\t\t\t\tcontinue\n\t\t\tconst href = absolute.toString()\n\t\t\tif (!sitemaps.includes(href)) sitemaps.push(href)\n\t\t} catch {\n\t\t\t// A malformed Sitemap: line must not cost us the other lines.\n\t\t}\n\t}\n\treturn { sitemaps, rules: parseRobotsRules(body) }\n}\n\nexport type RobotsRule = { allow: boolean; pattern: string }\n\n/**\n * The `Allow`/`Disallow` lines of the `User-agent: *` groups. We read pages\n * with a browser user agent, so a group naming a specific crawler is not ours;\n * the wildcard group is what the site asks of everyone. An empty `Disallow:`\n * allows everything and adds no rule.\n */\nexport function parseRobotsRules(body: string) {\n\tconst rules: RobotsRule[] = []\n\tlet agents: string[] = []\n\tlet inRules = false\n\n\tfor (const raw of body.split(/\\r?\\n/)) {\n\t\tconst match = /^\\s*([a-z-]+)\\s*:\\s*(.*?)\\s*$/i.exec(raw.replace(/#.*$/, ''))\n\t\tif (!match) continue\n\t\tconst field = (match[1] ?? '').toLowerCase()\n\t\tconst value = match[2] ?? ''\n\n\t\tif (field === 'user-agent') {\n\t\t\t// A user-agent line after rules opens a new group.\n\t\t\tif (inRules) agents = []\n\t\t\tinRules = false\n\t\t\tagents.push(value.toLowerCase())\n\t\t\tcontinue\n\t\t}\n\t\tif (field !== 'allow' && field !== 'disallow') continue\n\n\t\tinRules = true\n\t\tif (agents.includes('*') && value !== '')\n\t\t\trules.push({ allow: field === 'allow', pattern: value })\n\t}\n\treturn rules\n}\n\n/** Compiled once per pattern: the crawl asks for every link of every page. */\nconst robotsPatternCache = new Map<string, RegExp>()\n\nfunction robotsPatternRegex(pattern: string) {\n\tconst cached = robotsPatternCache.get(pattern)\n\tif (cached) return cached\n\n\tconst anchored = pattern.endsWith('$')\n\tconst body = (anchored ? pattern.slice(0, -1) : pattern)\n\t\t.split('*')\n\t\t.map((part) => escapeRegExp(part))\n\t\t.join('.*')\n\tconst regex = new RegExp(`^${body}${anchored ? '$' : ''}`)\n\t// Every site's patterns land here; a long-lived worker starts over rather than grow.\n\tif (robotsPatternCache.size >= 5_000) robotsPatternCache.clear()\n\trobotsPatternCache.set(pattern, regex)\n\treturn regex\n}\n\n/**\n * Whether robots.txt lets us read this URL: the longest matching rule wins and\n * `Allow` wins a tie, as Google reads it. Shopify disallows `/cart/`, and the\n * crawler had fetched `/cart/add?id=…&quantity=1` - a GET that fills a cart\n * (Good Habit audit §3.5).\n */\nexport function robotsAllows(rules: RobotsRule[], url: string) {\n\tlet path: string\n\ttry {\n\t\tconst parsed = new URL(url)\n\t\tpath = parsed.pathname + parsed.search\n\t} catch {\n\t\treturn true\n\t}\n\n\tlet best: RobotsRule | null = null\n\tfor (const rule of rules) {\n\t\tif (!robotsPatternRegex(rule.pattern).test(path)) continue\n\t\tif (!best || rule.pattern.length > best.pattern.length) best = rule\n\t\telse if (rule.pattern.length === best.pattern.length && rule.allow)\n\t\t\tbest = rule\n\t}\n\treturn best?.allow ?? true\n}\n\n/**\n * Reads a set of sitemap roots (following index nesting) into `found`.\n * Returns true when at least one of them yielded page URLs, so the caller can\n * stop trying further roots.\n */\nasync function readSitemapTree(\n\troots: string[],\n\tfound: Map<string, { url: string; lastmod: string | null }>,\n\tseen: Set<string>\n) {\n\tlet readable = false\n\tlet level = [...roots]\n\n\t// One level of the index at a time, its files read side by side: a shop\n\t// with a sitemap per locale and type read up to 20 files one after another,\n\t// on the path to the first page read. Results are applied in listed order,\n\t// so which copy of a URL is kept does not depend on network timing.\n\twhile (level.length > 0 && found.size < MAX_URLS) {\n\t\tconst batch: string[] = []\n\t\tfor (const sitemapUrl of level) {\n\t\t\tif (seen.has(sitemapUrl) || seen.size >= MAX_SITEMAP_FILES) continue\n\t\t\tseen.add(sitemapUrl)\n\t\t\tbatch.push(sitemapUrl)\n\t\t}\n\t\tconst bodies = await Promise.all(\n\t\t\tbatch.map((sitemapUrl) =>\n\t\t\t\tfetchText(sitemapUrl, 'application/xml,text/xml;q=0.9,*/*;q=0.8')\n\t\t\t)\n\t\t)\n\t\tconst next: string[] = []\n\t\tfor (const xml of bodies) {\n\t\t\tif (!xml) continue\n\t\t\tconst { pages, nested } = parseSitemapXml(xml)\n\t\t\t// An empty document is not a sitemap we can use - let the next root try.\n\t\t\tif (pages.length === 0 && nested.length === 0) continue\n\t\t\tif (pages.length > 0) readable = true\n\t\t\tnext.push(...nested)\n\t\t\tfor (const page of pages) {\n\t\t\t\tif (found.size >= MAX_URLS) break\n\t\t\t\t// Keyed on the normalised form so `/kontakt` and `/kontakt/` listed in\n\t\t\t\t// two sitemap files do not become two fetches.\n\t\t\t\tconst key = normalizeSitemapUrl(page.url)\n\t\t\t\tif (!found.has(key)) found.set(key, page)\n\t\t\t}\n\t\t}\n\t\tlevel = next\n\t}\n\treturn readable\n}\n\n/**\n * Every URL the site's sitemaps list, robots-declared sitemaps (`fetchRobots`)\n * first and the conventional roots as a fallback. Returns `[]` when the site has no usable\n * sitemap - a fifth of real customer sites have none, which is normal and not\n * an error: link-following discovery is the source, sitemaps a supplement.\n * Never throws.\n */\nexport async function discoverSitemapUrls(\n\twebsiteUrl: string,\n\trobotsSitemaps: string[]\n) {\n\tconst origin = originOf(websiteUrl)\n\tif (!origin) return []\n\tconst found = new Map<string, { url: string; lastmod: string | null }>()\n\t// Shared across roots so a broken sitemap index cannot spend the file budget\n\t// several times over.\n\tconst seen = new Set<string>()\n\n\t// robots.txt may declare several sitemaps (WordPress splits per post type),\n\t// so all of them are read together - only the conventional roots compete.\n\tif (\n\t\trobotsSitemaps.length > 0 &&\n\t\t(await readSitemapTree(robotsSitemaps, found, seen))\n\t) {\n\t\treturn [...found.values()]\n\t}\n\tfor (const path of SITEMAP_ROOT_PATHS) {\n\t\tif (await readSitemapTree([`${origin}${path}`], found, seen)) break\n\t}\n\treturn [...found.values()]\n}\n\n/** One key per page whatever the trailing slash or fragment. */\nexport function normalizeSitemapUrl(url: string) {\n\ttry {\n\t\tconst parsed = new URL(url)\n\t\tparsed.hash = ''\n\t\treturn parsed.toString().replace(/\\/+$/, '')\n\t} catch {\n\t\treturn url.replace(/\\/+$/, '')\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/sitemap/index.ts"],"names":[],"mappings":"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","sourcesContent":["import { getUniqueValues, isOneOf, trimTrailingSlash } from 'hanzio'\nimport { isAllowedByRobots, parseRobotsTxt } from 'hanzio/sitemap'\nimport { throughCrawlSnapshot } from '../crawl-snapshot.js'\nimport { type Fetch, guardedFetch } from '../net-guard.js'\nimport { decodeText, readBody } from '../read-body.js'\nimport { BROWSER_USER_AGENT } from '../user-agent.js'\n\nexport type { Fetch }\n\nconst FETCH_TIMEOUT_MS = 10_000\nconst MAX_SITEMAP_FILES = 20\nconst MAX_URLS = 20_000\n/** Google reads the first 500 KiB of a robots.txt and ignores the rest. */\nconst MAX_ROBOTS_BYTES = 500 * 1024\n/**\n * Read cap per sitemap file. 20,000 URLs fit in a few MB; a longer file is\n * read up to here and its complete entries used.\n */\nconst MAX_SITEMAP_BYTES = 10 * 1024 * 1024\n\nexport type SitemapOptions = {\n\t/**\n\t * The `fetch` robots.txt and sitemaps go through. Default\n\t * {@link guardedFetch} with the default guard (public addresses only, every\n\t * redirect checked); pass `converter.guardedFetch` to use the converter's.\n\t */\n\tfetch?: Fetch | undefined\n}\n\n/** Conventional sitemap locations, tried in order when robots.txt names none. */\nconst SITEMAP_ROOT_PATHS = [\n\t'/sitemap.xml',\n\t'/sitemap_index.xml',\n\t'/wp-sitemap.xml'\n]\n\nconst LOC_RE = /<loc>\\s*([^<]+?)\\s*<\\/loc>/gi\nconst URL_ENTRY_RE = /<url>([\\s\\S]*?)<\\/url>/gi\nconst SITEMAP_ENTRY_RE = /<sitemap>([\\s\\S]*?)<\\/sitemap>/gi\nconst ENTRY_LOC_RE = /<loc>\\s*([^<]+?)\\s*<\\/loc>/i\nconst LASTMOD_RE = /<lastmod>\\s*([^<]+?)\\s*<\\/lastmod>/i\nconst ROBOTS_SITEMAP_RE = /^[^\\S\\r\\n]*sitemap[^\\S\\r\\n]*:[^\\S\\r\\n]*(\\S+)/gim\n\nconst XML_ENTITIES = new Map([\n\t['&', '&'],\n\t['<', '<'],\n\t['>', '>'],\n\t['"', '\"'],\n\t[''', \"'\"],\n\t[''', \"'\"]\n])\n\n/** One pass, so `&lt;` decodes to `<` and not to `<`. */\nfunction decodeXmlEntities(value: string) {\n\treturn value.replace(\n\t\t/&(?:amp|lt|gt|quot|apos|#39);/g,\n\t\t(entity) => XML_ENTITIES.get(entity) ?? entity\n\t)\n}\n\n/** Parses one sitemap document into page entries and nested sitemap URLs. */\nexport function parseSitemapXml(xml: string) {\n\tconst pages: { url: string; lastmod: string | null }[] = []\n\tconst nested: string[] = []\n\n\tfor (const match of xml.matchAll(URL_ENTRY_RE)) {\n\t\tconst entry = match[1] ?? ''\n\t\tconst loc = entry.match(ENTRY_LOC_RE)?.[1]\n\t\tif (!loc) continue\n\t\tpages.push({\n\t\t\turl: decodeXmlEntities(loc),\n\t\t\tlastmod: entry.match(LASTMOD_RE)?.[1]?.trim() ?? null\n\t\t})\n\t}\n\tfor (const match of xml.matchAll(SITEMAP_ENTRY_RE)) {\n\t\tconst loc = match[1]?.match(ENTRY_LOC_RE)?.[1]\n\t\tif (loc) nested.push(decodeXmlEntities(loc))\n\t}\n\t// Plain `<loc>` lists without `<url>` wrappers (rare, but seen in the wild).\n\tif (pages.length === 0 && nested.length === 0) {\n\t\tfor (const match of xml.matchAll(LOC_RE)) {\n\t\t\tif (match[1])\n\t\t\t\tpages.push({ url: decodeXmlEntities(match[1]), lastmod: null })\n\t\t}\n\t}\n\treturn { pages, nested }\n}\n\ntype TextRequest = { accept: string; maxBytes: number; fetch: Fetch }\n\nfunction fetchText(url: string, request: TextRequest) {\n\treturn throughCrawlSnapshot(`text ${request.accept} ${url}`, () =>\n\t\tfetchTextLive(url, request)\n\t)\n}\n\nasync function fetchTextLive(url: string, request: TextRequest) {\n\ttry {\n\t\tconst response = await request.fetch(url, {\n\t\t\tsignal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n\t\t\theaders: { accept: request.accept, 'user-agent': BROWSER_USER_AGENT }\n\t\t})\n\t\tconst type = response.headers.get('content-type') ?? ''\n\t\t// A CMS that renders its 404 page for a missing file answers 200 with HTML.\n\t\tif (!response.ok || type.includes('text/html')) {\n\t\t\tvoid response.body?.cancel().catch(() => {})\n\t\t\treturn null\n\t\t}\n\t\treturn decodeText((await readBody(response, request.maxBytes)).bytes)\n\t} catch {\n\t\treturn null\n\t}\n}\n\nconst ROBOTS_REQUEST = {\n\taccept: 'text/plain,*/*;q=0.8',\n\tmaxBytes: MAX_ROBOTS_BYTES\n}\nconst SITEMAP_REQUEST = {\n\taccept: 'application/xml,text/xml;q=0.9,*/*;q=0.8',\n\tmaxBytes: MAX_SITEMAP_BYTES\n}\n\nfunction originOf(websiteUrl: string) {\n\ttry {\n\t\treturn new URL(websiteUrl).origin\n\t} catch {\n\t\treturn null\n\t}\n}\n\n/**\n * `/robots.txt`, read once: its `Sitemap:` lines, absolute-ised (many CMSs\n * publish their sitemap somewhere other than `/sitemap.xml` and only say so\n * here), and its rules. An HTML body or any non-200 means \"no robots.txt\";\n * never throws.\n */\nexport async function fetchRobots(\n\twebsiteUrl: string,\n\toptions: SitemapOptions = {}\n) {\n\tconst sitemaps: string[] = []\n\tconst origin = originOf(websiteUrl)\n\tif (!origin) return { sitemaps, rules: [] }\n\tconst robotsUrl = `${origin}/robots.txt`\n\tconst body = await fetchText(robotsUrl, {\n\t\t...ROBOTS_REQUEST,\n\t\tfetch: options.fetch ?? guardedFetch\n\t})\n\tif (!body) return { sitemaps, rules: [] }\n\n\tfor (const match of body.matchAll(ROBOTS_SITEMAP_RE)) {\n\t\tconst value = match[1]\n\t\tif (!value) continue\n\t\ttry {\n\t\t\tconst absolute = new URL(value, robotsUrl)\n\t\t\t// Only what we could actually GET - `ftp:` and friends are noise.\n\t\t\tif (!isOneOf(['http:', 'https:'], absolute.protocol)) continue\n\t\t\tsitemaps.push(absolute.toString())\n\t\t} catch {\n\t\t\t// A malformed Sitemap: line must not cost us the other lines.\n\t\t}\n\t}\n\treturn {\n\t\tsitemaps: getUniqueValues(sitemaps),\n\t\trules: parseRobotsRules(body)\n\t}\n}\n\nexport type RobotsRule = { allow: boolean; pattern: string }\n\n/**\n * The `Allow`/`Disallow` lines of the `User-agent: *` groups. We read pages\n * with a browser user agent, so a group naming a specific crawler is not ours;\n * the wildcard group is what the site asks of everyone. An empty `Disallow:`\n * allows everything and adds no rule.\n */\nexport function parseRobotsRules(body: string): RobotsRule[] {\n\tconst { allow, disallow } = parseRobotsTxt(body)\n\treturn [\n\t\t...allow.map((pattern) => ({ allow: true, pattern })),\n\t\t...disallow.map((pattern) => ({ allow: false, pattern }))\n\t]\n}\n\n/**\n * Whether robots.txt lets us read this URL: the longest matching rule wins and\n * `Allow` wins a tie, as Google reads it. Shopify disallows `/cart/`, and the\n * crawler had fetched `/cart/add?id=…&quantity=1` - a GET that fills a cart.\n */\nexport function robotsAllows(rules: RobotsRule[], url: string) {\n\treturn isAllowedByRobots(\n\t\t{\n\t\t\tallow: rules.filter((rule) => rule.allow).map((rule) => rule.pattern),\n\t\t\tdisallow: rules.filter((rule) => !rule.allow).map((rule) => rule.pattern)\n\t\t},\n\t\turl\n\t)\n}\n\n/**\n * Reads a set of sitemap roots (following index nesting) into `found`.\n * Returns true when at least one of them yielded page URLs, so the caller can\n * stop trying further roots.\n */\nasync function readSitemapTree(\n\troots: string[],\n\tfound: Map<string, { url: string; lastmod: string | null }>,\n\tseen: Set<string>,\n\tfetch: Fetch\n) {\n\tlet readable = false\n\tlet level = [...roots]\n\n\t// One level of the index at a time, its files read side by side: a shop\n\t// with a sitemap per locale and type read up to 20 files one after another,\n\t// on the path to the first page read. Results are applied in listed order,\n\t// so which copy of a URL is kept does not depend on network timing.\n\twhile (level.length > 0 && found.size < MAX_URLS) {\n\t\tconst batch: string[] = []\n\t\tfor (const sitemapUrl of level) {\n\t\t\tif (seen.has(sitemapUrl) || seen.size >= MAX_SITEMAP_FILES) continue\n\t\t\tseen.add(sitemapUrl)\n\t\t\tbatch.push(sitemapUrl)\n\t\t}\n\t\tconst bodies = await Promise.all(\n\t\t\tbatch.map((sitemapUrl) =>\n\t\t\t\tfetchText(sitemapUrl, { ...SITEMAP_REQUEST, fetch })\n\t\t\t)\n\t\t)\n\t\tconst next: string[] = []\n\t\tfor (const xml of bodies) {\n\t\t\tif (!xml) continue\n\t\t\tconst { pages, nested } = parseSitemapXml(xml)\n\t\t\t// An empty document is not a sitemap we can use - let the next root try.\n\t\t\tif (pages.length === 0 && nested.length === 0) continue\n\t\t\tif (pages.length > 0) readable = true\n\t\t\tnext.push(...nested)\n\t\t\tfor (const page of pages) {\n\t\t\t\tif (found.size >= MAX_URLS) break\n\t\t\t\t// Keyed on the normalised form so `/kontakt` and `/kontakt/` listed in\n\t\t\t\t// two sitemap files do not become two fetches.\n\t\t\t\tconst key = normalizeSitemapUrl(page.url)\n\t\t\t\tif (!found.has(key)) found.set(key, page)\n\t\t\t}\n\t\t}\n\t\tlevel = next\n\t}\n\treturn readable\n}\n\n/**\n * Every URL the site's sitemaps list, robots-declared sitemaps (`fetchRobots`)\n * first and the conventional roots as a fallback. Returns `[]` when the site has no usable\n * sitemap - a fifth of real customer sites have none, which is normal and not\n * an error: link-following discovery is the source, sitemaps a supplement.\n * Never throws.\n */\nexport async function discoverSitemapUrls(\n\twebsiteUrl: string,\n\trobotsSitemaps: string[],\n\toptions: SitemapOptions = {}\n) {\n\tconst origin = originOf(websiteUrl)\n\tif (!origin) return []\n\tconst fetch = options.fetch ?? guardedFetch\n\tconst found = new Map<string, { url: string; lastmod: string | null }>()\n\t// Shared across roots so a broken sitemap index cannot spend the file budget\n\t// several times over.\n\tconst seen = new Set<string>()\n\n\t// robots.txt may declare several sitemaps (WordPress splits per post type),\n\t// so all of them are read together - only the conventional roots compete.\n\tif (\n\t\trobotsSitemaps.length > 0 &&\n\t\t(await readSitemapTree(robotsSitemaps, found, seen, fetch))\n\t) {\n\t\treturn [...found.values()]\n\t}\n\tfor (const path of SITEMAP_ROOT_PATHS) {\n\t\tif (await readSitemapTree([`${origin}${path}`], found, seen, fetch)) break\n\t}\n\treturn [...found.values()]\n}\n\n/** One key per page whatever the trailing slash or fragment. */\nexport function normalizeSitemapUrl(url: string) {\n\ttry {\n\t\tconst parsed = new URL(url)\n\t\tparsed.hash = ''\n\t\treturn trimTrailingSlash(parsed.toString())\n\t} catch {\n\t\treturn trimTrailingSlash(url)\n\t}\n}\n"]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FetchedPage, FetchPageInput } from '../fetch-page.js';
|
|
2
|
+
/** A complete `FetchedPage`: an `ok` HTML read of `url` with nothing on it, plus `page`. */
|
|
3
|
+
export declare function fakePage(page: Partial<FetchedPage> & {
|
|
4
|
+
url: string;
|
|
5
|
+
}): FetchedPage;
|
|
6
|
+
/**
|
|
7
|
+
* A site in memory with the converter's `fetchPageAsMarkdown`: the pages
|
|
8
|
+
* given by URL, `gone` (404) for any other, `unchanged` (304) when the input's
|
|
9
|
+
* `etag`, `lastModified` or `htmlHash` matches the page's. `calls` lists every
|
|
10
|
+
* input in order.
|
|
11
|
+
*/
|
|
12
|
+
export declare function fakeSite(pages: Record<string, Omit<Partial<FetchedPage>, 'url'>>): {
|
|
13
|
+
fetchPageAsMarkdown: (input: FetchPageInput) => Promise<FetchedPage>;
|
|
14
|
+
calls: FetchPageInput[];
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAGnE,4FAA4F;AAC5F,wBAAgB,QAAQ,CACvB,IAAI,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAC1C,WAAW,CAwBb;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CACvB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,EAAE,KAAK,CAAC,CAAC;iCAGd,cAAc;;EAiBxD"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { asUnchanged, isUnchangedFor } from '../unchanged-page.js';
|
|
2
|
+
/** A complete `FetchedPage`: an `ok` HTML read of `url` with nothing on it, plus `page`. */
|
|
3
|
+
export function fakePage(page) {
|
|
4
|
+
return {
|
|
5
|
+
finalUrl: page.url,
|
|
6
|
+
status: 200,
|
|
7
|
+
outcome: 'ok',
|
|
8
|
+
source: 'workers-ai',
|
|
9
|
+
contentType: 'text/html; charset=utf-8',
|
|
10
|
+
etag: null,
|
|
11
|
+
lastModified: null,
|
|
12
|
+
markdown: '',
|
|
13
|
+
title: null,
|
|
14
|
+
lang: null,
|
|
15
|
+
canonical: null,
|
|
16
|
+
alternates: [],
|
|
17
|
+
alternateLanguages: {},
|
|
18
|
+
metaDescription: null,
|
|
19
|
+
jsonLd: [],
|
|
20
|
+
links: [],
|
|
21
|
+
images: [],
|
|
22
|
+
durationMs: 0,
|
|
23
|
+
captureIssue: null,
|
|
24
|
+
htmlHash: null,
|
|
25
|
+
...page
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A site in memory with the converter's `fetchPageAsMarkdown`: the pages
|
|
30
|
+
* given by URL, `gone` (404) for any other, `unchanged` (304) when the input's
|
|
31
|
+
* `etag`, `lastModified` or `htmlHash` matches the page's. `calls` lists every
|
|
32
|
+
* input in order.
|
|
33
|
+
*/
|
|
34
|
+
export function fakeSite(pages) {
|
|
35
|
+
const calls = [];
|
|
36
|
+
async function fetchPageAsMarkdown(input) {
|
|
37
|
+
calls.push(input);
|
|
38
|
+
const known = pages[input.url];
|
|
39
|
+
if (!known) {
|
|
40
|
+
return fakePage({
|
|
41
|
+
url: input.url,
|
|
42
|
+
status: 404,
|
|
43
|
+
outcome: 'gone',
|
|
44
|
+
source: null
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const page = fakePage({ ...known, url: input.url });
|
|
48
|
+
return isUnchangedFor(page, input)
|
|
49
|
+
? { ...asUnchanged(page), status: 304 }
|
|
50
|
+
: page;
|
|
51
|
+
}
|
|
52
|
+
return { fetchPageAsMarkdown, calls };
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAElE,4FAA4F;AAC5F,MAAM,UAAU,QAAQ,CACvB,IAA4C;IAE5C,OAAO;QACN,QAAQ,EAAE,IAAI,CAAC,GAAG;QAClB,MAAM,EAAE,GAAG;QACX,OAAO,EAAE,IAAI;QACb,MAAM,EAAE,YAAY;QACpB,WAAW,EAAE,0BAA0B;QACvC,IAAI,EAAE,IAAI;QACV,YAAY,EAAE,IAAI;QAClB,QAAQ,EAAE,EAAE;QACZ,KAAK,EAAE,IAAI;QACX,IAAI,EAAE,IAAI;QACV,SAAS,EAAE,IAAI;QACf,UAAU,EAAE,EAAE;QACd,kBAAkB,EAAE,EAAE;QACtB,eAAe,EAAE,IAAI;QACrB,MAAM,EAAE,EAAE;QACV,KAAK,EAAE,EAAE;QACT,MAAM,EAAE,EAAE;QACV,UAAU,EAAE,CAAC;QACb,YAAY,EAAE,IAAI;QAClB,QAAQ,EAAE,IAAI;QACd,GAAG,IAAI;KACP,CAAA;AACF,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,QAAQ,CACvB,KAAwD;IAExD,MAAM,KAAK,GAAqB,EAAE,CAAA;IAClC,KAAK,UAAU,mBAAmB,CAAC,KAAqB;QACvD,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACjB,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;QAC9B,IAAI,CAAC,KAAK,EAAE,CAAC;YACZ,OAAO,QAAQ,CAAC;gBACf,GAAG,EAAE,KAAK,CAAC,GAAG;gBACd,MAAM,EAAE,GAAG;gBACX,OAAO,EAAE,MAAM;gBACf,MAAM,EAAE,IAAI;aACZ,CAAC,CAAA;QACH,CAAC;QACD,MAAM,IAAI,GAAG,QAAQ,CAAC,EAAE,GAAG,KAAK,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,CAAC,CAAA;QACnD,OAAO,cAAc,CAAC,IAAI,EAAE,KAAK,CAAC;YACjC,CAAC,CAAC,EAAE,GAAG,WAAW,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE;YACvC,CAAC,CAAC,IAAI,CAAA;IACR,CAAC;IACD,OAAO,EAAE,mBAAmB,EAAE,KAAK,EAAE,CAAA;AACtC,CAAC","sourcesContent":["import type { FetchedPage, FetchPageInput } from '../fetch-page.js'\nimport { asUnchanged, isUnchangedFor } from '../unchanged-page.js'\n\n/** A complete `FetchedPage`: an `ok` HTML read of `url` with nothing on it, plus `page`. */\nexport function fakePage(\n\tpage: Partial<FetchedPage> & { url: string }\n): FetchedPage {\n\treturn {\n\t\tfinalUrl: page.url,\n\t\tstatus: 200,\n\t\toutcome: 'ok',\n\t\tsource: 'workers-ai',\n\t\tcontentType: 'text/html; charset=utf-8',\n\t\tetag: null,\n\t\tlastModified: null,\n\t\tmarkdown: '',\n\t\ttitle: null,\n\t\tlang: null,\n\t\tcanonical: null,\n\t\talternates: [],\n\t\talternateLanguages: {},\n\t\tmetaDescription: null,\n\t\tjsonLd: [],\n\t\tlinks: [],\n\t\timages: [],\n\t\tdurationMs: 0,\n\t\tcaptureIssue: null,\n\t\thtmlHash: null,\n\t\t...page\n\t}\n}\n\n/**\n * A site in memory with the converter's `fetchPageAsMarkdown`: the pages\n * given by URL, `gone` (404) for any other, `unchanged` (304) when the input's\n * `etag`, `lastModified` or `htmlHash` matches the page's. `calls` lists every\n * input in order.\n */\nexport function fakeSite(\n\tpages: Record<string, Omit<Partial<FetchedPage>, 'url'>>\n) {\n\tconst calls: FetchPageInput[] = []\n\tasync function fetchPageAsMarkdown(input: FetchPageInput) {\n\t\tcalls.push(input)\n\t\tconst known = pages[input.url]\n\t\tif (!known) {\n\t\t\treturn fakePage({\n\t\t\t\turl: input.url,\n\t\t\t\tstatus: 404,\n\t\t\t\toutcome: 'gone',\n\t\t\t\tsource: null\n\t\t\t})\n\t\t}\n\t\tconst page = fakePage({ ...known, url: input.url })\n\t\treturn isUnchangedFor(page, input)\n\t\t\t? { ...asUnchanged(page), status: 304 }\n\t\t\t: page\n\t}\n\treturn { fetchPageAsMarkdown, calls }\n}\n"]}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { FetchedPage, FetchPageInput } from './fetch-page.js';
|
|
2
|
+
/**
|
|
3
|
+
* Whether a full page read without validators is unchanged for a caller that
|
|
4
|
+
* holds these validators, decided the way the origin would have.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isUnchangedFor(page: FetchedPage, input: FetchPageInput): boolean;
|
|
7
|
+
/** `page` as the `unchanged` answer: validators and URLs kept, content dropped. */
|
|
8
|
+
export declare function asUnchanged(page: FetchedPage): FetchedPage;
|
|
9
|
+
//# sourceMappingURL=unchanged-page.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unchanged-page.d.ts","sourceRoot":"","sources":["../src/unchanged-page.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAElE;;;GAGG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,WAAW,EAAE,KAAK,EAAE,cAAc,WAStE;AAED,mFAAmF;AACnF,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,GAAG,WAAW,CAkB1D"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a full page read without validators is unchanged for a caller that
|
|
3
|
+
* holds these validators, decided the way the origin would have.
|
|
4
|
+
*/
|
|
5
|
+
export function isUnchangedFor(page, input) {
|
|
6
|
+
return (page.outcome === 'ok' &&
|
|
7
|
+
Boolean((input.htmlHash && page.htmlHash === input.htmlHash) ||
|
|
8
|
+
(input.etag && page.etag === input.etag) ||
|
|
9
|
+
(input.lastModified && page.lastModified === input.lastModified)));
|
|
10
|
+
}
|
|
11
|
+
/** `page` as the `unchanged` answer: validators and URLs kept, content dropped. */
|
|
12
|
+
export function asUnchanged(page) {
|
|
13
|
+
return {
|
|
14
|
+
...page,
|
|
15
|
+
outcome: 'unchanged',
|
|
16
|
+
source: 'not-modified',
|
|
17
|
+
markdown: '',
|
|
18
|
+
title: null,
|
|
19
|
+
lang: null,
|
|
20
|
+
canonical: null,
|
|
21
|
+
alternates: [],
|
|
22
|
+
alternateLanguages: {},
|
|
23
|
+
metaDescription: null,
|
|
24
|
+
jsonLd: [],
|
|
25
|
+
links: [],
|
|
26
|
+
images: [],
|
|
27
|
+
captureIssue: null,
|
|
28
|
+
htmlHash: null
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=unchanged-page.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unchanged-page.js","sourceRoot":"","sources":["../src/unchanged-page.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,MAAM,UAAU,cAAc,CAAC,IAAiB,EAAE,KAAqB;IACtE,OAAO,CACN,IAAI,CAAC,OAAO,KAAK,IAAI;QACrB,OAAO,CACN,CAAC,KAAK,CAAC,QAAQ,IAAI,IAAI,CAAC,QAAQ,KAAK,KAAK,CAAC,QAAQ,CAAC;YACpD,CAAC,KAAK,CAAC,IAAI,IAAI,IAAI,CAAC,IAAI,KAAK,KAAK,CAAC,IAAI,CAAC;YACxC,CAAC,KAAK,CAAC,YAAY,IAAI,IAAI,CAAC,YAAY,KAAK,KAAK,CAAC,YAAY,CAAC,CAChE,CACD,CAAA;AACF,CAAC;AAED,mFAAmF;AACnF,MAAM,UAAU,WAAW,CAAC,IAAiB;IAC5C,OAAO;QACN,GAAG,IAAI;QACP,OAAO,EAAE,WAAW;QACpB,MAAM,EAAE,cAAc;QACtB,QAAQ,EAAE,EAAE;QACZ,KAAK,EAAE,IAAI;QACX,IAAI,EAAE,IAAI;QACV,SAAS,EAAE,IAAI;QACf,UAAU,EAAE,EAAE;QACd,kBAAkB,EAAE,EAAE;QACtB,eAAe,EAAE,IAAI;QACrB,MAAM,EAAE,EAAE;QACV,KAAK,EAAE,EAAE;QACT,MAAM,EAAE,EAAE;QACV,YAAY,EAAE,IAAI;QAClB,QAAQ,EAAE,IAAI;KACd,CAAA;AACF,CAAC","sourcesContent":["import type { FetchedPage, FetchPageInput } from './fetch-page.js'\n\n/**\n * Whether a full page read without validators is unchanged for a caller that\n * holds these validators, decided the way the origin would have.\n */\nexport function isUnchangedFor(page: FetchedPage, input: FetchPageInput) {\n\treturn (\n\t\tpage.outcome === 'ok' &&\n\t\tBoolean(\n\t\t\t(input.htmlHash && page.htmlHash === input.htmlHash) ||\n\t\t\t(input.etag && page.etag === input.etag) ||\n\t\t\t(input.lastModified && page.lastModified === input.lastModified)\n\t\t)\n\t)\n}\n\n/** `page` as the `unchanged` answer: validators and URLs kept, content dropped. */\nexport function asUnchanged(page: FetchedPage): FetchedPage {\n\treturn {\n\t\t...page,\n\t\toutcome: 'unchanged',\n\t\tsource: 'not-modified',\n\t\tmarkdown: '',\n\t\ttitle: null,\n\t\tlang: null,\n\t\tcanonical: null,\n\t\talternates: [],\n\t\talternateLanguages: {},\n\t\tmetaDescription: null,\n\t\tjsonLd: [],\n\t\tlinks: [],\n\t\timages: [],\n\t\tcaptureIssue: null,\n\t\thtmlHash: null\n\t}\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "website-to-markdown",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Turn URLs, HTML and files into markdown, with sitemap discovery and per-host limits.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
"./schema": {
|
|
36
36
|
"types": "./dist/schema/index.d.ts",
|
|
37
37
|
"import": "./dist/schema/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./testing": {
|
|
40
|
+
"types": "./dist/testing/index.d.ts",
|
|
41
|
+
"import": "./dist/testing/index.js"
|
|
38
42
|
}
|
|
39
43
|
},
|
|
40
44
|
"bin": {
|
|
@@ -54,9 +58,10 @@
|
|
|
54
58
|
"scripts": {
|
|
55
59
|
"build": "bun scripts/build.ts",
|
|
56
60
|
"test": "bun test",
|
|
57
|
-
"typecheck": "
|
|
58
|
-
"
|
|
59
|
-
"lint
|
|
61
|
+
"typecheck": "bun check",
|
|
62
|
+
"fmt": "bun fmt --no-script '**/*.ts'",
|
|
63
|
+
"lint": "bun run typecheck && biome check && bun fmt --no-script --check '**/*.ts'",
|
|
64
|
+
"lint:write": "bun run typecheck && biome check --write && bun run fmt",
|
|
60
65
|
"attw": "attw --pack . --profile esm-only",
|
|
61
66
|
"publint": "publint",
|
|
62
67
|
"verify:package": "node scripts/verify-package.mjs"
|
|
@@ -66,7 +71,7 @@
|
|
|
66
71
|
"fflate": "^0.8.2"
|
|
67
72
|
},
|
|
68
73
|
"peerDependencies": {
|
|
69
|
-
"hanzio": "^3.
|
|
74
|
+
"hanzio": "^3.4.0",
|
|
70
75
|
"zod": "^4.0.0"
|
|
71
76
|
},
|
|
72
77
|
"devDependencies": {
|
|
@@ -74,7 +79,7 @@
|
|
|
74
79
|
"@biomejs/biome": "^2.2.4",
|
|
75
80
|
"@types/bun": "^1.2.0",
|
|
76
81
|
"@types/node": "^24",
|
|
77
|
-
"hanzio": "^3.
|
|
82
|
+
"hanzio": "^3.4.0",
|
|
78
83
|
"publint": "^0.3.12",
|
|
79
84
|
"typescript": "^5.9.2",
|
|
80
85
|
"zod": "^4.4.3"
|
|
@@ -82,5 +87,5 @@
|
|
|
82
87
|
"bugs": {
|
|
83
88
|
"url": "https://github.com/HansKristoffer/website-to-markdown/issues"
|
|
84
89
|
},
|
|
85
|
-
"packageManager": "bun@1.4.
|
|
90
|
+
"packageManager": "bun@1.4.4"
|
|
86
91
|
}
|
package/src/cli.ts
CHANGED
|
@@ -5,13 +5,31 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { readFile } from 'node:fs/promises'
|
|
7
7
|
import { parseEnv } from 'node:util'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type CliIO,
|
|
10
|
+
CliError,
|
|
11
|
+
CliUsageError,
|
|
12
|
+
defineCli,
|
|
13
|
+
defineCommand
|
|
14
|
+
} from 'hanzio/cli'
|
|
9
15
|
import { z } from 'zod'
|
|
10
16
|
import { createWebsiteToMarkdown } from './fetch-page.js'
|
|
11
17
|
|
|
12
18
|
export const cli = defineCli({
|
|
13
19
|
name: 'website-to-markdown',
|
|
20
|
+
// No URL prints the help and exits 1, as 1.0 did.
|
|
21
|
+
onError: (error, io) => {
|
|
22
|
+
if (
|
|
23
|
+
error instanceof CliUsageError &&
|
|
24
|
+
error.message.split('\n').some((line) => line.startsWith('<url>: '))
|
|
25
|
+
) {
|
|
26
|
+
io.stdout.write(error.help)
|
|
27
|
+
return 1
|
|
28
|
+
}
|
|
29
|
+
},
|
|
14
30
|
commands: defineCommand({
|
|
31
|
+
// Unknown flags and extra positionals are ignored, as in 1.0.
|
|
32
|
+
strict: false,
|
|
15
33
|
description:
|
|
16
34
|
'Fetch a public URL as markdown. Needs CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.',
|
|
17
35
|
input: z.object({
|
|
@@ -45,7 +63,9 @@ export const cli = defineCli({
|
|
|
45
63
|
cloudflare: {
|
|
46
64
|
apiToken: CLOUDFLARE_API_TOKEN,
|
|
47
65
|
accountId: CLOUDFLARE_ACCOUNT_ID
|
|
48
|
-
}
|
|
66
|
+
},
|
|
67
|
+
// Run by a person on their own machine, so a dev server is fair game.
|
|
68
|
+
allowLocalhost: true
|
|
49
69
|
})
|
|
50
70
|
|
|
51
71
|
ctx.stderr.write(`Fetching ${url}…\n`)
|
|
@@ -90,11 +110,10 @@ function readEnvFile(path: string) {
|
|
|
90
110
|
})
|
|
91
111
|
}
|
|
92
112
|
|
|
93
|
-
/** The bin
|
|
113
|
+
/** The bin. `--help` without a URL exits 1, as in 1.0. */
|
|
94
114
|
export async function main(argv: readonly string[], io?: CliIO) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return 1
|
|
115
|
+
const code = await cli.run(argv, io)
|
|
116
|
+
return code === 0 && argv.every((arg) => arg.startsWith('-')) ? 1 : code
|
|
98
117
|
}
|
|
99
118
|
|
|
100
119
|
if (import.meta.main) process.exitCode = await main(process.argv.slice(2))
|
package/src/content-type.ts
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
import { isOneOf, type Nullable } from 'hanzio'
|
|
2
|
+
|
|
3
|
+
const MARKDOWN_CONTENT_TYPES = [
|
|
2
4
|
'text/markdown',
|
|
3
5
|
'text/x-markdown',
|
|
4
6
|
'application/markdown'
|
|
5
|
-
]
|
|
7
|
+
] as const
|
|
6
8
|
|
|
7
|
-
const HTML_CONTENT_TYPES =
|
|
9
|
+
const HTML_CONTENT_TYPES = ['text/html', 'application/xhtml+xml'] as const
|
|
8
10
|
|
|
9
|
-
const TEXTUAL_CONTENT_TYPES =
|
|
11
|
+
const TEXTUAL_CONTENT_TYPES = [
|
|
10
12
|
'application/xml',
|
|
11
13
|
'application/json',
|
|
12
14
|
'application/rss+xml',
|
|
13
15
|
'application/atom+xml'
|
|
14
|
-
]
|
|
16
|
+
] as const
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* The formats Workers AI `toMarkdown` accepts as documents (its `/supported`
|
|
@@ -42,7 +44,7 @@ const CONVERTIBLE_DOCUMENT_EXTENSIONS = new Map([
|
|
|
42
44
|
['application/vnd.oasis.opendocument.presentation', 'odp']
|
|
43
45
|
])
|
|
44
46
|
|
|
45
|
-
function normalizeContentType(contentType: string
|
|
47
|
+
function normalizeContentType(contentType: Nullable<string>) {
|
|
46
48
|
return contentType?.split(';')[0]?.trim().toLowerCase() ?? ''
|
|
47
49
|
}
|
|
48
50
|
|
|
@@ -50,7 +52,7 @@ function isTextualContentType(type: string) {
|
|
|
50
52
|
return (
|
|
51
53
|
type === '' ||
|
|
52
54
|
type.startsWith('text/') ||
|
|
53
|
-
TEXTUAL_CONTENT_TYPES
|
|
55
|
+
isOneOf(TEXTUAL_CONTENT_TYPES, type) ||
|
|
54
56
|
// `application/ld+json`, `application/rdf+xml`, … but not `image/svg+xml`.
|
|
55
57
|
(type.startsWith('application/') &&
|
|
56
58
|
(type.endsWith('+json') || type.endsWith('+xml')))
|
|
@@ -69,8 +71,8 @@ export type ContentKind = 'markdown' | 'html' | 'text' | 'document' | 'asset'
|
|
|
69
71
|
/** What the fetch layer should do with a body, decided from the header alone. */
|
|
70
72
|
export function classifyContentType(contentType: string | null): ContentKind {
|
|
71
73
|
const type = normalizeContentType(contentType)
|
|
72
|
-
if (MARKDOWN_CONTENT_TYPES
|
|
73
|
-
if (HTML_CONTENT_TYPES
|
|
74
|
+
if (isOneOf(MARKDOWN_CONTENT_TYPES, type)) return 'markdown'
|
|
75
|
+
if (isOneOf(HTML_CONTENT_TYPES, type)) return 'html'
|
|
74
76
|
if (CONVERTIBLE_DOCUMENT_EXTENSIONS.has(type)) return 'document'
|
|
75
77
|
if (isTextualContentType(type)) return 'text'
|
|
76
78
|
return 'asset'
|