nothumanallowed 15.1.4 → 15.1.5
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/package.json +1 -1
- package/src/constants.mjs +1 -1
- package/src/server/routes/studio.mjs +229 -27
- package/src/services/tool-executor.mjs +63 -9
- package/src/services/web-tools.mjs +611 -284
- package/src/ui-dist/assets/{index-B9Bd4Mrx.js → index-Chhxajp8.js} +92 -68
- package/src/ui-dist/index.html +1 -1
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Web search + URL fetch tools for NHA CLI.
|
|
2
|
+
* Web search + URL fetch tools for NHA CLI — enterprise-grade.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Public exports:
|
|
5
|
+
* - fetchUrl(urlStr, opts?) → main fetch (back-compat)
|
|
6
|
+
* - fetchUrlRich(urlStr, opts?) → fetch + structured metadata extraction
|
|
7
|
+
* - headProbe(urlStr, opts?) → fast HEAD reachability check (smoke test)
|
|
8
|
+
* - extractMetadata(html, baseUrl?) → parse OG/Twitter/JSON-LD/meta tags
|
|
9
|
+
* - webSearch(query, max?) → DuckDuckGo HTML scraping
|
|
10
|
+
* - webSearchDeep(query, fetchN?) → search + fetch top N
|
|
6
11
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
12
|
+
* Hardening:
|
|
13
|
+
* - SSRF protection: IPv4 + IPv6 private ranges, localhost variants, DNS pre-resolution
|
|
14
|
+
* - Content-type allowlist (text/html, text/plain, application/json, application/xml, text/xml)
|
|
15
|
+
* - Size limits (2 MB download, 32 KB output by default — overridable)
|
|
16
|
+
* - Per-request timeout, total deadline budget, retry with exponential backoff + jitter
|
|
17
|
+
* - Realistic browser headers (Chrome 124 desktop) — bypasses naive bot-blockers
|
|
18
|
+
* - Decompression (gzip / deflate / brotli) handled by global fetch
|
|
19
|
+
* - DNS rebinding guard: re-validates final URL after redirects
|
|
13
20
|
*
|
|
14
21
|
* Zero npm dependencies — pure Node.js 22.
|
|
15
22
|
*/
|
|
@@ -20,28 +27,52 @@ import net from 'net';
|
|
|
20
27
|
|
|
21
28
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
22
29
|
|
|
23
|
-
const MAX_DOWNLOAD_BYTES
|
|
24
|
-
const MAX_OUTPUT_CHARS
|
|
25
|
-
const FETCH_TIMEOUT_MS
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
30
|
+
const MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024; // 2 MB raw HTML (was 100 KB — way too small)
|
|
31
|
+
const MAX_OUTPUT_CHARS = 32_000; // ~8K tokens (was 8 K — too short for real analysis)
|
|
32
|
+
const FETCH_TIMEOUT_MS = 15_000; // per-attempt
|
|
33
|
+
const TOTAL_DEADLINE_MS = 45_000; // across retries
|
|
34
|
+
const MAX_REDIRECTS = 10;
|
|
35
|
+
const MAX_SEARCH_RESULTS = 8;
|
|
36
|
+
const MAX_RETRIES = 3;
|
|
37
|
+
const RETRY_BACKOFF_MS = [800, 2_000, 4_500]; // exponential-ish with jitter
|
|
38
|
+
|
|
39
|
+
// Browser-realistic UA — Chrome 124 stable on Linux desktop.
|
|
40
|
+
// Many sites (Cloudflare/Akamai/Wordfence/ModSecurity) reject custom UAs outright.
|
|
41
|
+
const BROWSER_UA =
|
|
42
|
+
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
|
|
43
|
+
|
|
44
|
+
const BASE_HEADERS = Object.freeze({
|
|
45
|
+
'User-Agent': BROWSER_UA,
|
|
46
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,application/json;q=0.8,*/*;q=0.7',
|
|
47
|
+
'Accept-Language': 'it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
48
|
+
'Accept-Encoding': 'gzip, deflate, br',
|
|
49
|
+
'Cache-Control': 'no-cache',
|
|
50
|
+
'Pragma': 'no-cache',
|
|
51
|
+
'Sec-Ch-Ua': '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
|
|
52
|
+
'Sec-Ch-Ua-Mobile': '?0',
|
|
53
|
+
'Sec-Ch-Ua-Platform': '"Linux"',
|
|
54
|
+
'Sec-Fetch-Dest': 'document',
|
|
55
|
+
'Sec-Fetch-Mode': 'navigate',
|
|
56
|
+
'Sec-Fetch-Site': 'none',
|
|
57
|
+
'Sec-Fetch-User': '?1',
|
|
58
|
+
'Upgrade-Insecure-Requests': '1',
|
|
59
|
+
'DNT': '1',
|
|
60
|
+
'Connection': 'keep-alive',
|
|
61
|
+
});
|
|
31
62
|
|
|
32
63
|
// ── SSRF Protection ──────────────────────────────────────────────────────────
|
|
33
64
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
{ start: '
|
|
40
|
-
{ start: '
|
|
41
|
-
{ start: '
|
|
42
|
-
{ start: '
|
|
43
|
-
{ start: '
|
|
44
|
-
{ start: '
|
|
65
|
+
const PRIVATE_IPV4_RANGES = [
|
|
66
|
+
{ start: '10.0.0.0', end: '10.255.255.255' },
|
|
67
|
+
{ start: '172.16.0.0', end: '172.31.255.255' },
|
|
68
|
+
{ start: '192.168.0.0', end: '192.168.255.255' },
|
|
69
|
+
{ start: '127.0.0.0', end: '127.255.255.255' },
|
|
70
|
+
{ start: '169.254.0.0', end: '169.254.255.255' }, // link-local
|
|
71
|
+
{ start: '0.0.0.0', end: '0.255.255.255' },
|
|
72
|
+
{ start: '100.64.0.0', end: '100.127.255.255' }, // CGNAT
|
|
73
|
+
{ start: '198.18.0.0', end: '198.19.255.255' }, // benchmarking
|
|
74
|
+
{ start: '224.0.0.0', end: '239.255.255.255' }, // multicast
|
|
75
|
+
{ start: '240.0.0.0', end: '255.255.255.255' }, // reserved
|
|
45
76
|
];
|
|
46
77
|
|
|
47
78
|
function ipToLong(ip) {
|
|
@@ -49,18 +80,45 @@ function ipToLong(ip) {
|
|
|
49
80
|
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
|
50
81
|
}
|
|
51
82
|
|
|
52
|
-
function
|
|
53
|
-
if (!net.isIPv4(ip)) return false;
|
|
83
|
+
function isPrivateIPv4(ip) {
|
|
84
|
+
if (!net.isIPv4(ip)) return false;
|
|
54
85
|
const long = ipToLong(ip);
|
|
55
|
-
for (const
|
|
56
|
-
if (long >= ipToLong(
|
|
86
|
+
for (const r of PRIVATE_IPV4_RANGES) {
|
|
87
|
+
if (long >= ipToLong(r.start) && long <= ipToLong(r.end)) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* IPv6 SSRF protection — covers the practical attack surface:
|
|
94
|
+
* ::1 (loopback)
|
|
95
|
+
* fc00::/7 (unique local addresses)
|
|
96
|
+
* fe80::/10 (link-local)
|
|
97
|
+
* ::ffff:0:0/96 (IPv4-mapped — must re-check the embedded IPv4)
|
|
98
|
+
* :: (unspecified)
|
|
99
|
+
*/
|
|
100
|
+
function isPrivateIPv6(ip) {
|
|
101
|
+
if (!net.isIPv6(ip)) return false;
|
|
102
|
+
const lower = ip.toLowerCase();
|
|
103
|
+
if (lower === '::1' || lower === '::') return true;
|
|
104
|
+
if (lower.startsWith('fe80:') || lower.startsWith('fe80::')) return true;
|
|
105
|
+
if (/^(fc|fd)[0-9a-f]{2}:/.test(lower)) return true;
|
|
106
|
+
// IPv4-mapped: ::ffff:192.168.0.1 — extract the v4 portion
|
|
107
|
+
const v4MappedMatch = lower.match(/^::ffff:([0-9.]+)$/);
|
|
108
|
+
if (v4MappedMatch && net.isIPv4(v4MappedMatch[1])) {
|
|
109
|
+
return isPrivateIPv4(v4MappedMatch[1]);
|
|
57
110
|
}
|
|
58
111
|
return false;
|
|
59
112
|
}
|
|
60
113
|
|
|
114
|
+
function isPrivateIp(ip) {
|
|
115
|
+
return isPrivateIPv4(ip) || isPrivateIPv6(ip);
|
|
116
|
+
}
|
|
117
|
+
|
|
61
118
|
/**
|
|
62
119
|
* Validate URL for SSRF safety.
|
|
63
|
-
*
|
|
120
|
+
* - Resolves both A and AAAA records.
|
|
121
|
+
* - Allows hosts even if DNS partially fails, as long as at least one public IP exists.
|
|
64
122
|
*/
|
|
65
123
|
async function validateUrl(urlStr) {
|
|
66
124
|
let parsed;
|
|
@@ -70,366 +128,635 @@ async function validateUrl(urlStr) {
|
|
|
70
128
|
return { safe: false, reason: 'Invalid URL' };
|
|
71
129
|
}
|
|
72
130
|
|
|
73
|
-
// Protocol check
|
|
74
131
|
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
75
132
|
return { safe: false, reason: `Blocked protocol: ${parsed.protocol}` };
|
|
76
133
|
}
|
|
77
134
|
|
|
78
|
-
|
|
79
|
-
const hostname = parsed.hostname.toLowerCase();
|
|
135
|
+
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
80
136
|
if (
|
|
81
137
|
hostname === 'localhost' ||
|
|
82
138
|
hostname === '0.0.0.0' ||
|
|
83
|
-
hostname === '[::1]' ||
|
|
84
139
|
hostname === '::1' ||
|
|
85
|
-
/^0x[0-9a-f]+$/i.test(hostname) ||
|
|
86
|
-
/^\d+$/.test(hostname)
|
|
140
|
+
/^0x[0-9a-f]+$/i.test(hostname) ||
|
|
141
|
+
/^\d+$/.test(hostname) // decimal-encoded
|
|
87
142
|
) {
|
|
88
|
-
return { safe: false, reason: 'Blocked: localhost' };
|
|
143
|
+
return { safe: false, reason: 'Blocked: localhost / numeric host' };
|
|
89
144
|
}
|
|
90
145
|
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (isPrivateIp(addr)) {
|
|
96
|
-
return { safe: false, reason: `Blocked: ${hostname} resolves to private IP ${addr}` };
|
|
97
|
-
}
|
|
146
|
+
// Literal IP in hostname — validate directly without DNS
|
|
147
|
+
if (net.isIP(hostname)) {
|
|
148
|
+
if (isPrivateIp(hostname)) {
|
|
149
|
+
return { safe: false, reason: `Blocked: private IP literal ${hostname}` };
|
|
98
150
|
}
|
|
99
|
-
|
|
100
|
-
|
|
151
|
+
return { safe: true, hostname, addresses: [hostname] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// DNS resolution: use `dns.lookup` with all:true to get both A + AAAA in one call.
|
|
155
|
+
// `dns.lookup` uses the system resolver (incl. /etc/hosts and DNSSEC), more lenient
|
|
156
|
+
// than `dns.resolve4` which was the original failure mode.
|
|
157
|
+
let addresses = [];
|
|
158
|
+
try {
|
|
159
|
+
const all = await dns.lookup(hostname, { all: true, family: 0 });
|
|
160
|
+
addresses = all.map(a => a.address);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
// Fall back to explicit resolve4 + resolve6 — covers split-horizon DNS
|
|
163
|
+
try {
|
|
164
|
+
const [v4, v6] = await Promise.allSettled([
|
|
165
|
+
dns.resolve4(hostname),
|
|
166
|
+
dns.resolve6(hostname),
|
|
167
|
+
]);
|
|
168
|
+
if (v4.status === 'fulfilled') addresses.push(...v4.value);
|
|
169
|
+
if (v6.status === 'fulfilled') addresses.push(...v6.value);
|
|
170
|
+
} catch { /* both failed */ }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (addresses.length === 0) {
|
|
101
174
|
return { safe: false, reason: `DNS resolution failed for ${hostname}` };
|
|
102
175
|
}
|
|
103
176
|
|
|
104
|
-
|
|
177
|
+
for (const addr of addresses) {
|
|
178
|
+
if (isPrivateIp(addr)) {
|
|
179
|
+
return { safe: false, reason: `Blocked: ${hostname} resolves to private IP ${addr}` };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { safe: true, hostname, addresses };
|
|
105
184
|
}
|
|
106
185
|
|
|
107
|
-
// ── HTML
|
|
186
|
+
// ── HTML helpers ─────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
const HTML_ENTITIES = {
|
|
189
|
+
'&': '&', '<': '<', '>': '>', '"': '"', ''': "'",
|
|
190
|
+
' ': ' ', '—': '—', '–': '–', '…': '…',
|
|
191
|
+
'’': "'", '‘': "'", '”': '"', '“': '"',
|
|
192
|
+
'«': '«', '»': '»', '€': '€', '£': '£', '¥': '¥',
|
|
193
|
+
'©': '©', '®': '®', '™': '™',
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
function decodeEntities(s) {
|
|
197
|
+
return s
|
|
198
|
+
.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex) => {
|
|
199
|
+
try { return String.fromCodePoint(parseInt(hex, 16)); } catch { return ''; }
|
|
200
|
+
})
|
|
201
|
+
.replace(/&#(\d+);/g, (_, dec) => {
|
|
202
|
+
try { return String.fromCodePoint(parseInt(dec, 10)); } catch { return ''; }
|
|
203
|
+
})
|
|
204
|
+
.replace(/&[a-zA-Z]+;/g, (m) => HTML_ENTITIES[m] ?? m);
|
|
205
|
+
}
|
|
108
206
|
|
|
109
207
|
/**
|
|
110
|
-
* Extract readable text from HTML.
|
|
111
|
-
*
|
|
208
|
+
* Extract readable text from HTML. Removes scripts/styles/nav/headers/footers
|
|
209
|
+
* while preserving block boundaries (so paragraphs don't run together).
|
|
112
210
|
*/
|
|
113
211
|
function htmlToText(html) {
|
|
114
212
|
let text = html;
|
|
213
|
+
text = text.replace(/<!--[\s\S]*?-->/g, ' ');
|
|
214
|
+
text = text.replace(/<(script|style|svg|noscript|iframe|object|embed|template)[^>]*>[\s\S]*?<\/\1>/gi, ' ');
|
|
215
|
+
// Mark block-level boundaries before stripping tags so paragraphs separate.
|
|
216
|
+
text = text.replace(/<(\/?)(p|div|section|article|h[1-6]|li|tr|br|hr|blockquote|pre|figure|figcaption)[^>]*>/gi, '\n');
|
|
217
|
+
text = text.replace(/<[^>]+>/g, ' ');
|
|
218
|
+
text = decodeEntities(text);
|
|
219
|
+
text = text.replace(/[ \t ]+/g, ' ');
|
|
220
|
+
text = text.replace(/\n\s*\n\s*\n+/g, '\n\n');
|
|
221
|
+
return text.trim();
|
|
222
|
+
}
|
|
115
223
|
|
|
116
|
-
|
|
117
|
-
|
|
224
|
+
function pickAttr(tag, attr) {
|
|
225
|
+
const re = new RegExp(`${attr}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i');
|
|
226
|
+
const m = tag.match(re);
|
|
227
|
+
if (!m) return '';
|
|
228
|
+
return decodeEntities(m[2] ?? m[3] ?? m[4] ?? '');
|
|
229
|
+
}
|
|
118
230
|
|
|
119
|
-
|
|
120
|
-
|
|
231
|
+
function firstMatch(html, re) {
|
|
232
|
+
const m = html.match(re);
|
|
233
|
+
return m ? m[1] : '';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Extract structured metadata: title, description, OpenGraph, Twitter cards,
|
|
238
|
+
* canonical, lang, robots, h1..h3 headings, JSON-LD blocks (parsed).
|
|
239
|
+
*/
|
|
240
|
+
export function extractMetadata(html, baseUrl = '') {
|
|
241
|
+
const meta = {
|
|
242
|
+
title: '',
|
|
243
|
+
description: '',
|
|
244
|
+
canonical: '',
|
|
245
|
+
lang: '',
|
|
246
|
+
robots: '',
|
|
247
|
+
og: {},
|
|
248
|
+
twitter: {},
|
|
249
|
+
jsonLd: [],
|
|
250
|
+
headings: { h1: [], h2: [], h3: [] },
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const htmlTag = html.match(/<html[^>]*>/i)?.[0] ?? '';
|
|
254
|
+
meta.lang = pickAttr(htmlTag, 'lang');
|
|
255
|
+
|
|
256
|
+
meta.title = decodeEntities(firstMatch(html, /<title[^>]*>([\s\S]*?)<\/title>/i)).trim().slice(0, 300);
|
|
257
|
+
|
|
258
|
+
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
|
|
259
|
+
for (const tag of metaTags) {
|
|
260
|
+
const name = pickAttr(tag, 'name').toLowerCase();
|
|
261
|
+
const prop = pickAttr(tag, 'property').toLowerCase();
|
|
262
|
+
const content = pickAttr(tag, 'content');
|
|
263
|
+
if (!content) continue;
|
|
264
|
+
if (name === 'description') meta.description = content.trim().slice(0, 500);
|
|
265
|
+
else if (name === 'robots') meta.robots = content;
|
|
266
|
+
else if (prop.startsWith('og:')) meta.og[prop.slice(3)] = content;
|
|
267
|
+
else if (name.startsWith('twitter:')) meta.twitter[name.slice(8)] = content;
|
|
268
|
+
else if (name === 'twitter:title' || name === 'twitter:description') {
|
|
269
|
+
meta.twitter[name.slice(8)] = content;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
121
272
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
.replace(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
273
|
+
const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
|
|
274
|
+
for (const tag of linkTags) {
|
|
275
|
+
const rel = pickAttr(tag, 'rel').toLowerCase();
|
|
276
|
+
if (rel === 'canonical') {
|
|
277
|
+
try { meta.canonical = baseUrl ? new URL(pickAttr(tag, 'href'), baseUrl).href : pickAttr(tag, 'href'); }
|
|
278
|
+
catch { meta.canonical = pickAttr(tag, 'href'); }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// JSON-LD blocks (schema.org Product, Organization, BreadcrumbList, etc.)
|
|
283
|
+
const ldBlocks = html.match(/<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi) ?? [];
|
|
284
|
+
for (const block of ldBlocks) {
|
|
285
|
+
const inner = block.replace(/^<script[^>]*>/i, '').replace(/<\/script>$/i, '').trim();
|
|
286
|
+
if (!inner) continue;
|
|
287
|
+
try {
|
|
288
|
+
const parsed = JSON.parse(inner);
|
|
289
|
+
const flat = Array.isArray(parsed) ? parsed : [parsed];
|
|
290
|
+
for (const obj of flat) {
|
|
291
|
+
if (!obj || typeof obj !== 'object') continue;
|
|
292
|
+
const type = obj['@type'] || obj.type || 'Unknown';
|
|
293
|
+
meta.jsonLd.push({
|
|
294
|
+
type: Array.isArray(type) ? type.join(',') : String(type),
|
|
295
|
+
name: obj.name || obj.headline || '',
|
|
296
|
+
description: obj.description || '',
|
|
297
|
+
url: obj.url || '',
|
|
298
|
+
raw: obj,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
} catch { /* invalid JSON-LD, ignore */ }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Headings — useful for understanding page structure
|
|
305
|
+
const hMatcher = (level) => new RegExp(`<h${level}[^>]*>([\\s\\S]*?)<\\/h${level}>`, 'gi');
|
|
306
|
+
for (const lvl of [1, 2, 3]) {
|
|
307
|
+
let m;
|
|
308
|
+
const re = hMatcher(lvl);
|
|
309
|
+
while ((m = re.exec(html)) && meta.headings[`h${lvl}`].length < 20) {
|
|
310
|
+
const txt = decodeEntities(m[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ')).trim();
|
|
311
|
+
if (txt && txt.length < 300) meta.headings[`h${lvl}`].push(txt);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return meta;
|
|
144
316
|
}
|
|
145
317
|
|
|
146
318
|
/**
|
|
147
|
-
* Extract
|
|
319
|
+
* Extract main content using density heuristics:
|
|
320
|
+
* 1. Prefer <main>, <article>, [role=main], #content, .content, .main
|
|
321
|
+
* 2. Fallback: strip nav/header/footer/aside/menu and collapse to text
|
|
148
322
|
*/
|
|
149
|
-
function
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
323
|
+
function extractMainContent(html) {
|
|
324
|
+
const candidates = [
|
|
325
|
+
/<main\b[^>]*>([\s\S]*?)<\/main>/i,
|
|
326
|
+
/<article\b[^>]*>([\s\S]*?)<\/article>/i,
|
|
327
|
+
/<[^>]+role\s*=\s*["']main["'][^>]*>([\s\S]*?)<\/[^>]+>/i,
|
|
328
|
+
/<div[^>]+(id|class)\s*=\s*["'][^"']*(content|main-content|main)[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
|
|
329
|
+
];
|
|
330
|
+
for (const re of candidates) {
|
|
331
|
+
const m = html.match(re);
|
|
332
|
+
if (m && m[0] && m[0].length > 500) {
|
|
333
|
+
return htmlToText(m[0]);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Fallback — strip chrome elements then convert
|
|
337
|
+
const stripped = html.replace(/<(nav|header|footer|aside|form|menu)[^>]*>[\s\S]*?<\/\1>/gi, ' ');
|
|
338
|
+
return htmlToText(stripped);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── Retry helper ─────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
function sleep(ms) {
|
|
344
|
+
return new Promise(r => setTimeout(r, ms));
|
|
153
345
|
}
|
|
154
346
|
|
|
155
|
-
|
|
347
|
+
function jitter(ms) {
|
|
348
|
+
return ms + Math.floor(Math.random() * Math.floor(ms * 0.3));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function shouldRetry(status, err) {
|
|
352
|
+
if (err) {
|
|
353
|
+
const m = (err.message || '').toLowerCase();
|
|
354
|
+
if (m.includes('aborted')) return false; // explicit timeout — don't hammer
|
|
355
|
+
if (m.includes('etimedout')) return true;
|
|
356
|
+
if (m.includes('econnreset')) return true;
|
|
357
|
+
if (m.includes('econnrefused')) return false;
|
|
358
|
+
if (m.includes('enotfound')) return false; // DNS — won't fix on retry
|
|
359
|
+
if (m.includes('socket hang up')) return true;
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
if (!status) return true;
|
|
363
|
+
if (status === 429) return true; // rate limited
|
|
364
|
+
if (status >= 500 && status <= 599) return true; // transient server error
|
|
365
|
+
if (status === 408) return true; // request timeout
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Core fetch ───────────────────────────────────────────────────────────────
|
|
156
370
|
|
|
157
371
|
/**
|
|
158
|
-
* Fetch a URL with SSRF protection, size limits, and
|
|
159
|
-
*
|
|
372
|
+
* Fetch a URL with SSRF protection, size limits, retry+backoff, and full metadata.
|
|
373
|
+
*
|
|
374
|
+
* @param {string} urlStr
|
|
375
|
+
* @param {object} [opts]
|
|
376
|
+
* @param {number} [opts.timeout] per-attempt timeout (ms)
|
|
377
|
+
* @param {number} [opts.maxBytes] max raw body bytes
|
|
378
|
+
* @param {number} [opts.maxChars] max output chars
|
|
379
|
+
* @param {number} [opts.retries] max retries (default 3)
|
|
380
|
+
* @param {object} [opts.extraHeaders] additional headers to merge
|
|
381
|
+
* @param {boolean} [opts.rich] include extracted metadata + main content
|
|
382
|
+
* @returns {Promise<object>}
|
|
160
383
|
*/
|
|
161
|
-
export async function fetchUrl(urlStr) {
|
|
162
|
-
|
|
384
|
+
export async function fetchUrl(urlStr, opts = {}) {
|
|
385
|
+
const timeout = opts.timeout ?? FETCH_TIMEOUT_MS;
|
|
386
|
+
const maxBytes = opts.maxBytes ?? MAX_DOWNLOAD_BYTES;
|
|
387
|
+
const maxChars = opts.maxChars ?? MAX_OUTPUT_CHARS;
|
|
388
|
+
const retries = Math.max(0, Math.min(opts.retries ?? MAX_RETRIES, 5));
|
|
389
|
+
const startTime = Date.now();
|
|
390
|
+
|
|
391
|
+
// Validate URL once up-front
|
|
163
392
|
const validation = await validateUrl(urlStr);
|
|
164
393
|
if (!validation.safe) {
|
|
165
|
-
return { error: true, message: validation.reason };
|
|
394
|
+
return { error: true, code: 'SSRF_BLOCKED', message: validation.reason };
|
|
166
395
|
}
|
|
167
396
|
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
397
|
+
const headers = { ...BASE_HEADERS, ...(opts.extraHeaders || {}) };
|
|
398
|
+
// Set Referer to the origin to look like a normal navigation
|
|
171
399
|
try {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const contentType = (res.headers.get('content-type') || '').toLowerCase();
|
|
185
|
-
|
|
186
|
-
// Content-type allowlist
|
|
187
|
-
if (!contentType.startsWith('text/') && !contentType.includes('json') && !contentType.includes('xml')) {
|
|
188
|
-
return {
|
|
189
|
-
error: true,
|
|
190
|
-
message: `Blocked content-type: ${contentType}. Only text, JSON, and XML are allowed.`,
|
|
191
|
-
};
|
|
400
|
+
const u = new URL(urlStr);
|
|
401
|
+
headers['Referer'] = `${u.protocol}//${u.host}/`;
|
|
402
|
+
} catch { /* skip */ }
|
|
403
|
+
|
|
404
|
+
let lastErr = null;
|
|
405
|
+
let lastStatus = 0;
|
|
406
|
+
let attempt = 0;
|
|
407
|
+
|
|
408
|
+
while (attempt <= retries) {
|
|
409
|
+
if (Date.now() - startTime > TOTAL_DEADLINE_MS) {
|
|
410
|
+
return { error: true, code: 'DEADLINE_EXCEEDED', message: `Total deadline ${TOTAL_DEADLINE_MS}ms exceeded` };
|
|
192
411
|
}
|
|
193
412
|
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
413
|
+
const controller = new AbortController();
|
|
414
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
const res = await fetch(urlStr, {
|
|
418
|
+
method: 'GET',
|
|
419
|
+
headers,
|
|
420
|
+
signal: controller.signal,
|
|
421
|
+
redirect: 'follow',
|
|
422
|
+
});
|
|
423
|
+
clearTimeout(timer);
|
|
424
|
+
|
|
425
|
+
lastStatus = res.status;
|
|
426
|
+
|
|
427
|
+
// Retry on transient server errors
|
|
428
|
+
if (res.status >= 500 || res.status === 429 || res.status === 408) {
|
|
429
|
+
if (attempt < retries && shouldRetry(res.status, null)) {
|
|
430
|
+
attempt++;
|
|
431
|
+
await sleep(jitter(RETRY_BACKOFF_MS[Math.min(attempt - 1, RETRY_BACKOFF_MS.length - 1)]));
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
error: true,
|
|
436
|
+
code: `HTTP_${res.status}`,
|
|
437
|
+
message: `Upstream returned ${res.status}`,
|
|
438
|
+
status: res.status,
|
|
439
|
+
url: res.url,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 4xx (non-408/429) — surface to caller, no retry
|
|
444
|
+
if (res.status >= 400) {
|
|
445
|
+
return {
|
|
446
|
+
error: true,
|
|
447
|
+
code: `HTTP_${res.status}`,
|
|
448
|
+
message: `Upstream returned ${res.status}`,
|
|
449
|
+
status: res.status,
|
|
450
|
+
url: res.url,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const contentType = (res.headers.get('content-type') || '').toLowerCase();
|
|
455
|
+
const isHtml = contentType.includes('html');
|
|
456
|
+
const isJson = contentType.includes('json');
|
|
457
|
+
const isXml = contentType.includes('xml');
|
|
458
|
+
const isText = contentType.startsWith('text/') || isHtml || isJson || isXml;
|
|
459
|
+
|
|
460
|
+
if (!isText) {
|
|
461
|
+
return {
|
|
462
|
+
error: true,
|
|
463
|
+
code: 'CONTENT_TYPE_BLOCKED',
|
|
464
|
+
message: `Content-type "${contentType}" not allowed (text/json/xml only)`,
|
|
465
|
+
status: res.status,
|
|
466
|
+
url: res.url,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// DNS rebinding guard — re-validate after redirects
|
|
471
|
+
if (res.url && res.url !== urlStr) {
|
|
472
|
+
const reValidate = await validateUrl(res.url);
|
|
473
|
+
if (!reValidate.safe) {
|
|
474
|
+
return { error: true, code: 'REDIRECT_BLOCKED', message: `Redirect blocked: ${reValidate.reason}` };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Read body with size cap (uses streaming reader)
|
|
479
|
+
const reader = res.body.getReader();
|
|
480
|
+
const chunks = [];
|
|
481
|
+
let totalBytes = 0;
|
|
482
|
+
let truncated = false;
|
|
483
|
+
while (true) {
|
|
484
|
+
const { done, value } = await reader.read();
|
|
485
|
+
if (done) break;
|
|
486
|
+
if (totalBytes + value.length > maxBytes) {
|
|
487
|
+
const fit = maxBytes - totalBytes;
|
|
488
|
+
if (fit > 0) chunks.push(value.subarray(0, fit));
|
|
489
|
+
truncated = true;
|
|
490
|
+
try { reader.cancel(); } catch { /* ignore */ }
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
chunks.push(value);
|
|
494
|
+
totalBytes += value.length;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
498
|
+
const rawBody = decoder.decode(Buffer.concat(chunks.map(c => Buffer.from(c))));
|
|
499
|
+
|
|
500
|
+
// Build response
|
|
501
|
+
let body = '';
|
|
502
|
+
let title = '';
|
|
503
|
+
let metadata = null;
|
|
504
|
+
let mainContent = '';
|
|
505
|
+
|
|
506
|
+
if (isHtml) {
|
|
507
|
+
metadata = extractMetadata(rawBody, res.url || urlStr);
|
|
508
|
+
title = metadata.title;
|
|
509
|
+
mainContent = extractMainContent(rawBody);
|
|
510
|
+
body = mainContent;
|
|
511
|
+
} else if (isJson) {
|
|
512
|
+
try {
|
|
513
|
+
body = JSON.stringify(JSON.parse(rawBody), null, 2);
|
|
514
|
+
} catch {
|
|
515
|
+
body = rawBody;
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
body = isXml || contentType.startsWith('text/')
|
|
519
|
+
? rawBody.replace(/\s+/g, ' ').trim()
|
|
520
|
+
: rawBody;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (body.length > maxChars) {
|
|
524
|
+
body = body.slice(0, maxChars) + `\n\n[... content truncated at ${maxChars} chars]`;
|
|
205
525
|
truncated = true;
|
|
206
|
-
// Take only the part that fits
|
|
207
|
-
const overshoot = totalBytes - MAX_DOWNLOAD_BYTES;
|
|
208
|
-
chunks.push(value.slice(0, value.length - overshoot));
|
|
209
|
-
break;
|
|
210
526
|
}
|
|
211
|
-
chunks.push(value);
|
|
212
|
-
}
|
|
213
527
|
|
|
214
|
-
|
|
215
|
-
|
|
528
|
+
const excerpt = body.slice(0, 240).replace(/\s+/g, ' ').trim();
|
|
529
|
+
|
|
530
|
+
const result = {
|
|
531
|
+
error: false,
|
|
532
|
+
status: res.status,
|
|
533
|
+
contentType,
|
|
534
|
+
url: res.url || urlStr,
|
|
535
|
+
title,
|
|
536
|
+
excerpt,
|
|
537
|
+
body,
|
|
538
|
+
truncated,
|
|
539
|
+
bytes: totalBytes,
|
|
540
|
+
attempts: attempt + 1,
|
|
541
|
+
};
|
|
216
542
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
let excerpt = '';
|
|
221
|
-
|
|
222
|
-
if (contentType.includes('html')) {
|
|
223
|
-
title = extractTitle(rawBody);
|
|
224
|
-
body = htmlToText(rawBody);
|
|
225
|
-
} else if (contentType.includes('json')) {
|
|
226
|
-
try {
|
|
227
|
-
const parsed = JSON.parse(rawBody);
|
|
228
|
-
body = JSON.stringify(parsed, null, 2);
|
|
229
|
-
} catch {
|
|
230
|
-
body = rawBody;
|
|
543
|
+
if (opts.rich || metadata) {
|
|
544
|
+
result.metadata = metadata;
|
|
545
|
+
result.mainContent = mainContent;
|
|
231
546
|
}
|
|
232
|
-
} else {
|
|
233
|
-
body = rawBody;
|
|
234
|
-
}
|
|
235
547
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
548
|
+
return result;
|
|
549
|
+
} catch (err) {
|
|
550
|
+
clearTimeout(timer);
|
|
551
|
+
lastErr = err;
|
|
552
|
+
const isAbort = err.name === 'AbortError';
|
|
553
|
+
if (isAbort) {
|
|
554
|
+
// Per-attempt timeout — counts as transient, retry once more
|
|
555
|
+
if (attempt < retries) {
|
|
556
|
+
attempt++;
|
|
557
|
+
await sleep(jitter(RETRY_BACKOFF_MS[Math.min(attempt - 1, RETRY_BACKOFF_MS.length - 1)]));
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
return {
|
|
561
|
+
error: true,
|
|
562
|
+
code: 'TIMEOUT',
|
|
563
|
+
message: `Request timed out after ${timeout}ms (${attempt + 1} attempt${attempt > 0 ? 's' : ''})`,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
if (attempt < retries && shouldRetry(0, err)) {
|
|
567
|
+
attempt++;
|
|
568
|
+
await sleep(jitter(RETRY_BACKOFF_MS[Math.min(attempt - 1, RETRY_BACKOFF_MS.length - 1)]));
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
error: true,
|
|
573
|
+
code: 'NETWORK_ERROR',
|
|
574
|
+
message: `Fetch failed: ${err.message || String(err)}`,
|
|
575
|
+
cause: err.code || err.errno || null,
|
|
576
|
+
};
|
|
240
577
|
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return {
|
|
581
|
+
error: true,
|
|
582
|
+
code: lastStatus ? `HTTP_${lastStatus}` : 'UNKNOWN',
|
|
583
|
+
message: lastErr?.message || `Failed after ${attempt} attempts`,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
241
586
|
|
|
242
|
-
|
|
587
|
+
/** Convenience wrapper — always returns metadata. */
|
|
588
|
+
export async function fetchUrlRich(urlStr, opts = {}) {
|
|
589
|
+
return fetchUrl(urlStr, { ...opts, rich: true });
|
|
590
|
+
}
|
|
243
591
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
592
|
+
/**
|
|
593
|
+
* HEAD probe — fast reachability check used by the Studio smoke-test gate.
|
|
594
|
+
* Returns { ok, status, reason, finalUrl } in under ~5 s typically.
|
|
595
|
+
* Falls back to a tiny GET (Range: bytes=0-0) if HEAD is rejected (some servers
|
|
596
|
+
* return 405 on HEAD).
|
|
597
|
+
*/
|
|
598
|
+
export async function headProbe(urlStr, opts = {}) {
|
|
599
|
+
const timeout = opts.timeout ?? 8_000;
|
|
600
|
+
|
|
601
|
+
const validation = await validateUrl(urlStr);
|
|
602
|
+
if (!validation.safe) {
|
|
603
|
+
return { ok: false, status: 0, reason: validation.reason, finalUrl: urlStr };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const headers = { ...BASE_HEADERS };
|
|
607
|
+
try {
|
|
608
|
+
const u = new URL(urlStr);
|
|
609
|
+
headers['Referer'] = `${u.protocol}//${u.host}/`;
|
|
610
|
+
} catch { /* skip */ }
|
|
611
|
+
|
|
612
|
+
const attempt = async (method) => {
|
|
613
|
+
const controller = new AbortController();
|
|
614
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
615
|
+
try {
|
|
616
|
+
const res = await fetch(urlStr, {
|
|
617
|
+
method,
|
|
618
|
+
headers: method === 'GET' ? { ...headers, Range: 'bytes=0-0' } : headers,
|
|
619
|
+
signal: controller.signal,
|
|
620
|
+
redirect: 'follow',
|
|
621
|
+
});
|
|
622
|
+
clearTimeout(timer);
|
|
623
|
+
try { await res.body?.cancel(); } catch { /* ignore */ }
|
|
624
|
+
return { status: res.status, finalUrl: res.url || urlStr };
|
|
625
|
+
} catch (err) {
|
|
626
|
+
clearTimeout(timer);
|
|
627
|
+
return { error: err };
|
|
250
628
|
}
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// Try HEAD first
|
|
632
|
+
let r = await attempt('HEAD');
|
|
633
|
+
if (r.error || (r.status && (r.status === 405 || r.status === 501))) {
|
|
634
|
+
// Some sites reject HEAD — fall back to bytes=0-0 GET
|
|
635
|
+
r = await attempt('GET');
|
|
636
|
+
}
|
|
251
637
|
|
|
638
|
+
if (r.error) {
|
|
639
|
+
const isAbort = r.error.name === 'AbortError';
|
|
252
640
|
return {
|
|
253
|
-
|
|
254
|
-
status:
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
title,
|
|
258
|
-
excerpt,
|
|
259
|
-
truncated,
|
|
260
|
-
url: res.url,
|
|
641
|
+
ok: false,
|
|
642
|
+
status: 0,
|
|
643
|
+
reason: isAbort ? `Timeout after ${timeout}ms` : `Network: ${r.error.message || r.error.code || 'unknown'}`,
|
|
644
|
+
finalUrl: urlStr,
|
|
261
645
|
};
|
|
262
|
-
} catch (err) {
|
|
263
|
-
clearTimeout(timeout);
|
|
264
|
-
if (err.name === 'AbortError') {
|
|
265
|
-
return { error: true, message: 'Request timed out (10s limit)' };
|
|
266
|
-
}
|
|
267
|
-
return { error: true, message: `Fetch failed: ${err.message}` };
|
|
268
646
|
}
|
|
647
|
+
|
|
648
|
+
const ok = r.status >= 200 && r.status < 400;
|
|
649
|
+
return {
|
|
650
|
+
ok,
|
|
651
|
+
status: r.status,
|
|
652
|
+
reason: ok ? '' : `HTTP ${r.status}`,
|
|
653
|
+
finalUrl: r.finalUrl,
|
|
654
|
+
};
|
|
269
655
|
}
|
|
270
656
|
|
|
271
657
|
// ── Web Search (DuckDuckGo HTML) ─────────────────────────────────────────────
|
|
272
658
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
*/
|
|
281
|
-
export async function webSearch(query, maxResults = MAX_RESULTS) {
|
|
659
|
+
const SEARCH_HEADERS = {
|
|
660
|
+
...BASE_HEADERS,
|
|
661
|
+
'Sec-Fetch-Site': 'same-origin',
|
|
662
|
+
'Sec-Fetch-Mode': 'navigate',
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
export async function webSearch(query, maxResults = MAX_SEARCH_RESULTS) {
|
|
282
666
|
if (!query || query.trim().length < 2) {
|
|
283
|
-
return { error: true, message: 'Query too short' };
|
|
667
|
+
return { error: true, code: 'BAD_QUERY', message: 'Query too short' };
|
|
284
668
|
}
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
const searchUrl = `https://html.duckduckgo.com/html/?q=${encodedQuery}`;
|
|
669
|
+
const encoded = encodeURIComponent(query.trim());
|
|
670
|
+
const searchUrl = `https://html.duckduckgo.com/html/?q=${encoded}`;
|
|
288
671
|
|
|
289
672
|
const controller = new AbortController();
|
|
290
|
-
const
|
|
673
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
291
674
|
|
|
292
675
|
try {
|
|
293
|
-
const res = await fetch(searchUrl, {
|
|
294
|
-
|
|
295
|
-
'User-Agent': BROWSER_UA,
|
|
296
|
-
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
297
|
-
'Accept-Language': 'en-US,en;q=0.5',
|
|
298
|
-
'Accept-Encoding': 'identity',
|
|
299
|
-
'DNT': '1',
|
|
300
|
-
'Connection': 'keep-alive',
|
|
301
|
-
'Upgrade-Insecure-Requests': '1',
|
|
302
|
-
},
|
|
303
|
-
signal: controller.signal,
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
clearTimeout(timeout);
|
|
307
|
-
|
|
676
|
+
const res = await fetch(searchUrl, { headers: SEARCH_HEADERS, signal: controller.signal });
|
|
677
|
+
clearTimeout(timer);
|
|
308
678
|
if (!res.ok) {
|
|
309
|
-
return { error: true, message: `DuckDuckGo returned ${res.status}` };
|
|
679
|
+
return { error: true, code: `HTTP_${res.status}`, message: `DuckDuckGo returned ${res.status}` };
|
|
310
680
|
}
|
|
311
|
-
|
|
312
681
|
const html = await res.text();
|
|
313
682
|
const results = parseDuckDuckGoResults(html, maxResults);
|
|
314
|
-
|
|
315
|
-
return {
|
|
316
|
-
error: false,
|
|
317
|
-
query: query.trim(),
|
|
318
|
-
resultCount: results.length,
|
|
319
|
-
results,
|
|
320
|
-
};
|
|
683
|
+
return { error: false, query: query.trim(), resultCount: results.length, results };
|
|
321
684
|
} catch (err) {
|
|
322
|
-
clearTimeout(
|
|
685
|
+
clearTimeout(timer);
|
|
323
686
|
if (err.name === 'AbortError') {
|
|
324
|
-
return { error: true, message: 'Search timed out
|
|
687
|
+
return { error: true, code: 'TIMEOUT', message: 'Search timed out' };
|
|
325
688
|
}
|
|
326
|
-
return { error: true, message: `Search failed: ${err.message}` };
|
|
689
|
+
return { error: true, code: 'NETWORK_ERROR', message: `Search failed: ${err.message}` };
|
|
327
690
|
}
|
|
328
691
|
}
|
|
329
692
|
|
|
330
|
-
/**
|
|
331
|
-
* Parse DuckDuckGo HTML results page.
|
|
332
|
-
* Extracts title, URL, and snippet from result items.
|
|
333
|
-
*
|
|
334
|
-
* DDG HTML structure (current):
|
|
335
|
-
* <div class="result results_links results_links_deep web-result ">
|
|
336
|
-
* <h2 class="result__title">
|
|
337
|
-
* <a class="result__a" href="//duckduckgo.com/l/?uddg=ENCODED_URL&...">Title</a>
|
|
338
|
-
* </h2>
|
|
339
|
-
* <a class="result__snippet" href="...">Snippet text</a>
|
|
340
|
-
* </div>
|
|
341
|
-
*/
|
|
342
693
|
function parseDuckDuckGoResults(html, maxResults) {
|
|
343
694
|
const results = [];
|
|
344
|
-
|
|
345
|
-
// Split on result__body — stable across DDG HTML layout changes.
|
|
346
|
-
// Fallback to legacy class="result results_links" if result__body not present.
|
|
347
695
|
const primarySplit = 'result__body">';
|
|
348
696
|
const fallbackSplit = 'class="result results_links';
|
|
349
697
|
const splitOn = html.includes(primarySplit) ? primarySplit : fallbackSplit;
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
for (let i = 1; i < resultBlocks.length && results.length < maxResults; i++) {
|
|
353
|
-
const block = resultBlocks[i];
|
|
698
|
+
const blocks = html.split(splitOn);
|
|
354
699
|
|
|
355
|
-
|
|
700
|
+
for (let i = 1; i < blocks.length && results.length < maxResults; i++) {
|
|
701
|
+
const block = blocks[i];
|
|
356
702
|
let url = '';
|
|
357
703
|
const uddgMatch = block.match(/uddg=([^&"]+)/);
|
|
358
704
|
if (uddgMatch) {
|
|
359
|
-
try {
|
|
360
|
-
url = decodeURIComponent(uddgMatch[1]);
|
|
361
|
-
} catch {
|
|
362
|
-
url = uddgMatch[1];
|
|
363
|
-
}
|
|
705
|
+
try { url = decodeURIComponent(uddgMatch[1]); } catch { url = uddgMatch[1]; }
|
|
364
706
|
}
|
|
365
707
|
if (!url) continue;
|
|
366
708
|
|
|
367
|
-
// Extract title from result__a anchor text
|
|
368
709
|
let title = '';
|
|
369
710
|
const titleMatch = block.match(/class="result__a"[^>]*>([\s\S]*?)<\/a>/);
|
|
370
|
-
if (titleMatch)
|
|
371
|
-
title = htmlToText(titleMatch[1]).trim();
|
|
372
|
-
}
|
|
711
|
+
if (titleMatch) title = htmlToText(titleMatch[1]).trim();
|
|
373
712
|
if (!title) continue;
|
|
374
713
|
|
|
375
|
-
// Extract snippet from result__snippet anchor
|
|
376
714
|
let snippet = '';
|
|
377
715
|
const snippetMatch = block.match(/class="result__snippet"[^>]*>([\s\S]*?)<\/a>/);
|
|
378
|
-
if (snippetMatch)
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if (altSnippet) snippet = htmlToText(altSnippet[1]).trim();
|
|
716
|
+
if (snippetMatch) snippet = htmlToText(snippetMatch[1]).trim();
|
|
717
|
+
else {
|
|
718
|
+
const alt = block.match(/class="result__snippet"[^>]*>([\s\S]*?)<\//);
|
|
719
|
+
if (alt) snippet = htmlToText(alt[1]).trim();
|
|
383
720
|
}
|
|
384
|
-
|
|
385
|
-
results.push({ title, url, snippet: snippet.slice(0, 300) });
|
|
721
|
+
results.push({ title, url, snippet: snippet.slice(0, 320) });
|
|
386
722
|
}
|
|
387
723
|
|
|
388
724
|
return results;
|
|
389
725
|
}
|
|
390
726
|
|
|
391
727
|
/**
|
|
392
|
-
* Deep search
|
|
393
|
-
*
|
|
394
|
-
* @param {string} query
|
|
395
|
-
* @param {number} fetchCount - How many top results to fetch (default 3)
|
|
396
|
-
* @returns {Promise<{ results, deepResults }>}
|
|
728
|
+
* Deep search — search + fetch top N results for full content.
|
|
729
|
+
* Returns deepResults with main content + metadata for each.
|
|
397
730
|
*/
|
|
398
731
|
export async function webSearchDeep(query, fetchCount = 3) {
|
|
399
|
-
const
|
|
400
|
-
if (
|
|
732
|
+
const search = await webSearch(query);
|
|
733
|
+
if (search.error) return search;
|
|
401
734
|
|
|
402
|
-
const
|
|
403
|
-
const
|
|
735
|
+
const toFetch = search.results.slice(0, fetchCount);
|
|
736
|
+
const settled = await Promise.allSettled(
|
|
737
|
+
toFetch.map(r => fetchUrl(r.url, { rich: true, maxChars: 4_000 }))
|
|
738
|
+
);
|
|
404
739
|
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
} catch {}
|
|
418
|
-
return null;
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
const fetchedResults = await Promise.allSettled(fetches);
|
|
422
|
-
for (const result of fetchedResults) {
|
|
423
|
-
if (result.status === 'fulfilled' && result.value) {
|
|
424
|
-
deepResults.push(result.value);
|
|
740
|
+
const deepResults = [];
|
|
741
|
+
settled.forEach((s, i) => {
|
|
742
|
+
const r = toFetch[i];
|
|
743
|
+
if (s.status === 'fulfilled' && !s.value.error) {
|
|
744
|
+
deepResults.push({
|
|
745
|
+
title: s.value.title || r.title,
|
|
746
|
+
url: r.url,
|
|
747
|
+
snippet: r.snippet,
|
|
748
|
+
content: (s.value.body || '').slice(0, 2_000),
|
|
749
|
+
description: s.value.metadata?.description || '',
|
|
750
|
+
og: s.value.metadata?.og || {},
|
|
751
|
+
});
|
|
425
752
|
}
|
|
426
|
-
}
|
|
753
|
+
});
|
|
427
754
|
|
|
428
755
|
return {
|
|
429
756
|
error: false,
|
|
430
757
|
query: query.trim(),
|
|
431
|
-
resultCount:
|
|
432
|
-
results:
|
|
758
|
+
resultCount: search.results.length,
|
|
759
|
+
results: search.results,
|
|
433
760
|
deepFetched: deepResults.length,
|
|
434
761
|
deepResults,
|
|
435
762
|
};
|