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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }