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,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
|
+
}
|