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