@vierratale/ai 0.1.0-beta.1 → 0.1.0-beta.10
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/README.md +157 -15
- package/bin/vierrataleai.js +74 -1
- package/package.json +10 -2
- package/scripts/postinstall.js +35 -0
- package/src/catalog.js +94 -45
- package/src/cli.js +1098 -51
- package/src/cmd/agent.js +1711 -0
- package/src/cmd/executor.js +257 -0
- package/src/cmd/tools.js +153 -0
- package/src/config.js +54 -15
- package/src/index.js +2 -1
- package/src/installer.js +159 -15
- package/src/prompts/system.json +2 -2
- package/src/prompts/system.md +42 -0
- package/src/providers/anthropic.js +130 -0
- package/src/providers/base.js +18 -0
- package/src/providers/cortex.js +298 -0
- package/src/providers/gemini.js +128 -0
- package/src/providers/index.js +7 -3
- package/src/providers/openai.js +79 -34
- package/src/session.js +221 -0
- package/src/ui/banner.js +23 -12
- package/src/ui/branding.js +8 -9
- package/src/ui/chatbox.js +280 -0
- package/src/ui/input.js +501 -0
- package/src/ui/terminal.js +84 -8
- package/src/utils/downloader.js +364 -0
- package/src/utils/filewriter.js +97 -0
- package/src/utils/intents.js +102 -0
- package/src/utils/logger.js +67 -0
- package/src/utils/math.js +142 -0
- package/src/utils/webfetch.js +332 -0
- package/src/utils/websearch.js +262 -0
- package/build.sh +0 -17
- package/src/providers/ollama.js +0 -89
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// utils/math.js - Deterministic, safe math evaluator.
|
|
2
|
+
// Parses arithmetic ( + - * / ^ % parentheses, decimals, scientific notation )
|
|
3
|
+
// WITHOUT using eval(), so it can never execute arbitrary code. Returns null
|
|
4
|
+
// when the input is not a clean math expression, so callers fall back to the
|
|
5
|
+
// model.
|
|
6
|
+
|
|
7
|
+
// Extract a bare arithmetic expression from a line of chat text.
|
|
8
|
+
export function extractMathExpression(text) {
|
|
9
|
+
const s = String(text || '').trim();
|
|
10
|
+
// Strip a leading question/math verb phrase if present.
|
|
11
|
+
const cleaned = s.replace(
|
|
12
|
+
/^(?:what(?:'s| is| are)|what's|compute|calculate|solve|evaluate|equals?)\b\s*/i,
|
|
13
|
+
''
|
|
14
|
+
).replace(/\??$/, '').trim();
|
|
15
|
+
const expr = cleaned.replace(/[?,!.]$/, '').trim();
|
|
16
|
+
if (!expr) return null;
|
|
17
|
+
// Guard: must be purely numeric + arithmetic operators + parens + spaces.
|
|
18
|
+
if (!/^[0-9+\-*\/^%().\s]+$/.test(expr)) return null;
|
|
19
|
+
if (!/\d/.test(expr)) return null;
|
|
20
|
+
if (/[a-zA-Z]/.test(expr)) return null;
|
|
21
|
+
// Must contain at least one operator (otherwise "2" alone isn't a math ask).
|
|
22
|
+
if (!/[\+\-\*\/\^%]/.test(expr)) return null;
|
|
23
|
+
return expr;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Tokenize an expression into numbers and operators.
|
|
27
|
+
function tokenize(expr) {
|
|
28
|
+
const tokens = [];
|
|
29
|
+
let i = 0;
|
|
30
|
+
const n = expr.length;
|
|
31
|
+
while (i < n) {
|
|
32
|
+
const c = expr[i];
|
|
33
|
+
if (/\s/.test(c)) { i++; continue; }
|
|
34
|
+
if ('+-*/^%()'.includes(c)) {
|
|
35
|
+
tokens.push(c);
|
|
36
|
+
i++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// read a number (int, decimal, scientific)
|
|
40
|
+
const num = expr.slice(i).match(/^(\d+(?:\.\d+)?|\.\d+)([eE][+-]?\d+)?/);
|
|
41
|
+
if (!num) return null;
|
|
42
|
+
tokens.push(parseFloat(num[0]));
|
|
43
|
+
i += num[0].length;
|
|
44
|
+
}
|
|
45
|
+
return tokens;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Recursive-descent parser with correct precedence:
|
|
49
|
+
// expr := term (('+'|'-') term)*
|
|
50
|
+
// term := power (('*'|'/'|'%') power)*
|
|
51
|
+
// power := unary ('^' power)? right-associative
|
|
52
|
+
// unary := ('+'|'-') unary | factor
|
|
53
|
+
// factor := '(' expr ')' | number
|
|
54
|
+
class Parser {
|
|
55
|
+
constructor(tokens) {
|
|
56
|
+
this.tokens = tokens;
|
|
57
|
+
this.pos = 0;
|
|
58
|
+
}
|
|
59
|
+
peek() { return this.tokens[this.pos]; }
|
|
60
|
+
next() { return this.tokens[this.pos++]; }
|
|
61
|
+
expect(val) {
|
|
62
|
+
const t = this.next();
|
|
63
|
+
if (t !== val) throw new Error('syntax error');
|
|
64
|
+
}
|
|
65
|
+
parseExpr() { return this.parseTerm(); }
|
|
66
|
+
parseTerm() {
|
|
67
|
+
let left = this.parseMul();
|
|
68
|
+
while (['+', '-'].includes(this.peek())) {
|
|
69
|
+
const op = this.next();
|
|
70
|
+
const right = this.parseMul();
|
|
71
|
+
left = op === '+' ? left + right : left - right;
|
|
72
|
+
}
|
|
73
|
+
return left;
|
|
74
|
+
}
|
|
75
|
+
parseMul() {
|
|
76
|
+
let left = this.parsePower();
|
|
77
|
+
while (['*', '/', '%'].includes(this.peek())) {
|
|
78
|
+
const op = this.next();
|
|
79
|
+
const right = this.parsePower();
|
|
80
|
+
if (op === '*') left = left * right;
|
|
81
|
+
else if (op === '/') left = left / right;
|
|
82
|
+
else left = left % right;
|
|
83
|
+
}
|
|
84
|
+
return left;
|
|
85
|
+
}
|
|
86
|
+
parsePower() {
|
|
87
|
+
let left = this.parseUnary();
|
|
88
|
+
if (this.peek() === '^') {
|
|
89
|
+
this.next();
|
|
90
|
+
const right = this.parsePower();
|
|
91
|
+
left = Math.pow(left, right);
|
|
92
|
+
}
|
|
93
|
+
return left;
|
|
94
|
+
}
|
|
95
|
+
parseUnary() {
|
|
96
|
+
if (this.peek() === '+') { this.next(); return this.parseUnary(); }
|
|
97
|
+
if (this.peek() === '-') {
|
|
98
|
+
this.next();
|
|
99
|
+
return -this.parseUnary();
|
|
100
|
+
}
|
|
101
|
+
return this.parseFactor();
|
|
102
|
+
}
|
|
103
|
+
parseFactor() {
|
|
104
|
+
const t = this.peek();
|
|
105
|
+
if (t === '(') {
|
|
106
|
+
this.next();
|
|
107
|
+
const v = this.parseExpr();
|
|
108
|
+
this.expect(')');
|
|
109
|
+
return v;
|
|
110
|
+
}
|
|
111
|
+
if (typeof t === 'number') {
|
|
112
|
+
this.next();
|
|
113
|
+
return t;
|
|
114
|
+
}
|
|
115
|
+
throw new Error('syntax error');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function evaluateMath(expr) {
|
|
120
|
+
try {
|
|
121
|
+
const tokens = tokenize(expr);
|
|
122
|
+
if (!tokens || !tokens.length) return null;
|
|
123
|
+
const p = new Parser(tokens);
|
|
124
|
+
const result = p.parseExpr();
|
|
125
|
+
if (p.pos !== tokens.length) return null; // leftover tokens = not a clean expr
|
|
126
|
+
if (!Number.isFinite(result)) return null;
|
|
127
|
+
// Round to avoid floating point noise like 0.30000000000000004
|
|
128
|
+
const rounded = Math.round((result + Number.EPSILON) * 1e10) / 1e10;
|
|
129
|
+
return Number(rounded.toPrecision(12));
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Front-door: given a chat line, return a clean math answer or null.
|
|
136
|
+
export function trySolveMath(text) {
|
|
137
|
+
const expr = extractMathExpression(text);
|
|
138
|
+
if (!expr) return null;
|
|
139
|
+
const value = evaluateMath(expr);
|
|
140
|
+
if (value === null) return null;
|
|
141
|
+
return `${expr} = ${value}`;
|
|
142
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
export const UA =
|
|
2
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
|
|
3
|
+
'(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
|
4
|
+
|
|
5
|
+
const MAX_BYTES = 200000; // 200KB cap
|
|
6
|
+
const MAX_TEXT = 8000; // ~8k chars of readable text
|
|
7
|
+
|
|
8
|
+
const BLOCK_TAGS = new Set([
|
|
9
|
+
'script', 'style', 'noscript', 'svg', 'head', 'title',
|
|
10
|
+
'nav', 'footer', 'aside', 'iframe', 'form', 'button', 'noscript',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
// Body markers that indicate an anti-bot wall instead of real content.
|
|
14
|
+
const BLOCK_MARKERS = [
|
|
15
|
+
'just a moment',
|
|
16
|
+
'client challenge',
|
|
17
|
+
'checking your browser',
|
|
18
|
+
'enable javascript and cookies',
|
|
19
|
+
'cf-chl',
|
|
20
|
+
'challenge-platform',
|
|
21
|
+
'attention required!',
|
|
22
|
+
'powershell install',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// Browser-like headers so header-based bot checks see a real browser.
|
|
26
|
+
export function browserHeaders(accept = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8') {
|
|
27
|
+
return {
|
|
28
|
+
'User-Agent': UA,
|
|
29
|
+
Accept: accept,
|
|
30
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
31
|
+
Connection: 'keep-alive',
|
|
32
|
+
'Upgrade-Insecure-Requests': '1',
|
|
33
|
+
'Sec-Fetch-Dest': accept.startsWith('application/json') ? 'empty' : 'document',
|
|
34
|
+
'Sec-Fetch-Mode': accept.startsWith('application/json') ? 'cors' : 'navigate',
|
|
35
|
+
'Sec-Fetch-Site': 'none',
|
|
36
|
+
'Sec-Fetch-User': accept.startsWith('application/json') ? '?0' : '?1',
|
|
37
|
+
'Cache-Control': 'no-cache',
|
|
38
|
+
Pragma: 'no-cache',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// File types the AI can download off a fetched page.
|
|
43
|
+
const ASSET_TYPES = {
|
|
44
|
+
pdf: 'pdf', zip: 'zip', tar: 'tar', gz: 'gz', '7z': '7z', rar: 'rar',
|
|
45
|
+
doc: 'doc', docx: 'docx', xls: 'xls', xlsx: 'xlsx', ppt: 'ppt', pptx: 'pptx',
|
|
46
|
+
mp3: 'mp3', mp4: 'mp4', wav: 'wav', ogg: 'ogg', webm: 'webm', mov: 'mov',
|
|
47
|
+
csv: 'csv', json: 'json', ipynb: 'ipynb', txt: 'txt', md: 'md',
|
|
48
|
+
png: 'png', jpg: 'jpg', jpeg: 'jpeg', gif: 'gif', webp: 'webp', svg: 'svg', ico: 'ico',
|
|
49
|
+
py: 'py', js: 'js', ts: 'ts', sh: 'sh',
|
|
50
|
+
};
|
|
51
|
+
const ASSET_TYPE_RE = /\.([a-z0-9]+)(?:[?#].*)?$/i;
|
|
52
|
+
|
|
53
|
+
export function detectAssetType(url) {
|
|
54
|
+
const m = ASSET_TYPE_RE.exec(url);
|
|
55
|
+
if (!m) return null;
|
|
56
|
+
return ASSET_TYPES[m[1].toLowerCase()] || null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isBlockedBody(html) {
|
|
60
|
+
const s = String(html || '').toLowerCase().slice(0, 4000);
|
|
61
|
+
return BLOCK_MARKERS.some((m) => s.includes(m));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class WebFetch {
|
|
65
|
+
static normalizeUrl(input) {
|
|
66
|
+
const trimmed = (input || '').trim();
|
|
67
|
+
if (!trimmed) return null;
|
|
68
|
+
let url = trimmed;
|
|
69
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
|
|
70
|
+
// Already has an explicit scheme — must be http(s).
|
|
71
|
+
if (!/^https?:\/\//i.test(url)) return null;
|
|
72
|
+
} else if (!/^https?:\/\//i.test(url)) {
|
|
73
|
+
url = `https://${url}`;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const u = new URL(url);
|
|
77
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
78
|
+
return u.toString();
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
static async fetch(rawUrl, maxText = MAX_TEXT) {
|
|
85
|
+
const url = this.normalizeUrl(rawUrl);
|
|
86
|
+
if (!url) {
|
|
87
|
+
throw new Error('Invalid URL. Provide a valid http(s) address.');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let finalUrl = url;
|
|
91
|
+
let html = '';
|
|
92
|
+
let blocked = false;
|
|
93
|
+
try {
|
|
94
|
+
const res = await this._fetchHtml(url);
|
|
95
|
+
finalUrl = res.url;
|
|
96
|
+
html = res.html;
|
|
97
|
+
const status = res.status;
|
|
98
|
+
blocked = status !== 200 || isBlockedBody(html);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
blocked = true;
|
|
101
|
+
// _fetchHtml errors are network/cert/PROTOCOL issues; a fallback may
|
|
102
|
+
// still succeed (e.g. registry API is reachable even when the HTML
|
|
103
|
+
// page's CDN is not).
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Bot-walled npm/pypi pages degrade to their open JSON API.
|
|
107
|
+
if (blocked) {
|
|
108
|
+
const registry = await this._registryFallback(finalUrl || url, maxText);
|
|
109
|
+
if (registry) return registry;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (blocked) {
|
|
113
|
+
const status = 'blocked';
|
|
114
|
+
throw new Error(`Request blocked (${status}) for ${finalUrl || url}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
url: finalUrl,
|
|
119
|
+
title: this._extractTitle(html),
|
|
120
|
+
text: this._extractText(html).slice(0, maxText),
|
|
121
|
+
assets: this._extractAssets(html, finalUrl),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
static async _fetchHtml(url) {
|
|
126
|
+
let resp;
|
|
127
|
+
try {
|
|
128
|
+
resp = await fetch(url, {
|
|
129
|
+
headers: browserHeaders(),
|
|
130
|
+
redirect: 'follow',
|
|
131
|
+
signal: AbortSignal.timeout(15000),
|
|
132
|
+
});
|
|
133
|
+
} catch {
|
|
134
|
+
throw new Error(`Could not reach ${url}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const reader = resp.body.getReader();
|
|
138
|
+
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
139
|
+
let html = '';
|
|
140
|
+
let received = 0;
|
|
141
|
+
while (received < MAX_BYTES) {
|
|
142
|
+
const { done, value } = await reader.read();
|
|
143
|
+
if (done) break;
|
|
144
|
+
received += value.length;
|
|
145
|
+
html += decoder.decode(value, { stream: true });
|
|
146
|
+
}
|
|
147
|
+
reader.cancel();
|
|
148
|
+
html += decoder.decode();
|
|
149
|
+
|
|
150
|
+
return { url: resp.url || url, html, status: resp.status };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Registry fallback (npm + PyPI) via their open JSON APIs ---------- //
|
|
154
|
+
|
|
155
|
+
static async _registryFallback(url, maxText) {
|
|
156
|
+
const npm = this._npmSpec(url);
|
|
157
|
+
if (npm) {
|
|
158
|
+
try {
|
|
159
|
+
return await this._npmFallback(npm, url, maxText);
|
|
160
|
+
} catch {}
|
|
161
|
+
}
|
|
162
|
+
const pypi = this._pypiSpec(url);
|
|
163
|
+
if (pypi) {
|
|
164
|
+
try {
|
|
165
|
+
return await this._pypiFallback(pypi, url, maxText);
|
|
166
|
+
} catch {}
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
static _npmSpec(url) {
|
|
172
|
+
const m = /npmjs\.\w+\/package\/([@a-zA-Z0-9._~-]+(?:\/[@a-zA-Z0-9._~-]+)?)/i.exec(url);
|
|
173
|
+
return m ? m[1] : null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
static _pypiSpec(url) {
|
|
177
|
+
const m = /pypi\.org\/project\/([a-zA-Z0-9._-]+)/i.exec(url);
|
|
178
|
+
return m ? m[1] : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
static async _fetchJson(apiUrl) {
|
|
182
|
+
const resp = await fetch(apiUrl, {
|
|
183
|
+
headers: browserHeaders('application/json'),
|
|
184
|
+
redirect: 'follow',
|
|
185
|
+
signal: AbortSignal.timeout(15000),
|
|
186
|
+
});
|
|
187
|
+
if (!resp.ok) throw new Error(`API ${resp.status}`);
|
|
188
|
+
return resp.json();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
static async _npmFallback(spec, pageUrl, maxText) {
|
|
192
|
+
const pkg = encodeURIComponent(spec); // '@vierratale/ai' -> %40vierratale%2Fai
|
|
193
|
+
const data = await this._fetchJson(`https://registry.npmjs.org/${pkg}`);
|
|
194
|
+
const version =
|
|
195
|
+
(data['dist-tags'] && (data['dist-tags'].latest || data['dist-tags'].beta)) ||
|
|
196
|
+
Object.keys(data.versions || {})[0];
|
|
197
|
+
const meta = (data.versions && data.versions[version]) || {};
|
|
198
|
+
const deps = meta.dependencies || data.dependencies || {};
|
|
199
|
+
const lines = [
|
|
200
|
+
`${data.name} (npm package)`,
|
|
201
|
+
`Latest version: ${version}`,
|
|
202
|
+
meta.description || data.description || '',
|
|
203
|
+
`License: ${meta.license || data.license || 'N/A'}`,
|
|
204
|
+
`Dependencies: ${Object.keys(deps).length}`,
|
|
205
|
+
'',
|
|
206
|
+
];
|
|
207
|
+
const readme = String(data.readme || '').trim();
|
|
208
|
+
if (readme) lines.push(readme.slice(0, maxText));
|
|
209
|
+
return {
|
|
210
|
+
url: pageUrl,
|
|
211
|
+
title: `${data.name} · npm`,
|
|
212
|
+
text: lines.filter(Boolean).join('\n').slice(0, maxText),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
static async _pypiFallback(spec, pageUrl, maxText) {
|
|
217
|
+
const data = await this._fetchJson(`https://pypi.org/pypi/${encodeURIComponent(spec)}/json`);
|
|
218
|
+
const info = data.info || {};
|
|
219
|
+
const lines = [
|
|
220
|
+
`${info.name} (PyPI package)`,
|
|
221
|
+
`Version: ${info.version}`,
|
|
222
|
+
info.summary || '',
|
|
223
|
+
`Requires Python: ${info.requires_python || 'N/A'}`,
|
|
224
|
+
`License: ${info.license || 'N/A'}`,
|
|
225
|
+
`Home: ${info.home_page || info.project_url || 'N/A'}`,
|
|
226
|
+
'',
|
|
227
|
+
];
|
|
228
|
+
const desc = String(info.description || '').trim();
|
|
229
|
+
if (desc) lines.push(desc.slice(0, maxText));
|
|
230
|
+
return {
|
|
231
|
+
url: pageUrl,
|
|
232
|
+
title: `${info.name} · PyPI`,
|
|
233
|
+
text: lines.filter(Boolean).join('\n').slice(0, maxText),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- extraction -------------------------------------------------------- //
|
|
238
|
+
|
|
239
|
+
static _extractAssets(html, baseUrl) {
|
|
240
|
+
const found = [];
|
|
241
|
+
const seen = new Set();
|
|
242
|
+
const refs = [];
|
|
243
|
+
const hrefRe = /<a[^>]+href=["']([^"']+)["'][^>]*>/gi;
|
|
244
|
+
let m;
|
|
245
|
+
while ((m = hrefRe.exec(html)) !== null) refs.push([m[1], 'a']);
|
|
246
|
+
const srcRe = /<(?:img|source|video|audio|embed|iframe)[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
|
247
|
+
while ((m = srcRe.exec(html)) !== null) refs.push([m[1], 'src']);
|
|
248
|
+
const dataRe = /<(?:a|source)[^>]+data-(?:file|download|src)=["']([^"']+)["'][^>]*>/gi;
|
|
249
|
+
while ((m = dataRe.exec(html)) !== null) refs.push([m[1], 'data']);
|
|
250
|
+
|
|
251
|
+
for (const [raw, attr] of refs) {
|
|
252
|
+
const resolved = this._resolveAssetUrl(raw, baseUrl);
|
|
253
|
+
if (!resolved || seen.has(resolved)) continue;
|
|
254
|
+
const rel = attr === 'a' || attr === 'data' ? raw : raw;
|
|
255
|
+
if (rel.startsWith('tel:') || rel.startsWith('mailto:') || rel.startsWith('javascript:')) continue;
|
|
256
|
+
const type = detectAssetType(resolved.pathname);
|
|
257
|
+
if (!type) continue;
|
|
258
|
+
if (resolved.pathname.endsWith('.html') || resolved.pathname.endsWith('.htm')) continue;
|
|
259
|
+
seen.add(resolved.href);
|
|
260
|
+
found.push({
|
|
261
|
+
url: resolved.href,
|
|
262
|
+
name: this._assetName(resolved, type),
|
|
263
|
+
type,
|
|
264
|
+
});
|
|
265
|
+
if (found.length >= 30) break;
|
|
266
|
+
}
|
|
267
|
+
return found.sort((x, y) => x.type.localeCompare(y.type));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
static _resolveAssetUrl(raw, baseUrl) {
|
|
271
|
+
try {
|
|
272
|
+
const href = String(raw || '').trim().split('#')[0];
|
|
273
|
+
if (!href || /^data:/i.test(href)) return null;
|
|
274
|
+
const url = new URL(href, baseUrl);
|
|
275
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
|
276
|
+
return url;
|
|
277
|
+
} catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
static _assetName(url, type) {
|
|
283
|
+
const path = url.pathname.split('/').filter(Boolean);
|
|
284
|
+
let name = path.length ? decodeURIComponent(path[path.length - 1]) : '';
|
|
285
|
+
if (!name || type === 'unknown') name = `${url.hostname.replace(/[^a-z0-9.-]/gi, '_')}.${type}`;
|
|
286
|
+
if (!name.includes('.')) name = `${name}.${type}`;
|
|
287
|
+
return name.slice(0, 120);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
static _extractTitle(html) {
|
|
291
|
+
const m = /<title[^>]*>([^<]*)<\/title>/i.exec(html);
|
|
292
|
+
return m ? this._stripEntities(m[1].trim()) : '';
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
static _extractText(html) {
|
|
296
|
+
// Strip block tags' contents that add no readable value.
|
|
297
|
+
let s = html.replace(/<(script|style|noscript|svg|head|iframe|form|nav|footer|aside)[^>]*>[\s\S]*?<\/\1>/gi, ' ');
|
|
298
|
+
|
|
299
|
+
// Force spacing around block-level elements so words don't merge.
|
|
300
|
+
s = s.replace(/<\/(p|div|h[1-6]|li|tr|br|section|article)>/gi, '\n');
|
|
301
|
+
s = s.replace(/<(br|li|tr)[^>]*>/gi, '\n');
|
|
302
|
+
|
|
303
|
+
// Remove remaining tags.
|
|
304
|
+
s = s.replace(/<[^>]+>/g, ' ');
|
|
305
|
+
|
|
306
|
+
// College entities.
|
|
307
|
+
s = this._stripEntities(s);
|
|
308
|
+
|
|
309
|
+
// Collapse whitespace and trim lines.
|
|
310
|
+
return s
|
|
311
|
+
.replace(/[ \t]+/g, ' ')
|
|
312
|
+
.replace(/ *\n */g, '\n')
|
|
313
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
314
|
+
.replace(/[ \t]+/g, ' ')
|
|
315
|
+
.replace(/\u00a0/g, ' ')
|
|
316
|
+
.trim();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
static _stripEntities(text) {
|
|
320
|
+
const map = {
|
|
321
|
+
'&': '&', '<': '<', '>': '>', '"': '"',
|
|
322
|
+
''': "'", ''': "'", ''': "'", ' ': ' ',
|
|
323
|
+
'–': '–', '—': '—', '…': '...', '©': '©',
|
|
324
|
+
''': "'", '’': '’', '‘': '‘', '“': '“', '”': '”',
|
|
325
|
+
};
|
|
326
|
+
return String(text)
|
|
327
|
+
.replace(/<[^>]*>/g, '')
|
|
328
|
+
.replace(/&[a-zA-Z0-9#]+;/g, (m) => map[m] ?? '')
|
|
329
|
+
.replace(/\s+/g, ' ')
|
|
330
|
+
.trim();
|
|
331
|
+
}
|
|
332
|
+
}
|