crawldna 0.1.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 +689 -0
- package/README.md +525 -0
- package/bin/cli.mjs +607 -0
- package/index.d.ts +376 -0
- package/package.json +64 -0
- package/src/engine/actions.mjs +57 -0
- package/src/engine/consent.mjs +125 -0
- package/src/engine/crawl-page.mjs +511 -0
- package/src/engine/decide.mjs +555 -0
- package/src/engine/perceive.mjs +647 -0
- package/src/engine/reveal.mjs +799 -0
- package/src/eval/metrics.mjs +236 -0
- package/src/eval/report.mjs +149 -0
- package/src/extract.mjs +1208 -0
- package/src/index.mjs +1115 -0
- package/src/lib/browser.mjs +242 -0
- package/src/lib/challenge.mjs +110 -0
- package/src/lib/faithful.mjs +167 -0
- package/src/lib/fetcher.mjs +151 -0
- package/src/lib/incremental.mjs +75 -0
- package/src/lib/layout.mjs +279 -0
- package/src/lib/llm.mjs +534 -0
- package/src/lib/output.mjs +66 -0
- package/src/lib/pool.mjs +30 -0
- package/src/lib/relevance.mjs +139 -0
- package/src/lib/retrieve.mjs +165 -0
- package/src/lib/robots.mjs +125 -0
- package/src/lib/runs.mjs +562 -0
- package/src/lib/semantic.mjs +172 -0
- package/src/lib/settle.mjs +69 -0
- package/src/lib/simhash.mjs +112 -0
- package/src/lib/task.mjs +65 -0
- package/src/lib/url.mjs +155 -0
- package/src/profiles/docs/llms.mjs +77 -0
- package/src/profiles/docs/sitemap.mjs +121 -0
- package/src/profiles/docs.mjs +96 -0
- package/src/reshape.mjs +204 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
3
|
+
// #22 — the semantic relevance tier: embeddings-backed task→link scoring.
|
|
4
|
+
//
|
|
5
|
+
// The lexical scorer (lib/relevance.mjs) is fast, free and deterministic, but
|
|
6
|
+
// blind across languages and synonyms: an Italian task ("estrai i prezzi") on a
|
|
7
|
+
// German site scores "Preise" 0. When the user configures an `embedModel`, this
|
|
8
|
+
// tier embeds the task once per scan and every unique link ONCE (cached, batch
|
|
9
|
+
// calls), and cosine similarity becomes the relevance score — multilingual by
|
|
10
|
+
// nature, and hallucination-free (embeddings emit numbers, not text).
|
|
11
|
+
//
|
|
12
|
+
// Precision rules (the item's contract):
|
|
13
|
+
// - embeddings ORDER always, they CUT only through the explicit `minRelevance`
|
|
14
|
+
// opt-in — exactly like the lexical scores they replace;
|
|
15
|
+
// - the AI link gate stays the judge in targeted mode; this is the ranking
|
|
16
|
+
// signal under it, not a replacement;
|
|
17
|
+
// - a GENERIC task (no topic terms) never discriminates — everything scores 1,
|
|
18
|
+
// same as lexical, so a "get everything" crawl is never reordered into a
|
|
19
|
+
// preference it didn't express;
|
|
20
|
+
// - no `embedModel`, no-AI mode, or a failing backend → the lexical floor,
|
|
21
|
+
// with ONE loud warning (never a silent degrade);
|
|
22
|
+
// - rule #6: in no-AI mode the descriptor carries no embedModel and embed()
|
|
23
|
+
// refuses to run — zero model calls of ANY kind.
|
|
24
|
+
|
|
25
|
+
import { embed, llmDisabled } from './llm.mjs';
|
|
26
|
+
import { scoreLink, taskTerms } from './relevance.mjs';
|
|
27
|
+
|
|
28
|
+
const BATCH = 64; // texts per embed call — bounded requests, few round-trips
|
|
29
|
+
|
|
30
|
+
/** Cosine similarity clamped to [0, 1] (sentence embeddings sit ≥0 in practice;
|
|
31
|
+
* the clamp just keeps the score a valid relevance value). */
|
|
32
|
+
export function cosine(a, b) {
|
|
33
|
+
let dot = 0;
|
|
34
|
+
let na = 0;
|
|
35
|
+
let nb = 0;
|
|
36
|
+
const n = Math.min(a.length, b.length);
|
|
37
|
+
for (let i = 0; i < n; i++) {
|
|
38
|
+
dot += a[i] * b[i];
|
|
39
|
+
na += a[i] * a[i];
|
|
40
|
+
nb += b[i] * b[i];
|
|
41
|
+
}
|
|
42
|
+
if (!na || !nb) return 0;
|
|
43
|
+
return Math.max(0, Math.min(1, dot / Math.sqrt(na * nb)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The text a link is embedded as: its human label + nearby heading + decoded
|
|
47
|
+
* URL path — the same signals the lexical scorer reads, as one string. */
|
|
48
|
+
function linkText(link) {
|
|
49
|
+
let path = '';
|
|
50
|
+
try {
|
|
51
|
+
path = decodeURIComponent(new URL(link.href).pathname).replace(/[/_-]+/g, ' ').trim();
|
|
52
|
+
} catch {
|
|
53
|
+
path = String(link.href || '');
|
|
54
|
+
}
|
|
55
|
+
return [link.label, link.context, path].filter(Boolean).join(' — ').slice(0, 300);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Per-scan relevance scorer. `scoreAll(candidates)` returns Map<href, score 0..1>,
|
|
60
|
+
* semantic when an embedModel is configured and answering, lexical otherwise.
|
|
61
|
+
* Create ONE per scan (the task is fixed there) and reuse it — the vector cache
|
|
62
|
+
* and the one-time failure warning live on the instance.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} a
|
|
65
|
+
* @param {object} a.llm the resolveLlm descriptor (reads .embedModel)
|
|
66
|
+
* @param {string} a.task the scan's task (the query)
|
|
67
|
+
* @param {(msg:string)=>void} [a.onWarn] called ONCE if the backend fails
|
|
68
|
+
*/
|
|
69
|
+
export function createScorer({ llm, task, onWarn } = {}) {
|
|
70
|
+
const terms = taskTerms(task);
|
|
71
|
+
// Semantic only when it can help AND is allowed: an embedModel is set, AI is
|
|
72
|
+
// not disabled, and the task actually names a topic (a generic task must not
|
|
73
|
+
// discriminate — same contract as the lexical scorer's all-1s).
|
|
74
|
+
let semantic = !!(llm && !llmDisabled(llm) && llm.embedModel && terms.length);
|
|
75
|
+
const cache = new Map(); // linkText -> vector, per scan
|
|
76
|
+
let taskVec = null;
|
|
77
|
+
|
|
78
|
+
const lexicalAll = (candidates) => {
|
|
79
|
+
const out = new Map();
|
|
80
|
+
for (const c of candidates) out.set(c.href, scoreLink(terms, c).score);
|
|
81
|
+
return out;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
async function vectorsFor(texts) {
|
|
85
|
+
const missing = [...new Set(texts.filter((t) => !cache.has(t)))];
|
|
86
|
+
for (let i = 0; i < missing.length; i += BATCH) {
|
|
87
|
+
const batch = missing.slice(i, i + BATCH);
|
|
88
|
+
const vecs = await embed(llm, batch);
|
|
89
|
+
batch.forEach((t, j) => cache.set(t, vecs[j]));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
get semantic() {
|
|
95
|
+
return semantic;
|
|
96
|
+
},
|
|
97
|
+
/** @param {Array<{href:string,label?:string,context?:string}>} candidates
|
|
98
|
+
* @returns {Promise<Map<string, number>>} href → relevance score in [0,1] */
|
|
99
|
+
async scoreAll(candidates) {
|
|
100
|
+
if (!semantic) return lexicalAll(candidates);
|
|
101
|
+
try {
|
|
102
|
+
if (!taskVec) {
|
|
103
|
+
await vectorsFor([task]);
|
|
104
|
+
taskVec = cache.get(task);
|
|
105
|
+
}
|
|
106
|
+
const texts = candidates.map(linkText);
|
|
107
|
+
await vectorsFor(texts);
|
|
108
|
+
const out = new Map();
|
|
109
|
+
candidates.forEach((c, i) => out.set(c.href, cosine(taskVec, cache.get(texts[i]))));
|
|
110
|
+
return out;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
// ONE loud warning, then the lexical floor for the rest of the scan —
|
|
113
|
+
// a broken embedding backend must never break (or silently skew) a crawl.
|
|
114
|
+
semantic = false;
|
|
115
|
+
if (onWarn) {
|
|
116
|
+
try {
|
|
117
|
+
onWarn(
|
|
118
|
+
`Embedding model '${llm.embedModel}' failed (${String((err && err.message) || err).slice(0, 160)}) — ` +
|
|
119
|
+
'falling back to lexical relevance for this scan.',
|
|
120
|
+
);
|
|
121
|
+
} catch {
|
|
122
|
+
/* never break scoring over a warn */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return lexicalAll(candidates);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* #22 for the reshape retrieval (lib/retrieve.mjs): score every H1–H3 section of
|
|
133
|
+
* every document against the instruction, semantically. Returns a lookup
|
|
134
|
+
* `(di, si) => score in [0,1]`, or null when the semantic tier is off/failed —
|
|
135
|
+
* the caller then keeps the lexical retrieval unchanged.
|
|
136
|
+
*
|
|
137
|
+
* Each section is embedded by its GIST (heading + first 300 chars), not its full
|
|
138
|
+
* body — that bounds the cost to ~sections×75 tokens while still capturing what
|
|
139
|
+
* the section is about (the standard head-window trick).
|
|
140
|
+
*
|
|
141
|
+
* @param {object} a
|
|
142
|
+
* @param {object} a.llm
|
|
143
|
+
* @param {string} a.instruction
|
|
144
|
+
* @param {Array<Array<{heading:string,text:string}>>} a.docSections sections per document,
|
|
145
|
+
* in the SAME order/indices the caller will use (sectionizeDoc output)
|
|
146
|
+
* @returns {Promise<null | ((di:number, si:number) => number)>}
|
|
147
|
+
*/
|
|
148
|
+
export async function semanticSectionScores({ llm, instruction, docSections }) {
|
|
149
|
+
if (!llm || llmDisabled(llm) || !llm.embedModel) return null;
|
|
150
|
+
try {
|
|
151
|
+
const gists = [];
|
|
152
|
+
const keys = [];
|
|
153
|
+
docSections.forEach((sections, di) => {
|
|
154
|
+
sections.forEach((s, si) => {
|
|
155
|
+
keys.push(`${di}:${si}`);
|
|
156
|
+
gists.push(`${s.heading}\n${s.text.slice(0, 300)}`);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
if (!gists.length) return null;
|
|
160
|
+
const [qv, ...rest] = await (async () => {
|
|
161
|
+
const out = [];
|
|
162
|
+
const all = [instruction, ...gists];
|
|
163
|
+
for (let i = 0; i < all.length; i += BATCH) out.push(...(await embed(llm, all.slice(i, i + BATCH))));
|
|
164
|
+
return out;
|
|
165
|
+
})();
|
|
166
|
+
const scores = new Map();
|
|
167
|
+
keys.forEach((k, i) => scores.set(k, cosine(qv, rest[i])));
|
|
168
|
+
return (di, si) => scores.get(`${di}:${si}`) ?? 0;
|
|
169
|
+
} catch {
|
|
170
|
+
return null; // lexical retrieval takes over — never break a reshape over ranking
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
3
|
+
// The render-wait signal (#15): "response-quiet + stable text", bounded.
|
|
4
|
+
// Shared by the initial page render (engine/crawl-page.mjs), the reveal loop's
|
|
5
|
+
// post-click wait and base restore (engine/actions.mjs, engine/reveal.mjs), and
|
|
6
|
+
// the browser-escalation fetch (lib/fetcher.mjs). Pure JS over the Playwright
|
|
7
|
+
// page interface — no Playwright import, so it unit-tests with a fake page.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Wait for a page's async activity to FULLY land before the caller captures,
|
|
11
|
+
* WITHOUT over-waiting when nothing is happening.
|
|
12
|
+
*
|
|
13
|
+
* The hard case that shaped it (post-click): a widget whose FIRST interaction
|
|
14
|
+
* triggers a one-time cascade of lazy-loaded scripts (a booking calendar pulls
|
|
15
|
+
* in pikaday / jquery-ui / sweetalert / recaptcha the first time a day is
|
|
16
|
+
* picked) that delays the real content — the day's slot grid — by ~1s, during
|
|
17
|
+
* which the visible TEXT sits on a flat plateau. A DOM-stability poll is fooled
|
|
18
|
+
* by that plateau and snapshots the page without the slots; `networkidle` with
|
|
19
|
+
* a fixed timeout either under- or over-waits and, when it over-waits,
|
|
20
|
+
* desynchronises the click→reveal→restore rhythm so per-day panels are lost.
|
|
21
|
+
*
|
|
22
|
+
* The reliable signal is the RESPONSE STREAM: real content arrives on a network
|
|
23
|
+
* response, so wait until a response has come back AND the network has then
|
|
24
|
+
* been quiet for a short grace window, with the DOM text no longer changing.
|
|
25
|
+
* An action that fetches waits exactly as long as its cascade runs; one that
|
|
26
|
+
* fetches NOTHING sees no response and falls through after the same short
|
|
27
|
+
* grace — so no-ops stay cheap. Bounded by maxMs so recaptcha/ads heartbeats
|
|
28
|
+
* can't stall the crawl. Generic — no per-site assumptions.
|
|
29
|
+
*
|
|
30
|
+
* Crucially for the initial render (#15), quietness counts response EVENTS —
|
|
31
|
+
* not open connections like `networkidle` does — so a site holding a
|
|
32
|
+
* websocket / SSE / long-poll connection open (where the idle signal NEVER
|
|
33
|
+
* fires and a networkidle wait burned its full timeout on every page) exits
|
|
34
|
+
* after one grace window like any quiet page.
|
|
35
|
+
*
|
|
36
|
+
* @param {import('playwright').Page} page anything with on/off('response'),
|
|
37
|
+
* waitForTimeout(ms) and evaluate(fn) — duck-typed for testability
|
|
38
|
+
*/
|
|
39
|
+
export async function settle(page, { maxMs = 4500, graceMs = 650, intervalMs = 120 } = {}) {
|
|
40
|
+
const start = Date.now();
|
|
41
|
+
let sawResponse = false;
|
|
42
|
+
let lastResponse = start;
|
|
43
|
+
const onResponse = () => {
|
|
44
|
+
sawResponse = true;
|
|
45
|
+
lastResponse = Date.now();
|
|
46
|
+
};
|
|
47
|
+
page.on('response', onResponse);
|
|
48
|
+
try {
|
|
49
|
+
let prevLen = -1;
|
|
50
|
+
while (Date.now() - start < maxMs) {
|
|
51
|
+
await page.waitForTimeout(intervalMs);
|
|
52
|
+
let len;
|
|
53
|
+
try {
|
|
54
|
+
len = await page.evaluate(() => document.body.innerText.length);
|
|
55
|
+
} catch {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// "Quiet" = the grace window has passed since the last response (or, if no
|
|
59
|
+
// response ever came, since the start). Settled once it is quiet AND the
|
|
60
|
+
// text has stopped changing — so a late render that trails the final
|
|
61
|
+
// response still gets one more poll before we conclude.
|
|
62
|
+
const since = sawResponse ? Date.now() - lastResponse : Date.now() - start;
|
|
63
|
+
if (since >= graceMs && len === prevLen) return;
|
|
64
|
+
prevLen = len;
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
page.off('response', onResponse);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
3
|
+
// 64-bit SimHash (Charikar) — a near-duplicate fingerprint for text. Pure JS, no
|
|
4
|
+
// dependencies. Two documents that differ only in boilerplate/template produce
|
|
5
|
+
// fingerprints a small Hamming distance apart; genuinely different documents are far
|
|
6
|
+
// apart. Used (opt-in) to collapse near-duplicate pages the exact-hash dedup misses.
|
|
7
|
+
//
|
|
8
|
+
// Why a fingerprint and not a full diff: it is O(1) to compare (one XOR + popcount over
|
|
9
|
+
// 64 bits) and O(tokens) to build, so a whole crawl can be de-duped cheaply. Evidence:
|
|
10
|
+
// Manku/Google "Detecting Near-Duplicates for Web Crawling"; Charikar SimHash.
|
|
11
|
+
//
|
|
12
|
+
// A fingerprint is `{ hi, lo }` — two unsigned 32-bit halves of the 64-bit value — so the
|
|
13
|
+
// hot path (build + Hamming) stays on fast 32-bit integer math, never BigInt.
|
|
14
|
+
|
|
15
|
+
/** MurmurHash3-style 32-bit hash over a string's UTF-16 code units. Good bit mixing (the
|
|
16
|
+
* finalizer avalanches), deterministic, fast — ample for fingerprint features. */
|
|
17
|
+
function murmur3_32(str, seed) {
|
|
18
|
+
let h = seed >>> 0;
|
|
19
|
+
for (let i = 0; i < str.length; i++) {
|
|
20
|
+
let k = Math.imul(str.charCodeAt(i), 0xcc9e2d51);
|
|
21
|
+
k = (k << 15) | (k >>> 17);
|
|
22
|
+
k = Math.imul(k, 0x1b873593);
|
|
23
|
+
h ^= k;
|
|
24
|
+
h = (h << 13) | (h >>> 19);
|
|
25
|
+
h = (Math.imul(h, 5) + 0xe6546b64) | 0;
|
|
26
|
+
}
|
|
27
|
+
h ^= str.length;
|
|
28
|
+
h ^= h >>> 16;
|
|
29
|
+
h = Math.imul(h, 0x85ebca6b);
|
|
30
|
+
h ^= h >>> 13;
|
|
31
|
+
h = Math.imul(h, 0xc2b2ae35);
|
|
32
|
+
h ^= h >>> 16;
|
|
33
|
+
return h >>> 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A 64-bit hash of a feature, as two independent 32-bit halves. */
|
|
37
|
+
function hash64(str) {
|
|
38
|
+
return { hi: murmur3_32(str, 0x9747b28c), lo: murmur3_32(str, 0x01000193) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Lowercased alphanumeric tokens (length ≥ 2). */
|
|
42
|
+
function tokenize(text) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const t of String(text || '').toLowerCase().split(/[^a-z0-9]+/)) {
|
|
45
|
+
if (t.length >= 2) out.push(t);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Weighted features for the fingerprint: word SHINGLES (n-grams) counted by frequency.
|
|
52
|
+
* Shingles (default bigrams) localise a change to a couple of features, so a small edit
|
|
53
|
+
* flips only a few fingerprint bits — the property near-dup detection relies on. Falls
|
|
54
|
+
* back to unigrams for very short texts.
|
|
55
|
+
*/
|
|
56
|
+
function features(text, shingle) {
|
|
57
|
+
const toks = tokenize(text);
|
|
58
|
+
const map = new Map();
|
|
59
|
+
const n = Math.max(1, shingle);
|
|
60
|
+
if (toks.length < n) {
|
|
61
|
+
for (const t of toks) map.set(t, (map.get(t) || 0) + 1);
|
|
62
|
+
} else {
|
|
63
|
+
for (let i = 0; i + n <= toks.length; i++) {
|
|
64
|
+
const key = toks.slice(i, i + n).join(' ');
|
|
65
|
+
map.set(key, (map.get(key) || 0) + 1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return map;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compute the 64-bit SimHash of `text`.
|
|
73
|
+
* @param {string} text
|
|
74
|
+
* @param {{ shingle?: number }} [opts] n-gram size for features (default 2)
|
|
75
|
+
* @returns {{ hi: number, lo: number }} the fingerprint (two unsigned 32-bit halves)
|
|
76
|
+
*/
|
|
77
|
+
export function simhash(text, { shingle = 2 } = {}) {
|
|
78
|
+
const feats = features(text, shingle);
|
|
79
|
+
const v = new Array(64).fill(0); // per-bit weighted vote
|
|
80
|
+
for (const [feat, w] of feats) {
|
|
81
|
+
const { hi, lo } = hash64(feat);
|
|
82
|
+
for (let i = 0; i < 32; i++) {
|
|
83
|
+
v[i] += (lo >>> i) & 1 ? w : -w;
|
|
84
|
+
v[i + 32] += (hi >>> i) & 1 ? w : -w;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
let lo = 0;
|
|
88
|
+
let hi = 0;
|
|
89
|
+
for (let i = 0; i < 32; i++) {
|
|
90
|
+
if (v[i] > 0) lo |= 1 << i;
|
|
91
|
+
if (v[i + 32] > 0) hi |= 1 << i;
|
|
92
|
+
}
|
|
93
|
+
return { hi: hi >>> 0, lo: lo >>> 0 };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Count set bits in a 32-bit integer (SWAR popcount). */
|
|
97
|
+
function popcount32(x) {
|
|
98
|
+
x = x - ((x >>> 1) & 0x55555555);
|
|
99
|
+
x = (x & 0x33333333) + ((x >>> 2) & 0x33333333);
|
|
100
|
+
x = (x + (x >>> 4)) & 0x0f0f0f0f;
|
|
101
|
+
return (Math.imul(x, 0x01010101) >>> 24);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Hamming distance (0..64) between two fingerprints. */
|
|
105
|
+
export function hamming(a, b) {
|
|
106
|
+
return popcount32((a.hi ^ b.hi) >>> 0) + popcount32((a.lo ^ b.lo) >>> 0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** True when two fingerprints are within `maxHamming` bits — i.e. near-duplicates. */
|
|
110
|
+
export function isNearDup(a, b, maxHamming) {
|
|
111
|
+
return hamming(a, b) <= maxHamming;
|
|
112
|
+
}
|
package/src/lib/task.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
3
|
+
// Task classification shared by the orchestrator and the engine.
|
|
4
|
+
|
|
5
|
+
/** The valid values of the `mode` option (#20). */
|
|
6
|
+
export const MODES = ['auto', 'complete', 'targeted'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* #20 — resolve the EXPLICIT `mode` option into the three engine switches it
|
|
10
|
+
* controls. This is the single place where "what kind of crawl is this?" is
|
|
11
|
+
* decided, so the engine never re-derives it (and never sniffs the task prose
|
|
12
|
+
* outside of 'auto'):
|
|
13
|
+
*
|
|
14
|
+
* - `docsShortcuts`: try the completeness shortcuts first (llms-full.txt /
|
|
15
|
+
* sitemap seeding via the docs profile) instead of pure discovery crawling.
|
|
16
|
+
* - `scopeSections`: run aiScopeContent per page (keep only task-relevant
|
|
17
|
+
* sections, verbatim). Off = pages are always kept WHOLE.
|
|
18
|
+
* - `linkGate`: send discovered links to the AI link gate (aiSelectLinks).
|
|
19
|
+
* Off = every in-scope link is followed, ZERO gate calls — keep/drop has no
|
|
20
|
+
* meaning when the user asked for everything, and the mirror/variant dedup
|
|
21
|
+
* (default-on) is what keeps follow-everything contained.
|
|
22
|
+
*
|
|
23
|
+
* `complete` = "the whole site": shortcuts on, pages whole, no gate — AI (when
|
|
24
|
+
* enabled) still drives reveal + nav-plan, the jobs that find hidden content.
|
|
25
|
+
* `targeted` = "only what the task asks": gate + scoping on, regardless of how
|
|
26
|
+
* the task is phrased. Requires AI (refused with `noAi` — enforced upstream in
|
|
27
|
+
* crawlDocs, never silently).
|
|
28
|
+
* `auto` = the historical behaviour, kept ONLY for backward compatibility
|
|
29
|
+
* (library callers, saved runs, resume): the isDocsTask regex below decides.
|
|
30
|
+
* Anything unrecognised resolves to 'auto' here; crawlDocs validates first and
|
|
31
|
+
* rejects unknown values loudly.
|
|
32
|
+
*/
|
|
33
|
+
export function modeBehavior(mode, task) {
|
|
34
|
+
const m = mode === 'complete' || mode === 'targeted' ? mode : 'auto';
|
|
35
|
+
if (m === 'complete') return { mode: m, docsShortcuts: true, scopeSections: false, linkGate: false };
|
|
36
|
+
if (m === 'targeted') return { mode: m, docsShortcuts: false, scopeSections: true, linkGate: true };
|
|
37
|
+
const docs = isDocsTask(task);
|
|
38
|
+
return { mode: 'auto', docsShortcuts: docs, scopeSections: !docs, linkGate: true };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Does the task ask for a software/product DOCUMENTATION site (developer docs, API/SDK
|
|
43
|
+
* reference, guides) — the completeness-first docs path — versus a specific data task
|
|
44
|
+
* (a menu, prices, a list)? This is a deterministic backstop that reads the USER'S
|
|
45
|
+
* INSTRUCTION, never the website, so it stays universal (no per-site rules).
|
|
46
|
+
*
|
|
47
|
+
* #20: since the explicit `mode` option exists, this regex is consulted ONLY by
|
|
48
|
+
* `modeBehavior('auto', …)` — the backward-compatibility path. New callers (and
|
|
49
|
+
* the UI) pass mode 'complete' or 'targeted' and never reach it.
|
|
50
|
+
*
|
|
51
|
+
* It is MULTILINGUAL by design: it matches the documentation STEM rather than one
|
|
52
|
+
* language's spelling, so it works whatever language the task is written in (Latin
|
|
53
|
+
* script). `documenta` covers documentation / documentazione / documentación /
|
|
54
|
+
* documentação / documentatie; `dokumenta` covers German/Nordic (Dokumentation); plus
|
|
55
|
+
* `docs`, `api reference`, and `sdk`. The stem (not a bare "document") avoids false
|
|
56
|
+
* positives on data tasks like "extract the documents list".
|
|
57
|
+
*
|
|
58
|
+
* Why it matters: a true verdict (a) picks the docs profile (llms-full.txt / sitemap →
|
|
59
|
+
* complete, fast) and (b) keeps pages WHOLE (skips the per-section scoping meant for
|
|
60
|
+
* specific tasks). A non-English task wrongly read as "not docs" loses both — it was the
|
|
61
|
+
* bug where an Italian "documentazione" crawl explored blindly and trimmed sections.
|
|
62
|
+
*/
|
|
63
|
+
export function isDocsTask(task) {
|
|
64
|
+
return /\bdocs?\b|documenta|dokumenta|api[\s_-]*reference|\bsdk\b/i.test(task || '');
|
|
65
|
+
}
|
package/src/lib/url.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
3
|
+
// URL helpers: normalise, dedupe, scope. Pure, no dependencies.
|
|
4
|
+
|
|
5
|
+
/** Coerce a string|RegExp filter into a RegExp (or null). */
|
|
6
|
+
export function toRegExp(pattern) {
|
|
7
|
+
if (!pattern) return null;
|
|
8
|
+
if (pattern instanceof RegExp) return pattern;
|
|
9
|
+
try {
|
|
10
|
+
return new RegExp(String(pattern));
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalise a URL for dedup/comparison:
|
|
18
|
+
* - resolve against `base` when relative
|
|
19
|
+
* - FRAGMENT policy: a plain id anchor (#install, #main-content, #step-3) just
|
|
20
|
+
* points at a SECTION of the SAME page — keeping it makes the crawler fetch one
|
|
21
|
+
* page many times over (the single biggest source of wasted work on docs sites,
|
|
22
|
+
* e.g. firebase get-started#add-sdk / #kotlin / #next-steps as "separate pages").
|
|
23
|
+
* A hash-ROUTE (#/contact, #!/features) IS a real separate page in a hash-routed
|
|
24
|
+
* SPA. So we keep only route-like fragments (`#/…` or `#!…`) and drop plain
|
|
25
|
+
* anchors, collapsing `page#a`, `page#b` and `page` to one. Content-dedup stays
|
|
26
|
+
* as a second safety net.
|
|
27
|
+
* - lowercase the host
|
|
28
|
+
* - strip a trailing slash (except the root path)
|
|
29
|
+
* Returns null when the input cannot be parsed.
|
|
30
|
+
*/
|
|
31
|
+
// Query params that are tracking/analytics/locale noise — never select content.
|
|
32
|
+
// (Content-selecting params like `api`, `tab`, `version` are deliberately kept.)
|
|
33
|
+
const STRIP_PARAMS = new Set([
|
|
34
|
+
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id',
|
|
35
|
+
'gclid', 'fbclid', 'msclkid', 'mc_cid', 'mc_eid', '_ga', '_hsenc', '_hsmi',
|
|
36
|
+
'ref', 'ref_src', 'igshid', 'hl', 'lang', 'locale',
|
|
37
|
+
]);
|
|
38
|
+
// Some trackers append DYNAMIC param names (Google Analytics linker `_gl`, the
|
|
39
|
+
// per-stream `_ga_XXXXXXX`, GA4 `_up`). Match these by PREFIX so a session id baked
|
|
40
|
+
// into the name can't dodge the filter and spawn a duplicate URL of the same page.
|
|
41
|
+
const STRIP_PARAM_PREFIXES = ['_ga', '_gl', '_up', 'utm_', 'mc_'];
|
|
42
|
+
|
|
43
|
+
export function normalizeUrl(input, base) {
|
|
44
|
+
let u;
|
|
45
|
+
try {
|
|
46
|
+
u = new URL(input, base);
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
51
|
+
// A path that BEGINS with another absolute URL (`/https://other.site/…`) is the
|
|
52
|
+
// signature of a broken join — an absolute href glued onto a base — never a real
|
|
53
|
+
// route (seen live: https://0.vuetifyjs.com/https://v0play.vuetifyjs.com, a 404).
|
|
54
|
+
// Only the path PREFIX is rejected: nested URLs deeper in the path (Wayback-style
|
|
55
|
+
// /web/<ts>/https://…) or in the query (?redirect=https://…) are legitimate.
|
|
56
|
+
let decodedPath = u.pathname;
|
|
57
|
+
try {
|
|
58
|
+
decodedPath = decodeURIComponent(decodedPath);
|
|
59
|
+
} catch {
|
|
60
|
+
/* malformed %-escape: judge the raw path */
|
|
61
|
+
}
|
|
62
|
+
if (/^\/https?:\//i.test(decodedPath)) return null;
|
|
63
|
+
// Drop a plain in-page anchor; keep a hash ROUTE (#/… or #!…). See note above.
|
|
64
|
+
if (u.hash && !/^#[!/]/.test(u.hash)) u.hash = '';
|
|
65
|
+
u.hostname = u.hostname.toLowerCase();
|
|
66
|
+
if (u.pathname.length > 1) u.pathname = u.pathname.replace(/\/+$/, '');
|
|
67
|
+
for (const k of [...u.searchParams.keys()]) {
|
|
68
|
+
const lk = k.toLowerCase();
|
|
69
|
+
if (STRIP_PARAMS.has(lk) || STRIP_PARAM_PREFIXES.some((p) => lk.startsWith(p))) {
|
|
70
|
+
u.searchParams.delete(k);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return u.toString();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function hostOf(u) {
|
|
77
|
+
try {
|
|
78
|
+
return new URL(u).hostname.toLowerCase();
|
|
79
|
+
} catch {
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function originOf(u) {
|
|
85
|
+
try {
|
|
86
|
+
return new URL(u).origin;
|
|
87
|
+
} catch {
|
|
88
|
+
return '';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function pathOf(u) {
|
|
93
|
+
try {
|
|
94
|
+
return new URL(u).pathname;
|
|
95
|
+
} catch {
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** True when `url`'s host is the base host or a subdomain of it (not the parent). */
|
|
101
|
+
export function sameSite(url, baseUrl) {
|
|
102
|
+
const a = hostOf(url);
|
|
103
|
+
const b = hostOf(baseUrl);
|
|
104
|
+
if (!a || !b) return false;
|
|
105
|
+
return a === b || a.endsWith('.' + b);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Decide whether `url` is in scope for a crawl rooted at `baseUrl`.
|
|
110
|
+
* - `exclude` wins if it matches.
|
|
111
|
+
* - if `include` is set, the URL must match it.
|
|
112
|
+
* - otherwise default scope is the same site (host or subdomain).
|
|
113
|
+
*/
|
|
114
|
+
export function inScope(url, baseUrl, { include, exclude } = {}) {
|
|
115
|
+
const exc = toRegExp(exclude);
|
|
116
|
+
if (exc && exc.test(url)) return false;
|
|
117
|
+
const inc = toRegExp(include);
|
|
118
|
+
if (inc) return inc.test(url);
|
|
119
|
+
return sameSite(url, baseUrl);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Resolve a possibly-relative href against a base URL; null if invalid. */
|
|
123
|
+
export function resolveUrl(href, base) {
|
|
124
|
+
return normalizeUrl(href, base);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Key that groups URL-SIBLINGS: URLs whose path is the same once a leading
|
|
129
|
+
* locale-like segment (`/en/x`, `/pt-br/x` → `/x`) is stripped — so the same
|
|
130
|
+
* logical document reached via a mirror host (dev./staging./v2.), a UI-state
|
|
131
|
+
* query variant (`?panel=settings`), a hash-route twin, or a locale twin all
|
|
132
|
+
* share one key. Host and query are deliberately ignored: the frontier is
|
|
133
|
+
* already confined to one site by `inScope`, so a same-key page on another
|
|
134
|
+
* host is a same-site mirror, not a stranger. Sharing a key is only a HINT —
|
|
135
|
+
* callers must also check content (SimHash) before treating two pages as
|
|
136
|
+
* duplicates: e.g. `?version=1` vs `?version=2` share a key but genuinely
|
|
137
|
+
* differ, and measured Hamming distances keep them apart (see mirrorHamming).
|
|
138
|
+
*/
|
|
139
|
+
export function siblingKey(u) {
|
|
140
|
+
try {
|
|
141
|
+
const p = new URL(u).pathname.replace(/\/+$/, '') || '/';
|
|
142
|
+
return p.replace(/^\/[a-z]{2}(-[a-z0-9]{2,8})?(?=\/|$)/i, '') || '/';
|
|
143
|
+
} catch {
|
|
144
|
+
return '';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A filesystem-safe slug derived from arbitrary text. */
|
|
149
|
+
export function slug(text) {
|
|
150
|
+
return String(text || '')
|
|
151
|
+
.toLowerCase()
|
|
152
|
+
.replace(/[^\w]+/g, '-')
|
|
153
|
+
.replace(/^-+|-+$/g, '')
|
|
154
|
+
.slice(0, 80) || 'section';
|
|
155
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
3
|
+
// Tier 1 of the documentation profile: /llms-full.txt.
|
|
4
|
+
//
|
|
5
|
+
// If a site publishes /llms-full.txt it already contains the entire docs set as
|
|
6
|
+
// clean Markdown. We split it by sections and we are done — no browser, no
|
|
7
|
+
// per-page fetching. /llms.txt (without -full) is only a curated index and is
|
|
8
|
+
// never treated as the complete page list.
|
|
9
|
+
|
|
10
|
+
import { fetchText } from '../../lib/fetcher.mjs';
|
|
11
|
+
import { originOf, slug } from '../../lib/url.mjs';
|
|
12
|
+
|
|
13
|
+
function looksLikeHtml(text) {
|
|
14
|
+
return /^\s*<(?:!doctype|html|\?xml)/i.test(text);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Find an *explicit* source URL declared for a section (a `Source:`/`URL:`
|
|
19
|
+
* marker, optionally inside a blockquote, optionally as a markdown link).
|
|
20
|
+
* We deliberately do NOT fall back to the first arbitrary link in the body —
|
|
21
|
+
* that picks up unrelated outbound links and mislabels the page.
|
|
22
|
+
*/
|
|
23
|
+
function findSource(md) {
|
|
24
|
+
// Only look near the top of the section.
|
|
25
|
+
const head = md.split(/\r?\n/).slice(0, 6).join('\n');
|
|
26
|
+
const m = head.match(/^\s*>?\s*(?:source|url|canonical)\s*[:=]\s*(?:\[[^\]]*\]\()?\s*<?(https?:\/\/[^\s)>]+)/im);
|
|
27
|
+
return m ? m[1] : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Split a big markdown document on heading lines matching `re`. */
|
|
31
|
+
function splitOnHeading(text, re) {
|
|
32
|
+
const lines = text.split(/\r?\n/);
|
|
33
|
+
const sections = [];
|
|
34
|
+
let cur = null;
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
const m = re.exec(line);
|
|
37
|
+
if (m) {
|
|
38
|
+
if (cur) sections.push(cur);
|
|
39
|
+
cur = { title: m[1].trim(), lines: [line] };
|
|
40
|
+
} else {
|
|
41
|
+
if (!cur) cur = { title: '', lines: [] };
|
|
42
|
+
cur.lines.push(line);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (cur) sections.push(cur);
|
|
46
|
+
return sections;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fetch and split /llms-full.txt.
|
|
51
|
+
* @returns {Promise<null | { sourceUrl: string, pages: Array<{url,title,markdown}> }>}
|
|
52
|
+
*/
|
|
53
|
+
export async function tryLlmsFull(baseUrl) {
|
|
54
|
+
const origin = originOf(baseUrl);
|
|
55
|
+
if (!origin) return null;
|
|
56
|
+
const sourceUrl = origin + '/llms-full.txt';
|
|
57
|
+
|
|
58
|
+
const res = await fetchText(sourceUrl, { accept: 'text/plain, text/markdown, */*' });
|
|
59
|
+
if (!res.ok || !res.text || res.text.length < 500) return null;
|
|
60
|
+
if (looksLikeHtml(res.text)) return null; // a soft-404 served as HTML
|
|
61
|
+
|
|
62
|
+
// Prefer H1 boundaries; if the doc is one big H1-less blob, fall back to H2.
|
|
63
|
+
let sections = splitOnHeading(res.text, /^#\s+(.+)/);
|
|
64
|
+
if (sections.length < 2) sections = splitOnHeading(res.text, /^##\s+(.+)/);
|
|
65
|
+
|
|
66
|
+
const pages = sections
|
|
67
|
+
.map((s, i) => {
|
|
68
|
+
const markdown = s.lines.join('\n').trim();
|
|
69
|
+
const title = s.title || `Section ${i + 1}`;
|
|
70
|
+
const url = findSource(markdown) || `${origin}/#${slug(title)}`;
|
|
71
|
+
return { url, title, markdown };
|
|
72
|
+
})
|
|
73
|
+
.filter((p) => p.markdown.length > 0);
|
|
74
|
+
|
|
75
|
+
if (!pages.length) return null;
|
|
76
|
+
return { sourceUrl, pages };
|
|
77
|
+
}
|