@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,78 @@
|
|
|
1
|
+
import { bodyOf, type Element, parseHtml, textOf } from "./dom.js";
|
|
2
|
+
import { renderMarkdown } from "./markdown.js";
|
|
3
|
+
import { pruneTree } from "./prune.js";
|
|
4
|
+
|
|
5
|
+
const ARTICLE = `<main><article>
|
|
6
|
+
<h1>Real article</h1>
|
|
7
|
+
<p>This is the actual content of the page, a long paragraph carrying the substance of the article, dense readable text with many words and few links, exactly what a reader came for.</p>
|
|
8
|
+
<p>Second paragraph, also substantial, explaining details about the topic in complete sentences.</p>
|
|
9
|
+
</article></main>`;
|
|
10
|
+
|
|
11
|
+
const CHROME = `<nav><a href='/'>Home</a><a href='/about'>About</a><a href='/pricing'>Pricing</a></nav>
|
|
12
|
+
<div class='sidebar'><ul><li><a href='/a'>A</a></li><li><a href='/b'>B</a></li><li><a href='/c'>C</a></li></ul></div>
|
|
13
|
+
<footer><p>Copyright · <a href='/tos'>Terms</a></p></footer>
|
|
14
|
+
<div class='social-share'><a href='https://x.com/share'>Share on X</a></div>`;
|
|
15
|
+
|
|
16
|
+
const bodyFor = (html: string): Element => {
|
|
17
|
+
const body = bodyOf(parseHtml(`<html><body>${html}</body></html>`));
|
|
18
|
+
if (body === undefined) {
|
|
19
|
+
throw new Error("fixture has no body");
|
|
20
|
+
}
|
|
21
|
+
return body;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("pruneTree", () => {
|
|
25
|
+
it("keeps the article and drops navigation, sidebar, footer and share chrome", () => {
|
|
26
|
+
const body = bodyFor(CHROME + ARTICLE);
|
|
27
|
+
pruneTree(body);
|
|
28
|
+
const markdown = renderMarkdown(body, {});
|
|
29
|
+
expect(markdown).toContain("# Real article");
|
|
30
|
+
expect(markdown).toContain("Second paragraph");
|
|
31
|
+
expect(markdown).not.toContain("Pricing");
|
|
32
|
+
expect(markdown).not.toContain("Copyright");
|
|
33
|
+
expect(markdown).not.toContain("Share on X");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("reports the share of text mass it removed", () => {
|
|
37
|
+
const body = bodyFor(CHROME + ARTICLE);
|
|
38
|
+
const share = pruneTree(body);
|
|
39
|
+
expect(share).toBeGreaterThan(0.1);
|
|
40
|
+
expect(share).toBeLessThan(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("keeps link-heavy chrome when the threshold is lowered to zero", () => {
|
|
44
|
+
const body = bodyFor(`<div class='sidebar'><ul><li><a href='/a'>A</a></li></ul></div>${ARTICLE}`);
|
|
45
|
+
pruneTree(body, { threshold: 0 });
|
|
46
|
+
expect(textOf(body)).toContain("A");
|
|
47
|
+
expect(textOf(body)).toContain("Real article");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("always strips excluded tags, whatever the threshold", () => {
|
|
51
|
+
const body = bodyFor(`<script>evil()</script><noscript>enable js</noscript>${ARTICLE}`);
|
|
52
|
+
pruneTree(body, { threshold: 0 });
|
|
53
|
+
expect(textOf(body)).not.toContain("enable js");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keeps every token of a syntax-highlighted code block", () => {
|
|
57
|
+
const token = (cls: string, text: string): string => `<span class="token ${cls}">${text}</span>`;
|
|
58
|
+
const line = [
|
|
59
|
+
token("keyword", "import"),
|
|
60
|
+
" ",
|
|
61
|
+
token("punctuation", "{"),
|
|
62
|
+
" ",
|
|
63
|
+
token("plain", "useState"),
|
|
64
|
+
" ",
|
|
65
|
+
token("punctuation", "}"),
|
|
66
|
+
" ",
|
|
67
|
+
token("keyword", "from"),
|
|
68
|
+
" ",
|
|
69
|
+
token("string", "'react'"),
|
|
70
|
+
token("punctuation", ";"),
|
|
71
|
+
].join("");
|
|
72
|
+
const body = bodyFor(
|
|
73
|
+
`${CHROME}<main><article><h1>Hooks</h1><p>Import the hook before you call it at the top level of your component, like this example shows in full.</p><div class="code-block"><pre><code>${line}</code></pre></div></article></main>`,
|
|
74
|
+
);
|
|
75
|
+
pruneTree(body);
|
|
76
|
+
expect(renderMarkdown(body, {})).toContain("import { useState } from 'react';");
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/lib/prune.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Fit-content pruning: scores every element on text/link density, tag kind, class/id smell and text mass; drops
|
|
2
|
+
// subtrees under the threshold. Ports crawl4ai's PruningContentFilter (Apache-2.0,
|
|
3
|
+
// https://github.com/unclecode/crawl4ai), same weights and threshold. One post-order pass memoizes lengths instead of
|
|
4
|
+
// re-serializing each node.
|
|
5
|
+
import { attr, childElements, type Element, isElement, isText, remove, textOf } from "./dom.js";
|
|
6
|
+
|
|
7
|
+
// Never content; upstream's excluded_tags plus the invisible tags this parser still keeps.
|
|
8
|
+
const EXCLUDED = new Set(["nav", "footer", "header", "aside", "script", "style", "form", "iframe", "noscript", "template", "svg", "canvas"]);
|
|
9
|
+
|
|
10
|
+
// Class or id naming page chrome; upstream's negative_patterns, verbatim.
|
|
11
|
+
const NEGATIVE = /nav|footer|header|sidebar|ads|comment|promo|advert|social|share/i;
|
|
12
|
+
|
|
13
|
+
const TAG_WEIGHTS: Record<string, number> = {
|
|
14
|
+
div: 0.5,
|
|
15
|
+
p: 1.0,
|
|
16
|
+
article: 1.5,
|
|
17
|
+
section: 1.0,
|
|
18
|
+
span: 0.3,
|
|
19
|
+
li: 0.5,
|
|
20
|
+
ul: 0.5,
|
|
21
|
+
ol: 0.5,
|
|
22
|
+
h1: 1.2,
|
|
23
|
+
h2: 1.1,
|
|
24
|
+
h3: 1.0,
|
|
25
|
+
h4: 0.9,
|
|
26
|
+
h5: 0.8,
|
|
27
|
+
h6: 0.7,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const WEIGHTS = { textDensity: 0.4, linkDensity: 0.2, tagWeight: 0.2, classIdWeight: 0.1, textLength: 0.1 };
|
|
31
|
+
|
|
32
|
+
export interface PruneOptions {
|
|
33
|
+
/** Composite-score floor an element must clear to survive. Upstream default. */
|
|
34
|
+
readonly threshold?: number;
|
|
35
|
+
/** Fewer words than this in a subtree is an automatic drop (off by default, like upstream). */
|
|
36
|
+
readonly minWords?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface Mass {
|
|
40
|
+
readonly textLen: number;
|
|
41
|
+
readonly markupLen: number;
|
|
42
|
+
readonly linkTextLen: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Prunes in place under `body`; returns the fraction of text mass removed, for the capsule's honesty line. */
|
|
46
|
+
export const pruneTree = (body: Element, options: PruneOptions = {}): number => {
|
|
47
|
+
const threshold = options.threshold ?? 0.48;
|
|
48
|
+
const before = textOf(body).length;
|
|
49
|
+
stripExcluded(body);
|
|
50
|
+
const masses = new Map<Element, Mass>();
|
|
51
|
+
weigh(body, masses);
|
|
52
|
+
for (const child of childElements(body)) {
|
|
53
|
+
pruneNode(child, threshold, options.minWords, masses);
|
|
54
|
+
}
|
|
55
|
+
const after = textOf(body).length;
|
|
56
|
+
return before === 0 ? 0 : (before - after) / before;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const stripExcluded = (el: Element): void => {
|
|
60
|
+
for (const child of childElements(el)) {
|
|
61
|
+
if (EXCLUDED.has(child.tagName)) {
|
|
62
|
+
remove(child);
|
|
63
|
+
} else {
|
|
64
|
+
stripExcluded(child);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// One post-order pass computes text mass, approximate markup length, and direct <a> text length per element: the
|
|
70
|
+
// score's three inputs.
|
|
71
|
+
const weigh = (el: Element, memo: Map<Element, Mass>): Mass => {
|
|
72
|
+
let textLen = 0;
|
|
73
|
+
let markupLen = 0;
|
|
74
|
+
let linkTextLen = 0;
|
|
75
|
+
for (const child of el.childNodes) {
|
|
76
|
+
if (isText(child)) {
|
|
77
|
+
const trimmed = child.value.trim();
|
|
78
|
+
textLen += trimmed.length;
|
|
79
|
+
markupLen += trimmed.length;
|
|
80
|
+
} else if (isElement(child)) {
|
|
81
|
+
const mass = weigh(child, memo);
|
|
82
|
+
textLen += mass.textLen;
|
|
83
|
+
markupLen += mass.markupLen;
|
|
84
|
+
if (child.tagName === "a") {
|
|
85
|
+
linkTextLen += textOf(child).length;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// A code block is all content however many highlighter spans carry it: its inner markup would read as chrome and
|
|
90
|
+
// sink every wrapper around it, so only the <pre>'s own tags count against it.
|
|
91
|
+
if (el.tagName === "pre") {
|
|
92
|
+
markupLen = textLen;
|
|
93
|
+
}
|
|
94
|
+
// <tag attrs></tag> overhead: 2 brackets + name, twice, plus each attribute's name="value".
|
|
95
|
+
markupLen += 2 * (el.tagName.length + 2) + el.attrs.reduce((sum, a) => sum + a.name.length + a.value.length + 4, 0);
|
|
96
|
+
const mass = { textLen, markupLen, linkTextLen };
|
|
97
|
+
memo.set(el, mass);
|
|
98
|
+
return mass;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const pruneNode = (el: Element, threshold: number, minWords: number | undefined, masses: Map<Element, Mass>): void => {
|
|
102
|
+
if (compositeScore(el, minWords, masses) < threshold) {
|
|
103
|
+
remove(el);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Inside a code block every token span scores as chrome; dropping one silently rewrites the code.
|
|
107
|
+
if (el.tagName === "pre") {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
for (const child of childElements(el)) {
|
|
111
|
+
pruneNode(child, threshold, minWords, masses);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const compositeScore = (el: Element, minWords: number | undefined, masses: Map<Element, Mass>): number => {
|
|
116
|
+
const mass = masses.get(el) ?? { textLen: 0, markupLen: 0, linkTextLen: 0 };
|
|
117
|
+
if (minWords !== undefined && textOf(el).split(" ").filter(Boolean).length < minWords) {
|
|
118
|
+
return -1;
|
|
119
|
+
}
|
|
120
|
+
const textDensity = mass.markupLen > 0 ? mass.textLen / mass.markupLen : 0;
|
|
121
|
+
const linkDensity = 1 - (mass.textLen > 0 ? mass.linkTextLen / mass.textLen : 0);
|
|
122
|
+
const tagWeight = TAG_WEIGHTS[el.tagName] ?? 0.5;
|
|
123
|
+
const classId = attr(el, "class") ?? "";
|
|
124
|
+
const id = attr(el, "id") ?? "";
|
|
125
|
+
const classIdScore = (NEGATIVE.test(classId) ? -0.5 : 0) + (NEGATIVE.test(id) ? -0.5 : 0);
|
|
126
|
+
const score =
|
|
127
|
+
WEIGHTS.textDensity * textDensity +
|
|
128
|
+
WEIGHTS.linkDensity * linkDensity +
|
|
129
|
+
WEIGHTS.tagWeight * tagWeight +
|
|
130
|
+
WEIGHTS.classIdWeight * Math.max(0, classIdScore) +
|
|
131
|
+
WEIGHTS.textLength * Math.log(mass.textLen + 1);
|
|
132
|
+
return score / (WEIGHTS.textDensity + WEIGHTS.linkDensity + WEIGHTS.tagWeight + WEIGHTS.classIdWeight + WEIGHTS.textLength);
|
|
133
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { isAllowed, parseRobots } from "./robots.js";
|
|
2
|
+
|
|
3
|
+
describe("parseRobots", () => {
|
|
4
|
+
it("takes the wildcard group and reads sitemaps and crawl-delay", () => {
|
|
5
|
+
const rules = parseRobots(
|
|
6
|
+
[
|
|
7
|
+
"User-agent: googlebot",
|
|
8
|
+
"Disallow: /google-only",
|
|
9
|
+
"",
|
|
10
|
+
"User-agent: *",
|
|
11
|
+
"Disallow: /private",
|
|
12
|
+
"Crawl-delay: 2",
|
|
13
|
+
"",
|
|
14
|
+
"Sitemap: https://ex.com/sitemap.xml",
|
|
15
|
+
].join("\n"),
|
|
16
|
+
);
|
|
17
|
+
expect(rules.disallows).toEqual(["/private"]);
|
|
18
|
+
expect(rules.crawlDelayS).toBe(2);
|
|
19
|
+
expect(rules.sitemaps).toEqual(["https://ex.com/sitemap.xml"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("prefers a group naming webq over the wildcard", () => {
|
|
23
|
+
const rules = parseRobots(["User-agent: *", "Disallow: /", "", "User-agent: webq", "Disallow: /only-this"].join("\n"));
|
|
24
|
+
expect(rules.disallows).toEqual(["/only-this"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("lets several user-agent lines share one group", () => {
|
|
28
|
+
const rules = parseRobots(["User-agent: a", "User-agent: *", "Disallow: /x"].join("\n"));
|
|
29
|
+
expect(rules.disallows).toEqual(["/x"]);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("isAllowed", () => {
|
|
34
|
+
it("applies longest-match-wins with allow beating disallow on ties", () => {
|
|
35
|
+
const rules = parseRobots(["User-agent: *", "Disallow: /docs", "Allow: /docs/public"].join("\n"));
|
|
36
|
+
expect(isAllowed(rules, "/docs/private")).toBe(false);
|
|
37
|
+
expect(isAllowed(rules, "/docs/public/page")).toBe(true);
|
|
38
|
+
expect(isAllowed(rules, "/other")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("supports * wildcards and $ end anchors", () => {
|
|
42
|
+
const rules = parseRobots(["User-agent: *", "Disallow: /*.pdf$", "Disallow: /tmp/*"].join("\n"));
|
|
43
|
+
expect(isAllowed(rules, "/report.pdf")).toBe(false);
|
|
44
|
+
expect(isAllowed(rules, "/report.pdf.html")).toBe(true);
|
|
45
|
+
expect(isAllowed(rules, "/tmp/x/y")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("treats an empty disallow as allow-all", () => {
|
|
49
|
+
const rules = parseRobots(["User-agent: *", "Disallow:"].join("\n"));
|
|
50
|
+
expect(isAllowed(rules, "/anything")).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/* robots.txt, parsed the way Google documents it: rules grouped by user-agent (several agent lines can share one group). */
|
|
2
|
+
|
|
3
|
+
export interface RobotsRules {
|
|
4
|
+
readonly allows: string[];
|
|
5
|
+
readonly disallows: string[];
|
|
6
|
+
readonly crawlDelayS: number | undefined;
|
|
7
|
+
readonly sitemaps: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const EVERYTHING_ALLOWED: RobotsRules = { allows: [], disallows: [], crawlDelayS: undefined, sitemaps: [] };
|
|
11
|
+
|
|
12
|
+
const OUR_NAME = "webq";
|
|
13
|
+
|
|
14
|
+
export const parseRobots = (text: string): RobotsRules => {
|
|
15
|
+
interface Group {
|
|
16
|
+
agents: string[];
|
|
17
|
+
allows: string[];
|
|
18
|
+
disallows: string[];
|
|
19
|
+
crawlDelayS: number | undefined;
|
|
20
|
+
}
|
|
21
|
+
const groups: Group[] = [];
|
|
22
|
+
const sitemaps: string[] = [];
|
|
23
|
+
let current: Group | undefined;
|
|
24
|
+
let openForAgents = false;
|
|
25
|
+
for (const rawLine of text.split("\n")) {
|
|
26
|
+
const line = (rawLine.split("#")[0] ?? "").trim();
|
|
27
|
+
const colon = line.indexOf(":");
|
|
28
|
+
if (colon < 0) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const field = line.slice(0, colon).trim().toLowerCase();
|
|
32
|
+
const value = line.slice(colon + 1).trim();
|
|
33
|
+
if (field === "sitemap") {
|
|
34
|
+
sitemaps.push(value);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (field === "user-agent") {
|
|
38
|
+
if (!openForAgents || current === undefined) {
|
|
39
|
+
current = { agents: [], allows: [], disallows: [], crawlDelayS: undefined };
|
|
40
|
+
groups.push(current);
|
|
41
|
+
openForAgents = true;
|
|
42
|
+
}
|
|
43
|
+
current.agents.push(value.toLowerCase());
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
openForAgents = false;
|
|
47
|
+
if (current === undefined) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (field === "allow") {
|
|
51
|
+
current.allows.push(value);
|
|
52
|
+
} else if (field === "disallow") {
|
|
53
|
+
current.disallows.push(value);
|
|
54
|
+
} else if (field === "crawl-delay") {
|
|
55
|
+
const delay = Number(value);
|
|
56
|
+
current.crawlDelayS = Number.isFinite(delay) ? delay : undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const named = groups.find((group) => group.agents.some((agent) => OUR_NAME.includes(agent) || agent.includes(OUR_NAME)));
|
|
60
|
+
const wildcard = groups.find((group) => group.agents.includes("*"));
|
|
61
|
+
const chosen = named ?? wildcard;
|
|
62
|
+
return {
|
|
63
|
+
allows: chosen?.allows ?? [],
|
|
64
|
+
disallows: chosen?.disallows ?? [],
|
|
65
|
+
crawlDelayS: chosen?.crawlDelayS,
|
|
66
|
+
sitemaps,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const isAllowed = (rules: RobotsRules, path: string): boolean => {
|
|
71
|
+
const disallow = longestMatch(rules.disallows, path);
|
|
72
|
+
if (disallow === undefined) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
const allow = longestMatch(rules.allows, path);
|
|
76
|
+
// Ties go to allow, per the spec's "least restrictive" rule.
|
|
77
|
+
return allow !== undefined && allow >= disallow;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Length of the longest pattern in `patterns` matching `path`, or undefined when none match. */
|
|
81
|
+
const longestMatch = (patterns: string[], path: string): number | undefined => {
|
|
82
|
+
let longest: number | undefined;
|
|
83
|
+
for (const pattern of patterns) {
|
|
84
|
+
if (pattern === "") {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (matches(pattern, path) && (longest === undefined || pattern.length > longest)) {
|
|
88
|
+
longest = pattern.length;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return longest;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const matches = (pattern: string, path: string): boolean => {
|
|
95
|
+
const escaped = pattern.replaceAll(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
|
|
96
|
+
const anchored = escaped.endsWith("$") ? `^${escaped.slice(0, -2)}$` : `^${escaped}`;
|
|
97
|
+
try {
|
|
98
|
+
return new RegExp(anchored).test(path);
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { fetchSitemapUrls } from "./sitemap.js";
|
|
2
|
+
|
|
3
|
+
const stub =
|
|
4
|
+
(map: Record<string, string>) =>
|
|
5
|
+
(url: string): Promise<{ body: string } | undefined> =>
|
|
6
|
+
Promise.resolve(map[url] === undefined ? undefined : { body: map[url] });
|
|
7
|
+
|
|
8
|
+
describe("fetchSitemapUrls", () => {
|
|
9
|
+
it("reads a urlset, decoding entities and normalizing", async () => {
|
|
10
|
+
const seed = await fetchSitemapUrls(
|
|
11
|
+
"https://ex.com/start",
|
|
12
|
+
[],
|
|
13
|
+
stub({
|
|
14
|
+
"https://ex.com/sitemap.xml": `<urlset><url><loc> https://ex.com/a </loc></url><url><loc>https://ex.com/b?x=1&y=2</loc></url></urlset>`,
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
expect(seed.urls).toEqual(["https://ex.com/a", "https://ex.com/b?x=1&y=2"]);
|
|
18
|
+
expect(seed.capped).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("follows one level of sitemap index and prefers robots-declared sitemaps", async () => {
|
|
22
|
+
const seed = await fetchSitemapUrls(
|
|
23
|
+
"https://ex.com/",
|
|
24
|
+
["https://ex.com/custom.xml"],
|
|
25
|
+
stub({
|
|
26
|
+
"https://ex.com/custom.xml": `<sitemapindex><sitemap><loc>https://ex.com/part1.xml</loc></sitemap></sitemapindex>`,
|
|
27
|
+
"https://ex.com/part1.xml": `<urlset><url><loc>https://ex.com/deep</loc></url></urlset>`,
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
expect(seed.urls).toEqual(["https://ex.com/deep"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns empty when no sitemap answers", async () => {
|
|
34
|
+
const seed = await fetchSitemapUrls("https://ex.com/", [], stub({}));
|
|
35
|
+
expect(seed.urls).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/* Sitemap seeding: the URLs a site already published as its own map, so a crawl starts from the real table of contents instead of discovering it hop by hop. */
|
|
2
|
+
import { gunzipSync } from "node:zlib";
|
|
3
|
+
import { normalizeUrl } from "./links.js";
|
|
4
|
+
|
|
5
|
+
const URL_CAP = 2_000;
|
|
6
|
+
const CHILD_SITEMAP_CAP = 10;
|
|
7
|
+
|
|
8
|
+
export interface SitemapSeed {
|
|
9
|
+
readonly urls: string[];
|
|
10
|
+
/** True when the cap cut discovery short — the crawl capsule reports it instead of implying totality. */
|
|
11
|
+
readonly capped: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const fetchSitemapUrls = async (
|
|
15
|
+
startUrl: string,
|
|
16
|
+
robotsSitemaps: string[],
|
|
17
|
+
fetchText: (url: string) => Promise<{ body: string; bytes?: Uint8Array } | undefined>,
|
|
18
|
+
): Promise<SitemapSeed> => {
|
|
19
|
+
const origin = new URL(startUrl).origin;
|
|
20
|
+
const roots = robotsSitemaps.length > 0 ? robotsSitemaps : [`${origin}/sitemap.xml`];
|
|
21
|
+
const urls = new Set<string>();
|
|
22
|
+
let capped = false;
|
|
23
|
+
let childBudget = CHILD_SITEMAP_CAP;
|
|
24
|
+
const queue = [...roots];
|
|
25
|
+
while (queue.length > 0 && urls.size < URL_CAP) {
|
|
26
|
+
const sitemapUrl = queue.shift();
|
|
27
|
+
if (sitemapUrl === undefined) {
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
const fetched = await fetchText(sitemapUrl);
|
|
31
|
+
if (fetched === undefined) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const xml = maybeGunzip(sitemapUrl, fetched);
|
|
35
|
+
const locs = [...xml.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/g)].map((m) => m[1] ?? "");
|
|
36
|
+
if (/<sitemapindex/i.test(xml)) {
|
|
37
|
+
for (const loc of locs) {
|
|
38
|
+
if (childBudget > 0) {
|
|
39
|
+
queue.push(loc);
|
|
40
|
+
childBudget -= 1;
|
|
41
|
+
} else {
|
|
42
|
+
capped = true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
for (const loc of locs) {
|
|
48
|
+
const url = normalizeUrl(decodeXmlEntities(loc));
|
|
49
|
+
if (url === undefined) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (urls.size >= URL_CAP) {
|
|
53
|
+
capped = true;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
urls.add(url);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { urls: [...urls], capped };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const maybeGunzip = (url: string, fetched: { body: string; bytes?: Uint8Array }): string => {
|
|
63
|
+
if (!url.endsWith(".gz") || fetched.bytes === undefined) {
|
|
64
|
+
return fetched.body;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return gunzipSync(fetched.bytes).toString("utf8");
|
|
68
|
+
} catch {
|
|
69
|
+
return fetched.body;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const decodeXmlEntities = (text: string): string =>
|
|
74
|
+
text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", '"').replaceAll("'", "'");
|