nothumanallowed 15.1.4 → 15.1.6

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.
@@ -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
- * - web_search: DuckDuckGo HTML scraping (zero API key, zero dependencies)
5
- * - fetch_url: SSRF-protected HTMLtext extraction
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
- * Enterprise-grade security:
8
- * - SSRF protection (private IP blocking, protocol validation, DNS pre-resolution)
9
- * - Content-type allowlist (text/* only)
10
- * - Size limits (100KB download, 8KB output)
11
- * - Timeout protection (10s)
12
- * - No binary/PDF/script content
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 = 100 * 1024; // 100KB
24
- const MAX_OUTPUT_CHARS = 8000; // ~2K tokens
25
- const FETCH_TIMEOUT_MS = 10000; // 10s
26
- const MAX_REDIRECTS = 5;
27
- const MAX_RESULTS = 8;
28
-
29
- const USER_AGENT = 'NHA-CLI/9.0 (NotHumanAllowed; +https://nothumanallowed.com)';
30
- const BROWSER_UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0';
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
- * Private/internal IP ranges that MUST be blocked to prevent SSRF.
36
- */
37
- const PRIVATE_RANGES = [
38
- // IPv4
39
- { start: '10.0.0.0', end: '10.255.255.255' },
40
- { start: '172.16.0.0', end: '172.31.255.255' },
41
- { start: '192.168.0.0', end: '192.168.255.255' },
42
- { start: '127.0.0.0', end: '127.255.255.255' },
43
- { start: '169.254.0.0', end: '169.254.255.255' },
44
- { start: '0.0.0.0', end: '0.255.255.255' },
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 isPrivateIp(ip) {
53
- if (!net.isIPv4(ip)) return false; // IPv6 — block by default for safety
83
+ function isPrivateIPv4(ip) {
84
+ if (!net.isIPv4(ip)) return false;
54
85
  const long = ipToLong(ip);
55
- for (const range of PRIVATE_RANGES) {
56
- if (long >= ipToLong(range.start) && long <= ipToLong(range.end)) return true;
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
- * Returns { safe: true, hostname } or { safe: false, reason }.
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
- // Localhost detection (various encodings)
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) || // hex-encoded
86
- /^\d+$/.test(hostname) // decimal-encoded
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
- // DNS pre-resolution to catch internal hostnames
92
- try {
93
- const addresses = await dns.resolve4(hostname);
94
- for (const addr of addresses) {
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
- } catch {
100
- // DNS resolution failed — hostname might not exist
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
- return { safe: true, hostname };
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 Text Extraction ───────────────────────────────────────────────────
186
+ // ── HTML helpers ─────────────────────────────────────────────────────────────
187
+
188
+ const HTML_ENTITIES = {
189
+ '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'",
190
+ '&nbsp;': ' ', '&mdash;': '—', '&ndash;': '–', '&hellip;': '…',
191
+ '&rsquo;': "'", '&lsquo;': "'", '&rdquo;': '"', '&ldquo;': '"',
192
+ '&laquo;': '«', '&raquo;': '»', '&euro;': '€', '&pound;': '£', '&yen;': '¥',
193
+ '&copy;': '©', '&reg;': '®', '&trade;': '™',
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
- * Strips scripts, styles, nav, header, footer. Decodes entities.
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
- // Remove script, style, nav, header, footer, svg, noscript
117
- text = text.replace(/<(script|style|svg|noscript|nav|header|footer|aside|iframe)[^>]*>[\s\S]*?<\/\1>/gi, ' ');
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
- // Remove all HTML tags
120
- text = text.replace(/<[^>]+>/g, ' ');
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
- // Decode HTML entities
123
- text = text
124
- .replace(/&#x([0-9A-Fa-f]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
125
- .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
126
- .replace(/&amp;/g, '&')
127
- .replace(/&lt;/g, '<')
128
- .replace(/&gt;/g, '>')
129
- .replace(/&quot;/g, '"')
130
- .replace(/&apos;/g, "'")
131
- .replace(/&nbsp;/g, ' ')
132
- .replace(/&mdash;/g, '')
133
- .replace(/&ndash;/g, '–')
134
- .replace(/&hellip;/g, '...')
135
- .replace(/&rsquo;/g, "'")
136
- .replace(/&lsquo;/g, "'")
137
- .replace(/&rdquo;/g, '"')
138
- .replace(/&ldquo;/g, '"');
139
-
140
- // Collapse whitespace
141
- text = text.replace(/\s+/g, ' ').trim();
142
-
143
- return text;
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 <title> from HTML.
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 extractTitle(html) {
150
- const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
151
- if (!match) return '';
152
- return htmlToText(match[1]).slice(0, 200);
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
- // ── Fetch with protection ────────────────────────────────────────────────────
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 timeout.
159
- * Returns { status, contentType, body, title, excerpt, truncated }.
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
- // Validate URL
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 controller = new AbortController();
169
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
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 res = await fetch(urlStr, {
173
- headers: {
174
- 'User-Agent': USER_AGENT,
175
- 'Accept': 'text/html, text/plain, application/json, text/xml',
176
- },
177
- signal: controller.signal,
178
- redirect: 'follow',
179
- // Node.js fetch follows redirects by default (max 20)
180
- });
181
-
182
- clearTimeout(timeout);
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
- // Read body with size limit
195
- const reader = res.body.getReader();
196
- const chunks = [];
197
- let totalBytes = 0;
198
- let truncated = false;
199
-
200
- while (true) {
201
- const { done, value } = await reader.read();
202
- if (done) break;
203
- totalBytes += value.length;
204
- if (totalBytes > MAX_DOWNLOAD_BYTES) {
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
- const decoder = new TextDecoder('utf-8', { fatal: false });
215
- const rawBody = decoder.decode(Buffer.concat(chunks));
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
- // Extract useful content
218
- let body;
219
- let title = '';
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
- // Enforce output limit
237
- if (body.length > MAX_OUTPUT_CHARS) {
238
- body = body.slice(0, MAX_OUTPUT_CHARS) + '\n\n[... content truncated at 8000 chars]';
239
- truncated = true;
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
- excerpt = body.slice(0, 200).replace(/\s+/g, ' ').trim();
587
+ /** Convenience wrapper always returns metadata. */
588
+ export async function fetchUrlRich(urlStr, opts = {}) {
589
+ return fetchUrl(urlStr, { ...opts, rich: true });
590
+ }
243
591
 
244
- // DNS rebinding check on final URL (after redirects)
245
- if (res.url !== urlStr) {
246
- const finalValidation = await validateUrl(res.url);
247
- if (!finalValidation.safe) {
248
- return { error: true, message: `Redirect blocked: ${finalValidation.reason}` };
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
- error: false,
254
- status: res.status,
255
- contentType,
256
- body,
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
- * Search the web using DuckDuckGo HTML (no API key needed).
275
- * Parses the HTML results page to extract links, titles, and snippets.
276
- *
277
- * @param {string} query - Search query
278
- * @param {number} maxResults - Max results to return (default 8)
279
- * @returns {Promise<{ results: Array<{ title, url, snippet }>, query }>}
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 encodedQuery = encodeURIComponent(query.trim());
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 timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
673
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
291
674
 
292
675
  try {
293
- const res = await fetch(searchUrl, {
294
- headers: {
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(timeout);
685
+ clearTimeout(timer);
323
686
  if (err.name === 'AbortError') {
324
- return { error: true, message: 'Search timed out (10s limit)' };
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 resultBlocks = html.split(splitOn);
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
- // Extract URL from uddg= parameter in the result__a href
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
- snippet = htmlToText(snippetMatch[1]).trim();
380
- } else {
381
- const altSnippet = block.match(/class="result__snippet"[^>]*>([\s\S]*?)<\//);
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: search + fetch top N results for full content.
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 searchResult = await webSearch(query);
400
- if (searchResult.error) return searchResult;
732
+ const search = await webSearch(query);
733
+ if (search.error) return search;
401
734
 
402
- const deepResults = [];
403
- const urlsToFetch = searchResult.results.slice(0, fetchCount);
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 fetches = urlsToFetch.map(async (result) => {
406
- try {
407
- const content = await fetchUrl(result.url);
408
- if (!content.error) {
409
- return {
410
- title: content.title || result.title,
411
- url: result.url,
412
- snippet: result.snippet,
413
- content: content.body ? content.body.slice(0, 2000) : '',
414
- excerpt: content.excerpt,
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: searchResult.results.length,
432
- results: searchResult.results,
758
+ resultCount: search.results.length,
759
+ results: search.results,
433
760
  deepFetched: deepResults.length,
434
761
  deepResults,
435
762
  };