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,75 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // #6 — incremental re-crawl: decide which baseline pages are still FRESH.
4
+ //
5
+ // The crawl itself is unchanged; this module only partitions a prior run's pages
6
+ // into "reuse as-is" vs "re-crawl", using the site's CURRENT sitemap <lastmod>.
7
+ // It is deliberately pure (no I/O) so the safety-critical decision is unit-tested.
8
+
9
+ import { normalizeUrl } from './url.mjs';
10
+
11
+ /**
12
+ * Partition baseline pages by freshness against the current sitemap lastmods.
13
+ *
14
+ * CONSERVATIVE BY CONSTRUCTION (rule #1 — never lose content): a page is REUSED
15
+ * only on positive evidence that it is unchanged — the stored lastmod and the
16
+ * current lastmod are BOTH present and EQUAL. Every uncertain case (either side
17
+ * missing/blank, or the URL absent from the current sitemap) goes to `recrawl`,
18
+ * so a page that actually changed can never be skipped.
19
+ *
20
+ * @param {Array<{page:object, links?:string[]}>} baselineRecords journal records from the baseline run
21
+ * @param {Map<string,string>} currentLastmod normalizedUrl -> current <lastmod>
22
+ * @returns {{ reuse: Array, recrawl: Array }}
23
+ */
24
+ export function planIncremental(baselineRecords, currentLastmod) {
25
+ const map = currentLastmod instanceof Map ? currentLastmod : new Map();
26
+ const reuse = [];
27
+ const recrawl = [];
28
+ for (const rec of baselineRecords || []) {
29
+ const page = rec && rec.page;
30
+ if (!page || !page.url) continue;
31
+ const stored = page.meta && page.meta.lastmod;
32
+ const current = map.get(normalizeUrl(page.url) || page.url);
33
+ if (stored && current && String(stored) === String(current)) reuse.push(rec);
34
+ else recrawl.push(rec);
35
+ }
36
+ return { reuse, recrawl };
37
+ }
38
+
39
+ /**
40
+ * Is a page safe to shortcut on an HTTP 304? Only when its content came from a
41
+ * SINGLE rendered state with nothing left hidden — then the served document IS the
42
+ * page and a server 304 truly means unchanged. A multi-state reveal or leftover
43
+ * hidden text means content is click/JS-driven, where a shell 304 does NOT prove
44
+ * the content is unchanged — those are never trusted to a 304 (rule #1).
45
+ */
46
+ export function isStaticSafe(page) {
47
+ if (!page) return false;
48
+ if (Array.isArray(page.states) && page.states.length > 1) return false;
49
+ return ((page.meta && page.meta.revealResidualChars) || 0) === 0;
50
+ }
51
+
52
+ /** A page's stored HTTP validators ('' when absent). */
53
+ export function httpValidators(page) {
54
+ const m = (page && page.meta) || {};
55
+ return { etag: m.httpEtag || '', lastModified: m.httpLastModified || '' };
56
+ }
57
+
58
+ /**
59
+ * Of the pages lastmod could NOT clear as fresh, which are ELIGIBLE for a 304
60
+ * pre-check — static-safe AND carrying at least one stored validator. The rest are
61
+ * always re-crawled. Pure: the network 304 check itself happens in the caller.
62
+ * @param {Array<{page:object}>} recrawlRecords
63
+ * @returns {{ eligible: Array, rest: Array }}
64
+ */
65
+ export function planConditional(recrawlRecords) {
66
+ const eligible = [];
67
+ const rest = [];
68
+ for (const rec of recrawlRecords || []) {
69
+ const page = rec && rec.page;
70
+ const v = httpValidators(page);
71
+ if (page && isStaticSafe(page) && (v.etag || v.lastModified)) eligible.push(rec);
72
+ else rest.push(rec);
73
+ }
74
+ return { eligible, rest };
75
+ }
@@ -0,0 +1,279 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // Output layout (Phase 1) — assemble a scan's crawled pages into its .md file.
4
+ //
5
+ // The two-phase model (set by the product): the CRAWL produces a faithful,
6
+ // VERBATIM extraction and nothing more — one consolidated .md per link (per
7
+ // scan). It never splits, filters or reshapes; all of that is Phase 2 ("reshape",
8
+ // the chat over the saved files — see src/reshape.mjs + engine/decide.mjs
9
+ // aiReshape). So this module's whole job is: concatenate the kept pages, in crawl
10
+ // order, under a small front-matter header, losing nothing.
11
+
12
+ import { slug, pathOf, hostOf } from './url.mjs';
13
+
14
+ /** Sanitise a name to a safe `*.md` filename. */
15
+ function sanitizeName(raw) {
16
+ const base = slug(String(raw || '').replace(/\.md$/i, '')) || 'content';
17
+ return `${base}.md`;
18
+ }
19
+
20
+ /** Derive a fallback filename from the task. */
21
+ function taskToName(task) {
22
+ const stop = new Set([
23
+ 'extract', 'get', 'the', 'a', 'an', 'of', 'all', 'from', 'and', 'to', 'for',
24
+ 'me', 'please', 'only', 'list', 'every', 'their', 'its', 'in', 'on', 'with',
25
+ ]);
26
+ const words = String(task || '')
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9\s]/g, ' ')
29
+ .split(/\s+/)
30
+ .filter((w) => w && !stop.has(w))
31
+ .slice(0, 4);
32
+ return words.join('-') || 'content';
33
+ }
34
+
35
+ /** #23 — a task-less scan (noAi: the task has no role, naming included) is named
36
+ * from its SITE instead. */
37
+ function siteName(url) {
38
+ const host = hostOf(url || '');
39
+ return host ? host.replace(/^www\./, '') : '';
40
+ }
41
+
42
+ function deriveTitle(filename) {
43
+ return filename
44
+ .replace(/\.md$/i, '')
45
+ .replace(/[-_]+/g, ' ')
46
+ .replace(/\b\w/g, (m) => m.toUpperCase());
47
+ }
48
+
49
+ function frontMatter({ task, sources, generatedAt }) {
50
+ const lines = ['---', `task: ${JSON.stringify(task || '')}`, `generatedAt: ${generatedAt}`];
51
+ if (sources && sources.length) {
52
+ lines.push('sources:');
53
+ for (const s of sources) lines.push(` - ${s}`);
54
+ }
55
+ lines.push('---', '');
56
+ return lines.join('\n');
57
+ }
58
+
59
+ /**
60
+ * Assemble one scan's kept pages into its output file(s). Always a SINGLE
61
+ * consolidated, verbatim .md: every page's Markdown is concatenated in crawl
62
+ * order. When a scan spans more than one page, each page's content is introduced
63
+ * by a heading (its title) and a source line so provenance is clear and Phase 2
64
+ * can address pages — a structural header only, the page content stays untouched.
65
+ *
66
+ * @param {object} a
67
+ * @param {string} a.task the scope task that drove the crawl (names the file)
68
+ * @param {Array} a.pages result.pages — { url, title, markdown }
69
+ * @returns {Array<{ filename, title, markdown, bytes, pages: string[] }>}
70
+ */
71
+ export function assembleScan({ task, pages }) {
72
+ const all = (pages || []).filter((p) => (p.markdown || '').trim());
73
+ if (all.length === 0) return [];
74
+ const generatedAt = new Date().toISOString();
75
+ const multi = all.length > 1;
76
+
77
+ const sources = [];
78
+ const seen = new Set();
79
+ const parts = [];
80
+ for (const p of all) {
81
+ if (p.url && !seen.has(p.url)) {
82
+ seen.add(p.url);
83
+ sources.push(p.url);
84
+ }
85
+ // Structural per-page header (multi-page scans): provenance must be clear and
86
+ // Phase 2 must be able to address pages. When the page's own content already
87
+ // OPENS with an H1 (most pages), repeating the <title>-derived name above it
88
+ // would print two near-identical top headings back to back — the source line
89
+ // alone identifies the page and the content keeps its own skeleton.
90
+ const body = p.markdown.trim();
91
+ const hasOwnH1 = /^#\s/.test(body);
92
+ const header = multi
93
+ ? (hasOwnH1 ? '' : `# ${(p.title || p.url || 'Page').trim()}\n\n`) + `_Source: ${p.url || ''}_\n\n`
94
+ : '';
95
+ parts.push(header + body);
96
+ }
97
+
98
+ const body = parts.join(multi ? '\n\n---\n\n' : '\n\n').trim();
99
+ const named = String(task || '').trim() ? taskToName(task) : siteName(all[0].url);
100
+ const filename = sanitizeName(named);
101
+ const markdown = frontMatter({ task, sources, generatedAt }) + body + '\n';
102
+
103
+ return [
104
+ {
105
+ filename,
106
+ title: deriveTitle(filename),
107
+ markdown,
108
+ bytes: Buffer.byteLength(markdown, 'utf8'),
109
+ pages: sources,
110
+ },
111
+ ];
112
+ }
113
+
114
+ // =========================================================================
115
+ // #10 — OPTIONAL per-document packaging
116
+ // =========================================================================
117
+ // The consolidated .md above is convenient for a human; a PROGRAMMATIC consumer (a
118
+ // script, a pipeline, an index — refdna included) usually wants ONE document per page
119
+ // with metadata and a stable id, so pages can be handled individually (Markdown-AST
120
+ // chunking, topic filtering, incremental updates). This is pure RE-PACKAGING of the
121
+ // exact same kept pages — nothing is filtered, transformed or lost: the union of the
122
+ // per-document bodies is identical to what the consolidated file contains. Off by
123
+ // default (opt-in via the `perDocument` option); the crawl itself stays verbatim.
124
+
125
+ /** A lightweight H1–H3 outline of a page (verbatim heading text), fence-aware — for
126
+ * metadata / RAG section paths. Never alters content. */
127
+ export function extractHeadings(markdown) {
128
+ const out = [];
129
+ let inFence = false;
130
+ for (const line of String(markdown || '').split('\n')) {
131
+ if (/^\s*(```|~~~)/.test(line)) {
132
+ inFence = !inFence;
133
+ continue;
134
+ }
135
+ if (inFence) continue;
136
+ const m = line.match(/^(#{1,3})\s+(.+?)\s*#*\s*$/);
137
+ if (m) out.push({ level: m[1].length, text: m[2].trim() });
138
+ }
139
+ return out;
140
+ }
141
+
142
+ /** A stable, human-readable title for the crawl index — from the task, or from the
143
+ * site when there is no task (#23, noAi). */
144
+ function taskToTitle(task, fallbackUrl) {
145
+ const named = String(task || '').trim() ? taskToName(task) : siteName(fallbackUrl);
146
+ return deriveTitle(sanitizeName(named));
147
+ }
148
+
149
+ /** Minimal self-describing front-matter for a per-document .md file. */
150
+ function docFrontMatter({ url, title, fetchedAt }) {
151
+ const lines = ['---'];
152
+ if (url) lines.push(`url: ${JSON.stringify(url)}`);
153
+ if (title) lines.push(`title: ${JSON.stringify(title)}`);
154
+ if (fetchedAt) lines.push(`fetchedAt: ${fetchedAt}`);
155
+ lines.push('---', '');
156
+ return lines.join('\n');
157
+ }
158
+
159
+ /**
160
+ * Package one scan's kept pages as INDIVIDUAL documents (opt-in, #10). Verbatim: each
161
+ * document's body is the page's own Markdown, untouched. Returns everything needed to
162
+ * expose the format in memory AND write it to disk:
163
+ * - `documents` — one record per page: { id, url, title, fetchedAt, bytes, markdown,
164
+ * headings, file } (stable, URL-derived id; content is the verbatim page Markdown).
165
+ * - `files` — the per-document .md files ({ filename, markdown }), each with a
166
+ * small front-matter header (url/title/fetchedAt) then the verbatim body.
167
+ * - `index` — an llms.txt-style index of what was crawled ({ filename, markdown }).
168
+ * - `jsonl` — a machine-readable line-per-document manifest ({ filename, content }).
169
+ *
170
+ * @param {object} a
171
+ * @param {string} a.task
172
+ * @param {Array} a.pages result.pages — { url, title, markdown, meta:{ fetchedAt } }
173
+ */
174
+ export function assemblePerDocument({ task, pages }) {
175
+ const all = (pages || []).filter((p) => (p.markdown || '').trim());
176
+ if (all.length === 0) return { documents: [], files: [], index: null, jsonl: null };
177
+
178
+ const usedIds = new Set();
179
+ const stableId = (url, i) => {
180
+ const p = pathOf(url || '');
181
+ const source = p && p !== '/' ? p : hostOf(url || '') || `page-${i + 1}`;
182
+ const base = slug(source) || `page-${i + 1}`;
183
+ let id = base;
184
+ let n = 2;
185
+ while (usedIds.has(id)) id = `${base}-${n++}`; // stable + unique within the scan
186
+ usedIds.add(id);
187
+ return id;
188
+ };
189
+
190
+ const documents = [];
191
+ const files = [];
192
+ for (let i = 0; i < all.length; i++) {
193
+ const p = all[i];
194
+ const id = stableId(p.url || '', i);
195
+ const md = p.markdown.trim();
196
+ const title = (p.title || '').trim();
197
+ const fetchedAt = (p.meta && p.meta.fetchedAt) || '';
198
+ const filename = `${id}.md`;
199
+ documents.push({
200
+ id,
201
+ url: p.url || '',
202
+ title,
203
+ fetchedAt,
204
+ bytes: Buffer.byteLength(md, 'utf8'),
205
+ markdown: md, // VERBATIM page body (no header) — clean for programmatic use
206
+ headings: extractHeadings(md),
207
+ file: filename,
208
+ });
209
+ files.push({ filename, markdown: docFrontMatter({ url: p.url, title, fetchedAt }) + md + '\n' });
210
+ }
211
+
212
+ const indexLines = [
213
+ `# ${taskToTitle(task, all[0].url)}`,
214
+ '',
215
+ `_${documents.length} document(s) · generated ${new Date().toISOString()}_`,
216
+ '',
217
+ ];
218
+ for (const d of documents) {
219
+ indexLines.push(`- [${d.title || d.id}](documents/${d.file})${d.url ? ` — ${d.url}` : ''}`);
220
+ }
221
+ const index = { filename: 'index.md', markdown: indexLines.join('\n') + '\n' };
222
+
223
+ const jsonl = {
224
+ filename: 'documents.jsonl',
225
+ content:
226
+ documents
227
+ .map((d) =>
228
+ JSON.stringify({ id: d.id, url: d.url, title: d.title, fetchedAt: d.fetchedAt, bytes: d.bytes, file: `documents/${d.file}`, headings: d.headings }),
229
+ )
230
+ .join('\n') + '\n',
231
+ };
232
+
233
+ return { documents, files, index, jsonl };
234
+ }
235
+
236
+ /**
237
+ * Package the FAITHFUL per-state record for pages whose reveal captured MORE THAN ONE
238
+ * state (tabs, swapped views, partial changes). The consolidated .md is compact (the
239
+ * shared frame once); this writes each page's whole snapshots VERBATIM — one section
240
+ * per state under a `## State: <label>` marker — so the complete co-occurrence a
241
+ * partial change scatters (`state 3 = r,b,d`) is always recoverable on disk. Single-
242
+ * state pages don't qualify (their one state IS the consolidated page). Pure
243
+ * repackaging: nothing filtered or transformed. Written under `states/` (see
244
+ * output.mjs); never touches the manifest or the default consolidated output.
245
+ *
246
+ * @param {object} a
247
+ * @param {Array} a.pages result.pages — some carrying `states: [{label,provenance,order,markdown}]`
248
+ * @returns {{ files: Array<{filename: string, markdown: string}> }}
249
+ */
250
+ export function assembleStates({ pages }) {
251
+ const all = (pages || []).filter((p) => Array.isArray(p.states) && p.states.length > 1);
252
+ if (all.length === 0) return { files: [] };
253
+
254
+ const usedIds = new Set();
255
+ const stableId = (url, i) => {
256
+ const p = pathOf(url || '');
257
+ const source = p && p !== '/' ? p : hostOf(url || '') || `page-${i + 1}`;
258
+ const base = slug(source) || `page-${i + 1}`;
259
+ let id = base;
260
+ let n = 2;
261
+ while (usedIds.has(id)) id = `${base}-${n++}`; // stable + unique within the scan
262
+ usedIds.add(id);
263
+ return id;
264
+ };
265
+
266
+ const files = [];
267
+ for (let i = 0; i < all.length; i++) {
268
+ const p = all[i];
269
+ const id = stableId(p.url || '', i);
270
+ const parts = [docFrontMatter({ url: p.url, title: (p.title || '').trim(), fetchedAt: (p.meta && p.meta.fetchedAt) || '' })];
271
+ parts.push(`> Faithful reveal record — ${p.states.length} distinct states, each whole and verbatim.`);
272
+ p.states.forEach((s, k) => {
273
+ const name = s.label || (s.provenance && s.provenance !== 'baseline' ? s.provenance : k === 0 ? 'base' : `state ${k + 1}`);
274
+ parts.push(`## State: ${name}\n\n${String(s.markdown || '').trim()}`);
275
+ });
276
+ files.push({ filename: `${id}.md`, markdown: parts.join('\n\n') + '\n' });
277
+ }
278
+ return { files };
279
+ }