@intentic/webq 1.317.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/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/app.d.ts +2 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +38 -0
- package/dist/app.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/cache.command.d.ts +3 -0
- package/dist/commands/cache.command.d.ts.map +1 -0
- package/dist/commands/cache.command.js +54 -0
- package/dist/commands/cache.command.js.map +1 -0
- package/dist/commands/crawl.command.d.ts +3 -0
- package/dist/commands/crawl.command.d.ts.map +1 -0
- package/dist/commands/crawl.command.js +77 -0
- package/dist/commands/crawl.command.js.map +1 -0
- package/dist/commands/fetch.command.d.ts +3 -0
- package/dist/commands/fetch.command.d.ts.map +1 -0
- package/dist/commands/fetch.command.js +55 -0
- package/dist/commands/fetch.command.js.map +1 -0
- package/dist/lib/bm25.d.ts +7 -0
- package/dist/lib/bm25.d.ts.map +1 -0
- package/dist/lib/bm25.js +51 -0
- package/dist/lib/bm25.js.map +1 -0
- package/dist/lib/browser.d.ts +8 -0
- package/dist/lib/browser.d.ts.map +1 -0
- package/dist/lib/browser.js +29 -0
- package/dist/lib/browser.js.map +1 -0
- package/dist/lib/cache.d.ts +15 -0
- package/dist/lib/cache.d.ts.map +1 -0
- package/dist/lib/cache.js +28 -0
- package/dist/lib/cache.js.map +1 -0
- package/dist/lib/crawl.d.ts +31 -0
- package/dist/lib/crawl.d.ts.map +1 -0
- package/dist/lib/crawl.js +188 -0
- package/dist/lib/crawl.js.map +1 -0
- package/dist/lib/dom.d.ts +23 -0
- package/dist/lib/dom.d.ts.map +1 -0
- package/dist/lib/dom.js +74 -0
- package/dist/lib/dom.js.map +1 -0
- package/dist/lib/env.d.ts +2 -0
- package/dist/lib/env.d.ts.map +1 -0
- package/dist/lib/env.js +4 -0
- package/dist/lib/env.js.map +1 -0
- package/dist/lib/flags.d.ts +69 -0
- package/dist/lib/flags.d.ts.map +1 -0
- package/dist/lib/flags.js +23 -0
- package/dist/lib/flags.js.map +1 -0
- package/dist/lib/http.d.ts +16 -0
- package/dist/lib/http.d.ts.map +1 -0
- package/dist/lib/http.js +75 -0
- package/dist/lib/http.js.map +1 -0
- package/dist/lib/links.d.ts +9 -0
- package/dist/lib/links.d.ts.map +1 -0
- package/dist/lib/links.js +41 -0
- package/dist/lib/links.js.map +1 -0
- package/dist/lib/markdown.d.ts +6 -0
- package/dist/lib/markdown.d.ts.map +1 -0
- package/dist/lib/markdown.js +292 -0
- package/dist/lib/markdown.js.map +1 -0
- package/dist/lib/output.d.ts +20 -0
- package/dist/lib/output.d.ts.map +1 -0
- package/dist/lib/output.js +64 -0
- package/dist/lib/output.js.map +1 -0
- package/dist/lib/page.d.ts +26 -0
- package/dist/lib/page.d.ts.map +1 -0
- package/dist/lib/page.js +153 -0
- package/dist/lib/page.js.map +1 -0
- package/dist/lib/prune.d.ts +7 -0
- package/dist/lib/prune.d.ts.map +1 -0
- package/dist/lib/prune.js +100 -0
- package/dist/lib/prune.js.map +1 -0
- package/dist/lib/robots.d.ts +10 -0
- package/dist/lib/robots.d.ts.map +1 -0
- package/dist/lib/robots.js +84 -0
- package/dist/lib/robots.js.map +1 -0
- package/dist/lib/sitemap.d.ts +9 -0
- package/dist/lib/sitemap.d.ts.map +1 -0
- package/dist/lib/sitemap.js +61 -0
- package/dist/lib/sitemap.js.map +1 -0
- package/dist/lib/version.d.ts +2 -0
- package/dist/lib/version.d.ts.map +1 -0
- package/dist/lib/version.js +3 -0
- package/dist/lib/version.js.map +1 -0
- package/package.json +69 -0
- package/src/app.ts +43 -0
- package/src/cli.integration.test.ts +191 -0
- package/src/cli.ts +6 -0
- package/src/commands/cache.command.ts +54 -0
- package/src/commands/crawl.command.ts +94 -0
- package/src/commands/fetch.command.ts +61 -0
- package/src/lib/bm25.test.ts +33 -0
- package/src/lib/bm25.ts +70 -0
- package/src/lib/browser.ts +42 -0
- package/src/lib/cache.ts +44 -0
- package/src/lib/crawl.ts +247 -0
- package/src/lib/dom.ts +107 -0
- package/src/lib/env.ts +6 -0
- package/src/lib/flags.ts +41 -0
- package/src/lib/http.ts +101 -0
- package/src/lib/links.test.ts +37 -0
- package/src/lib/links.ts +47 -0
- package/src/lib/markdown.test.ts +76 -0
- package/src/lib/markdown.ts +337 -0
- package/src/lib/output.test.ts +16 -0
- package/src/lib/output.ts +86 -0
- package/src/lib/page.ts +213 -0
- package/src/lib/prune.test.ts +78 -0
- package/src/lib/prune.ts +133 -0
- package/src/lib/robots.test.ts +52 -0
- package/src/lib/robots.ts +102 -0
- package/src/lib/sitemap.test.ts +37 -0
- package/src/lib/sitemap.ts +74 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// DOM to GitHub-flavored markdown for an agent reader: no character escaping except table pipes and code backticks,
|
|
2
|
+
// absolute URLs (a relative link is a broken fetch), data: images dropped to alt text. Mixed content flows: inline runs
|
|
3
|
+
// pool into a paragraph buffer flushed by a block; unknown elements are transparent containers.
|
|
4
|
+
import { attr, type Element, isElement, isText, type Node, rawTextOf } from "./dom.js";
|
|
5
|
+
|
|
6
|
+
const NON_CONTENT = new Set(["script", "style", "noscript", "template", "svg", "canvas", "iframe", "object", "embed", "link", "meta", "head"]);
|
|
7
|
+
|
|
8
|
+
const INLINE: Record<string, { open: string; close: string }> = {
|
|
9
|
+
strong: { open: "**", close: "**" },
|
|
10
|
+
b: { open: "**", close: "**" },
|
|
11
|
+
em: { open: "*", close: "*" },
|
|
12
|
+
i: { open: "*", close: "*" },
|
|
13
|
+
del: { open: "~~", close: "~~" },
|
|
14
|
+
s: { open: "~~", close: "~~" },
|
|
15
|
+
strike: { open: "~~", close: "~~" },
|
|
16
|
+
kbd: { open: "`", close: "`" },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const HEADINGS: Record<string, number> = { h1: 1, h2: 2, h3: 3, h4: 4, h5: 5, h6: 6 };
|
|
20
|
+
|
|
21
|
+
export interface MarkdownOptions {
|
|
22
|
+
/** The page's final URL; every href/src resolves against it. Absent, URLs pass through untouched. */
|
|
23
|
+
readonly baseUrl?: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const renderMarkdown = (root: Element, options: MarkdownOptions = {}): string => {
|
|
27
|
+
const blocks = renderFlow(root.childNodes, options);
|
|
28
|
+
return `${blocks
|
|
29
|
+
.join("\n\n")
|
|
30
|
+
.replaceAll(/[ \t]+$/gm, "")
|
|
31
|
+
.replaceAll(/\n{3,}/g, "\n\n")
|
|
32
|
+
.trim()}\n`;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/* Children of a container: inline runs pool into paragraphs, block elements flush and stand alone. */
|
|
36
|
+
const renderFlow = (nodes: Node[], options: MarkdownOptions): string[] => {
|
|
37
|
+
const blocks: string[] = [];
|
|
38
|
+
let run = "";
|
|
39
|
+
const flush = (): void => {
|
|
40
|
+
const paragraph = collapse(run);
|
|
41
|
+
if (paragraph !== "") {
|
|
42
|
+
blocks.push(paragraph);
|
|
43
|
+
}
|
|
44
|
+
run = "";
|
|
45
|
+
};
|
|
46
|
+
for (const node of nodes) {
|
|
47
|
+
if (isText(node)) {
|
|
48
|
+
run += node.value;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!isElement(node) || NON_CONTENT.has(node.tagName)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const block = renderBlock(node, options);
|
|
55
|
+
if (block === undefined) {
|
|
56
|
+
run += renderInline(node, options);
|
|
57
|
+
} else {
|
|
58
|
+
flush();
|
|
59
|
+
if (block !== "") {
|
|
60
|
+
blocks.push(block);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
flush();
|
|
65
|
+
return blocks;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** A block element's markdown, or undefined when the element is inline and belongs to the current run. */
|
|
69
|
+
const renderBlock = (el: Element, options: MarkdownOptions): string | undefined => {
|
|
70
|
+
const heading = HEADINGS[el.tagName];
|
|
71
|
+
if (heading !== undefined) {
|
|
72
|
+
const text = collapse(renderInlineChildren(el, options));
|
|
73
|
+
return text === "" ? "" : `${"#".repeat(heading)} ${text}`;
|
|
74
|
+
}
|
|
75
|
+
switch (el.tagName) {
|
|
76
|
+
case "p":
|
|
77
|
+
return collapse(renderInlineChildren(el, options));
|
|
78
|
+
case "ul":
|
|
79
|
+
case "ol":
|
|
80
|
+
return renderList(el, options, 0);
|
|
81
|
+
case "pre":
|
|
82
|
+
return renderPre(el);
|
|
83
|
+
case "blockquote":
|
|
84
|
+
return renderFlow(el.childNodes, options)
|
|
85
|
+
.join("\n\n")
|
|
86
|
+
.split("\n")
|
|
87
|
+
.map((line) => `> ${line}`.trimEnd())
|
|
88
|
+
.join("\n");
|
|
89
|
+
case "table":
|
|
90
|
+
return renderTable(el, options);
|
|
91
|
+
case "hr":
|
|
92
|
+
return "---";
|
|
93
|
+
case "figure": {
|
|
94
|
+
const caption = el.childNodes.filter(isElement).find((child) => child.tagName === "figcaption");
|
|
95
|
+
const body = renderFlow(
|
|
96
|
+
el.childNodes.filter((child) => !(isElement(child) && child.tagName === "figcaption")),
|
|
97
|
+
options,
|
|
98
|
+
);
|
|
99
|
+
const captionText = caption === undefined ? "" : collapse(renderInlineChildren(caption, options));
|
|
100
|
+
return [body.join("\n\n"), captionText === "" ? "" : `*${captionText}*`].filter(Boolean).join("\n\n");
|
|
101
|
+
}
|
|
102
|
+
case "dl":
|
|
103
|
+
return renderDefinitionList(el, options);
|
|
104
|
+
case "details": {
|
|
105
|
+
const summary = el.childNodes.filter(isElement).find((child) => child.tagName === "summary");
|
|
106
|
+
const rest = renderFlow(
|
|
107
|
+
el.childNodes.filter((child) => !(isElement(child) && child.tagName === "summary")),
|
|
108
|
+
options,
|
|
109
|
+
);
|
|
110
|
+
const label = summary === undefined ? "" : `**${collapse(renderInlineChildren(summary, options))}**`;
|
|
111
|
+
return [label, rest.join("\n\n")].filter(Boolean).join("\n\n");
|
|
112
|
+
}
|
|
113
|
+
case "div":
|
|
114
|
+
case "section":
|
|
115
|
+
case "article":
|
|
116
|
+
case "main":
|
|
117
|
+
case "body":
|
|
118
|
+
case "aside":
|
|
119
|
+
case "header":
|
|
120
|
+
case "footer":
|
|
121
|
+
case "nav":
|
|
122
|
+
case "form":
|
|
123
|
+
case "fieldset":
|
|
124
|
+
case "address":
|
|
125
|
+
case "center":
|
|
126
|
+
return renderFlow(el.childNodes, options).join("\n\n");
|
|
127
|
+
default:
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const renderInlineChildren = (el: Element, options: MarkdownOptions): string =>
|
|
133
|
+
el.childNodes.map((child) => (isText(child) ? child.value : isElement(child) ? renderInline(child, options) : "")).join("");
|
|
134
|
+
|
|
135
|
+
const renderInline = (el: Element, options: MarkdownOptions): string => {
|
|
136
|
+
if (NON_CONTENT.has(el.tagName)) {
|
|
137
|
+
return "";
|
|
138
|
+
}
|
|
139
|
+
if (el.tagName === "br") {
|
|
140
|
+
// Sentinel, not "\n": HTML newlines are whitespace and collapse to spaces; only this survives as a break.
|
|
141
|
+
return BR;
|
|
142
|
+
}
|
|
143
|
+
if (el.tagName === "img") {
|
|
144
|
+
return renderImage(el, options);
|
|
145
|
+
}
|
|
146
|
+
if (el.tagName === "a") {
|
|
147
|
+
return renderLink(el, options);
|
|
148
|
+
}
|
|
149
|
+
if (el.tagName === "code") {
|
|
150
|
+
const code = rawTextOf(el).trim();
|
|
151
|
+
return code.includes("`") ? `\`\` ${code} \`\`` : `\`${code}\``;
|
|
152
|
+
}
|
|
153
|
+
const wrap = INLINE[el.tagName];
|
|
154
|
+
const inner = renderInlineChildren(el, options);
|
|
155
|
+
if (wrap === undefined) {
|
|
156
|
+
// Unknown/neutral inline (span, small, q) is transparent; blocks never reach here, flow dispatches those first.
|
|
157
|
+
return inner;
|
|
158
|
+
}
|
|
159
|
+
const trimmed = collapse(inner);
|
|
160
|
+
return trimmed === "" ? "" : `${wrap.open}${trimmed}${wrap.close}`;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const renderLink = (el: Element, options: MarkdownOptions): string => {
|
|
164
|
+
const text = collapse(renderInlineChildren(el, options));
|
|
165
|
+
const href = resolveUrl(attr(el, "href"), options.baseUrl);
|
|
166
|
+
if (href === undefined || href.startsWith("javascript:")) {
|
|
167
|
+
return text;
|
|
168
|
+
}
|
|
169
|
+
if (href.startsWith("mailto:") || href.startsWith("tel:")) {
|
|
170
|
+
return text === "" ? href.slice(href.indexOf(":") + 1) : text;
|
|
171
|
+
}
|
|
172
|
+
if (text === "") {
|
|
173
|
+
return "";
|
|
174
|
+
}
|
|
175
|
+
return `[${text}](${href})`;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const renderImage = (el: Element, options: MarkdownOptions): string => {
|
|
179
|
+
const alt = attr(el, "alt")?.trim() ?? "";
|
|
180
|
+
const src = resolveUrl(attr(el, "src"), options.baseUrl);
|
|
181
|
+
if (src === undefined || src.startsWith("data:")) {
|
|
182
|
+
return alt;
|
|
183
|
+
}
|
|
184
|
+
return ``;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const renderList = (el: Element, options: MarkdownOptions, depth: number): string => {
|
|
188
|
+
const ordered = el.tagName === "ol";
|
|
189
|
+
const start = Number(attr(el, "start") ?? "1");
|
|
190
|
+
const items = el.childNodes.filter(isElement).filter((child) => child.tagName === "li");
|
|
191
|
+
const lines: string[] = [];
|
|
192
|
+
items.forEach((item, index) => {
|
|
193
|
+
const marker = ordered ? `${start + index}. ` : "- ";
|
|
194
|
+
const indent = " ".repeat(depth);
|
|
195
|
+
// An item is flow of its own: loose text becomes the item line, nested lists/blocks follow.
|
|
196
|
+
const nested: string[] = [];
|
|
197
|
+
const inlineParts: string[] = [];
|
|
198
|
+
for (const child of item.childNodes) {
|
|
199
|
+
if (isElement(child) && (child.tagName === "ul" || child.tagName === "ol")) {
|
|
200
|
+
nested.push(renderList(child, options, depth + 1));
|
|
201
|
+
} else if (isElement(child) && renderBlock(child, options) !== undefined) {
|
|
202
|
+
const block = renderBlock(child, options) ?? "";
|
|
203
|
+
if (block !== "") {
|
|
204
|
+
nested.push(
|
|
205
|
+
block
|
|
206
|
+
.split("\n")
|
|
207
|
+
.map((line) => `${indent} ${line}`.trimEnd())
|
|
208
|
+
.join("\n"),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
} else if (isText(child)) {
|
|
212
|
+
inlineParts.push(child.value);
|
|
213
|
+
} else if (isElement(child)) {
|
|
214
|
+
inlineParts.push(renderInline(child, options));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
lines.push(`${indent}${marker}${collapse(inlineParts.join(""))}`);
|
|
218
|
+
lines.push(...nested);
|
|
219
|
+
});
|
|
220
|
+
return lines.join("\n");
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const renderPre = (el: Element): string => {
|
|
224
|
+
const codeChild = el.childNodes.filter(isElement).find((child) => child.tagName === "code");
|
|
225
|
+
const classes = `${attr(el, "class") ?? ""} ${codeChild === undefined ? "" : (attr(codeChild, "class") ?? "")}`;
|
|
226
|
+
const lang = /(?:language|lang)-([\w+-]+)/.exec(classes)?.[1] ?? "";
|
|
227
|
+
const code = preTextOf(el).replace(/^\n/, "").trimEnd();
|
|
228
|
+
// The fence must be longer than any backtick run inside the code it fences.
|
|
229
|
+
const longestRun = Math.max(2, ...[...code.matchAll(/`+/g)].map((m) => m[0].length));
|
|
230
|
+
const fence = "`".repeat(Math.max(3, longestRun + 1));
|
|
231
|
+
return `${fence}${lang}\n${code}\n${fence}`;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Elements that are a line of their own inside a <pre>: editors like CodeMirror render one <div> per line and no
|
|
235
|
+
// newline text between them, so the line breaks exist only as structure.
|
|
236
|
+
const PRE_LINE = new Set(["div", "p", "li", "tr", "section", "article", "table", "tbody", "ul", "ol", "h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
237
|
+
|
|
238
|
+
/** A code block's text, with a line break at each <br> and around each block-level child. */
|
|
239
|
+
const preTextOf = (node: Node): string => {
|
|
240
|
+
if (isText(node)) {
|
|
241
|
+
return node.value;
|
|
242
|
+
}
|
|
243
|
+
if (!isElement(node) || NON_CONTENT.has(node.tagName)) {
|
|
244
|
+
return "";
|
|
245
|
+
}
|
|
246
|
+
if (node.tagName === "br") {
|
|
247
|
+
return "\n";
|
|
248
|
+
}
|
|
249
|
+
let text = "";
|
|
250
|
+
let afterLine = false;
|
|
251
|
+
for (const child of node.childNodes) {
|
|
252
|
+
if (isElement(child) && PRE_LINE.has(child.tagName)) {
|
|
253
|
+
if (text !== "" && !text.endsWith("\n")) {
|
|
254
|
+
text += "\n";
|
|
255
|
+
}
|
|
256
|
+
// An empty line is a line element holding only a <br>: the element is the line, the <br> adds nothing.
|
|
257
|
+
text += `${preTextOf(child).replace(/\n$/, "")}\n`;
|
|
258
|
+
afterLine = true;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// Pretty-printed markup puts a newline between line elements that already ended their line.
|
|
262
|
+
if (afterLine && isText(child) && /^\r?\n$/.test(child.value)) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
text += preTextOf(child);
|
|
266
|
+
afterLine = false;
|
|
267
|
+
}
|
|
268
|
+
return text;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const renderTable = (el: Element, options: MarkdownOptions): string => {
|
|
272
|
+
const rows: string[][] = [];
|
|
273
|
+
for (const tr of collectRows(el)) {
|
|
274
|
+
const cells = tr.childNodes.filter(isElement).filter((cell) => cell.tagName === "td" || cell.tagName === "th");
|
|
275
|
+
rows.push(cells.map((cell) => collapse(renderInlineChildren(cell, options)).replaceAll("|", "\\|").replaceAll("\n", " ")));
|
|
276
|
+
}
|
|
277
|
+
if (rows.length === 0) {
|
|
278
|
+
return "";
|
|
279
|
+
}
|
|
280
|
+
const width = Math.max(...rows.map((row) => row.length));
|
|
281
|
+
const pad = (row: string[]): string[] => [...row, ...Array.from({ length: width - row.length }, () => "")];
|
|
282
|
+
const [header, ...body] = rows.map(pad) as [string[], ...string[][]];
|
|
283
|
+
return [tableRow(header), tableRow(header.map(() => "---")), ...body.map(tableRow)].join("\n");
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const tableRow = (cells: string[]): string => `| ${cells.join(" | ")} |`;
|
|
287
|
+
|
|
288
|
+
const collectRows = (el: Element): Element[] => {
|
|
289
|
+
const rows: Element[] = [];
|
|
290
|
+
for (const child of el.childNodes.filter(isElement)) {
|
|
291
|
+
if (child.tagName === "tr") {
|
|
292
|
+
rows.push(child);
|
|
293
|
+
} else if (child.tagName === "thead" || child.tagName === "tbody" || child.tagName === "tfoot") {
|
|
294
|
+
rows.push(...child.childNodes.filter(isElement).filter((row) => row.tagName === "tr"));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return rows;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const renderDefinitionList = (el: Element, options: MarkdownOptions): string => {
|
|
301
|
+
const lines: string[] = [];
|
|
302
|
+
for (const child of el.childNodes.filter(isElement)) {
|
|
303
|
+
if (child.tagName === "dt") {
|
|
304
|
+
lines.push(`**${collapse(renderInlineChildren(child, options))}**`);
|
|
305
|
+
} else if (child.tagName === "dd") {
|
|
306
|
+
lines.push(`: ${collapse(renderInlineChildren(child, options))}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return lines.join("\n");
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const resolveUrl = (raw: string | undefined, base: string | undefined): string | undefined => {
|
|
313
|
+
if (raw === undefined || raw === "") {
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
const trimmed = raw.trim();
|
|
317
|
+
if (/^(?:javascript|mailto|tel|data):/i.test(trimmed)) {
|
|
318
|
+
return trimmed;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
return base === undefined ? trimmed : new URL(trimmed, base).href;
|
|
322
|
+
} catch {
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// Control character no HTML text node carries; whitespace collapse skips it, and only it becomes a line break.
|
|
328
|
+
const BR = "\u0001";
|
|
329
|
+
|
|
330
|
+
// Collapses whitespace without eating <br>'s line breaks: splits on the sentinel first, so only the rejoins are
|
|
331
|
+
// newlines. Split, not a regex over the control character (a lint error elsewhere).
|
|
332
|
+
const collapse = (text: string): string =>
|
|
333
|
+
text
|
|
334
|
+
.split(BR)
|
|
335
|
+
.map((part) => part.replaceAll(/\s+/g, " ").trim())
|
|
336
|
+
.join("\n")
|
|
337
|
+
.trim();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { slugFor } from "./output.js";
|
|
2
|
+
|
|
3
|
+
describe("slugFor", () => {
|
|
4
|
+
it("is readable, stable, and distinguishes URLs that slug identically", () => {
|
|
5
|
+
const first = slugFor("https://docs.ex.com/guide/setup");
|
|
6
|
+
expect(first).toMatch(/^docs-ex-com-guide-setup-[0-9a-f]{8}\.md$/);
|
|
7
|
+
expect(slugFor("https://docs.ex.com/guide/setup")).toBe(first);
|
|
8
|
+
expect(slugFor("https://docs.ex.com/guide/setup?page=2")).not.toBe(first);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("cannot spell a path that escapes the output directory", () => {
|
|
12
|
+
const slug = slugFor("https://ex.com/../../etc/passwd");
|
|
13
|
+
expect(slug).not.toContain("/");
|
|
14
|
+
expect(slug).not.toContain("..");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/* Where markdown lands and how it is named. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { estimateTokens } from "@intentic/base/format";
|
|
6
|
+
import type { PageResult } from "./page.js";
|
|
7
|
+
|
|
8
|
+
export interface SavedPage {
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly tokens: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const slugFor = (url: string): string => {
|
|
14
|
+
const hash = createHash("sha256").update(url).digest("hex").slice(0, 8);
|
|
15
|
+
let readable: string;
|
|
16
|
+
try {
|
|
17
|
+
const parsed = new URL(url);
|
|
18
|
+
readable = `${parsed.host}${parsed.pathname}`;
|
|
19
|
+
} catch {
|
|
20
|
+
readable = url;
|
|
21
|
+
}
|
|
22
|
+
const slug = readable
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
25
|
+
.replaceAll(/^-+|-+$/g, "")
|
|
26
|
+
.slice(0, 80);
|
|
27
|
+
return `${slug === "" ? "page" : slug}-${hash}.md`;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const savePage = async (outDir: string, page: PageResult, fetchedAt: Date): Promise<SavedPage> => {
|
|
31
|
+
await mkdir(outDir, { recursive: true });
|
|
32
|
+
const path = join(outDir, slugFor(page.url));
|
|
33
|
+
const frontMatter = [
|
|
34
|
+
"---",
|
|
35
|
+
`url: ${page.url}`,
|
|
36
|
+
...(page.finalUrl === page.url ? [] : [`final_url: ${page.finalUrl}`]),
|
|
37
|
+
`title: ${JSON.stringify(page.meta.title)}`,
|
|
38
|
+
`fetched_at: ${fetchedAt.toISOString()}`,
|
|
39
|
+
`source: ${page.source}`,
|
|
40
|
+
...page.notes.map((note) => `note: ${JSON.stringify(note)}`),
|
|
41
|
+
"---",
|
|
42
|
+
"",
|
|
43
|
+
].join("\n");
|
|
44
|
+
const content = `${frontMatter}${page.markdown}`;
|
|
45
|
+
await writeFile(path, content);
|
|
46
|
+
return { path, tokens: estimateTokens(page.markdown) };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export interface IndexEntry {
|
|
50
|
+
readonly url: string;
|
|
51
|
+
readonly title: string;
|
|
52
|
+
readonly path: string;
|
|
53
|
+
readonly tokens: number;
|
|
54
|
+
readonly depth: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CrawlIndex {
|
|
58
|
+
readonly markdownPath: string;
|
|
59
|
+
readonly jsonPath: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The crawl's table of contents, as markdown for a reader and JSON for a program. */
|
|
63
|
+
export const saveIndex = async (outDir: string, startUrl: string, entries: IndexEntry[], skipped: Record<string, number>): Promise<CrawlIndex> => {
|
|
64
|
+
await mkdir(outDir, { recursive: true });
|
|
65
|
+
const sorted = entries.toSorted((a, b) => a.url.localeCompare(b.url));
|
|
66
|
+
const markdownPath = join(outDir, "index.md");
|
|
67
|
+
const jsonPath = join(outDir, "index.json");
|
|
68
|
+
const skippedLine = Object.entries(skipped)
|
|
69
|
+
.filter(([, count]) => count > 0)
|
|
70
|
+
.map(([reason, count]) => `${reason} ${count}`)
|
|
71
|
+
.join(", ");
|
|
72
|
+
const lines = [
|
|
73
|
+
`# Crawl of ${startUrl}`,
|
|
74
|
+
"",
|
|
75
|
+
`${entries.length} pages, ${entries.reduce((sum, entry) => sum + entry.tokens, 0)} tokens total.`,
|
|
76
|
+
...(skippedLine === "" ? [] : [`Skipped: ${skippedLine}.`]),
|
|
77
|
+
"",
|
|
78
|
+
"| title | url | file | tokens |",
|
|
79
|
+
"| --- | --- | --- | --- |",
|
|
80
|
+
...sorted.map((entry) => `| ${entry.title.replaceAll("|", "\\|")} | ${entry.url} | ${entry.path} | ${entry.tokens} |`),
|
|
81
|
+
"",
|
|
82
|
+
];
|
|
83
|
+
await writeFile(markdownPath, lines.join("\n"));
|
|
84
|
+
await writeFile(jsonPath, JSON.stringify({ startUrl, pages: sorted, skipped }, null, 2));
|
|
85
|
+
return { markdownPath, jsonPath };
|
|
86
|
+
};
|
package/src/lib/page.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// One URL to one PageResult: cache first, static HTTP second, Chromium only for an empty-looking app shell (or forced).
|
|
2
|
+
// Result carries its own honesty: source, prune share, query-filter outcome and every degradation as a note; a silent
|
|
3
|
+
// fallback would read as the whole page.
|
|
4
|
+
import { neutralizeOutsideText } from "@intentic/base/outside-text";
|
|
5
|
+
import { bm25Rank } from "./bm25.js";
|
|
6
|
+
import { chromiumAvailable, renderPage } from "./browser.js";
|
|
7
|
+
import { cacheRead, cacheWrite, DEFAULT_MAX_AGE_S, type RenderMode } from "./cache.js";
|
|
8
|
+
import { bodyOf, type Document, type Element, elementsByTag, metaOf, type PageMeta, parseHtml, remove, textOf, walk } from "./dom.js";
|
|
9
|
+
import { httpFetch, USER_AGENT } from "./http.js";
|
|
10
|
+
import { extractLinks, type Link } from "./links.js";
|
|
11
|
+
import { renderMarkdown } from "./markdown.js";
|
|
12
|
+
import { pruneTree } from "./prune.js";
|
|
13
|
+
|
|
14
|
+
export type BrowserMode = "auto" | "never" | "force";
|
|
15
|
+
|
|
16
|
+
export interface FetchPageOptions {
|
|
17
|
+
readonly raw?: boolean;
|
|
18
|
+
readonly query?: string | undefined;
|
|
19
|
+
readonly browser?: BrowserMode;
|
|
20
|
+
/** Cache freshness window in seconds; 0 means bypass reads (a write still lands). */
|
|
21
|
+
readonly maxAgeS?: number;
|
|
22
|
+
readonly timeoutMs?: number;
|
|
23
|
+
readonly maxBytes?: number;
|
|
24
|
+
readonly threshold?: number | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PageResult {
|
|
28
|
+
readonly url: string;
|
|
29
|
+
readonly finalUrl: string;
|
|
30
|
+
readonly status: number;
|
|
31
|
+
readonly contentType: string;
|
|
32
|
+
readonly markdown: string;
|
|
33
|
+
readonly meta: PageMeta;
|
|
34
|
+
readonly links: Link[];
|
|
35
|
+
readonly source: "cache" | "network" | "browser";
|
|
36
|
+
/** Share of text mass fit-pruning removed, when it ran. */
|
|
37
|
+
readonly prunedShare: number | undefined;
|
|
38
|
+
readonly notes: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const fetchPage = async (url: string, options: FetchPageOptions = {}): Promise<PageResult> => {
|
|
42
|
+
const browserMode = options.browser ?? "auto";
|
|
43
|
+
const maxAgeS = options.maxAgeS ?? DEFAULT_MAX_AGE_S;
|
|
44
|
+
const timeoutMs = options.timeoutMs ?? 20_000;
|
|
45
|
+
const notes: string[] = [];
|
|
46
|
+
|
|
47
|
+
let acquired = browserMode === "force" ? undefined : await acquireStatic(url, options, maxAgeS, timeoutMs, notes);
|
|
48
|
+
if (acquired === undefined || (browserMode !== "never" && looksLikeAppShell(acquired.doc, acquired.body))) {
|
|
49
|
+
const rendered = await acquireBrowser(url, maxAgeS, timeoutMs, notes);
|
|
50
|
+
if (rendered !== undefined) {
|
|
51
|
+
acquired = rendered;
|
|
52
|
+
} else if (acquired === undefined) {
|
|
53
|
+
throw new Error("browser rendering unavailable (no Chromium in this image) and --browser force was asked for");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const { doc, body, finalUrl, status, contentType, source } = acquired;
|
|
57
|
+
|
|
58
|
+
if (!isHtml(contentType)) {
|
|
59
|
+
return {
|
|
60
|
+
url,
|
|
61
|
+
finalUrl,
|
|
62
|
+
status,
|
|
63
|
+
contentType,
|
|
64
|
+
markdown: neutralizeOutsideText(nonHtmlMarkdown(acquired.rawBody, contentType, notes)),
|
|
65
|
+
meta: { title: url, description: undefined, lang: undefined },
|
|
66
|
+
links: [],
|
|
67
|
+
source,
|
|
68
|
+
prunedShare: undefined,
|
|
69
|
+
notes,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Read before pruning mutates the tree: navigation is chrome to the reader, structure to the crawler.
|
|
74
|
+
const rawMeta = metaOf(doc);
|
|
75
|
+
const meta: PageMeta = {
|
|
76
|
+
title: neutralizeOutsideText(rawMeta.title),
|
|
77
|
+
description: rawMeta.description === undefined ? undefined : neutralizeOutsideText(rawMeta.description),
|
|
78
|
+
lang: rawMeta.lang,
|
|
79
|
+
};
|
|
80
|
+
const links = extractLinks(doc, finalUrl);
|
|
81
|
+
|
|
82
|
+
let prunedShare: number | undefined;
|
|
83
|
+
if (body !== undefined && options.raw !== true) {
|
|
84
|
+
prunedShare = pruneTree(body, options.threshold === undefined ? {} : { threshold: options.threshold });
|
|
85
|
+
}
|
|
86
|
+
if (body !== undefined && options.query !== undefined && options.query !== "") {
|
|
87
|
+
notes.push(applyQueryFilter(body, options.query));
|
|
88
|
+
}
|
|
89
|
+
// Neutralized here: stdout rides the daemon's untrusted wrapper, but a saved file is read back unwrapped.
|
|
90
|
+
const markdown = body === undefined ? "" : neutralizeOutsideText(renderMarkdown(body, { baseUrl: finalUrl }));
|
|
91
|
+
return { url, finalUrl, status, contentType, markdown, meta, links, source, prunedShare, notes };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
interface Acquired {
|
|
95
|
+
readonly doc: Document;
|
|
96
|
+
readonly body: Element | undefined;
|
|
97
|
+
readonly rawBody: string;
|
|
98
|
+
readonly finalUrl: string;
|
|
99
|
+
readonly status: number;
|
|
100
|
+
readonly contentType: string;
|
|
101
|
+
readonly source: "cache" | "network" | "browser";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const acquireStatic = async (url: string, options: FetchPageOptions, maxAgeS: number, timeoutMs: number, notes: string[]): Promise<Acquired> => {
|
|
105
|
+
const cached = maxAgeS > 0 ? await cacheRead(url, "static", maxAgeS) : undefined;
|
|
106
|
+
if (cached !== undefined) {
|
|
107
|
+
return parsed(cached.body, cached.finalUrl, cached.status, cached.contentType, "cache");
|
|
108
|
+
}
|
|
109
|
+
const fetched = await httpFetch(url, {
|
|
110
|
+
timeoutMs,
|
|
111
|
+
userAgent: USER_AGENT,
|
|
112
|
+
...(options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes }),
|
|
113
|
+
});
|
|
114
|
+
if (fetched.truncated) {
|
|
115
|
+
notes.push("byte cap hit mid-body: this page is cut short");
|
|
116
|
+
}
|
|
117
|
+
await cacheWrite({
|
|
118
|
+
url,
|
|
119
|
+
finalUrl: fetched.finalUrl,
|
|
120
|
+
status: fetched.status,
|
|
121
|
+
contentType: fetched.contentType,
|
|
122
|
+
body: fetched.body,
|
|
123
|
+
mode: "static",
|
|
124
|
+
fetchedAt: Date.now(),
|
|
125
|
+
});
|
|
126
|
+
return parsed(fetched.body, fetched.finalUrl, fetched.status, fetched.contentType, "network");
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const acquireBrowser = async (url: string, maxAgeS: number, timeoutMs: number, notes: string[]): Promise<Acquired | undefined> => {
|
|
130
|
+
const mode: RenderMode = "browser";
|
|
131
|
+
const cached = maxAgeS > 0 ? await cacheRead(url, mode, maxAgeS) : undefined;
|
|
132
|
+
if (cached !== undefined) {
|
|
133
|
+
return parsed(cached.body, cached.finalUrl, cached.status, cached.contentType, "browser");
|
|
134
|
+
}
|
|
135
|
+
if (!(await chromiumAvailable())) {
|
|
136
|
+
notes.push("page looks JS-rendered but this image has no Chromium (browser feature pack): static HTML served best-effort");
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
const rendered = await renderPage(url, timeoutMs);
|
|
140
|
+
await cacheWrite({ url, finalUrl: rendered.finalUrl, status: 200, contentType: "text/html", body: rendered.html, mode, fetchedAt: Date.now() });
|
|
141
|
+
return parsed(rendered.html, rendered.finalUrl, 200, "text/html", "browser");
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const parsed = (html: string, finalUrl: string, status: number, contentType: string, source: Acquired["source"]): Acquired => {
|
|
145
|
+
const doc = isHtml(contentType) ? parseHtml(html) : parseHtml("");
|
|
146
|
+
return { doc, body: bodyOf(doc), rawBody: html, finalUrl, status, contentType, source };
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const isHtml = (contentType: string): boolean => contentType === "" || /text\/html|application\/xhtml/i.test(contentType);
|
|
150
|
+
|
|
151
|
+
// JS-shell tell: almost no text next to scripts meant to produce some. Thresholded on text, not framework fingerprints,
|
|
152
|
+
// which age.
|
|
153
|
+
const looksLikeAppShell = (doc: Document, body: Element | undefined): boolean => {
|
|
154
|
+
if (body === undefined) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
const text = textOf(body);
|
|
158
|
+
if (text.length >= 200) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
return elementsByTag(doc, "script").length > 0;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const nonHtmlMarkdown = (rawBody: string, contentType: string, notes: string[]): string => {
|
|
165
|
+
if (/json/i.test(contentType)) {
|
|
166
|
+
return `\`\`\`json\n${rawBody.trim()}\n\`\`\`\n`;
|
|
167
|
+
}
|
|
168
|
+
if (/xml/i.test(contentType)) {
|
|
169
|
+
return `\`\`\`xml\n${rawBody.trim()}\n\`\`\`\n`;
|
|
170
|
+
}
|
|
171
|
+
if (/^text\//i.test(contentType)) {
|
|
172
|
+
return `${rawBody.trim()}\n`;
|
|
173
|
+
}
|
|
174
|
+
notes.push(`unsupported content type ${contentType.split(";")[0] ?? contentType}: no markdown produced`);
|
|
175
|
+
return "";
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Blocks a query filter may drop; headings stay put so the survivors keep their skeleton.
|
|
179
|
+
const FILTERABLE = new Set(["p", "li", "pre", "table", "blockquote", "dd"]);
|
|
180
|
+
|
|
181
|
+
const applyQueryFilter = (body: Element, query: string): string => {
|
|
182
|
+
const candidates: Element[] = [];
|
|
183
|
+
walk(body, (el) => {
|
|
184
|
+
if (FILTERABLE.has(el.tagName) && !hasFilterableAncestorWithin(el, body)) {
|
|
185
|
+
candidates.push(el);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
const ranked = bm25Rank(candidates, (el) => textOf(el), query);
|
|
189
|
+
const keep = new Set(ranked.map((entry) => entry.block));
|
|
190
|
+
// Almost no match would erase the page; keep everything rather than three coincidentally matching sentences.
|
|
191
|
+
if (keep.size < Math.min(3, candidates.length)) {
|
|
192
|
+
return `query matched too little (${keep.size}/${candidates.length} blocks): kept the whole page`;
|
|
193
|
+
}
|
|
194
|
+
for (const candidate of candidates) {
|
|
195
|
+
if (!keep.has(candidate)) {
|
|
196
|
+
remove(candidate);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return `query kept ${keep.size}/${candidates.length} blocks`;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Only top-most filterable blocks compete; a <li> inside a kept <table> cell must not be scored (and possibly removed)
|
|
203
|
+
// twice.
|
|
204
|
+
const hasFilterableAncestorWithin = (el: Element, root: Element): boolean => {
|
|
205
|
+
let parent = el.parentNode;
|
|
206
|
+
while (parent !== null && parent !== root) {
|
|
207
|
+
if ("tagName" in parent && FILTERABLE.has(parent.tagName)) {
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
parent = "parentNode" in parent && parent.parentNode !== undefined ? parent.parentNode : null;
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
};
|