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,139 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // Task-relevance scoring for discovered links — the universal, dependency-free core
4
+ // of "information foraging" (focused crawling). Given the user's TASK and a link
5
+ // (its URL + anchor label + nearby heading), it returns how related the link looks to
6
+ // the task, using only TEXT the user wrote and text on the page — never any per-site or
7
+ // per-framework URL-shape rule. The crawler uses this to:
8
+ // - follow the most on-task links FIRST (best-first frontier), and
9
+ // - OPTIONALLY prune clearly off-task links before the AI gate (focused mode, opt-in).
10
+ // It NEVER decides to drop a link on its own in the default configuration: scoring only
11
+ // reorders, and pruning happens only when the caller sets `minRelevance > 0`. The AI
12
+ // link gate stays the primary judge; this is a cheap signal that helps it, not replaces
13
+ // it. Aligned with: precision over speed (no drop by default), universal (no per-site
14
+ // rules), task-driven (the task is the query).
15
+
16
+ // Words that frame the REQUEST rather than name the TOPIC, plus URL noise. Kept
17
+ // deliberately small and conservative — we stop "extract"/"di"/"www", never topic nouns
18
+ // like "documentation", "menu", "prices", "auth". Multilingual (en + it) because tasks
19
+ // here are often Italian.
20
+ const STOP = new Set([
21
+ // english articles / prepositions / aux
22
+ 'the', 'a', 'an', 'and', 'or', 'of', 'for', 'to', 'in', 'on', 'with', 'from', 'by',
23
+ 'as', 'at', 'is', 'are', 'be', 'this', 'that', 'these', 'those', 'all', 'any', 'it',
24
+ 'its', 'your', 'you', 'we', 'our', 'into',
25
+ // italian articles / prepositions
26
+ 'di', 'la', 'il', 'lo', 'le', 'gli', 'un', 'una', 'uno', 'e', 'o', 'per', 'con', 'da',
27
+ 'del', 'della', 'dei', 'degli', 'delle', 'al', 'alla', 'allo', 'ai', 'agli', 'alle',
28
+ 'che', 'come', 'su', 'nel', 'nella', 'questo', 'questa', 'tutti', 'tutte', 'suo', 'sua',
29
+ // request-framing verbs (asking, not topic)
30
+ 'extract', 'estrai', 'estrarre', 'get', 'find', 'fetch', 'scrape', 'use', 'usare',
31
+ 'using', 'give', 'list', 'crawl', 'collect', 'gather', 'complete', 'completa',
32
+ 'completo', 'completi', 'full', 'whole', 'entire', 'everything', 'tutto', 'tutta',
33
+ // url / locale noise
34
+ 'www', 'http', 'https', 'com', 'org', 'net', 'html', 'htm', 'php', 'aspx', 'index',
35
+ 'en', 'us',
36
+ ]);
37
+
38
+ // CJK scripts (Han + kana + Hangul) carry no word boundaries, so word-splitting
39
+ // alone yields one giant unusable token — those runs are emitted as character
40
+ // BIGRAMS instead, the standard boundary-free trick, so two texts about the same
41
+ // thing share tokens ("提取价格" and "价格表" meet on 价格).
42
+ const CJK_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
43
+ const CJK_SPLIT_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+|[^\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/gu;
44
+
45
+ /** Split arbitrary text into lowercased word tokens (camelCase-aware), minus stopwords,
46
+ * pure numbers and 1-char fragments. Pure/deterministic.
47
+ *
48
+ * #22 (the one lexical upgrade kept — the n-gram/IDF rework was evaluated and
49
+ * REJECTED): Unicode-aware instead of ASCII-only. Accented words survive with
50
+ * their diacritics FOLDED (NFKD, combining marks stripped) so "menù"/"menu" and
51
+ * "documentación"/"documenta…" connect; Cyrillic/Greek tokenize as words; CJK
52
+ * runs become character bigrams. The old ASCII splitter silently DESTROYED all
53
+ * of these ("perché" → "perch", Cyrillic → nothing). Cross-language synonyms
54
+ * remain the semantic tier's job (lib/semantic.mjs) — this only stops the
55
+ * tokenizer from eating non-English text. */
56
+ export function tokenize(text) {
57
+ const out = [];
58
+ const raw = String(text || '')
59
+ .replace(/([\p{Ll}\p{N}])(\p{Lu})/gu, '$1 $2') // camelCase → camel Case
60
+ .normalize('NFKD')
61
+ .replace(/\p{M}+/gu, ''); // fold diacritics; leaves Cyrillic/Greek/CJK intact
62
+ for (const tok of raw.toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
63
+ if (!tok) continue;
64
+ if (CJK_RE.test(tok)) {
65
+ for (const run of tok.match(CJK_SPLIT_RE) || []) {
66
+ if (CJK_RE.test(run)) {
67
+ if (run.length === 1) out.push(run);
68
+ else for (let i = 0; i < run.length - 1; i++) out.push(run.slice(i, i + 2));
69
+ } else if (run.length >= 2 && !/^\d+$/.test(run) && !STOP.has(run)) {
70
+ out.push(run);
71
+ }
72
+ }
73
+ continue;
74
+ }
75
+ if (tok.length < 2) continue;
76
+ if (/^\d+$/.test(tok)) continue; // version/page numbers are noise here
77
+ if (STOP.has(tok)) continue;
78
+ out.push(tok);
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /** Tokens mined from a URL: its path segments + query keys/values (host ignored — it's
84
+ * the same site, so it carries no discriminating signal). */
85
+ export function urlTokens(href) {
86
+ try {
87
+ const u = new URL(href);
88
+ const q = [...u.searchParams.entries()].map(([k, v]) => `${k} ${v}`).join(' ');
89
+ return tokenize(decodeURIComponent(u.pathname) + ' ' + q);
90
+ } catch {
91
+ return tokenize(href);
92
+ }
93
+ }
94
+
95
+ /** The distinct topic terms of a task (the "query"). Empty when the task is purely
96
+ * generic ("extract everything") — callers treat that as "don't discriminate". */
97
+ export function taskTerms(task) {
98
+ return [...new Set(tokenize(task))];
99
+ }
100
+
101
+ /** Does link-token `l` satisfy task-term `t`? Exact match, or a shared prefix for
102
+ * longer words so "price"/"prices" and "document"/"documentazione" still connect — a
103
+ * light, language-agnostic stemming with no per-language rules.
104
+ * (Exported for reuse by the reshape context retrieval, lib/retrieve.mjs.) */
105
+ export function termHit(t, l) {
106
+ if (l === t) return true;
107
+ return t.length >= 4 && l.length >= 4 && (l.startsWith(t) || t.startsWith(l));
108
+ }
109
+
110
+ /**
111
+ * Score one link's relevance to the task in [0, 1].
112
+ * 0 = shares no topic term with the task,
113
+ * 0.5 = shares one,
114
+ * 1 = shares two or more (saturates — one strong, on-topic match is enough).
115
+ * When the task has NO topic terms (fully generic), returns 1 for everything so scoring
116
+ * never disrupts a generic crawl.
117
+ *
118
+ * @param {string[]} terms result of taskTerms(task) (precompute once per scan)
119
+ * @param {{href?:string, label?:string, context?:string}} link
120
+ * @returns {{ score:number, matched:number }}
121
+ */
122
+ export function scoreLink(terms, link) {
123
+ if (!terms || terms.length === 0) return { score: 1, matched: 0 };
124
+ const toks = new Set([
125
+ ...urlTokens(link.href || ''),
126
+ ...tokenize(link.label || ''),
127
+ ...tokenize(link.context || ''),
128
+ ]);
129
+ let matched = 0;
130
+ for (const t of terms) {
131
+ for (const l of toks) {
132
+ if (termHit(t, l)) {
133
+ matched++;
134
+ break;
135
+ }
136
+ }
137
+ }
138
+ return { score: Math.min(1, matched / 2), matched };
139
+ }
@@ -0,0 +1,165 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // Context retrieval for reshape (#11, root cause): when the crawled sources exceed
4
+ // the model budget, choose WHICH verbatim slices fill it — instead of blindly sending
5
+ // the first N characters and letting the model "answer" the rest from its own memory
6
+ // (observed live: a 2.7MB Vuetify extraction, "v-alert props" past the cap → the model
7
+ // fabricated a plausible props table with wrong defaults, silently).
8
+ //
9
+ // Universal and deterministic: sections are scored against the USER'S INSTRUCTION with
10
+ // the same task-tokenisation the crawler uses for link relevance (no per-site rules,
11
+ // no embeddings, no dependencies). The AI stays the judge of the ANSWER; this only
12
+ // decides what it gets to read. Content is never rewritten — sections are passed
13
+ // verbatim, in document order, with omissions marked.
14
+
15
+ import { tokenize, termHit } from './relevance.mjs';
16
+
17
+ const norm = (s) => String(s || '').toLowerCase();
18
+
19
+ /** Split Markdown into H1–H3 sections (fence-aware). Each section's `text` is the
20
+ * verbatim slice including its heading line; content before the first heading becomes
21
+ * an "(intro)" section. Mirrors the crawl's scoping granularity. */
22
+ export function sectionizeDoc(markdown) {
23
+ const lines = String(markdown || '').split('\n');
24
+ const sections = [];
25
+ let cur = { heading: '(intro)', lines: [] };
26
+ let inFence = false;
27
+ for (const line of lines) {
28
+ if (/^\s*(```|~~~)/.test(line)) inFence = !inFence;
29
+ const h = !inFence && line.match(/^(#{1,3})\s+(.*)/);
30
+ if (h) {
31
+ if (cur.lines.length || sections.length === 0) sections.push(cur);
32
+ cur = { heading: h[2].trim().slice(0, 120), lines: [line] };
33
+ } else {
34
+ cur.lines.push(line);
35
+ }
36
+ }
37
+ sections.push(cur);
38
+ return sections
39
+ .map((s) => ({ heading: s.heading, text: s.lines.join('\n').trim() }))
40
+ .filter((s) => s.text);
41
+ }
42
+
43
+ /** How relevant one section is to the query terms: heading hits weigh most (a section
44
+ * ABOUT the topic), body occurrences add up (capped, so a long page can't drown a
45
+ * focused one). 0 = shares nothing with the request. */
46
+ function scoreSection(section, terms, headTokens) {
47
+ let score = 0;
48
+ const bodyLC = norm(section.text);
49
+ for (const t of terms) {
50
+ if (headTokens.some((k) => termHit(t, k))) score += 3;
51
+ let count = 0;
52
+ let idx = 0;
53
+ while (count < 5 && (idx = bodyLC.indexOf(t, idx)) !== -1) {
54
+ count++;
55
+ idx += t.length;
56
+ }
57
+ score += count;
58
+ }
59
+ return score;
60
+ }
61
+
62
+ /**
63
+ * Choose the source content that fits a character budget, most-relevant first.
64
+ *
65
+ * @param {Array<{filename?:string, bytes?:number, content:string}>} documents
66
+ * @param {string} instruction the user's request (the query)
67
+ * @param {number} budget character budget for the combined content
68
+ * @param {(di:number, si:number, section:{heading:string,text:string}) => number} [sectionScore]
69
+ * #22 — optional external scorer (the semantic tier: cosine 0..1 per
70
+ * section). When given, it REPLACES the lexical term scoring — so a
71
+ * cross-language request still ranks the right sections — while the
72
+ * explicit document-reference boost and the packing stay identical.
73
+ * @returns {{
74
+ * docs: Array<object>, the documents to show (content possibly a verbatim subset;
75
+ * `partial: true` marks a doc whose sections were omitted)
76
+ * truncated: boolean, whether ANYTHING had to be left out
77
+ * mode: 'full'|'retrieval'|'head',
78
+ * omittedDocs: number documents left out entirely (nothing relevant in them)
79
+ * }}
80
+ *
81
+ * Modes: 'full' = everything fits, untouched. 'retrieval' = the instruction's terms
82
+ * (or an explicit document reference by filename/byte size) selected the sections.
83
+ * 'head' = nothing to discriminate by — the caller keeps the legacy head-slice.
84
+ */
85
+ export function selectRelevant(documents, instruction, budget, sectionScore = null) {
86
+ const docs = (documents || []).map((d) => ({ ...d }));
87
+ const total = docs.reduce((n, d) => n + String(d.content || '').length, 0);
88
+ if (total <= budget) return { docs, truncated: false, mode: 'full', omittedDocs: 0 };
89
+
90
+ const terms = tokenize(instruction);
91
+ // A document the user names explicitly — by filename or by its byte size (people do:
92
+ // "the original 2788831b") — is wanted regardless of term overlap: boost all of it.
93
+ const instrLC = norm(instruction);
94
+ const referenced = new Set();
95
+ docs.forEach((d, i) => {
96
+ if (d.filename && instrLC.includes(norm(d.filename))) referenced.add(i);
97
+ else if (d.bytes && instrLC.includes(String(d.bytes))) referenced.add(i);
98
+ });
99
+
100
+ // A semantic scorer can discriminate even when no lexical term overlaps
101
+ // (cross-language requests) — only bail to the head-slice when NOTHING can.
102
+ if (!terms.length && referenced.size === 0 && !sectionScore) {
103
+ return { docs, truncated: true, mode: 'head', omittedDocs: 0 };
104
+ }
105
+
106
+ // Score every section of every document against the instruction. The semantic
107
+ // score (cosine 0..1) is scaled ×10 so its ordering is meaningful next to the
108
+ // ×100 referenced-document boost, which must keep packing first.
109
+ const pool = [];
110
+ const perDocCount = new Array(docs.length).fill(0);
111
+ docs.forEach((d, di) => {
112
+ for (const [si, s] of sectionizeDoc(d.content).entries()) {
113
+ perDocCount[di]++;
114
+ const base = referenced.has(di) ? 100 : 0; // a named doc packs first, in order
115
+ const score =
116
+ base +
117
+ (sectionScore
118
+ ? Math.max(0, Number(sectionScore(di, si, s)) || 0) * 10
119
+ : terms.length
120
+ ? scoreSection(s, terms, tokenize(s.heading))
121
+ : 0);
122
+ pool.push({ di, si, score, text: s.text });
123
+ }
124
+ });
125
+ const scored = pool.filter((p) => p.score > 0);
126
+ if (!scored.length) return { docs, truncated: true, mode: 'head', omittedDocs: 0 };
127
+
128
+ // Greedy pack by score; ties resolve to document order so the result is stable.
129
+ scored.sort((a, b) => b.score - a.score || a.di - b.di || a.si - b.si);
130
+ let remaining = budget;
131
+ const chosen = [];
132
+ for (const p of scored) {
133
+ const size = p.text.length;
134
+ if (size <= remaining) {
135
+ chosen.push(p);
136
+ remaining -= size;
137
+ } else if (chosen.length === 0) {
138
+ // The single most relevant section alone exceeds the budget: take its head so
139
+ // the model always gets SOMETHING on-topic rather than nothing.
140
+ chosen.push({ ...p, text: p.text.slice(0, remaining) });
141
+ remaining = 0;
142
+ }
143
+ if (remaining < 200) break;
144
+ }
145
+
146
+ // Reassemble each document's chosen sections in DOCUMENT order, with omissions
147
+ // marked, so the model reads coherent (if abridged) documents.
148
+ const out = [];
149
+ docs.forEach((d, di) => {
150
+ const mine = chosen.filter((p) => p.di === di).sort((a, b) => a.si - b.si);
151
+ if (!mine.length) return;
152
+ const partial = mine.length < perDocCount[di];
153
+ out.push({
154
+ ...d,
155
+ content: mine.map((p) => p.text).join('\n\n[… sections not relevant to this request were omitted …]\n\n'),
156
+ partial,
157
+ });
158
+ });
159
+ return {
160
+ docs: out,
161
+ truncated: out.length < docs.length || out.some((d) => d.partial),
162
+ mode: 'retrieval',
163
+ omittedDocs: docs.length - out.length,
164
+ };
165
+ }
@@ -0,0 +1,125 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // #14 — politeness, OPT-IN (the tool stays user-directed, like wget):
4
+ // - a minimum per-HOST gap between requests (`delay`), reserved-slot style so
5
+ // concurrent workers queue behind each other instead of bursting;
6
+ // - robots.txt reading (`respectRobots`): Disallow/Allow with Google's
7
+ // longest-match semantics plus Crawl-delay. Disallowed URLs are SKIPPED WITH
8
+ // A WARNING — never silently (the warning is the contract).
9
+ // Both are off by default: existing behaviour is byte-identical until the user
10
+ // asks. Pure parsing lives here (offline-testable); fetching the file is the
11
+ // caller's job (one request per origin, cached for the run).
12
+
13
+ /**
14
+ * Parse robots.txt and return the rule group that applies to `ua`.
15
+ * Group selection follows the standard: the MOST SPECIFIC matching User-agent
16
+ * token wins (longest token contained in our UA); `*` is the fallback.
17
+ *
18
+ * @param {string} text the robots.txt body
19
+ * @param {string} [ua] our user-agent product token
20
+ * @returns {{ rules: Array<{type:'allow'|'disallow', path:string}>, crawlDelay: number|null }}
21
+ */
22
+ export function parseRobots(text, ua = 'crawldna') {
23
+ const groups = [];
24
+ let cur = null;
25
+ let lastWasAgent = false;
26
+ for (const raw of String(text || '').split(/\r?\n/)) {
27
+ const line = raw.replace(/#.*$/, '').trim();
28
+ if (!line) continue;
29
+ const m = line.match(/^([A-Za-z-]+)\s*:\s*(.*)$/);
30
+ if (!m) continue;
31
+ const key = m[1].toLowerCase();
32
+ const value = m[2].trim();
33
+ if (key === 'user-agent') {
34
+ // consecutive User-agent lines share one group; a rule line closes it
35
+ if (!cur || !lastWasAgent) {
36
+ cur = { agents: [], rules: [], crawlDelay: null };
37
+ groups.push(cur);
38
+ }
39
+ cur.agents.push(value.toLowerCase());
40
+ lastWasAgent = true;
41
+ continue;
42
+ }
43
+ lastWasAgent = false;
44
+ if (!cur) continue; // rules before any User-agent line are invalid — ignored
45
+ if (key === 'disallow' || key === 'allow') cur.rules.push({ type: key, path: value });
46
+ else if (key === 'crawl-delay') {
47
+ const n = Number(value);
48
+ if (Number.isFinite(n) && n > 0) cur.crawlDelay = n;
49
+ }
50
+ }
51
+
52
+ const uaLC = String(ua || '').toLowerCase();
53
+ let best = null;
54
+ let bestLen = -1;
55
+ for (const g of groups) {
56
+ for (const a of g.agents) {
57
+ if (a === '*') {
58
+ if (bestLen < 0) best = g; // fallback only when nothing specific matched
59
+ } else if (uaLC.includes(a) && a.length > bestLen) {
60
+ best = g;
61
+ bestLen = a.length;
62
+ }
63
+ }
64
+ }
65
+ return best ? { rules: best.rules, crawlDelay: best.crawlDelay } : { rules: [], crawlDelay: null };
66
+ }
67
+
68
+ /** Does a single robots path rule (with `*` wildcards and a `$` end anchor)
69
+ * match this URL path? An empty rule path matches nothing (per the spec,
70
+ * "Disallow:" with no value allows everything). */
71
+ function ruleMatches(rulePath, path) {
72
+ if (!rulePath) return false;
73
+ const anchored = rulePath.endsWith('$');
74
+ const body = anchored ? rulePath.slice(0, -1) : rulePath;
75
+ const re = body
76
+ .split('*')
77
+ .map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
78
+ .join('[\\s\\S]*');
79
+ return new RegExp('^' + re + (anchored ? '$' : '')).test(path);
80
+ }
81
+
82
+ /**
83
+ * Is this URL path allowed by the parsed rules? Google semantics: the matching
84
+ * rule with the LONGEST path wins; on a tie, Allow beats Disallow; no matching
85
+ * rule = allowed.
86
+ *
87
+ * @param {Array<{type:string, path:string}>} rules from parseRobots().rules
88
+ * @param {string} path the URL's pathname + search
89
+ */
90
+ export function isAllowed(rules, path) {
91
+ let best = null;
92
+ for (const r of rules || []) {
93
+ if (!r.path || !ruleMatches(r.path, path)) continue;
94
+ if (!best || r.path.length > best.path.length || (r.path.length === best.path.length && r.type === 'allow')) {
95
+ best = r;
96
+ }
97
+ }
98
+ return !best || best.type === 'allow';
99
+ }
100
+
101
+ /**
102
+ * Per-host request pacer. `wait(url, delayMs)` resolves when this request may
103
+ * start, reserving the NEXT slot `delayMs` later — so N concurrent workers
104
+ * hitting one host space out at exactly one request per delay, while different
105
+ * hosts never wait on each other. delayMs ≤ 0 = no-op (the default-off path
106
+ * costs nothing).
107
+ */
108
+ export function createHostGate() {
109
+ const nextAt = new Map(); // host → earliest ms timestamp the next request may start
110
+ return {
111
+ async wait(url, delayMs) {
112
+ if (!(delayMs > 0)) return;
113
+ let host;
114
+ try {
115
+ host = new URL(url).host;
116
+ } catch {
117
+ return;
118
+ }
119
+ const now = Date.now();
120
+ const at = Math.max(now, nextAt.get(host) || 0);
121
+ nextAt.set(host, at + delayMs);
122
+ if (at > now) await new Promise((r) => setTimeout(r, at - now));
123
+ },
124
+ };
125
+ }