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,555 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
3
|
+
// The AI judgment layer.
|
|
4
|
+
//
|
|
5
|
+
// PHASE 1 (crawl) — three jobs, all of which keep the captured content VERBATIM:
|
|
6
|
+
// - aiSelectRevealers: which interactive controls actually HIDE content worth
|
|
7
|
+
// revealing (the discovery core — "don't miss anything").
|
|
8
|
+
// - aiScopeContent: keep only the sections relevant to the task (drop the
|
|
9
|
+
// landing/footer/cookie/marketing chrome) — the "stay focused" core. Output
|
|
10
|
+
// is the ORIGINAL text of the kept sections; the model never rewrites content.
|
|
11
|
+
// - aiSelectLinks: which discovered links lead to more task-relevant pages.
|
|
12
|
+
// All three bias toward completeness: when the model is unsure or errors, KEEP.
|
|
13
|
+
//
|
|
14
|
+
// PHASE 2 (reshape) — aiReshape: a separate, AFTER-the-crawl step that reworks the
|
|
15
|
+
// already-extracted files on request (a table, a split, a filtered subset), reusing
|
|
16
|
+
// the same extraction as context like a knowledge base. Value-faithful: it copies
|
|
17
|
+
// every kept value exactly and never invents. This is the ONLY place AI reshapes
|
|
18
|
+
// output; the crawl itself stays verbatim.
|
|
19
|
+
|
|
20
|
+
// All model communication goes through the provider-agnostic transport layer.
|
|
21
|
+
// These functions take an `llm` descriptor ({ provider, model, baseUrl, apiKey })
|
|
22
|
+
// and never care whether it is backed by Ollama or an OpenAI-compatible API.
|
|
23
|
+
//
|
|
24
|
+
// NO-AI MODE (`noAi: true` → provider 'none'): every phase-1 judgment below
|
|
25
|
+
// short-circuits to its completeness-bias fallback WITHOUT building a prompt or
|
|
26
|
+
// touching the transport — heuristic reveal triage, keep pages whole, follow all
|
|
27
|
+
// in-scope links, no targeted navigation. Identical behaviour to a model outage
|
|
28
|
+
// (the fallbacks are shared), but intentional, warned once, and free of the
|
|
29
|
+
// failed calls' latency. Zero tokens by construction.
|
|
30
|
+
import { chat, llmDisabled } from '../lib/llm.mjs';
|
|
31
|
+
import { selectRelevant, sectionizeDoc } from '../lib/retrieve.mjs';
|
|
32
|
+
import { semanticSectionScores } from '../lib/semantic.mjs';
|
|
33
|
+
|
|
34
|
+
/** Pull the first JSON value out of a model reply. */
|
|
35
|
+
function parseJson(text) {
|
|
36
|
+
if (!text) return null;
|
|
37
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
38
|
+
const body = fenced ? fenced[1] : text;
|
|
39
|
+
const m = body.match(/[[{][\s\S]*[\]}]/);
|
|
40
|
+
if (!m) return null;
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(m[0]);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// PROMPT-CACHE CONTRACT (#4): every judgment call's SYSTEM prompt below is a plain
|
|
49
|
+
// string literal — byte-identical across all calls of its type. That identity is what
|
|
50
|
+
// lets remote providers (OpenAI, DeepSeek, vLLM, OpenRouter) serve the repeated prefix
|
|
51
|
+
// from their prompt cache (~10× cheaper input) on a crawl's thousands of calls. Keep
|
|
52
|
+
// per-call data (task, page text, links, controls) in the USER message — never
|
|
53
|
+
// interpolate it into a system prompt. Locked by a test (decide.test.mjs).
|
|
54
|
+
|
|
55
|
+
// JSON shapes the crawl-judgment calls must return. Passed to the transport for
|
|
56
|
+
// CONSTRAINED DECODING (Ollama `format`) / guaranteed JSON (OpenAI `response_format`),
|
|
57
|
+
// so the model can't emit fences, prose or a wrong shape — the #1 cause of a parse
|
|
58
|
+
// failure (which would force the completeness-bias "follow/keep ALL" fallback and waste
|
|
59
|
+
// the crawl). Per-call downstream validation is unchanged (it stays the safety net).
|
|
60
|
+
const SCHEMAS = {
|
|
61
|
+
reveal: { type: 'object', properties: { click: { type: 'array', items: { type: 'integer' } } }, required: ['click'], additionalProperties: false },
|
|
62
|
+
links: { type: 'object', properties: { follow: { type: 'array', items: { type: 'integer' } } }, required: ['follow'], additionalProperties: false },
|
|
63
|
+
keep: { type: 'object', properties: { keep: { type: 'array', items: { type: 'integer' } } }, required: ['keep'], additionalProperties: false },
|
|
64
|
+
plan: { type: 'object', properties: { direction: { type: ['integer', 'null'] }, target: { type: ['string', 'null'] } }, required: ['direction', 'target'], additionalProperties: false },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Split markdown into heading-delimited sections (verbatim text preserved). */
|
|
68
|
+
function sectionize(markdown) {
|
|
69
|
+
const lines = markdown.split('\n');
|
|
70
|
+
const sections = [];
|
|
71
|
+
let cur = { heading: '(intro)', lines: [] };
|
|
72
|
+
let inFence = false;
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
if (/^\s*(```|~~~)/.test(line)) inFence = !inFence;
|
|
75
|
+
const h = !inFence && line.match(/^(#{1,3})\s+(.*)/);
|
|
76
|
+
if (h) {
|
|
77
|
+
if (cur.lines.length || sections.length === 0) sections.push(cur);
|
|
78
|
+
cur = { heading: h[2].trim().slice(0, 100), lines: [line] };
|
|
79
|
+
} else {
|
|
80
|
+
cur.lines.push(line);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
sections.push(cur);
|
|
84
|
+
return sections.map((s, i) => ({ index: i, heading: s.heading, text: s.lines.join('\n').trim() }));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// =========================================================================
|
|
88
|
+
// PHASE 2 — reshape (the "chat with your extraction" step)
|
|
89
|
+
// =========================================================================
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse a reshape reply into `{ reply, files }`. The model emits deliverables as
|
|
93
|
+
* FILE BLOCKS — `===FILE: name.md===` … `===END===` — and anything outside the
|
|
94
|
+
* blocks is the conversational reply. Robust to large content (no JSON escaping)
|
|
95
|
+
* and to an accidental whole-file code fence around a block's body.
|
|
96
|
+
*/
|
|
97
|
+
function parseReshape(text) {
|
|
98
|
+
const out = { reply: '', files: [] };
|
|
99
|
+
const raw = String(text || '');
|
|
100
|
+
if (!raw.trim()) return out;
|
|
101
|
+
|
|
102
|
+
const re = /===FILE:\s*([^\n=]+?)\s*===\r?\n([\s\S]*?)\r?\n===END===/g;
|
|
103
|
+
const replyParts = [];
|
|
104
|
+
let last = 0;
|
|
105
|
+
let m;
|
|
106
|
+
while ((m = re.exec(raw))) {
|
|
107
|
+
replyParts.push(raw.slice(last, m.index));
|
|
108
|
+
last = re.lastIndex;
|
|
109
|
+
const filename = m[1].trim();
|
|
110
|
+
let content = m[2].replace(/^\s*\n/, '').replace(/\s+$/, '');
|
|
111
|
+
const fence = content.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```$/i);
|
|
112
|
+
if (fence) content = fence[1];
|
|
113
|
+
// A local model sometimes DOUBLES the block markers (a nested `===FILE: …===`
|
|
114
|
+
// first line and a stray `===END` fragment at the end) — strip them so the
|
|
115
|
+
// artefact never leaks into the saved file.
|
|
116
|
+
content = content
|
|
117
|
+
.replace(/^===FILE:[^\n]*===\s*\r?\n/, '')
|
|
118
|
+
.replace(/\r?\n={2,}\s*END=*\s*$/, '')
|
|
119
|
+
.trim();
|
|
120
|
+
if (filename && content.trim()) out.files.push({ filename, content });
|
|
121
|
+
}
|
|
122
|
+
replyParts.push(raw.slice(last));
|
|
123
|
+
out.reply = replyParts.join('').replace(/\n{3,}/g, '\n\n').trim();
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Is a chat reply "document-worthy" — i.e. is it itself a deliverable the user
|
|
129
|
+
* would want as a file (a table, several sections, or a long list), rather than a
|
|
130
|
+
* short conversational answer? Models often produce such content inline instead
|
|
131
|
+
* of in a FILE BLOCK; this lets the caller promote it to a saved document so the
|
|
132
|
+
* user always gets a file when the answer is one. Short, unstructured Q&A stays
|
|
133
|
+
* a plain chat message.
|
|
134
|
+
*/
|
|
135
|
+
function isDocumentWorthy(text) {
|
|
136
|
+
const t = String(text || '').trim();
|
|
137
|
+
if (!t) return false;
|
|
138
|
+
const lines = t.split('\n');
|
|
139
|
+
// a Markdown table (a header row followed by a |---|---| separator)
|
|
140
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
141
|
+
if (lines[i].includes('|') && /-/.test(lines[i + 1]) && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1])) return true;
|
|
142
|
+
}
|
|
143
|
+
const headings = (t.match(/^#{1,6}\s+\S/gm) || []).length;
|
|
144
|
+
const bullets = (t.match(/^\s*([-*+]|\d+\.)\s+\S/gm) || []).length;
|
|
145
|
+
return headings >= 2 || bullets >= 5;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Derive a readable .md filename for a promoted document (sanitised later). */
|
|
149
|
+
function deriveDocName(instruction, reply) {
|
|
150
|
+
const h = String(reply || '').match(/^#{1,6}\s+(.+?)\s*$/m);
|
|
151
|
+
const base = (h ? h[1] : String(instruction || 'answer')).replace(/[*_`#|]/g, '').trim();
|
|
152
|
+
return (base.slice(0, 60) || 'answer') + '.md';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// How many characters of source content to send the model per turn. Large crawls
|
|
156
|
+
// can exceed a model's context window; we cap and flag truncation rather than fail
|
|
157
|
+
// (the full extraction always stays on disk).
|
|
158
|
+
const RESHAPE_CAP = 60000;
|
|
159
|
+
|
|
160
|
+
/** One-line identity for a document so the model can be told (and the user can
|
|
161
|
+
* reference) exactly which file it is: name · size · title · source URL(s). */
|
|
162
|
+
function docLabel(d, i, kind) {
|
|
163
|
+
return [
|
|
164
|
+
d.filename ? `"${d.filename}"` : `${kind} ${i + 1}`,
|
|
165
|
+
d.bytes || d.bytes === 0 ? `${d.bytes} bytes` : '',
|
|
166
|
+
d.title ? `titled "${d.title}"` : '',
|
|
167
|
+
d.sources && d.sources.length ? `crawled from ${d.sources.join(' , ')}` : '',
|
|
168
|
+
d.partial ? 'PARTIAL EXTRACT — only the sections relevant to this request are shown' : '',
|
|
169
|
+
]
|
|
170
|
+
.filter(Boolean)
|
|
171
|
+
.join(' · ');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Render a labelled set of documents, each under a header that states its identity. */
|
|
175
|
+
function renderDocs(set, kind) {
|
|
176
|
+
return set
|
|
177
|
+
.map((d, i) => `===== ${kind} [${i + 1}] — ${docLabel(d, i, kind)} =====\n${String(d.content || '').trim()}`)
|
|
178
|
+
.join('\n\n');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Rework already-extracted content to fulfil a user request, like answering from a
|
|
183
|
+
* knowledge base built over the whole crawl output. This is Phase 2: it runs on
|
|
184
|
+
* the SAVED files, on demand, as many times as the user wants — the crawl itself
|
|
185
|
+
* (Phase 1) never reshapes. The model MAY filter, reorder, regroup and reformat
|
|
186
|
+
* (e.g. into a Markdown table); it MUST keep every kept value (name/number/price/
|
|
187
|
+
* time/URL/string) EXACTLY as written and never invent or alter one.
|
|
188
|
+
*
|
|
189
|
+
* Context is passed as IDENTIFIABLE DOCUMENTS, not an anonymous blob, so the model
|
|
190
|
+
* knows what it's looking at and can honour references like "the original md" or
|
|
191
|
+
* "the 4574 bytes file":
|
|
192
|
+
* - `documents` — the ORIGINAL crawled extraction (the default thing to reshape),
|
|
193
|
+
* each `{ filename, bytes, title?, sources?, content }`.
|
|
194
|
+
* - `produced` — files the user produced earlier in THIS chat (so they can
|
|
195
|
+
* iterate on one), each `{ filename, bytes?, content }`. Clearly separated.
|
|
196
|
+
* - `corpus` — back-compat: a bare string becomes a single unnamed document.
|
|
197
|
+
*
|
|
198
|
+
* @param {object} a
|
|
199
|
+
* @param {{provider,model,baseUrl,apiKey}} a.llm
|
|
200
|
+
* @param {string} a.instruction the user's latest message
|
|
201
|
+
* @param {Array<{role:string, content:string}>} [a.history] prior turns (this session)
|
|
202
|
+
* @param {Array<object>} [a.documents] original crawled source documents
|
|
203
|
+
* @param {Array<object>} [a.produced] files produced earlier in this chat
|
|
204
|
+
* @param {string} [a.corpus] back-compat: unnamed source content
|
|
205
|
+
* @returns {Promise<{ reply: string, files: Array<{ filename, content }>, truncated: boolean }>}
|
|
206
|
+
*/
|
|
207
|
+
export async function aiReshape({ llm, instruction, history = [], documents = null, produced = [], corpus = '' }) {
|
|
208
|
+
let docs = Array.isArray(documents) ? documents : null;
|
|
209
|
+
if (!docs) docs = String(corpus || '').trim() ? [{ filename: '', content: String(corpus) }] : [];
|
|
210
|
+
docs = docs.filter((d) => String(d.content || '').trim());
|
|
211
|
+
if (!docs.length) return { reply: 'There is no extracted content to work from yet.', files: [], truncated: false, contextMode: 'full' };
|
|
212
|
+
|
|
213
|
+
// #11 root cause: when the sources exceed the model budget, RETRIEVE the sections
|
|
214
|
+
// relevant to the instruction instead of blindly sending the first RESHAPE_CAP chars.
|
|
215
|
+
// The blind head-slice is what made the model "answer" out-of-budget topics from its
|
|
216
|
+
// own memory (a fabricated v-alert props table, live). Verbatim subsets, document
|
|
217
|
+
// order preserved, omissions marked; 'head' mode = the legacy slice, kept only when
|
|
218
|
+
// nothing in the instruction discriminates (e.g. "tidy everything up").
|
|
219
|
+
// #22: with an embedModel configured, sections are ranked SEMANTICALLY (each by its
|
|
220
|
+
// heading + head window) so a cross-language request still pulls the right ones;
|
|
221
|
+
// null (tier off / backend down) keeps the lexical retrieval byte-identical. Only
|
|
222
|
+
// computed when the sources actually exceed the budget — a fitting corpus embeds nothing.
|
|
223
|
+
let sectionScore = null;
|
|
224
|
+
if (llm && llm.embedModel && !llmDisabled(llm)) {
|
|
225
|
+
const total = docs.reduce((n, d) => n + String(d.content || '').length, 0);
|
|
226
|
+
if (total > RESHAPE_CAP) {
|
|
227
|
+
sectionScore = await semanticSectionScores({ llm, instruction, docSections: docs.map((d) => sectionizeDoc(d.content)) });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const sel = selectRelevant(docs, instruction, RESHAPE_CAP, sectionScore);
|
|
231
|
+
let contextMode = sel.mode;
|
|
232
|
+
let truncated = sel.truncated;
|
|
233
|
+
let sourcesBlock = renderDocs(sel.docs, 'SOURCE DOCUMENT');
|
|
234
|
+
if (sourcesBlock.length > RESHAPE_CAP) {
|
|
235
|
+
sourcesBlock = sourcesBlock.slice(0, RESHAPE_CAP);
|
|
236
|
+
truncated = true;
|
|
237
|
+
if (contextMode === 'full') contextMode = 'head';
|
|
238
|
+
}
|
|
239
|
+
if (sel.omittedDocs > 0) {
|
|
240
|
+
sourcesBlock += `\n\n(${sel.omittedDocs} other source document(s) contained nothing relevant to this request and were omitted.)`;
|
|
241
|
+
}
|
|
242
|
+
// Prior chat outputs as secondary context, only with whatever budget remains so
|
|
243
|
+
// they can never crowd out the originals.
|
|
244
|
+
const prod = (produced || []).filter((d) => String(d.content || '').trim());
|
|
245
|
+
let producedBlock = prod.length ? renderDocs(prod, 'FILE YOU PRODUCED EARLIER IN THIS CHAT') : '';
|
|
246
|
+
const budget = Math.max(0, RESHAPE_CAP - sourcesBlock.length);
|
|
247
|
+
if (producedBlock.length > budget) producedBlock = producedBlock.slice(0, budget);
|
|
248
|
+
|
|
249
|
+
const system =
|
|
250
|
+
'You help a user reshape and answer questions over ALREADY-EXTRACTED website content, ' +
|
|
251
|
+
'like a knowledge base. The content is given as one or more SOURCE DOCUMENTS — the original, ' +
|
|
252
|
+
'verbatim crawl output — each labelled with its filename and byte size. STRICT RULES:\n' +
|
|
253
|
+
'- The SOURCE DOCUMENTS are your only source of facts AND the DEFAULT thing to work on. When ' +
|
|
254
|
+
'the user says "the original", "the original md", a filename, or a size (e.g. "the 4574 bytes ' +
|
|
255
|
+
'file"), they mean the matching SOURCE DOCUMENT — operate on THAT document.\n' +
|
|
256
|
+
'- NEVER supply a fact, value, prop, price, example or code snippet from your own knowledge ' +
|
|
257
|
+
'of the site, product or framework — even when you are confident you know it. If the SOURCE ' +
|
|
258
|
+
'DOCUMENTS do not contain what the user asks for, say so plainly ("the extraction does not ' +
|
|
259
|
+
'contain X") — an honest gap beats an invented answer every time.\n' +
|
|
260
|
+
'- Never invent, add, infer or alter a value: keep every name, number, price, time, URL and ' +
|
|
261
|
+
'string EXACTLY as written. You may select, drop, reorder, regroup, reformat (e.g. into a ' +
|
|
262
|
+
'Markdown table) and tidy the layout — but never change the actual content or values.\n' +
|
|
263
|
+
'- "Redo/rewrite the original better without changing its content" means reproduce that WHOLE ' +
|
|
264
|
+
'document, tidied and well-structured, with every value preserved verbatim.\n' +
|
|
265
|
+
'- Files labelled "FILE YOU PRODUCED EARLIER IN THIS CHAT" are your own prior outputs; revise ' +
|
|
266
|
+
'one only when the user clearly refers to it. They are NOT the originals.\n' +
|
|
267
|
+
'- When you produce deliverable content, emit it as one or more FILE BLOCKS, each in this EXACT ' +
|
|
268
|
+
'format on their own lines:\n' +
|
|
269
|
+
'===FILE: name.md===\n' +
|
|
270
|
+
'<the file\'s Markdown>\n' +
|
|
271
|
+
'===END===\n' +
|
|
272
|
+
'You may emit SEVERAL files (e.g. split by category or by day). Use short, descriptive .md ' +
|
|
273
|
+
'filenames. Do NOT wrap a block\'s body in a code fence.\n' +
|
|
274
|
+
'- Only emit FILE BLOCKS when the user asks you to CREATE, RESHAPE, REDO, SPLIT, FILTER or ' +
|
|
275
|
+
'REFORMAT a deliverable (or the answer is itself inherently a document — a table, several ' +
|
|
276
|
+
'sections, a long list). A QUESTION is NOT such a request: when the user ASKS something (e.g. ' +
|
|
277
|
+
'"what street is it on?", "how many slots are free?"), answer in plain text with NO file blocks, ' +
|
|
278
|
+
'and do NOT reproduce a SOURCE DOCUMENT as a file. Put any explanation OUTSIDE the blocks, brief.\n' +
|
|
279
|
+
'- If the request cannot be satisfied from the documents, say so plainly (no blocks).';
|
|
280
|
+
|
|
281
|
+
const convo = (history || [])
|
|
282
|
+
.map((h) => `${h.role === 'assistant' ? 'Assistant' : 'User'}: ${h.content}`)
|
|
283
|
+
.join('\n');
|
|
284
|
+
const user =
|
|
285
|
+
'SOURCE DOCUMENTS — the original crawled extraction (verbatim). By DEFAULT these are what you ' +
|
|
286
|
+
'reshape; refer to them by filename or size when the user does:\n\n' +
|
|
287
|
+
sourcesBlock +
|
|
288
|
+
'\n\n' +
|
|
289
|
+
(producedBlock
|
|
290
|
+
? 'FILES YOU PRODUCED EARLIER IN THIS CHAT (you may revise one if asked; NOT the originals):\n\n' +
|
|
291
|
+
producedBlock +
|
|
292
|
+
'\n\n'
|
|
293
|
+
: '') +
|
|
294
|
+
(convo ? 'Conversation so far:\n' + convo + '\n\n' : '') +
|
|
295
|
+
'User: ' +
|
|
296
|
+
String(instruction || '');
|
|
297
|
+
|
|
298
|
+
let ans;
|
|
299
|
+
try {
|
|
300
|
+
ans = await chat(llm, system, user, null, 'reshape');
|
|
301
|
+
} catch (err) {
|
|
302
|
+
// Surface the real reason (bad key, unreachable URL, unknown model) — this is
|
|
303
|
+
// a user-facing chat turn, not a silent crawl decision.
|
|
304
|
+
return {
|
|
305
|
+
reply:
|
|
306
|
+
'The model call failed: ' +
|
|
307
|
+
((err && err.message) || String(err)) +
|
|
308
|
+
'. Check the selected model, and (for an API provider) the base URL and API key.',
|
|
309
|
+
files: [],
|
|
310
|
+
truncated,
|
|
311
|
+
contextMode,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const parsed = parseReshape(ans);
|
|
315
|
+
if (!parsed.reply && !parsed.files.length) {
|
|
316
|
+
return { reply: 'The model did not return a usable response. Try rephrasing, or check the model is reachable.', files: [], truncated, contextMode };
|
|
317
|
+
}
|
|
318
|
+
// "Auto" mode safety net: if the model answered with document-worthy content but
|
|
319
|
+
// didn't wrap it in a FILE BLOCK, promote that content to a saved document so the
|
|
320
|
+
// user gets a file — not just a chat message. Short Q&A is left as a message.
|
|
321
|
+
if (!parsed.files.length && isDocumentWorthy(parsed.reply)) {
|
|
322
|
+
parsed.files.push({ filename: deriveDocName(instruction, parsed.reply), content: parsed.reply });
|
|
323
|
+
parsed.reply = '';
|
|
324
|
+
}
|
|
325
|
+
return { ...parsed, truncated, contextMode };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// =========================================================================
|
|
329
|
+
// PHASE 1 — crawl-time judgment (scope, links, reveal)
|
|
330
|
+
// =========================================================================
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Keep only task-relevant sections, verbatim. This is the "stay focused" step:
|
|
334
|
+
* for a "documentation" task it drops the landing page, footer, pricing, etc.;
|
|
335
|
+
* for "the pizza menu" it keeps only the menu. It never rewrites content — it
|
|
336
|
+
* returns the ORIGINAL text of the kept sections — and biases toward KEEP.
|
|
337
|
+
* @returns {Promise<{ markdown: string, relevant: boolean }>}
|
|
338
|
+
*/
|
|
339
|
+
export async function aiScopeContent({ llm, task, title, markdown }) {
|
|
340
|
+
if (llmDisabled(llm)) return { markdown, relevant: !!markdown }; // no-AI: keep whole
|
|
341
|
+
if (!markdown || markdown.length < 1200) return { markdown, relevant: !!markdown };
|
|
342
|
+
|
|
343
|
+
const sections = sectionize(markdown);
|
|
344
|
+
if (sections.length <= 1) {
|
|
345
|
+
// Single blob: keep it whole, no model call. The only thing a relevance verdict
|
|
346
|
+
// could do here is DROP the page, and the drop-guard was "irrelevant AND thin
|
|
347
|
+
// (< 600 chars)" — unreachable, since everything under 1200 chars already
|
|
348
|
+
// returned above. So the call could never change the outcome; it only burned
|
|
349
|
+
// one scope call per single-section page. Narrowing a kept page to the task is
|
|
350
|
+
// the job of section-scoping below and of Phase 2 (reshape).
|
|
351
|
+
return { markdown, relevant: true };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const outline = sections
|
|
355
|
+
.map((s) => `${s.index}: ${s.heading} — ${s.text.replace(/\s+/g, ' ').slice(0, 140)}`)
|
|
356
|
+
.join('\n');
|
|
357
|
+
|
|
358
|
+
const ans = await chat(
|
|
359
|
+
llm,
|
|
360
|
+
'You select which sections of a page belong to a user extraction task. ' +
|
|
361
|
+
'Keep every section that contains task-relevant content. Drop only clearly-irrelevant ' +
|
|
362
|
+
'sections such as site navigation, footers, cookie/consent notices, marketing call-to-action, ' +
|
|
363
|
+
'newsletter signups, "related/recommended" widgets, comments, or unrelated topics. ' +
|
|
364
|
+
'When unsure, KEEP. Answer with JSON only.',
|
|
365
|
+
`Task: "${task}"\nPage title: ${title || ''}\n\nSections (index: heading — preview):\n${outline}\n\n` +
|
|
366
|
+
'Reply with {"keep": [list of section indexes to keep]}.',
|
|
367
|
+
SCHEMAS.keep,
|
|
368
|
+
'scope',
|
|
369
|
+
).catch(() => '');
|
|
370
|
+
|
|
371
|
+
const j = parseJson(ans);
|
|
372
|
+
if (!j || !Array.isArray(j.keep)) return { markdown, relevant: true };
|
|
373
|
+
|
|
374
|
+
const keep = new Set(j.keep.map(Number).filter((n) => Number.isInteger(n)));
|
|
375
|
+
if (keep.size === 0) return { markdown, relevant: true }; // keep-bias on empty
|
|
376
|
+
const kept = sections.filter((s) => keep.has(s.index)).map((s) => s.text).filter(Boolean);
|
|
377
|
+
const out = kept.join('\n\n').trim();
|
|
378
|
+
return { markdown: out || markdown, relevant: out.length > 0 };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Choose which links to follow for the task.
|
|
383
|
+
* @param {object} a
|
|
384
|
+
* @param {Array<{href,label}>} a.links in-scope candidates
|
|
385
|
+
* @returns {Promise<string[]>} hrefs to enqueue
|
|
386
|
+
*/
|
|
387
|
+
export async function aiSelectLinks({ llm, task, links }) {
|
|
388
|
+
const capped = links.slice(0, 160);
|
|
389
|
+
if (capped.length === 0) return [];
|
|
390
|
+
if (llmDisabled(llm)) return capped.map((l) => l.href); // no-AI: follow all in-scope
|
|
391
|
+
|
|
392
|
+
const list = capped.map((l, i) => `${i}: ${l.label ? l.label.slice(0, 60) + ' — ' : ''}${l.href}`).join('\n');
|
|
393
|
+
const ans = await chat(
|
|
394
|
+
llm,
|
|
395
|
+
'You decide which links to follow while crawling to fulfil an extraction task. ' +
|
|
396
|
+
'You are given raw destinations exactly as they appear on the page — the crawler makes NO ' +
|
|
397
|
+
'assumptions about their shape, so YOU must recognise what is a real, separate page. A real ' +
|
|
398
|
+
'page can be a normal URL, a single-page-app route carried in the URL fragment ' +
|
|
399
|
+
'(e.g. #/contact, #!/features, #/products/42) or in the query string (e.g. ?view=pricing, ' +
|
|
400
|
+
'?page=2), or any other site-specific routing/pagination scheme. Treat all of these as real ' +
|
|
401
|
+
'pages. Do NOT follow: same-page anchors that merely jump within the CURRENT page ' +
|
|
402
|
+
'(e.g. #overview, #section-3, #top — a fragment with no route-like path), links that clearly ' +
|
|
403
|
+
'reload the current page, mailto/tel, or external sites. ' +
|
|
404
|
+
'Among real pages, follow a link ONLY if its destination is the SAME KIND of content the task ' +
|
|
405
|
+
'asks for. For a documentation task the right kind is reference / guide / tutorial / API / ' +
|
|
406
|
+
'concept / configuration (incl. release notes / changelog); avoid blog/news, marketing/landing, ' +
|
|
407
|
+
'pricing, about/team/careers, community/showcase, login/signup, legal. For any other task ' +
|
|
408
|
+
'(a menu, prices, contact info, products, …) apply the same principle for that category. ' +
|
|
409
|
+
'When unsure whether something is on-task, prefer to follow (completeness matters more than speed). ' +
|
|
410
|
+
'Judge by the label and the whole destination string. Answer with JSON only.',
|
|
411
|
+
`Task: "${task}"\n\nDestinations (index: label — href):\n${list}\n\n` +
|
|
412
|
+
'Reply with {"follow": [indexes to follow]}. Include every real, on-task page; exclude same-page anchors and off-task links.',
|
|
413
|
+
SCHEMAS.links,
|
|
414
|
+
'links',
|
|
415
|
+
).catch(() => '');
|
|
416
|
+
|
|
417
|
+
const j = parseJson(ans);
|
|
418
|
+
if (!j || !Array.isArray(j.follow)) return capped.map((l) => l.href); // follow all in-scope on failure
|
|
419
|
+
const idx = new Set(j.follow.map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n < capped.length));
|
|
420
|
+
// A non-empty answer whose indexes are ALL out of range is garbage → treat as failure.
|
|
421
|
+
// A well-formed EMPTY list is a legitimate verdict ("none of these are on-task pages")
|
|
422
|
+
// and must be honoured — overriding it to follow-all is what sent crawls wandering
|
|
423
|
+
// through 160 off-task links whenever a page had nothing worth following.
|
|
424
|
+
if (j.follow.length && idx.size === 0) return capped.map((l) => l.href);
|
|
425
|
+
return capped.filter((_, i) => idx.has(i)).map((l) => l.href);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Plan navigation ONCE for a multi-view page — the crawl4ai-inspired split: the AI
|
|
430
|
+
* makes a single high-level PLAN, then the reveal loop EXECUTES it deterministically
|
|
431
|
+
* (no LLM in the click loop, which is what made a per-step navigator flaky on a local
|
|
432
|
+
* model). A page can be a sequence/graph of views reached by clicking controls that
|
|
433
|
+
* move between them: paginators ("next"/"previous"/page N), calendar month arrows,
|
|
434
|
+
* wizard steps, "load next", view switchers.
|
|
435
|
+
*
|
|
436
|
+
* The model answers one easy question: for THIS task, which control ADVANCES toward
|
|
437
|
+
* the target, and what literal TEXT marks the target view (so the loop can stop by a
|
|
438
|
+
* plain substring check, not another model call)? For an open-ended task
|
|
439
|
+
* ("everything"/"all") there is no single target — it returns `direction: null` and
|
|
440
|
+
* the loop explores every control instead.
|
|
441
|
+
*
|
|
442
|
+
* @param {object} a
|
|
443
|
+
* @param {{provider,model,baseUrl,apiKey}} a.llm
|
|
444
|
+
* @param {string} a.task
|
|
445
|
+
* @param {{title?:string, snippet?:string}} a.current the view we're on now
|
|
446
|
+
* @param {Array<{signature:string, kind?:string, label?:string, context?:string}>} a.controls
|
|
447
|
+
* @returns {Promise<{direction:number|null, target:string|null}|null>}
|
|
448
|
+
* direction = index into `controls` of the advancing control; target = literal text
|
|
449
|
+
* (in the PAGE's language) that appears in the target view; or null on failure.
|
|
450
|
+
*/
|
|
451
|
+
export async function aiPlanNavigation({ llm, task, current = {}, controls = [] }) {
|
|
452
|
+
const list = (controls || []).slice(0, 60);
|
|
453
|
+
if (list.length === 0) return { direction: null, target: null };
|
|
454
|
+
// no-AI: no targeted walk — the reveal loop explores every control open-ended.
|
|
455
|
+
if (llmDisabled(llm)) return { direction: null, target: null };
|
|
456
|
+
|
|
457
|
+
const lines = list
|
|
458
|
+
.map((c, i) => `${i}: [${c.kind || 'control'}] "${(c.label || '(no label)').slice(0, 70)}"` + (c.context ? ` — under "${c.context}"` : ''))
|
|
459
|
+
.join('\n');
|
|
460
|
+
|
|
461
|
+
const ans = await chat(
|
|
462
|
+
llm,
|
|
463
|
+
'You plan how to navigate a web page to fulfil an extraction task. Some pages are a ' +
|
|
464
|
+
'SEQUENCE of views reached by clicking a control repeatedly (a "next month" arrow on ' +
|
|
465
|
+
'a calendar, a "next page" paginator, wizard steps). Decide ONE plan:\n' +
|
|
466
|
+
'- If the task targets a SPECIFIC view reachable by such navigation (a particular ' +
|
|
467
|
+
'month+year, page, section or date), return the index of the control that ADVANCES ' +
|
|
468
|
+
'toward it (e.g. the next/forward/"successivo" arrow to reach a LATER month; the ' +
|
|
469
|
+
'previous/"precedente" one for an EARLIER month — reason from the current view) AND a ' +
|
|
470
|
+
'short "target" string that will literally appear in that target view. The target ' +
|
|
471
|
+
'string MUST be written in the SAME LANGUAGE/spelling as the page (look at the current ' +
|
|
472
|
+
'view text: if months read "GIUGNO", "LUGLIO" then August is "agosto", not "august").\n' +
|
|
473
|
+
'- If the task is open-ended ("everything", "all", "the whole …") or needs no such ' +
|
|
474
|
+
'navigation, return {"direction": null, "target": null}.\n' +
|
|
475
|
+
'Answer with JSON only.',
|
|
476
|
+
`Task: "${task || ''}"\n\n` +
|
|
477
|
+
`Current view (title + visible text):\n${(current.title || '').slice(0, 100)}\n${(current.snippet || '').replace(/\s+/g, ' ').slice(0, 700)}\n\n` +
|
|
478
|
+
`Controls on the page (index: [kind] "label"):\n${lines}\n\n` +
|
|
479
|
+
'Reply with {"direction": <index or null>, "target": "<text that marks the target view, or null>"}.',
|
|
480
|
+
SCHEMAS.plan,
|
|
481
|
+
'nav-plan',
|
|
482
|
+
).catch(() => '');
|
|
483
|
+
|
|
484
|
+
const j = parseJson(ans);
|
|
485
|
+
if (!j) return null; // signal: caller falls back to open-ended exploration
|
|
486
|
+
// null-safe on purpose: Number(null) is 0, so a model answering the documented
|
|
487
|
+
// "no navigation" form {"direction": null} used to be misread as "direction =
|
|
488
|
+
// the first control" — which the reveal loop then RESERVED for a targeted walk
|
|
489
|
+
// that never ran, so that control was never clicked at all (found by the #21
|
|
490
|
+
// closed-loop tests; the schema explicitly allows null here).
|
|
491
|
+
const dir = j.direction == null ? NaN : Number(j.direction);
|
|
492
|
+
const direction = Number.isInteger(dir) && dir >= 0 && dir < list.length ? dir : null;
|
|
493
|
+
const target = typeof j.target === 'string' && j.target.trim() ? j.target.trim() : null;
|
|
494
|
+
return { direction, target };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Decide which interactive controls on a page actually HIDE content worth
|
|
499
|
+
* revealing — the AI-driven core of discovery. The model reads each candidate
|
|
500
|
+
* (label, kind, class, nearby heading) like a human and judges whether clicking
|
|
501
|
+
* it would surface currently-hidden readable content (tabs, accordions, "show
|
|
502
|
+
* more", variant switches), versus controls that reveal nothing (copy/share,
|
|
503
|
+
* theme toggles, live-demo widgets, plain navigation). This is what lets the
|
|
504
|
+
* crawler find content in non-obvious places on ANY site without per-site rules.
|
|
505
|
+
*
|
|
506
|
+
* Completeness-biased: "when unsure, include". Returns a Set of the chosen
|
|
507
|
+
* candidates' `signature`s, or null on parse failure so the caller can fall back
|
|
508
|
+
* to the deterministic heuristic (no missed content if the model is down).
|
|
509
|
+
*
|
|
510
|
+
* @param {object} a
|
|
511
|
+
* @param {Array<{signature:string, kind:string, label:string, cls?:string, context?:string}>} a.candidates
|
|
512
|
+
* @returns {Promise<Set<string>|null>}
|
|
513
|
+
*/
|
|
514
|
+
export async function aiSelectRevealers({ llm, task, candidates }) {
|
|
515
|
+
const list = (candidates || []).slice(0, 150);
|
|
516
|
+
if (list.length === 0) return new Set();
|
|
517
|
+
// no-AI: null = "use the per-candidate DOM heuristic" (same signal as a model
|
|
518
|
+
// outage), so reveal coverage never drops below the deterministic baseline.
|
|
519
|
+
if (llmDisabled(llm)) return null;
|
|
520
|
+
|
|
521
|
+
const lines = list
|
|
522
|
+
.map(
|
|
523
|
+
(c, i) =>
|
|
524
|
+
`${i}: [${c.kind || 'control'}] "${(c.label || '(no label)').slice(0, 80)}"` +
|
|
525
|
+
(c.cls ? ` .${c.cls}` : '') +
|
|
526
|
+
(c.context ? ` — under "${c.context}"` : ''),
|
|
527
|
+
)
|
|
528
|
+
.join('\n');
|
|
529
|
+
|
|
530
|
+
const ans = await chat(
|
|
531
|
+
llm,
|
|
532
|
+
'You are reading a web page like a human in order to extract ALL of its content, ' +
|
|
533
|
+
'including content that stays hidden until you interact. You are given the ' +
|
|
534
|
+
'interactive controls found in the main content area. Decide which ones, WHEN ' +
|
|
535
|
+
'CLICKED, would reveal additional readable content that is currently hidden — ' +
|
|
536
|
+
'e.g. tabs that swap in different text/code, accordions and expanders, ' +
|
|
537
|
+
'"show more"/"read more"/"load more"/"see details", version or platform or ' +
|
|
538
|
+
'variant switchers. Do NOT pick controls that reveal no new text: ' +
|
|
539
|
+
'copy/share/print buttons, theme or dark-mode toggles, pure interactive demos ' +
|
|
540
|
+
'or playgrounds (date pickers, sliders, colour pickers, steppers, rating stars, ' +
|
|
541
|
+
'carousels of the same widget), cookie notices, or plain links/navigation. ' +
|
|
542
|
+
'When you are unsure whether a control reveals hidden content, INCLUDE it — ' +
|
|
543
|
+
'missing content is far worse than one wasted click. Answer with JSON only.',
|
|
544
|
+
`Task (for context only — reveal everything regardless): "${task || ''}"\n\n` +
|
|
545
|
+
`Controls (index: [kind] "label" .class — context):\n${lines}\n\n` +
|
|
546
|
+
'Reply with {"click":[indexes of controls that reveal hidden content]}.',
|
|
547
|
+
SCHEMAS.reveal,
|
|
548
|
+
'reveal',
|
|
549
|
+
).catch(() => '');
|
|
550
|
+
|
|
551
|
+
const j = parseJson(ans);
|
|
552
|
+
if (!j || !Array.isArray(j.click)) return null; // signal: use the fallback
|
|
553
|
+
const keep = new Set(j.click.map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n < list.length));
|
|
554
|
+
return new Set([...keep].map((i) => list[i].signature));
|
|
555
|
+
}
|