altalandi 0.4.0 → 0.6.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 +114 -0
- package/altalandi.mjs +325 -223
- package/auth.mjs +223 -215
- package/batch.mjs +620 -0
- package/client.d.ts +9 -0
- package/client.mjs +13 -2
- package/package.json +5 -2
- package/report.mjs +288 -1
- package/segment.mjs +1059 -0
- package/skill/SKILL.md +112 -117
- package/update.mjs +3 -1
- package/web.mjs +558 -0
package/web.mjs
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
// Web pages for `altalandi <url>`: fetch politely, keep the readable part, and
|
|
2
|
+
// turn it into Markdown that the folder pipeline then refines like any other
|
|
3
|
+
// document.
|
|
4
|
+
//
|
|
5
|
+
// readPage(url) one page → { url, title, markdown, links }
|
|
6
|
+
// readSite(url) up to maxPages same-origin pages under the URL's path,
|
|
7
|
+
// from /sitemap.xml or, without one, by following links
|
|
8
|
+
// htmlToMarkdown() main/article content (else body) without navigation,
|
|
9
|
+
// footers, scripts, styles, forms or hidden elements
|
|
10
|
+
//
|
|
11
|
+
// Every request names altalandi in its User-Agent, and robots.txt is obeyed: a
|
|
12
|
+
// Disallow for altalandi or for * stops the fetch, and Crawl-delay is honoured.
|
|
13
|
+
// Pages are fetched one at a time.
|
|
14
|
+
//
|
|
15
|
+
// Zero dependencies, like the rest of the CLI.
|
|
16
|
+
|
|
17
|
+
import { decodeEntities, tagAttributes, tokenizeHtml, VOID } from "./segment.mjs";
|
|
18
|
+
|
|
19
|
+
export const ROBOTS_AGENT = "altalandi";
|
|
20
|
+
export const userAgent = (version) => `altalandi-cli/${version} (+https://altalandi.is)`;
|
|
21
|
+
|
|
22
|
+
const MAX_PAGE_BYTES = 5 * 1024 * 1024;
|
|
23
|
+
const PAGE_TIMEOUT_MS = 30_000;
|
|
24
|
+
const MAX_CRAWL_DELAY_MS = 30_000;
|
|
25
|
+
const MAX_CHILD_SITEMAPS = 10;
|
|
26
|
+
const NOT_A_PAGE = /\.(?:pdf|png|jpe?g|gif|svg|webp|avif|ico|bmp|tiff?|zip|gz|tgz|rar|7z|tar|mp3|mp4|m4a|wav|ogg|webm|mov|avi|css|js|mjs|json|xml|rss|atom|txt|csv|xlsx?|docx?|pptx?|odt|woff2?|ttf|otf|eot|exe|dmg|apk|wasm)$/i;
|
|
27
|
+
|
|
28
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
29
|
+
|
|
30
|
+
function decodeBody(buf, contentType) {
|
|
31
|
+
let label = /charset\s*=\s*["']?([\w-]+)/i.exec(contentType || "")?.[1];
|
|
32
|
+
if (!label) {
|
|
33
|
+
const head = buf.subarray(0, 4096).toString("latin1");
|
|
34
|
+
label = /<meta[^>]+charset\s*=\s*["']?\s*([\w-]+)/i.exec(head)?.[1];
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
return new TextDecoder(label || "utf-8").decode(buf);
|
|
38
|
+
} catch {
|
|
39
|
+
return new TextDecoder("utf-8").decode(buf);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** GET with our User-Agent, a timeout and a size limit; the body is decoded
|
|
44
|
+
* with the page's own charset. */
|
|
45
|
+
async function get(url, io, { timeoutMs = PAGE_TIMEOUT_MS } = {}) {
|
|
46
|
+
const fetchImpl = io.fetch || globalThis.fetch;
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetchImpl(url, {
|
|
51
|
+
headers: {
|
|
52
|
+
"user-agent": io.userAgent || userAgent("0"),
|
|
53
|
+
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
54
|
+
},
|
|
55
|
+
redirect: "follow",
|
|
56
|
+
signal: controller.signal,
|
|
57
|
+
});
|
|
58
|
+
const type = res.headers?.get?.("content-type") || "";
|
|
59
|
+
if (Number(res.headers?.get?.("content-length") || 0) > MAX_PAGE_BYTES) throw new Error("larger than 5 MB");
|
|
60
|
+
const buf = typeof res.arrayBuffer === "function"
|
|
61
|
+
? Buffer.from(await res.arrayBuffer())
|
|
62
|
+
: Buffer.from(String((await res.text()) ?? ""), "utf8");
|
|
63
|
+
if (buf.length > MAX_PAGE_BYTES) throw new Error("larger than 5 MB");
|
|
64
|
+
return { status: res.status, ok: res.ok ?? (res.status >= 200 && res.status < 300), url: res.url || url, type, body: decodeBody(buf, type) };
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (controller.signal.aborted) throw new Error(`timed out after ${timeoutMs / 1000} s`);
|
|
67
|
+
throw e;
|
|
68
|
+
} finally {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── robots.txt ──────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/** Groups of a robots.txt: { agents, rules: [{ allow, path }], crawlDelay }. */
|
|
76
|
+
export function parseRobots(text) {
|
|
77
|
+
const groups = [];
|
|
78
|
+
let group = null;
|
|
79
|
+
let agentLine = false;
|
|
80
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
81
|
+
const line = raw.replace(/#.*$/, "").trim();
|
|
82
|
+
const m = /^([A-Za-z-]+)\s*:\s*(.*)$/.exec(line);
|
|
83
|
+
if (!m) continue;
|
|
84
|
+
const field = m[1].toLowerCase();
|
|
85
|
+
const value = m[2].trim();
|
|
86
|
+
if (field === "user-agent") {
|
|
87
|
+
if (!group || !agentLine) groups.push(group = { agents: [], rules: [], crawlDelay: null });
|
|
88
|
+
group.agents.push(value.toLowerCase());
|
|
89
|
+
agentLine = true;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
agentLine = false;
|
|
93
|
+
if (!group) continue;
|
|
94
|
+
if (field === "disallow" && value) group.rules.push({ allow: false, path: value });
|
|
95
|
+
else if (field === "allow" && value) group.rules.push({ allow: true, path: value });
|
|
96
|
+
else if (field === "crawl-delay" && Number.isFinite(Number(value))) group.crawlDelay = Number(value);
|
|
97
|
+
}
|
|
98
|
+
return groups;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const ours = (agent) => agent !== "*" && (agent.startsWith(ROBOTS_AGENT) || agent === `${ROBOTS_AGENT}-cli`);
|
|
102
|
+
|
|
103
|
+
function ruleMatches(rule, target) {
|
|
104
|
+
const anchored = rule.path.endsWith("$");
|
|
105
|
+
const body = anchored ? rule.path.slice(0, -1) : rule.path;
|
|
106
|
+
const pattern = body.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
107
|
+
const m = new RegExp(`^${pattern}${anchored ? "$" : ""}`).exec(target);
|
|
108
|
+
return m ? m[0].length : -1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function groupAllows(groups, target) {
|
|
112
|
+
// The longest matching rule wins; on a tie, Allow wins (RFC 9309).
|
|
113
|
+
let best = { length: -1, allow: true };
|
|
114
|
+
for (const g of groups) {
|
|
115
|
+
for (const rule of g.rules) {
|
|
116
|
+
const length = ruleMatches(rule, target);
|
|
117
|
+
if (length > best.length || (length === best.length && length >= 0 && rule.allow)) best = { length, allow: rule.allow };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return best.allow;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** May altalandi fetch this URL? Both the altalandi group and the * group must
|
|
124
|
+
* allow it. */
|
|
125
|
+
export function robotsAllows(groups, url) {
|
|
126
|
+
const u = new URL(url);
|
|
127
|
+
const target = u.pathname + u.search;
|
|
128
|
+
const mine = groups.filter((g) => g.agents.some(ours));
|
|
129
|
+
const star = groups.filter((g) => g.agents.includes("*"));
|
|
130
|
+
return (!mine.length || groupAllows(mine, target)) && (!star.length || groupAllows(star, target));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function crawlDelayMs(groups) {
|
|
134
|
+
const delays = groups
|
|
135
|
+
.filter((g) => g.agents.some((a) => a === "*" || ours(a)))
|
|
136
|
+
.map((g) => g.crawlDelay)
|
|
137
|
+
.filter((d) => d != null);
|
|
138
|
+
return delays.length ? Math.min(MAX_CRAWL_DELAY_MS, Math.max(...delays) * 1000) : 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function robotsFor(url, io, cache) {
|
|
142
|
+
const origin = new URL(url).origin;
|
|
143
|
+
if (!cache.has(origin)) {
|
|
144
|
+
let groups;
|
|
145
|
+
try {
|
|
146
|
+
const res = await get(`${origin}/robots.txt`, io, { timeoutMs: 15_000 });
|
|
147
|
+
if (res.status >= 200 && res.status < 300) groups = parseRobots(res.body);
|
|
148
|
+
else if (res.status >= 400 && res.status < 500) groups = []; // no robots.txt: nothing is disallowed
|
|
149
|
+
else groups = { error: `HTTP ${res.status}` };
|
|
150
|
+
} catch (e) {
|
|
151
|
+
groups = { error: e.message };
|
|
152
|
+
}
|
|
153
|
+
cache.set(origin, groups);
|
|
154
|
+
}
|
|
155
|
+
const groups = cache.get(origin);
|
|
156
|
+
// A robots.txt that exists but cannot be read is treated as "disallow all".
|
|
157
|
+
if (!Array.isArray(groups)) throw new Error(`could not read ${origin}/robots.txt (${groups.error}), so nothing was fetched from ${origin}`);
|
|
158
|
+
return groups;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── sitemap ─────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/** Page URLs and child sitemaps listed in a sitemap or sitemap index. */
|
|
164
|
+
export function parseSitemap(xml) {
|
|
165
|
+
const locs = (tag) => [...String(xml).matchAll(new RegExp(`<(?:\\w+:)?${tag}\\b[^>]*>([\\s\\S]*?)<\\/(?:\\w+:)?${tag}>`, "gi"))]
|
|
166
|
+
.map((m) => /<(?:\w+:)?loc>\s*(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?\s*<\/(?:\w+:)?loc>/i.exec(m[1])?.[1])
|
|
167
|
+
.filter(Boolean)
|
|
168
|
+
.map((s) => decodeEntities(s.trim()));
|
|
169
|
+
return { pages: locs("url"), sitemaps: locs("sitemap") };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function readSitemap(origin, io) {
|
|
173
|
+
let res;
|
|
174
|
+
try { res = await get(`${origin}/sitemap.xml`, io, { timeoutMs: 15_000 }); } catch { return null; }
|
|
175
|
+
if (!res.ok || !/<(?:\w+:)?(?:urlset|sitemapindex)\b/i.test(res.body)) return null;
|
|
176
|
+
const { pages, sitemaps } = parseSitemap(res.body);
|
|
177
|
+
const sameOrigin = (u) => { try { return new URL(u).origin === origin; } catch { return false; } };
|
|
178
|
+
for (const child of sitemaps.filter((u) => sameOrigin(u) && !/\.gz$/i.test(u)).slice(0, MAX_CHILD_SITEMAPS)) {
|
|
179
|
+
try {
|
|
180
|
+
const sub = await get(child, io, { timeoutMs: 15_000 });
|
|
181
|
+
if (sub.ok) pages.push(...parseSitemap(sub.body).pages);
|
|
182
|
+
} catch { /* one unreadable child sitemap does not end the run */ }
|
|
183
|
+
}
|
|
184
|
+
return pages.filter(sameOrigin);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── HTML → tree ─────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
// Elements whose start closes an open <p> (the HTML parsing rules, simplified).
|
|
190
|
+
const CLOSES_P = new Set([
|
|
191
|
+
"address", "article", "aside", "blockquote", "details", "dialog", "div", "dl", "fieldset", "figcaption",
|
|
192
|
+
"figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "hr", "main", "menu",
|
|
193
|
+
"nav", "ol", "p", "pre", "section", "table", "ul",
|
|
194
|
+
]);
|
|
195
|
+
const IMPLIED = {
|
|
196
|
+
li: [["li"], ["ul", "ol"]],
|
|
197
|
+
dt: [["dt", "dd"], ["dl"]],
|
|
198
|
+
dd: [["dt", "dd"], ["dl"]],
|
|
199
|
+
tr: [["tr", "td", "th"], ["table", "thead", "tbody", "tfoot"]],
|
|
200
|
+
td: [["td", "th"], ["tr", "table"]],
|
|
201
|
+
th: [["td", "th"], ["tr", "table"]],
|
|
202
|
+
thead: [["thead", "tbody", "tfoot", "tr", "td", "th"], ["table"]],
|
|
203
|
+
tbody: [["thead", "tbody", "tfoot", "tr", "td", "th"], ["table"]],
|
|
204
|
+
tfoot: [["thead", "tbody", "tfoot", "tr", "td", "th"], ["table"]],
|
|
205
|
+
option: [["option"], ["select", "datalist", "optgroup"]],
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
function closeOpen(stack, names, scope) {
|
|
209
|
+
for (let k = stack.length - 1; k > 0; k--) {
|
|
210
|
+
if (scope.includes(stack[k].name)) return;
|
|
211
|
+
if (names.includes(stack[k].name)) { stack.length = k; return; }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A forgiving tree: { name, attrs, children }, text as { name: "#text", text }. */
|
|
216
|
+
export function parseHtml(src) {
|
|
217
|
+
const root = { name: "#document", attrs: {}, children: [] };
|
|
218
|
+
const stack = [root];
|
|
219
|
+
const top = () => stack[stack.length - 1];
|
|
220
|
+
for (const t of tokenizeHtml(src)) {
|
|
221
|
+
if (t.type === "text") top().children.push({ name: "#text", text: decodeEntities(src.slice(t.start, t.end)) });
|
|
222
|
+
else if (t.type === "rawtext" && (t.parent === "title" || t.parent === "textarea")) {
|
|
223
|
+
top().children.push({ name: "#text", text: decodeEntities(src.slice(t.start, t.end)) });
|
|
224
|
+
} else if (t.type === "tag") {
|
|
225
|
+
if (t.closing) {
|
|
226
|
+
for (let k = stack.length - 1; k > 0; k--) if (stack[k].name === t.name) { stack.length = k; break; }
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (CLOSES_P.has(t.name)) closeOpen(stack, ["p"], ["button", "td", "th", "table", "li"]);
|
|
230
|
+
if (IMPLIED[t.name]) closeOpen(stack, ...IMPLIED[t.name]);
|
|
231
|
+
const el = { name: t.name, attrs: tagAttributes(t.raw), children: [] };
|
|
232
|
+
top().children.push(el);
|
|
233
|
+
if (!VOID.has(t.name) && !t.selfClosing) stack.push(el);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return root;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function find(node, pred, { first = false, nested = true } = {}) {
|
|
240
|
+
const out = [];
|
|
241
|
+
const walk = (n) => {
|
|
242
|
+
for (const c of n.children || []) {
|
|
243
|
+
if (c.name[0] === "#") continue;
|
|
244
|
+
if (pred(c)) {
|
|
245
|
+
out.push(c);
|
|
246
|
+
if (first) return true;
|
|
247
|
+
if (!nested) continue;
|
|
248
|
+
}
|
|
249
|
+
if (walk(c) && first) return true;
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
};
|
|
253
|
+
walk(node);
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const textOf = (node) => (node.name === "#text" ? node.text : (node.children || []).map(textOf).join(""));
|
|
258
|
+
|
|
259
|
+
// ── HTML → Markdown ─────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
const ALWAYS_DROP = new Set([
|
|
262
|
+
"script", "style", "noscript", "template", "svg", "canvas", "iframe", "object", "embed", "nav", "footer",
|
|
263
|
+
"form", "button", "select", "input", "textarea", "dialog", "head", "link", "meta", "map", "audio", "video",
|
|
264
|
+
"source", "track",
|
|
265
|
+
]);
|
|
266
|
+
const CHROME = /(?:^|[\s_-])(?:cookie|consent|breadcrumbs?|skip-link|sr-only|visually-hidden|screen-reader-text)(?:$|[\s_-])/i;
|
|
267
|
+
const INLINE = new Set([
|
|
268
|
+
"a", "abbr", "b", "bdi", "bdo", "big", "cite", "code", "data", "del", "dfn", "em", "font", "i", "img", "ins",
|
|
269
|
+
"kbd", "label", "mark", "nobr", "q", "s", "samp", "small", "span", "strike", "strong", "sub", "sup", "time",
|
|
270
|
+
"tt", "u", "var", "wbr", "br",
|
|
271
|
+
]);
|
|
272
|
+
const BR = "\u0000br\u0000";
|
|
273
|
+
|
|
274
|
+
function dropped(el, ctx) {
|
|
275
|
+
const role = (el.attrs.role || "").toLowerCase();
|
|
276
|
+
return ALWAYS_DROP.has(el.name)
|
|
277
|
+
|| el.attrs.hidden !== undefined
|
|
278
|
+
|| el.attrs["aria-hidden"] === "true"
|
|
279
|
+
|| /^(?:navigation|contentinfo|search)$/.test(role)
|
|
280
|
+
|| CHROME.test(`${el.attrs.class || ""} ${el.attrs.id || ""}`)
|
|
281
|
+
// Without a <main> or <article>, the page's own header and sidebars are
|
|
282
|
+
// site chrome, not content.
|
|
283
|
+
|| (ctx.fallback && (el.name === "header" || el.name === "aside" || /^(?:banner|complementary)$/.test(role)));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Text of an element without what dropped() removes (a "Copy" button in a <pre>).
|
|
287
|
+
const visibleText = (node, ctx) => (node.name === "#text" ? node.text
|
|
288
|
+
: node.name[0] === "#" || dropped(node, ctx) ? "" : (node.children || []).map((c) => visibleText(c, ctx)).join(""));
|
|
289
|
+
|
|
290
|
+
// Characters that would turn plain text into Markdown syntax.
|
|
291
|
+
const escapeText = (s) => s
|
|
292
|
+
.replace(/[\\`*[\]<]/g, (c) => `\\${c}`)
|
|
293
|
+
.replace(/(?<![A-Za-z0-9])_|_(?![A-Za-z0-9])/g, "\\_");
|
|
294
|
+
|
|
295
|
+
function escapeLineStart(line) {
|
|
296
|
+
if (/^(?:#{1,6}(?:\s|$)|[-+](?:\s|$)|>|={3,}\s*$)/.test(line)) return `\\${line}`;
|
|
297
|
+
const num = /^(\d{1,9})([.)])(?:\s|$)/.exec(line);
|
|
298
|
+
return num ? `${num[1]}\\${line.slice(num[1].length)}` : line;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function absolute(href, ctx) {
|
|
302
|
+
try {
|
|
303
|
+
return new URL(href, ctx.base).href.replace(/\(/g, "%28").replace(/\)/g, "%29");
|
|
304
|
+
} catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const wrapWith = (s, mark) => {
|
|
310
|
+
const m = /^(\s*)([\s\S]*?)(\s*)$/.exec(s);
|
|
311
|
+
return m[2] ? `${m[1]}${mark}${m[2]}${mark}${m[3]}` : s;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
function codeSpan(text) {
|
|
315
|
+
const s = text.replace(/\s+/g, " ").trim();
|
|
316
|
+
if (!s) return "";
|
|
317
|
+
const longest = Math.max(0, ...(s.match(/`+/g) || []).map((r) => r.length));
|
|
318
|
+
const ticks = "`".repeat(longest + 1);
|
|
319
|
+
const pad = s.startsWith("`") || s.endsWith("`") ? " " : "";
|
|
320
|
+
return `${ticks}${pad}${s}${pad}${ticks}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function inline(node, ctx) {
|
|
324
|
+
if (node.name === "#text") return escapeText(node.text);
|
|
325
|
+
if (node.name[0] === "#" || dropped(node, ctx)) return "";
|
|
326
|
+
const inner = () => node.children.map((c) => inline(c, ctx)).join("");
|
|
327
|
+
switch (node.name) {
|
|
328
|
+
case "br": return BR;
|
|
329
|
+
case "img": {
|
|
330
|
+
const src = node.attrs.src ? absolute(node.attrs.src, ctx) : null;
|
|
331
|
+
return src ? `` : "";
|
|
332
|
+
}
|
|
333
|
+
case "a": {
|
|
334
|
+
const text = inner().trim();
|
|
335
|
+
const href = node.attrs.href;
|
|
336
|
+
if (!text) return "";
|
|
337
|
+
if (!href || href.startsWith("#") || /^\s*javascript:/i.test(href)) return text;
|
|
338
|
+
const target = absolute(href, ctx);
|
|
339
|
+
return target ? `[${text}](${target})` : text;
|
|
340
|
+
}
|
|
341
|
+
case "strong": case "b": return wrapWith(inner(), "**");
|
|
342
|
+
case "em": case "i": return wrapWith(inner(), "*");
|
|
343
|
+
case "del": case "s": case "strike": return wrapWith(inner(), "~~");
|
|
344
|
+
case "code": case "kbd": case "samp": case "var": case "tt": return codeSpan(visibleText(node, ctx));
|
|
345
|
+
default: return inner();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function paragraph(raw) {
|
|
350
|
+
const s = raw.replace(/\s+/g, " ")
|
|
351
|
+
.replace(new RegExp(`\\s*${BR}\\s*`, "g"), BR)
|
|
352
|
+
.replace(new RegExp(`^(?:${BR}|\\s)+|(?:${BR}|\\s)+$`, "g"), "");
|
|
353
|
+
return s ? s.split(BR).map((line) => escapeLineStart(line.trim())).join("\\\n") : "";
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function blocks(node, ctx) {
|
|
357
|
+
const out = [];
|
|
358
|
+
let run = [];
|
|
359
|
+
const flush = () => {
|
|
360
|
+
const p = paragraph(run.join(""));
|
|
361
|
+
if (p) out.push(p);
|
|
362
|
+
run = [];
|
|
363
|
+
};
|
|
364
|
+
for (const child of node.children || []) {
|
|
365
|
+
if (child.name === "#text") { run.push(escapeText(child.text)); continue; }
|
|
366
|
+
if (child.name[0] === "#" || dropped(child, ctx)) continue;
|
|
367
|
+
if (INLINE.has(child.name)) { run.push(inline(child, ctx)); continue; }
|
|
368
|
+
flush();
|
|
369
|
+
out.push(...block(child, ctx));
|
|
370
|
+
}
|
|
371
|
+
flush();
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const indentBlock = (s, pad) => s.split("\n").map((l) => (l ? pad + l : l)).join("\n");
|
|
376
|
+
const isList = (s) => /^(?:- |\d+\. )/.test(s);
|
|
377
|
+
|
|
378
|
+
function block(el, ctx) {
|
|
379
|
+
const h = /^h([1-6])$/.exec(el.name);
|
|
380
|
+
if (h) {
|
|
381
|
+
const text = paragraph(el.children.map((c) => inline(c, ctx)).join("")).replace(/\\\n/g, " ");
|
|
382
|
+
return text ? [`${"#".repeat(Number(h[1]))} ${text}`] : [];
|
|
383
|
+
}
|
|
384
|
+
switch (el.name) {
|
|
385
|
+
case "p": {
|
|
386
|
+
const p = paragraph(el.children.map((c) => inline(c, ctx)).join(""));
|
|
387
|
+
return p ? [p] : [];
|
|
388
|
+
}
|
|
389
|
+
case "ul":
|
|
390
|
+
case "ol": {
|
|
391
|
+
let n = Number.parseInt(el.attrs.start, 10) || 1;
|
|
392
|
+
const items = el.children.filter((c) => c.name === "li").map((li) => {
|
|
393
|
+
const marker = el.name === "ol" ? `${n++}. ` : "- ";
|
|
394
|
+
const body = blocks(li, ctx);
|
|
395
|
+
if (!body.length) return null;
|
|
396
|
+
const pad = " ".repeat(marker.length);
|
|
397
|
+
let s = marker + indentBlock(body[0], pad).slice(pad.length);
|
|
398
|
+
for (const b of body.slice(1)) s += (isList(b) ? "\n" : "\n\n") + indentBlock(b, pad);
|
|
399
|
+
return s;
|
|
400
|
+
}).filter(Boolean);
|
|
401
|
+
return items.length ? [items.join("\n")] : [];
|
|
402
|
+
}
|
|
403
|
+
case "blockquote": {
|
|
404
|
+
const inner = blocks(el, ctx).join("\n\n");
|
|
405
|
+
return inner ? [inner.split("\n").map((l) => (l ? `> ${l}` : ">")).join("\n")] : [];
|
|
406
|
+
}
|
|
407
|
+
case "pre": {
|
|
408
|
+
const code = visibleText(el, ctx).replace(/^\n/, "").replace(/\s+$/, "");
|
|
409
|
+
if (!code) return [];
|
|
410
|
+
const lang = /(?:^|\s)(?:language|lang)-([\w+-]+)/.exec(`${el.attrs.class || ""} ${find(el, (c) => c.name === "code", { first: true })[0]?.attrs.class || ""}`)?.[1] || "";
|
|
411
|
+
const longest = Math.max(2, ...(code.match(/`{3,}/g) || []).map((r) => r.length));
|
|
412
|
+
const fence = "`".repeat(longest + 1);
|
|
413
|
+
return [`${fence}${lang}\n${code}\n${fence}`];
|
|
414
|
+
}
|
|
415
|
+
case "table": {
|
|
416
|
+
const rows = find(el, (c) => c.name === "tr", { nested: false }).map((tr) => tr.children
|
|
417
|
+
.filter((c) => c.name === "td" || c.name === "th")
|
|
418
|
+
.map((cell) => paragraph(cell.children.map((c) => inline(c, ctx)).join("")).replace(/\\\n/g, " ").replace(/\|/g, "\\|")));
|
|
419
|
+
const width = Math.max(0, ...rows.map((r) => r.length));
|
|
420
|
+
if (!width) return [];
|
|
421
|
+
const line = (cells) => `| ${[...cells, ...Array(width - cells.length).fill("")].join(" | ")} |`;
|
|
422
|
+
return [[line(rows[0]), line(Array(width).fill("---")), ...rows.slice(1).map(line)].join("\n")];
|
|
423
|
+
}
|
|
424
|
+
case "hr": return ["---"];
|
|
425
|
+
case "dl": return el.children
|
|
426
|
+
.filter((c) => c.name === "dt" || c.name === "dd")
|
|
427
|
+
.flatMap((c) => (c.name === "dt"
|
|
428
|
+
? [wrapWith(paragraph(c.children.map((x) => inline(x, ctx)).join("")), "**")].filter(Boolean)
|
|
429
|
+
: blocks(c, ctx)));
|
|
430
|
+
default: return blocks(el, ctx); // div, section, article, li outside a list, figure …
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The readable part of a page as Markdown, with the page title. */
|
|
435
|
+
export function htmlToMarkdown(html, pageUrl) {
|
|
436
|
+
const doc = parseHtml(html);
|
|
437
|
+
const baseHref = find(doc, (el) => el.name === "base" && el.attrs.href, { first: true })[0]?.attrs.href;
|
|
438
|
+
let base = pageUrl;
|
|
439
|
+
try { if (baseHref) base = new URL(baseHref, pageUrl).href; } catch { /* keep the page URL */ }
|
|
440
|
+
const title = textOf(find(doc, (el) => el.name === "title", { first: true })[0] || { name: "#text", text: "" }).replace(/\s+/g, " ").trim();
|
|
441
|
+
|
|
442
|
+
const main = find(doc, (el) => el.name === "main" || el.attrs.role === "main", { first: true })[0];
|
|
443
|
+
const articles = main ? [] : find(doc, (el) => el.name === "article", { nested: false });
|
|
444
|
+
const body = find(doc, (el) => el.name === "body", { first: true })[0] || doc;
|
|
445
|
+
const ctx = { base, fallback: !main && !articles.length };
|
|
446
|
+
const root = main || (articles.length === 1 ? articles[0] : articles.length ? { name: "#group", attrs: {}, children: articles } : body);
|
|
447
|
+
|
|
448
|
+
const parts = root.name === "#group" ? root.children.flatMap((a) => blocks(a, ctx)) : blocks(root, ctx);
|
|
449
|
+
if (title && !parts.some((p) => /^# /.test(p))) parts.unshift(`# ${escapeText(title)}`);
|
|
450
|
+
return { title, markdown: parts.join("\n\n") };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Links on a page, absolute, without fragments; rel=nofollow is respected. */
|
|
454
|
+
export function pageLinks(html, pageUrl) {
|
|
455
|
+
const doc = parseHtml(html);
|
|
456
|
+
const baseHref = find(doc, (el) => el.name === "base" && el.attrs.href, { first: true })[0]?.attrs.href;
|
|
457
|
+
let base = pageUrl;
|
|
458
|
+
try { if (baseHref) base = new URL(baseHref, pageUrl).href; } catch { /* keep the page URL */ }
|
|
459
|
+
const links = [];
|
|
460
|
+
for (const a of find(doc, (el) => el.name === "a" && el.attrs.href)) {
|
|
461
|
+
if (/(?:^|\s)nofollow(?:\s|$)/i.test(a.attrs.rel || "")) continue;
|
|
462
|
+
try {
|
|
463
|
+
const u = new URL(a.attrs.href, base);
|
|
464
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") continue;
|
|
465
|
+
u.hash = "";
|
|
466
|
+
links.push(u.href);
|
|
467
|
+
} catch { /* not a URL */ }
|
|
468
|
+
}
|
|
469
|
+
return links;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── pages and sites ─────────────────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
const withoutHash = (url) => { const u = new URL(url); u.hash = ""; return u.href; };
|
|
475
|
+
|
|
476
|
+
/** Fetch one page if robots.txt allows it. Throws with a readable reason. */
|
|
477
|
+
export async function readPage(url, io = {}, robots = new Map()) {
|
|
478
|
+
if (!robotsAllows(await robotsFor(url, io, robots), url)) throw new Error(`robots.txt does not allow altalandi to read ${url}`);
|
|
479
|
+
const res = await get(url, io);
|
|
480
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
|
|
481
|
+
if (res.url !== url && !robotsAllows(await robotsFor(res.url, io, robots), res.url)) {
|
|
482
|
+
throw new Error(`robots.txt does not allow altalandi to read ${res.url}`);
|
|
483
|
+
}
|
|
484
|
+
if (res.type && !/html|xml/i.test(res.type)) throw new Error(`${url} is not an HTML page (${res.type.split(";")[0]})`);
|
|
485
|
+
const { title, markdown } = htmlToMarkdown(res.body, res.url);
|
|
486
|
+
return { url: res.url, title, markdown, links: pageLinks(res.body, res.url) };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Pages of a site, starting with `url`: same origin, under the URL's folder
|
|
490
|
+
* (https://x.is/docs/intro covers /docs/…), from /sitemap.xml or, without
|
|
491
|
+
* one, by following links. Returns { pages, skipped, source }. */
|
|
492
|
+
export async function readSite(url, { maxPages = 20, io = {}, onPage } = {}) {
|
|
493
|
+
const robots = new Map();
|
|
494
|
+
const wait = io.sleep || sleep;
|
|
495
|
+
const first = await readPage(url, io, robots);
|
|
496
|
+
onPage?.(first);
|
|
497
|
+
const start = new URL(first.url);
|
|
498
|
+
const scope = start.pathname.slice(0, start.pathname.lastIndexOf("/") + 1) || "/";
|
|
499
|
+
const inScope = (link) => {
|
|
500
|
+
try {
|
|
501
|
+
const u = new URL(link);
|
|
502
|
+
return u.origin === start.origin && u.pathname.startsWith(scope) && !NOT_A_PAGE.test(u.pathname);
|
|
503
|
+
} catch {
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
const pages = [first];
|
|
508
|
+
const skipped = [];
|
|
509
|
+
const seen = new Set([withoutHash(url), withoutHash(first.url)]);
|
|
510
|
+
const listed = await readSitemap(start.origin, io);
|
|
511
|
+
const source = listed && listed.length ? "sitemap" : "links";
|
|
512
|
+
const queue = (source === "sitemap" ? listed : first.links).filter(inScope);
|
|
513
|
+
const delay = crawlDelayMs(await robotsFor(first.url, io, robots));
|
|
514
|
+
while (queue.length && pages.length < maxPages) {
|
|
515
|
+
const next = withoutHash(queue.shift());
|
|
516
|
+
if (seen.has(next)) continue;
|
|
517
|
+
seen.add(next);
|
|
518
|
+
if (!robotsAllows(await robotsFor(next, io, robots), next)) { skipped.push({ url: next, reason: "robots.txt" }); continue; }
|
|
519
|
+
if (delay) await wait(delay);
|
|
520
|
+
let page;
|
|
521
|
+
try {
|
|
522
|
+
page = await readPage(next, io, robots);
|
|
523
|
+
} catch (e) {
|
|
524
|
+
skipped.push({ url: next, reason: e.message });
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const landed = withoutHash(page.url);
|
|
528
|
+
if (landed !== next && seen.has(landed)) continue;
|
|
529
|
+
seen.add(landed);
|
|
530
|
+
if (!inScope(page.url)) { skipped.push({ url: next, reason: `redirected to ${page.url}` }); continue; }
|
|
531
|
+
pages.push(page);
|
|
532
|
+
onPage?.(page);
|
|
533
|
+
if (source === "links") for (const link of page.links) if (inScope(link) && !seen.has(withoutHash(link))) queue.push(link);
|
|
534
|
+
}
|
|
535
|
+
return { pages, skipped, source };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** A file name for a page: /docs/intro → docs/intro.md, / → index.md. */
|
|
539
|
+
export function pagePath(url) {
|
|
540
|
+
const u = new URL(url);
|
|
541
|
+
const decode = (s) => { try { return decodeURIComponent(s.replace(/\+/g, " ")); } catch { return s; } };
|
|
542
|
+
let path = decode(u.pathname).replace(/^\/+/, "");
|
|
543
|
+
if (!path || path.endsWith("/")) path += "index";
|
|
544
|
+
path = path.replace(/\.(?:html?|php|aspx?)$/i, "");
|
|
545
|
+
const segments = path.split("/").map(safeSegment).filter((s) => s && s !== "." && s !== "..");
|
|
546
|
+
let rel = segments.join("/") || "index";
|
|
547
|
+
if (u.search) rel += `-${safeSegment(decode(u.search.slice(1)).replace(/[=&;]/g, "-")).slice(0, 40)}`;
|
|
548
|
+
return `${rel}.md`;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** One path segment that is safe on Windows, macOS and Linux. */
|
|
552
|
+
export function safeSegment(s) {
|
|
553
|
+
const clean = String(s)
|
|
554
|
+
.replace(/[<>:"/\\|?*\u0000-\u001f\s]+/g, "-")
|
|
555
|
+
.replace(/-{2,}/g, "-")
|
|
556
|
+
.replace(/^[-.]+|[-.]+$/g, "");
|
|
557
|
+
return /^(?:con|prn|aux|nul|com\d|lpt\d)$/i.test(clean) ? `${clean}-` : clean;
|
|
558
|
+
}
|