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,121 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // Tier 2 of the documentation profile: sitemap enumeration.
4
+ //
5
+ // Follows sitemap indexes recursively, collects every <loc>, and also reads
6
+ // Sitemap: directives from robots.txt. Filtering to the docs section is done by
7
+ // the caller (it knows the base path).
8
+
9
+ import { fetchText } from '../../lib/fetcher.mjs';
10
+ import { XMLParser } from 'fast-xml-parser';
11
+ import { originOf, normalizeUrl } from '../../lib/url.mjs';
12
+
13
+ function toArray(x) {
14
+ if (x == null) return [];
15
+ return Array.isArray(x) ? x : [x];
16
+ }
17
+
18
+ /**
19
+ * Pull { url, lastmod } from a parsed <urlset> (fast-xml-parser output). Pure — no
20
+ * network — so the #6 lastmod extraction is unit-testable. `lastmod` is '' when the
21
+ * entry omits it.
22
+ * @param {object} xml parsed sitemap XML
23
+ * @returns {Array<{url:string,lastmod:string}>}
24
+ */
25
+ export function sitemapEntriesFromXml(xml) {
26
+ const out = [];
27
+ for (const u of toArray(xml && xml.urlset && xml.urlset.url)) {
28
+ if (u && u.loc) out.push({ url: String(u.loc).trim(), lastmod: u.lastmod != null ? String(u.lastmod).trim() : '' });
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /**
34
+ * Collect { url, lastmod } for every page reachable from a site's sitemaps.
35
+ * Follows sitemap indexes recursively; the first lastmod seen for a URL wins.
36
+ * @param {string} baseUrl
37
+ * @param {object} [opts]
38
+ * @param {() => boolean} [opts.shouldStop]
39
+ * @param {number} [opts.maxDepth] sitemap-index recursion depth
40
+ * @returns {Promise<Array<{url:string,lastmod:string}>>}
41
+ */
42
+ export async function collectSitemapEntries(baseUrl, { shouldStop, maxDepth = 4 } = {}) {
43
+ const origin = originOf(baseUrl);
44
+ if (!origin) return [];
45
+
46
+ const candidates = [
47
+ origin + '/sitemap.xml',
48
+ origin + '/sitemap_index.xml',
49
+ origin + '/sitemap-index.xml',
50
+ ];
51
+
52
+ // robots.txt may advertise sitemaps elsewhere.
53
+ const robots = await fetchText(origin + '/robots.txt', { accept: 'text/plain, */*' });
54
+ if (robots.ok && robots.text) {
55
+ for (const m of robots.text.matchAll(/^\s*sitemap:\s*(\S+)/gim)) {
56
+ candidates.push(m[1].trim());
57
+ }
58
+ }
59
+
60
+ const parser = new XMLParser({ ignoreAttributes: true, trimValues: true });
61
+ const seen = new Set();
62
+ const entries = new Map(); // url -> lastmod (first wins)
63
+
64
+ async function walk(smUrl, depth) {
65
+ if (depth > maxDepth || seen.has(smUrl)) return;
66
+ if (shouldStop && shouldStop()) return;
67
+ seen.add(smUrl);
68
+
69
+ const res = await fetchText(smUrl, { accept: 'application/xml, text/xml, */*' });
70
+ if (!res.ok || !res.text) return;
71
+
72
+ let xml;
73
+ try {
74
+ xml = parser.parse(res.text);
75
+ } catch {
76
+ return;
77
+ }
78
+
79
+ if (xml.sitemapindex) {
80
+ for (const sm of toArray(xml.sitemapindex.sitemap)) {
81
+ if (sm && sm.loc) await walk(String(sm.loc).trim(), depth + 1);
82
+ }
83
+ } else if (xml.urlset) {
84
+ for (const e of sitemapEntriesFromXml(xml)) {
85
+ if (!entries.has(e.url)) entries.set(e.url, e.lastmod);
86
+ }
87
+ }
88
+ }
89
+
90
+ for (const c of [...new Set(candidates)]) {
91
+ if (shouldStop && shouldStop()) break;
92
+ await walk(c, 0);
93
+ }
94
+
95
+ return [...entries].map(([url, lastmod]) => ({ url, lastmod }));
96
+ }
97
+
98
+ /**
99
+ * Collect all page URLs reachable from a site's sitemaps.
100
+ * @param {string} baseUrl
101
+ * @param {object} [opts]
102
+ * @returns {Promise<string[]>}
103
+ */
104
+ export async function collectSitemapUrls(baseUrl, opts = {}) {
105
+ return (await collectSitemapEntries(baseUrl, opts)).map((e) => e.url);
106
+ }
107
+
108
+ /**
109
+ * #6 — a Map of normalizedUrl -> <lastmod> for a site's sitemap. Only URLs that
110
+ * actually carry a lastmod are included (a blank one is no freshness evidence).
111
+ * @param {string} baseUrl
112
+ * @param {object} [opts]
113
+ * @returns {Promise<Map<string,string>>}
114
+ */
115
+ export async function sitemapLastmodMap(baseUrl, opts = {}) {
116
+ const map = new Map();
117
+ for (const e of await collectSitemapEntries(baseUrl, opts)) {
118
+ if (e.lastmod) map.set(normalizeUrl(e.url) || e.url, e.lastmod);
119
+ }
120
+ return map;
121
+ }
@@ -0,0 +1,96 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // Documentation task profile (§4). For docs the priorities are completeness and
4
+ // precision, so:
5
+ // 1. /llms-full.txt — the publisher's own complete export; use it verbatim.
6
+ // 2. /sitemap.xml — an authoritative page list; use it to SEED the engine.
7
+ // 3. otherwise — let the engine crawl from the entry, discovering pages.
8
+ //
9
+ // Every page (except the llms-full shortcut) goes through the browser-first
10
+ // engine so dynamic/interaction-hidden docs (Firebase tabs, SPA nav, …) are
11
+ // fully revealed — not just statically scraped.
12
+
13
+ import { tryLlmsFull } from './docs/llms.mjs';
14
+ import { collectSitemapUrls } from './docs/sitemap.mjs';
15
+ import { normalizeUrl, inScope, pathOf } from '../lib/url.mjs';
16
+
17
+ const now = () => new Date().toISOString();
18
+ const bytes = (s) => Buffer.byteLength(s || '', 'utf8');
19
+
20
+ /**
21
+ * The path prefix to constrain a docs crawl to. For "extract all documentation"
22
+ * the user points at *a* doc page but wants the whole docs tree, so we scope to
23
+ * the first path segment (the docs root: /docs, /en, /guide, …) rather than the
24
+ * exact entry path. Narrow it with --include when you only want one section.
25
+ */
26
+ function scopePrefixFor(baseUrl) {
27
+ const p = pathOf(normalizeUrl(baseUrl) || baseUrl);
28
+ if (!p || p === '/') return null;
29
+ const segs = p.replace(/\/+$/, '').split('/').filter(Boolean);
30
+ if (!segs.length) return null;
31
+ return '/' + segs[0];
32
+ }
33
+
34
+ /** Keep same-site URLs under the docs path, honour include/exclude, dedupe. */
35
+ function filterDocUrls(urls, baseUrl, options, prefix) {
36
+ const out = new Set();
37
+ for (const u of urls) {
38
+ const n = normalizeUrl(u, baseUrl);
39
+ if (!n || !inScope(n, baseUrl, options)) continue;
40
+ if (prefix) {
41
+ const p = pathOf(n);
42
+ if (!(p === prefix || p.startsWith(prefix + '/'))) continue;
43
+ }
44
+ out.add(n);
45
+ }
46
+ return [...out];
47
+ }
48
+
49
+ export async function runDocsProfile(target, ctx) {
50
+ const { url, task } = target;
51
+
52
+ // -- Tier 1: llms-full.txt (complete, verbatim, no browser) ---------------
53
+ const llms = await tryLlmsFull(url).catch(() => null);
54
+ if (llms && !ctx.shouldStop()) {
55
+ ctx.emit({ type: 'strategy', url, strategy: 'docs:llms-full' });
56
+ ctx.emit({ type: 'discover', url, count: llms.pages.length });
57
+ ctx.setTotal(llms.pages.length);
58
+ for (const p of llms.pages) {
59
+ if (ctx.shouldStop()) break;
60
+ ctx.addPage({
61
+ url: p.url,
62
+ task,
63
+ title: p.title,
64
+ markdown: p.markdown,
65
+ meta: { strategy: 'docs:llms-full', source: llms.sourceUrl, fetchedAt: now(), bytes: bytes(p.markdown) },
66
+ });
67
+ ctx.markProcessed(); // bar advances per page handled (kept or deduped)
68
+ }
69
+ return;
70
+ }
71
+
72
+ const prefix = scopePrefixFor(url);
73
+
74
+ // -- Tier 2: sitemap → seed the engine ------------------------------------
75
+ const sitemap = await collectSitemapUrls(url, { shouldStop: ctx.shouldStop }).catch(() => []);
76
+ const seeds = filterDocUrls(sitemap, url, ctx.options, prefix);
77
+
78
+ if (seeds.length > 1 && !ctx.shouldStop()) {
79
+ ctx.emit({ type: 'strategy', url, strategy: 'docs:sitemap' });
80
+ ctx.emit({ type: 'discover', url, count: seeds.length });
81
+ ctx.setTotal(seeds.length);
82
+ await ctx.runEngine(target, { seeds, announce: false, scopePrefix: prefix });
83
+ return;
84
+ }
85
+
86
+ // -- Tier 3: no page list → engine crawls from the entry and discovers ----
87
+ ctx.emit({
88
+ type: 'warn',
89
+ url,
90
+ reason: 'no-page-list',
91
+ message:
92
+ 'No llms-full.txt or usable sitemap found; the engine will crawl from the entry page and ' +
93
+ 'discover pages as it goes. Completeness is not guaranteed.',
94
+ });
95
+ await ctx.runEngine(target, { announce: true, scopePrefix: prefix });
96
+ }
@@ -0,0 +1,204 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // Phase 2 — "reshape" (chat with your extraction).
4
+ //
5
+ // The crawl (Phase 1) produces a faithful, VERBATIM extraction per link. This
6
+ // step reworks those SAVED files into whatever the user asks — a table, a split,
7
+ // a filtered subset, a regroup — on demand, as many times as they want, reusing
8
+ // the same extraction as context (like querying a knowledge base). The crawl's
9
+ // own files are never touched; every reshape output lands under <scan>/chat/ and
10
+ // the conversation is recorded in that scan's session. Value-faithful by design:
11
+ // aiReshape copies every kept value exactly and never invents one.
12
+
13
+ import {
14
+ getRun,
15
+ readRunFile,
16
+ readChatFile,
17
+ getChatSession,
18
+ saveChatSession,
19
+ writeChatFile,
20
+ } from './lib/runs.mjs';
21
+ import { aiReshape } from './engine/decide.mjs';
22
+ import { resolveLlm } from './lib/llm.mjs';
23
+ import { slug } from './lib/url.mjs';
24
+ import { verifyValues, fidelityBanner, stripFidelityBanner } from './lib/faithful.mjs';
25
+ import { simhash, hamming } from './lib/simhash.mjs';
26
+
27
+ // How many of the user's own prior chat outputs to surface back as context, so a
28
+ // follow-up like "redo the table you made" can reference them without flooding
29
+ // the prompt as the conversation grows.
30
+ const PRODUCED_CONTEXT = 6;
31
+
32
+ function stripFrontMatter(md) {
33
+ const m = String(md || '').match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
34
+ return m ? String(md).slice(m[0].length) : String(md || '');
35
+ }
36
+
37
+ function ensureTrailingNewline(s) {
38
+ const t = String(s || '');
39
+ return t.endsWith('\n') ? t : t + '\n';
40
+ }
41
+
42
+ /** Sanitise a model-proposed filename to a safe, unique `*.md` name. */
43
+ function sanitizeChatName(raw, used) {
44
+ const base = slug(String(raw || '').replace(/\.md$/i, '')) || 'reshaped';
45
+ let name = `${base}.md`;
46
+ let n = 2;
47
+ while (used.has(name) || name === 'session.json') name = `${base}-${n++}.md`;
48
+ used.add(name);
49
+ return name;
50
+ }
51
+
52
+ /** Find a scan in a run manifest by id (''/undefined = the only/first scan). */
53
+ function findScan(manifest, scanId) {
54
+ const scans = manifest.scans || [];
55
+ const sid = String(scanId || '');
56
+ return scans.find((s) => String(s.scanId || '') === sid) || (sid ? null : scans[0]) || null;
57
+ }
58
+
59
+ /**
60
+ * Run one reshape turn over a saved scan's extraction.
61
+ *
62
+ * @param {object} a
63
+ * @param {string} a.runId
64
+ * @param {string} [a.scanId] '' = the run's only/first scan
65
+ * @param {string} a.message the user's request
66
+ * @param {string} a.model model id/name
67
+ * @param {string} [a.provider] 'ollama' (default) | 'openai'
68
+ * @param {string} [a.host] Ollama host override (provider 'ollama')
69
+ * @param {string} [a.baseUrl] OpenAI-compatible API base URL (provider 'openai')
70
+ * @param {string} [a.apiKey] API key (provider 'openai')
71
+ * @param {string} [a.embedModel] #22 — optional embedding model id: upgrades the
72
+ * context retrieval to semantic section ranking
73
+ * (cross-language asks pull the right sections)
74
+ * @param {string} [a.cacheDir] runs-cache override
75
+ * @param {boolean} [a.verify] #11 fidelity check (default true): value-like atoms of
76
+ * every produced file are verified against the FULL crawled
77
+ * sources; unverifiable ones are flagged with a warning
78
+ * banner inside the file instead of served silently.
79
+ * @returns {Promise<{ reply: string, files: Array<{ filename, bytes, at, fidelity? }>, truncated: boolean, contextMode?: string }>}
80
+ */
81
+ export async function reshape({ runId, scanId = '', message, model, provider, host, baseUrl, apiKey, embedModel, cacheDir, verify = true }) {
82
+ if (!String(message || '').trim()) throw new Error('empty message');
83
+ if (!model) throw new Error('no model selected');
84
+ const llm = resolveLlm({ provider, model, embedModel, ollamaHost: host, baseUrl, apiKey });
85
+ const opts = cacheDir ? { cacheDir } : {};
86
+
87
+ const { manifest } = await getRun(runId, opts);
88
+ const scan = findScan(manifest, scanId);
89
+ if (!scan) throw new Error('scan not found');
90
+ const sid = String(scan.scanId || '');
91
+
92
+ // The scan's ORIGINAL crawled files, each as an IDENTIFIABLE document (filename
93
+ // + on-disk size + source URLs). Passing identity — not an anonymous blob — is
94
+ // what lets the model honour references like "the original md" / "the 4574b
95
+ // file" and treat these as the default thing to reshape.
96
+ const documents = [];
97
+ for (const f of scan.files || []) {
98
+ try {
99
+ const raw = await readRunFile(runId, sid, f.filename, opts);
100
+ documents.push({
101
+ filename: f.filename,
102
+ title: f.title || '',
103
+ bytes: typeof f.bytes === 'number' ? f.bytes : Buffer.byteLength(raw, 'utf8'),
104
+ sources: Array.isArray(f.pages) && f.pages.length ? f.pages : scan.url ? [scan.url] : [],
105
+ content: stripFrontMatter(raw),
106
+ });
107
+ } catch {
108
+ /* skip an unreadable file rather than fail the whole turn */
109
+ }
110
+ }
111
+ if (!documents.some((d) => d.content.trim())) throw new Error('no extracted content for this link');
112
+
113
+ const session = await getChatSession(runId, sid, opts);
114
+
115
+ // The user's own prior outputs from THIS chat, as clearly-separate context so a
116
+ // follow-up can revise one ("redo the table you made") — never confused with
117
+ // the originals.
118
+ const produced = [];
119
+ for (const f of (session.files || []).slice(-PRODUCED_CONTEXT)) {
120
+ try {
121
+ // Strip our own fidelity banner: it is a warning for the USER, not content the
122
+ // model should iterate on (or copy into new files).
123
+ produced.push({
124
+ filename: f.filename,
125
+ bytes: f.bytes,
126
+ content: stripFidelityBanner(stripFrontMatter(await readChatFile(runId, sid, f.filename, opts))),
127
+ });
128
+ } catch {
129
+ /* a derived file may have been removed — skip it */
130
+ }
131
+ }
132
+
133
+ const { reply, files, truncated, contextMode } = await aiReshape({
134
+ llm,
135
+ documents,
136
+ produced,
137
+ instruction: message,
138
+ history: (session.messages || []).map((m) => ({ role: m.role, content: m.content })),
139
+ });
140
+
141
+ // Deterministic guards on what the model produced, BEFORE anything is saved.
142
+ //
143
+ // (1) RE-EMISSION FILTER: models re-deliver earlier files under new names as the
144
+ // conversation grows (observed live: three near-identical "pagination" docs in one
145
+ // chat). A produced file whose SimHash is within Hamming 3 of a file already in this
146
+ // chat (or of another file from this same turn) is skipped, with a note — never
147
+ // silently.
148
+ //
149
+ // (2) FIDELITY CHECK (#11): every value-like atom (numbers, URLs, inline code, quoted
150
+ // literals, code lines) of a kept file is verified against the FULL crawled sources —
151
+ // not the model's context — plus the user's own instruction. Unverifiable values are
152
+ // flagged with a clearly tool-generated banner INSIDE the file, and reported in the
153
+ // result, instead of being served as if they were extracted facts.
154
+ const sourceTexts = documents.map((d) => d.content);
155
+ const priorHashes = produced.map((p) => simhash(p.content));
156
+ const used = new Set((session.files || []).map((f) => f.filename));
157
+ const savedFiles = [];
158
+ const notes = [];
159
+ for (const file of files) {
160
+ const raw = String(file.content);
161
+ const sh = simhash(raw);
162
+ if (priorHashes.some((h) => hamming(sh, h) <= 3)) {
163
+ notes.push(`(Skipped "${file.filename}" — near-identical to a file already produced in this chat.)`);
164
+ continue;
165
+ }
166
+ priorHashes.push(sh);
167
+
168
+ let fidelity = null;
169
+ let content = raw;
170
+ if (verify) {
171
+ const v = verifyValues(raw, sourceTexts, { allow: message });
172
+ fidelity = { checked: v.total, verified: v.verified, unverified: v.unverified.slice(0, 20) };
173
+ if (v.unverified.length) content = fidelityBanner(v) + '\n\n' + raw;
174
+ }
175
+
176
+ const name = sanitizeChatName(file.filename, used);
177
+ const body = ensureTrailingNewline(content);
178
+ await writeChatFile(runId, sid, name, body, opts);
179
+ savedFiles.push({
180
+ filename: name,
181
+ bytes: Buffer.byteLength(body, 'utf8'),
182
+ at: new Date().toISOString(),
183
+ ...(fidelity ? { fidelity } : {}),
184
+ });
185
+ }
186
+ const replyOut = [reply, ...notes].filter(Boolean).join('\n\n');
187
+
188
+ // Record the turn (user + assistant) and register the new files.
189
+ const now = new Date().toISOString();
190
+ session.messages = session.messages || [];
191
+ session.messages.push({ role: 'user', content: String(message), at: now });
192
+ session.messages.push({
193
+ role: 'assistant',
194
+ content: replyOut,
195
+ files: savedFiles.map((f) => f.filename),
196
+ at: now,
197
+ });
198
+ session.files = [...(session.files || []), ...savedFiles];
199
+ await saveChatSession(runId, sid, session, opts);
200
+
201
+ return { reply: replyOut, files: savedFiles, truncated, contextMode };
202
+ }
203
+
204
+ export default reshape;