website-to-markdown 1.1.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +135 -5
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +3 -1
  4. package/dist/cli.js.map +1 -1
  5. package/dist/content-type.d.ts.map +1 -1
  6. package/dist/content-type.js +9 -8
  7. package/dist/content-type.js.map +1 -1
  8. package/dist/crawl-snapshot.d.ts +2 -1
  9. package/dist/crawl-snapshot.d.ts.map +1 -1
  10. package/dist/crawl-snapshot.js +15 -8
  11. package/dist/crawl-snapshot.js.map +1 -1
  12. package/dist/fetch-page.d.ts +38 -22
  13. package/dist/fetch-page.d.ts.map +1 -1
  14. package/dist/fetch-page.js +209 -167
  15. package/dist/fetch-page.js.map +1 -1
  16. package/dist/hash.d.ts +3 -7
  17. package/dist/hash.d.ts.map +1 -1
  18. package/dist/hash.js +3 -7
  19. package/dist/hash.js.map +1 -1
  20. package/dist/html-metadata.d.ts.map +1 -1
  21. package/dist/html-metadata.js +16 -20
  22. package/dist/html-metadata.js.map +1 -1
  23. package/dist/index.d.ts +3 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/limit.d.ts +1 -1
  28. package/dist/limit.js +1 -1
  29. package/dist/limit.js.map +1 -1
  30. package/dist/markdown-refs.d.ts +8 -0
  31. package/dist/markdown-refs.d.ts.map +1 -1
  32. package/dist/markdown-refs.js +104 -59
  33. package/dist/markdown-refs.js.map +1 -1
  34. package/dist/memory-limit.d.ts +1 -1
  35. package/dist/memory-limit.js +1 -1
  36. package/dist/memory-limit.js.map +1 -1
  37. package/dist/net-guard.d.ts +38 -2
  38. package/dist/net-guard.d.ts.map +1 -1
  39. package/dist/net-guard.js +88 -17
  40. package/dist/net-guard.js.map +1 -1
  41. package/dist/rate-limit-retry.d.ts.map +1 -1
  42. package/dist/rate-limit-retry.js +12 -21
  43. package/dist/rate-limit-retry.js.map +1 -1
  44. package/dist/read-body.d.ts +12 -0
  45. package/dist/read-body.d.ts.map +1 -0
  46. package/dist/read-body.js +40 -0
  47. package/dist/read-body.js.map +1 -0
  48. package/dist/retry-after.d.ts.map +1 -1
  49. package/dist/retry-after.js +8 -12
  50. package/dist/retry-after.js.map +1 -1
  51. package/dist/schema/index.d.ts +9 -0
  52. package/dist/schema/index.d.ts.map +1 -1
  53. package/dist/schema/index.js +28 -0
  54. package/dist/schema/index.js.map +1 -1
  55. package/dist/sitemap/index.d.ts +13 -4
  56. package/dist/sitemap/index.d.ts.map +1 -1
  57. package/dist/sitemap/index.js +58 -86
  58. package/dist/sitemap/index.js.map +1 -1
  59. package/dist/testing/index.d.ts +16 -0
  60. package/dist/testing/index.d.ts.map +1 -0
  61. package/dist/testing/index.js +54 -0
  62. package/dist/testing/index.js.map +1 -0
  63. package/dist/unchanged-page.d.ts +9 -0
  64. package/dist/unchanged-page.d.ts.map +1 -0
  65. package/dist/unchanged-page.js +31 -0
  66. package/dist/unchanged-page.js.map +1 -0
  67. package/package.json +12 -7
  68. package/src/cli.ts +3 -1
  69. package/src/content-type.ts +11 -9
  70. package/src/crawl-snapshot.ts +21 -8
  71. package/src/fetch-page.ts +304 -221
  72. package/src/hash.ts +3 -7
  73. package/src/html-metadata.ts +20 -18
  74. package/src/index.ts +7 -0
  75. package/src/limit.ts +1 -1
  76. package/src/markdown-refs.ts +129 -57
  77. package/src/memory-limit.ts +1 -1
  78. package/src/net-guard.ts +126 -16
  79. package/src/rate-limit-retry.ts +20 -20
  80. package/src/read-body.ts +38 -0
  81. package/src/retry-after.ts +11 -18
  82. package/src/schema/index.ts +34 -0
  83. package/src/sitemap/index.ts +83 -86
  84. package/src/testing/index.ts +60 -0
  85. package/src/unchanged-page.ts +37 -0
@@ -1,8 +1,19 @@
1
- import { escapeRegExp } from 'hanzio';
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, accept) {
59
- return throughCrawlSnapshot(`text ${accept} ${url}`, () => fetchTextLive(url, accept));
69
+ function fetchText(url, request) {
70
+ return throughCrawlSnapshot(`text ${request.accept} ${url}`, () => fetchTextLive(url, request));
60
71
  }
61
- async function fetchTextLive(url, accept) {
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
- return await response.text();
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, 'text/plain,*/*;q=0.8');
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 (absolute.protocol !== 'http:' && absolute.protocol !== 'https:')
131
+ if (!isOneOf(['http:', 'https:'], absolute.protocol))
110
132
  continue;
111
- const href = absolute.toString();
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 { sitemaps, rules: parseRobotsRules(body) };
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 rules = [];
129
- let agents = [];
130
- let inRules = false;
131
- for (const raw of body.split(/\r?\n/)) {
132
- const match = /^\s*([a-z-]+)\s*:\s*(.*?)\s*$/i.exec(raw.replace(/#.*$/, ''));
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
- let path;
179
- try {
180
- const parsed = new URL(url);
181
- path = parsed.pathname + parsed.search;
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, 'application/xml,text/xml;q=0.9,*/*;q=0.8')));
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().replace(/\/+$/, '');
247
+ return trimTrailingSlash(parsed.toString());
276
248
  }
277
249
  catch {
278
- return url.replace(/\/+$/, '');
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['&amp;', '&'],\n\t['&lt;', '<'],\n\t['&gt;', '>'],\n\t['&quot;', '\"'],\n\t['&apos;', \"'\"],\n\t['&#39;', \"'\"]\n])\n\n/** One pass, so `&amp;lt;` decodes to `&lt;` 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":"AAAA,OAAO,EAAE,eAAe,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,QAAQ,CAAA;AACpE,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AAClE,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAA;AAC3D,OAAO,EAAc,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAC1D,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AACtD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAA;AAIrD,MAAM,gBAAgB,GAAG,MAAM,CAAA;AAC/B,MAAM,iBAAiB,GAAG,EAAE,CAAA;AAC5B,MAAM,QAAQ,GAAG,MAAM,CAAA;AACvB,2EAA2E;AAC3E,MAAM,gBAAgB,GAAG,GAAG,GAAG,IAAI,CAAA;AACnC;;;GAGG;AACH,MAAM,iBAAiB,GAAG,EAAE,GAAG,IAAI,GAAG,IAAI,CAAA;AAW1C,iFAAiF;AACjF,MAAM,kBAAkB,GAAG;IAC1B,cAAc;IACd,oBAAoB;IACpB,iBAAiB;CACjB,CAAA;AAED,MAAM,MAAM,GAAG,8BAA8B,CAAA;AAC7C,MAAM,YAAY,GAAG,0BAA0B,CAAA;AAC/C,MAAM,gBAAgB,GAAG,kCAAkC,CAAA;AAC3D,MAAM,YAAY,GAAG,6BAA6B,CAAA;AAClD,MAAM,UAAU,GAAG,qCAAqC,CAAA;AACxD,MAAM,iBAAiB,GAAG,iDAAiD,CAAA;AAE3E,MAAM,YAAY,GAAG,IAAI,GAAG,CAAC;IAC5B,CAAC,OAAO,EAAE,GAAG,CAAC;IACd,CAAC,MAAM,EAAE,GAAG,CAAC;IACb,CAAC,MAAM,EAAE,GAAG,CAAC;IACb,CAAC,QAAQ,EAAE,GAAG,CAAC;IACf,CAAC,QAAQ,EAAE,GAAG,CAAC;IACf,CAAC,OAAO,EAAE,GAAG,CAAC;CACd,CAAC,CAAA;AAEF,gEAAgE;AAChE,SAAS,iBAAiB,CAAC,KAAa;IACvC,OAAO,KAAK,CAAC,OAAO,CACnB,gCAAgC,EAChC,CAAC,MAAM,EAAE,EAAE,CAAC,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,MAAM,CAC9C,CAAA;AACF,CAAC;AAED,6EAA6E;AAC7E,MAAM,UAAU,eAAe,CAAC,GAAW;IAC1C,MAAM,KAAK,GAA8C,EAAE,CAAA;IAC3D,MAAM,MAAM,GAAa,EAAE,CAAA;IAE3B,KAAK,MAAM,KAAK,IAAI,GAAG,CAAC,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC;QAChD,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;QAC5B,MAAM,GAAG,GAAG,KAAK,CAAC,KAAK,CAAC,YAAY,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;QAC1C,IAAI,CAAC,GAAG;YAAE,SAAQ;QAClB,KAAK,CAAC,IAAI,CAAC;YACV,GAAG,EAAE,iBAAiB,CAAC,GAAG,CAAC;YAC3B,OAAO,EAAE,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,IAAI;SACrD,CAAC,CAAA;IACH,CAAC;IACD,KAAK,MAAM,KAAK,IAAI,GAAG,CAAC,QAAQ,CAAC,gBAAgB,CAAC,EAAE,CAAC;QACpD,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,YAAY,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;QAC9C,IAAI,GAAG;YAAE,MAAM,CAAC,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,CAAC,CAAA;IAC7C,CAAC;IACD,6EAA6E;IAC7E,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC/C,KAAK,MAAM,KAAK,IAAI,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;YAC1C,IAAI,KAAK,CAAC,CAAC,CAAC;gBACX,KAAK,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,iBAAiB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;QACjE,CAAC;IACF,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,CAAA;AACzB,CAAC;AAID,SAAS,SAAS,CAAC,GAAW,EAAE,OAAoB;IACnD,OAAO,oBAAoB,CAAC,QAAQ,OAAO,CAAC,MAAM,IAAI,GAAG,EAAE,EAAE,GAAG,EAAE,CACjE,aAAa,CAAC,GAAG,EAAE,OAAO,CAAC,CAC3B,CAAA;AACF,CAAC;AAED,KAAK,UAAU,aAAa,CAAC,GAAW,EAAE,OAAoB;IAC7D,IAAI,CAAC;QACJ,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,KAAK,CAAC,GAAG,EAAE;YACzC,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,gBAAgB,CAAC;YAC7C,OAAO,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,YAAY,EAAE,kBAAkB,EAAE;SACrE,CAAC,CAAA;QACF,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,IAAI,EAAE,CAAA;QACvD,4EAA4E;QAC5E,IAAI,CAAC,QAAQ,CAAC,EAAE,IAAI,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC;YAChD,KAAK,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;YAC5C,OAAO,IAAI,CAAA;QACZ,CAAC;QACD,OAAO,UAAU,CAAC,CAAC,MAAM,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAA;IACtE,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAA;IACZ,CAAC;AACF,CAAC;AAED,MAAM,cAAc,GAAG;IACtB,MAAM,EAAE,sBAAsB;IAC9B,QAAQ,EAAE,gBAAgB;CAC1B,CAAA;AACD,MAAM,eAAe,GAAG;IACvB,MAAM,EAAE,0CAA0C;IAClD,QAAQ,EAAE,iBAAiB;CAC3B,CAAA;AAED,SAAS,QAAQ,CAAC,UAAkB;IACnC,IAAI,CAAC;QACJ,OAAO,IAAI,GAAG,CAAC,UAAU,CAAC,CAAC,MAAM,CAAA;IAClC,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAA;IACZ,CAAC;AACF,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAChC,UAAkB,EAClB,UAA0B,EAAE;IAE5B,MAAM,QAAQ,GAAa,EAAE,CAAA;IAC7B,MAAM,MAAM,GAAG,QAAQ,CAAC,UAAU,CAAC,CAAA;IACnC,IAAI,CAAC,MAAM;QAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,EAAE,CAAA;IAC3C,MAAM,SAAS,GAAG,GAAG,MAAM,aAAa,CAAA;IACxC,MAAM,IAAI,GAAG,MAAM,SAAS,CAAC,SAAS,EAAE;QACvC,GAAG,cAAc;QACjB,KAAK,EAAE,OAAO,CAAC,KAAK,IAAI,YAAY;KACpC,CAAC,CAAA;IACF,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,EAAE,CAAA;IAEzC,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC;QACtD,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,CAAA;QACtB,IAAI,CAAC,KAAK;YAAE,SAAQ;QACpB,IAAI,CAAC;YACJ,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,KAAK,EAAE,SAAS,CAAC,CAAA;YAC1C,kEAAkE;YAClE,IAAI,CAAC,OAAO,CAAC,CAAC,OAAO,EAAE,QAAQ,CAAC,EAAE,QAAQ,CAAC,QAAQ,CAAC;gBAAE,SAAQ;YAC9D,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,EAAE,CAAC,CAAA;QACnC,CAAC;QAAC,MAAM,CAAC;YACR,8DAA8D;QAC/D,CAAC;IACF,CAAC;IACD,OAAO;QACN,QAAQ,EAAE,eAAe,CAAC,QAAQ,CAAC;QACnC,KAAK,EAAE,gBAAgB,CAAC,IAAI,CAAC;KAC7B,CAAA;AACF,CAAC;AAID;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAY;IAC5C,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,cAAc,CAAC,IAAI,CAAC,CAAA;IAChD,OAAO;QACN,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;QACrD,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,CAAC;KACzD,CAAA;AACF,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,KAAmB,EAAE,GAAW;IAC5D,OAAO,iBAAiB,CACvB;QACC,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC;QACrE,QAAQ,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC;KACzE,EACD,GAAG,CACH,CAAA;AACF,CAAC;AAED;;;;GAIG;AACH,KAAK,UAAU,eAAe,CAC7B,KAAe,EACf,KAA2D,EAC3D,IAAiB,EACjB,KAAY;IAEZ,IAAI,QAAQ,GAAG,KAAK,CAAA;IACpB,IAAI,KAAK,GAAG,CAAC,GAAG,KAAK,CAAC,CAAA;IAEtB,wEAAwE;IACxE,4EAA4E;IAC5E,2EAA2E;IAC3E,oEAAoE;IACpE,OAAO,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,IAAI,GAAG,QAAQ,EAAE,CAAC;QAClD,MAAM,KAAK,GAAa,EAAE,CAAA;QAC1B,KAAK,MAAM,UAAU,IAAI,KAAK,EAAE,CAAC;YAChC,IAAI,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,IAAI,CAAC,IAAI,IAAI,iBAAiB;gBAAE,SAAQ;YACpE,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,CAAA;YACpB,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAA;QACvB,CAAC;QACD,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,GAAG,CAC/B,KAAK,CAAC,GAAG,CAAC,CAAC,UAAU,EAAE,EAAE,CACxB,SAAS,CAAC,UAAU,EAAE,EAAE,GAAG,eAAe,EAAE,KAAK,EAAE,CAAC,CACpD,CACD,CAAA;QACD,MAAM,IAAI,GAAa,EAAE,CAAA;QACzB,KAAK,MAAM,GAAG,IAAI,MAAM,EAAE,CAAC;YAC1B,IAAI,CAAC,GAAG;gBAAE,SAAQ;YAClB,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,eAAe,CAAC,GAAG,CAAC,CAAA;YAC9C,yEAAyE;YACzE,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;gBAAE,SAAQ;YACvD,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;gBAAE,QAAQ,GAAG,IAAI,CAAA;YACrC,IAAI,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,CAAA;YACpB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;gBAC1B,IAAI,KAAK,CAAC,IAAI,IAAI,QAAQ;oBAAE,MAAK;gBACjC,uEAAuE;gBACvE,+CAA+C;gBAC/C,MAAM,GAAG,GAAG,mBAAmB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;gBACzC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC;oBAAE,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAA;YAC1C,CAAC;QACF,CAAC;QACD,KAAK,GAAG,IAAI,CAAA;IACb,CAAC;IACD,OAAO,QAAQ,CAAA;AAChB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACxC,UAAkB,EAClB,cAAwB,EACxB,UAA0B,EAAE;IAE5B,MAAM,MAAM,GAAG,QAAQ,CAAC,UAAU,CAAC,CAAA;IACnC,IAAI,CAAC,MAAM;QAAE,OAAO,EAAE,CAAA;IACtB,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,YAAY,CAAA;IAC3C,MAAM,KAAK,GAAG,IAAI,GAAG,EAAmD,CAAA;IACxE,6EAA6E;IAC7E,sBAAsB;IACtB,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAA;IAE9B,4EAA4E;IAC5E,0EAA0E;IAC1E,IACC,cAAc,CAAC,MAAM,GAAG,CAAC;QACzB,CAAC,MAAM,eAAe,CAAC,cAAc,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,EAC1D,CAAC;QACF,OAAO,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,CAAA;IAC3B,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,kBAAkB,EAAE,CAAC;QACvC,IAAI,MAAM,eAAe,CAAC,CAAC,GAAG,MAAM,GAAG,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,CAAC;YAAE,MAAK;IAC3E,CAAC;IACD,OAAO,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,CAAA;AAC3B,CAAC;AAED,gEAAgE;AAChE,MAAM,UAAU,mBAAmB,CAAC,GAAW;IAC9C,IAAI,CAAC;QACJ,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAA;QAC3B,MAAM,CAAC,IAAI,GAAG,EAAE,CAAA;QAChB,OAAO,iBAAiB,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAA;IAC5C,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,iBAAiB,CAAC,GAAG,CAAC,CAAA;IAC9B,CAAC;AACF,CAAC","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['&amp;', '&'],\n\t['&lt;', '<'],\n\t['&gt;', '>'],\n\t['&quot;', '\"'],\n\t['&apos;', \"'\"],\n\t['&#39;', \"'\"]\n])\n\n/** One pass, so `&amp;lt;` decodes to `&lt;` 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": "1.1.1",
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": "tsc --noEmit",
58
- "lint": "bun run typecheck && biome check",
59
- "lint:write": "bun run typecheck && biome check --write",
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.3.0",
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.3.0",
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.2"
90
+ "packageManager": "bun@1.4.4"
86
91
  }
package/src/cli.ts CHANGED
@@ -63,7 +63,9 @@ export const cli = defineCli({
63
63
  cloudflare: {
64
64
  apiToken: CLOUDFLARE_API_TOKEN,
65
65
  accountId: CLOUDFLARE_ACCOUNT_ID
66
- }
66
+ },
67
+ // Run by a person on their own machine, so a dev server is fair game.
68
+ allowLocalhost: true
67
69
  })
68
70
 
69
71
  ctx.stderr.write(`Fetching ${url}…\n`)
@@ -1,17 +1,19 @@
1
- const MARKDOWN_CONTENT_TYPES = new Set([
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 = new Set(['text/html', 'application/xhtml+xml'])
9
+ const HTML_CONTENT_TYPES = ['text/html', 'application/xhtml+xml'] as const
8
10
 
9
- const TEXTUAL_CONTENT_TYPES = new Set([
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 | null | undefined) {
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.has(type) ||
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.has(type)) return 'markdown'
73
- if (HTML_CONTENT_TYPES.has(type)) return 'html'
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'