@phneakngar/cli 0.0.2 → 0.0.3
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/dist/bin-mcp.js +2991 -0
- package/dist/index.js +4908 -186
- package/dist/session-runner.js +1088 -42
- package/dist/web-brain-mcp.js +2991 -0
- package/package.json +3 -2
package/dist/bin-mcp.js
ADDED
|
@@ -0,0 +1,2991 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
4
|
+
|
|
5
|
+
// ../web-brain/src/mcp-server.ts
|
|
6
|
+
import { writeSync } from "node:fs";
|
|
7
|
+
import { homedir as homedir4 } from "node:os";
|
|
8
|
+
import { join as join5 } from "node:path";
|
|
9
|
+
|
|
10
|
+
// ../web-brain/src/fetch.ts
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { lookup } from "node:dns/promises";
|
|
13
|
+
|
|
14
|
+
// ../web-brain/src/ssrf.ts
|
|
15
|
+
import { isIP } from "node:net";
|
|
16
|
+
var BLOCKED_HOSTNAMES = new Set([
|
|
17
|
+
"localhost",
|
|
18
|
+
"localhost.localdomain",
|
|
19
|
+
"metadata.google.internal",
|
|
20
|
+
"metadata",
|
|
21
|
+
"0.0.0.0"
|
|
22
|
+
]);
|
|
23
|
+
function fail(code, message) {
|
|
24
|
+
return { ok: false, code, message };
|
|
25
|
+
}
|
|
26
|
+
function isBlockedIPv4(ip) {
|
|
27
|
+
const parts = ip.split(".").map((p) => Number(p));
|
|
28
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
const [a, b] = parts;
|
|
32
|
+
if (a === 0)
|
|
33
|
+
return true;
|
|
34
|
+
if (a === 10)
|
|
35
|
+
return true;
|
|
36
|
+
if (a === 127)
|
|
37
|
+
return true;
|
|
38
|
+
if (a === 169 && b === 254)
|
|
39
|
+
return true;
|
|
40
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
41
|
+
return true;
|
|
42
|
+
if (a === 192 && b === 168)
|
|
43
|
+
return true;
|
|
44
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
45
|
+
return true;
|
|
46
|
+
if (a === 192 && b === 0 && parts[2] === 0)
|
|
47
|
+
return true;
|
|
48
|
+
if (a === 192 && b === 0 && parts[2] === 2)
|
|
49
|
+
return true;
|
|
50
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
51
|
+
return true;
|
|
52
|
+
if (a === 198 && b === 51 && parts[2] === 100)
|
|
53
|
+
return true;
|
|
54
|
+
if (a === 203 && b === 0 && parts[2] === 113)
|
|
55
|
+
return true;
|
|
56
|
+
if (a >= 224)
|
|
57
|
+
return true;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
function isBlockedIPv6(ip) {
|
|
61
|
+
const normalized = ip.toLowerCase().replace(/^\[|\]$/g, "");
|
|
62
|
+
if (normalized === "::" || normalized === "::1")
|
|
63
|
+
return true;
|
|
64
|
+
if (normalized.startsWith("fe80:"))
|
|
65
|
+
return true;
|
|
66
|
+
if (normalized.startsWith("fc") || normalized.startsWith("fd"))
|
|
67
|
+
return true;
|
|
68
|
+
if (normalized.startsWith("ff"))
|
|
69
|
+
return true;
|
|
70
|
+
const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
71
|
+
if (mapped)
|
|
72
|
+
return isBlockedIPv4(mapped[1]);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
function isBlockedIpAddress(ip) {
|
|
76
|
+
const v = isIP(ip);
|
|
77
|
+
if (v === 4)
|
|
78
|
+
return isBlockedIPv4(ip);
|
|
79
|
+
if (v === 6)
|
|
80
|
+
return isBlockedIPv6(ip);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
function assertSafeHttpUrl(raw, opts = {}) {
|
|
84
|
+
if (typeof raw !== "string" || !raw.trim()) {
|
|
85
|
+
return fail("invalid_url", "URL is required");
|
|
86
|
+
}
|
|
87
|
+
let url;
|
|
88
|
+
try {
|
|
89
|
+
url = new URL(raw.trim());
|
|
90
|
+
} catch {
|
|
91
|
+
return fail("invalid_url", `Invalid URL: ${raw}`);
|
|
92
|
+
}
|
|
93
|
+
const scheme = url.protocol.replace(/:$/, "").toLowerCase();
|
|
94
|
+
if (scheme !== "http" && scheme !== "https") {
|
|
95
|
+
return fail("blocked_scheme", `Blocked URL scheme: ${scheme}`);
|
|
96
|
+
}
|
|
97
|
+
if (url.username || url.password) {
|
|
98
|
+
return fail("invalid_url", "URLs with embedded credentials are not allowed");
|
|
99
|
+
}
|
|
100
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
101
|
+
if (!host) {
|
|
102
|
+
return fail("invalid_url", "URL host is required");
|
|
103
|
+
}
|
|
104
|
+
if (!opts.allowPrivateNetwork) {
|
|
105
|
+
if (BLOCKED_HOSTNAMES.has(host) || host.endsWith(".localhost") || host.endsWith(".local")) {
|
|
106
|
+
return fail("blocked_host", `Blocked host: ${host}`);
|
|
107
|
+
}
|
|
108
|
+
const ipVersion = isIP(host);
|
|
109
|
+
if (ipVersion === 4 || ipVersion === 6) {
|
|
110
|
+
if (isBlockedIpAddress(host)) {
|
|
111
|
+
return fail("blocked_ip", `Blocked IP address: ${host}`);
|
|
112
|
+
}
|
|
113
|
+
} else if (/^\d+$/.test(host)) {
|
|
114
|
+
try {
|
|
115
|
+
const n = Number(host) >>> 0;
|
|
116
|
+
const dotted = [n >>> 24 & 255, n >>> 16 & 255, n >>> 8 & 255, n & 255].join(".");
|
|
117
|
+
if (isBlockedIPv4(dotted)) {
|
|
118
|
+
return fail("blocked_ip", `Blocked IP address: ${host}`);
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
return fail("invalid_url", `Invalid host: ${host}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { ok: true, url };
|
|
126
|
+
}
|
|
127
|
+
function assertResolvedAddressesSafe(addresses, opts = {}) {
|
|
128
|
+
if (opts.allowPrivateNetwork)
|
|
129
|
+
return { ok: true };
|
|
130
|
+
if (!addresses.length) {
|
|
131
|
+
return fail("dns_failed", "DNS returned no addresses");
|
|
132
|
+
}
|
|
133
|
+
for (const addr of addresses) {
|
|
134
|
+
if (isBlockedIpAddress(addr)) {
|
|
135
|
+
return fail("blocked_ip", `Resolved to blocked address: ${addr}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { ok: true };
|
|
139
|
+
}
|
|
140
|
+
function toWebError(code, message) {
|
|
141
|
+
return { ok: false, error: { code, message } };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ../web-brain/src/extract.ts
|
|
145
|
+
function decodeEntities(s) {
|
|
146
|
+
return s.replace(/ /gi, " ").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/"/gi, '"').replace(/'/gi, "'").replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)));
|
|
147
|
+
}
|
|
148
|
+
function stripTags(html) {
|
|
149
|
+
return decodeEntities(html.replace(/<[^>]+>/g, " "));
|
|
150
|
+
}
|
|
151
|
+
function collapseWs(s) {
|
|
152
|
+
return s.replace(/[ \t]+\n/g, `
|
|
153
|
+
`).replace(/\n{3,}/g, `
|
|
154
|
+
|
|
155
|
+
`).replace(/[ \t]{2,}/g, " ").trim();
|
|
156
|
+
}
|
|
157
|
+
function extractTitle(html) {
|
|
158
|
+
const t = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
159
|
+
if (t?.[1]) {
|
|
160
|
+
const title = collapseWs(stripTags(t[1]));
|
|
161
|
+
if (title)
|
|
162
|
+
return title.slice(0, 500);
|
|
163
|
+
}
|
|
164
|
+
const h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
|
|
165
|
+
if (h1?.[1]) {
|
|
166
|
+
const title = collapseWs(stripTags(h1[1]));
|
|
167
|
+
if (title)
|
|
168
|
+
return title.slice(0, 500);
|
|
169
|
+
}
|
|
170
|
+
const og = html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) || html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i);
|
|
171
|
+
if (og?.[1])
|
|
172
|
+
return collapseWs(decodeEntities(og[1])).slice(0, 500);
|
|
173
|
+
return "";
|
|
174
|
+
}
|
|
175
|
+
function stripBoilerplate(html) {
|
|
176
|
+
let s = html;
|
|
177
|
+
s = s.replace(/<script\b[\s\S]*?<\/script>/gi, "");
|
|
178
|
+
s = s.replace(/<style\b[\s\S]*?<\/style>/gi, "");
|
|
179
|
+
s = s.replace(/<noscript\b[\s\S]*?<\/noscript>/gi, "");
|
|
180
|
+
s = s.replace(/<!--[\s\S]*?-->/g, "");
|
|
181
|
+
s = s.replace(/<svg\b[\s\S]*?<\/svg>/gi, "");
|
|
182
|
+
s = s.replace(/<(header|footer|nav|aside|form)\b[\s\S]*?<\/\1>/gi, "");
|
|
183
|
+
return s;
|
|
184
|
+
}
|
|
185
|
+
function bodyHtml(html) {
|
|
186
|
+
const m = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
|
187
|
+
return m?.[1] ?? html;
|
|
188
|
+
}
|
|
189
|
+
function mainContentHtml(html) {
|
|
190
|
+
const article = html.match(/<article\b[^>]*>([\s\S]*?)<\/article>/i);
|
|
191
|
+
if (article?.[1] && article[1].length > 80)
|
|
192
|
+
return article[1];
|
|
193
|
+
const main = html.match(/<main\b[^>]*>([\s\S]*?)<\/main>/i);
|
|
194
|
+
if (main?.[1] && main[1].length > 80)
|
|
195
|
+
return main[1];
|
|
196
|
+
return bodyHtml(html);
|
|
197
|
+
}
|
|
198
|
+
function htmlToMarkdown(html, maxChars = 30000) {
|
|
199
|
+
let s = stripBoilerplate(mainContentHtml(html));
|
|
200
|
+
s = s.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_, level, inner) => {
|
|
201
|
+
const text = collapseWs(stripTags(inner));
|
|
202
|
+
if (!text)
|
|
203
|
+
return `
|
|
204
|
+
`;
|
|
205
|
+
return `
|
|
206
|
+
|
|
207
|
+
${"#".repeat(Number(level))} ${text}
|
|
208
|
+
|
|
209
|
+
`;
|
|
210
|
+
});
|
|
211
|
+
s = s.replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href, inner) => {
|
|
212
|
+
const text = collapseWs(stripTags(inner)) || href;
|
|
213
|
+
return `[${text}](${href})`;
|
|
214
|
+
});
|
|
215
|
+
s = s.replace(/<img\b[^>]*alt=["']([^"']*)["'][^>]*>/gi, (_, alt) => alt ? ` ${alt} ` : " ");
|
|
216
|
+
s = s.replace(/<img\b[^>]*>/gi, " ");
|
|
217
|
+
s = s.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, (_, inner) => {
|
|
218
|
+
const text = collapseWs(stripTags(inner));
|
|
219
|
+
return text ? `
|
|
220
|
+
- ${text}` : "";
|
|
221
|
+
});
|
|
222
|
+
s = s.replace(/<\/p>/gi, `
|
|
223
|
+
|
|
224
|
+
`);
|
|
225
|
+
s = s.replace(/<br\s*\/?>/gi, `
|
|
226
|
+
`);
|
|
227
|
+
s = s.replace(/<\/div>/gi, `
|
|
228
|
+
`);
|
|
229
|
+
s = s.replace(/<\/tr>/gi, `
|
|
230
|
+
`);
|
|
231
|
+
s = s.replace(/<\/(ul|ol|table|section|blockquote)>/gi, `
|
|
232
|
+
|
|
233
|
+
`);
|
|
234
|
+
s = s.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, (_, inner) => {
|
|
235
|
+
const text = decodeEntities(inner.replace(/<[^>]+>/g, ""));
|
|
236
|
+
return `
|
|
237
|
+
|
|
238
|
+
\`\`\`
|
|
239
|
+
${text.trim()}
|
|
240
|
+
\`\`\`
|
|
241
|
+
|
|
242
|
+
`;
|
|
243
|
+
});
|
|
244
|
+
s = s.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, (_, inner) => {
|
|
245
|
+
const text = collapseWs(stripTags(inner));
|
|
246
|
+
return text ? `\`${text}\`` : "";
|
|
247
|
+
});
|
|
248
|
+
s = stripTags(s);
|
|
249
|
+
s = collapseWs(s);
|
|
250
|
+
if (s.length > maxChars) {
|
|
251
|
+
let cut = s.slice(0, maxChars);
|
|
252
|
+
const lastBreak = Math.max(cut.lastIndexOf(`
|
|
253
|
+
|
|
254
|
+
`), cut.lastIndexOf(`
|
|
255
|
+
`));
|
|
256
|
+
if (lastBreak > maxChars * 0.6)
|
|
257
|
+
cut = cut.slice(0, lastBreak);
|
|
258
|
+
s = `${cut.trimEnd()}
|
|
259
|
+
|
|
260
|
+
[... content truncated]`;
|
|
261
|
+
}
|
|
262
|
+
return s;
|
|
263
|
+
}
|
|
264
|
+
function extractFromHtml(html, maxChars = 30000) {
|
|
265
|
+
const title = extractTitle(html);
|
|
266
|
+
const markdown = htmlToMarkdown(html, maxChars);
|
|
267
|
+
return { title, markdown, textLength: markdown.length };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ../web-brain/src/auth.ts
|
|
271
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
272
|
+
import { homedir } from "node:os";
|
|
273
|
+
import { join } from "node:path";
|
|
274
|
+
function defaultAuthPaths() {
|
|
275
|
+
const home = homedir();
|
|
276
|
+
return [
|
|
277
|
+
process.env.PHNEAKNGAR_AUTH_STATE || "",
|
|
278
|
+
process.env.PHNEAKNGAR_AUTH_COOKIES || "",
|
|
279
|
+
join(home, ".phneakngar", "auth", "storageState.json"),
|
|
280
|
+
join(home, ".phneakngar", "auth", "cookies.txt")
|
|
281
|
+
].filter(Boolean);
|
|
282
|
+
}
|
|
283
|
+
function cookiesFromNetscape(text, host) {
|
|
284
|
+
const parts = [];
|
|
285
|
+
for (const line of text.split(/\r?\n/)) {
|
|
286
|
+
if (!line || line.startsWith("#"))
|
|
287
|
+
continue;
|
|
288
|
+
const cols = line.split("\t");
|
|
289
|
+
if (cols.length < 7)
|
|
290
|
+
continue;
|
|
291
|
+
const domain = cols[0].replace(/^\./, "");
|
|
292
|
+
const name = cols[5];
|
|
293
|
+
const value = cols[6];
|
|
294
|
+
if (host && !host.endsWith(domain) && domain !== host)
|
|
295
|
+
continue;
|
|
296
|
+
parts.push(`${name}=${value}`);
|
|
297
|
+
}
|
|
298
|
+
return parts.length ? parts.join("; ") : null;
|
|
299
|
+
}
|
|
300
|
+
function cookiesFromStorageState(json, host) {
|
|
301
|
+
if (!json || typeof json !== "object")
|
|
302
|
+
return null;
|
|
303
|
+
const cookies = json.cookies;
|
|
304
|
+
if (!Array.isArray(cookies))
|
|
305
|
+
return null;
|
|
306
|
+
const parts = [];
|
|
307
|
+
for (const c of cookies) {
|
|
308
|
+
if (!c || typeof c !== "object")
|
|
309
|
+
continue;
|
|
310
|
+
const rec = c;
|
|
311
|
+
if (!rec.name || rec.value == null)
|
|
312
|
+
continue;
|
|
313
|
+
if (host && rec.domain) {
|
|
314
|
+
const d = rec.domain.replace(/^\./, "");
|
|
315
|
+
if (!host.endsWith(d) && d !== host)
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
parts.push(`${rec.name}=${rec.value}`);
|
|
319
|
+
}
|
|
320
|
+
return parts.length ? parts.join("; ") : null;
|
|
321
|
+
}
|
|
322
|
+
function resolveAuth(opts = {}) {
|
|
323
|
+
if (!opts.useAuth && !opts.cookieHeader && !opts.authStatePath) {
|
|
324
|
+
if (process.env.PHNEAKNGAR_USE_AUTH !== "1")
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
if (opts.useAuth === false)
|
|
328
|
+
return null;
|
|
329
|
+
if (opts.cookieHeader) {
|
|
330
|
+
return { cookieHeader: opts.cookieHeader, source: "explicit" };
|
|
331
|
+
}
|
|
332
|
+
const envCookie = process.env.PHNEAKNGAR_AUTH_COOKIE_HEADER;
|
|
333
|
+
if (envCookie) {
|
|
334
|
+
return { cookieHeader: envCookie, source: "env:PHNEAKNGAR_AUTH_COOKIE_HEADER" };
|
|
335
|
+
}
|
|
336
|
+
const paths = opts.authStatePath ? [opts.authStatePath] : defaultAuthPaths();
|
|
337
|
+
for (const p of paths) {
|
|
338
|
+
if (!p || !existsSync(p))
|
|
339
|
+
continue;
|
|
340
|
+
try {
|
|
341
|
+
const raw = readFileSync(p, "utf-8");
|
|
342
|
+
if (p.endsWith(".json") || raw.trimStart().startsWith("{")) {
|
|
343
|
+
const cookie = cookiesFromStorageState(JSON.parse(raw), opts.host);
|
|
344
|
+
if (cookie)
|
|
345
|
+
return { cookieHeader: cookie, source: p };
|
|
346
|
+
} else {
|
|
347
|
+
const cookie = cookiesFromNetscape(raw, opts.host);
|
|
348
|
+
if (cookie)
|
|
349
|
+
return { cookieHeader: cookie, source: p };
|
|
350
|
+
}
|
|
351
|
+
} catch {}
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
function applyAuthHeaders(base, auth) {
|
|
356
|
+
const h = { ...base ?? {} };
|
|
357
|
+
if (auth?.cookieHeader)
|
|
358
|
+
h.Cookie = auth.cookieHeader;
|
|
359
|
+
if (auth?.headers)
|
|
360
|
+
Object.assign(h, auth.headers);
|
|
361
|
+
return h;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ../web-brain/src/fetch.ts
|
|
365
|
+
var DEFAULT_MAX_CHARS = 30000;
|
|
366
|
+
var DEFAULT_TIMEOUT_MS = 15000;
|
|
367
|
+
var MAX_BYTES = 2000000;
|
|
368
|
+
var MAX_REDIRECTS = 5;
|
|
369
|
+
var TEXT_TYPES = [
|
|
370
|
+
"text/html",
|
|
371
|
+
"application/xhtml",
|
|
372
|
+
"text/plain",
|
|
373
|
+
"text/markdown",
|
|
374
|
+
"application/json"
|
|
375
|
+
];
|
|
376
|
+
function contentTypeOk(ct) {
|
|
377
|
+
if (!ct)
|
|
378
|
+
return true;
|
|
379
|
+
const base = ct.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
380
|
+
return TEXT_TYPES.some((t) => base.includes(t)) || base.startsWith("text/");
|
|
381
|
+
}
|
|
382
|
+
function hashContent(s) {
|
|
383
|
+
return createHash("sha256").update(s).digest("hex");
|
|
384
|
+
}
|
|
385
|
+
async function resolveAndCheckHost(hostname, allowPrivateNetwork) {
|
|
386
|
+
const { isIP: isIP2 } = await import("node:net");
|
|
387
|
+
if (isIP2(hostname.replace(/^\[|\]$/g, "")))
|
|
388
|
+
return null;
|
|
389
|
+
try {
|
|
390
|
+
const results = await lookup(hostname, { all: true });
|
|
391
|
+
const addrs = results.map((r) => r.address);
|
|
392
|
+
const check = assertResolvedAddressesSafe(addrs, { allowPrivateNetwork });
|
|
393
|
+
if (!check.ok) {
|
|
394
|
+
return toWebError(check.code, check.message);
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
} catch (err) {
|
|
398
|
+
return toWebError("dns_failed", `DNS lookup failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async function webFetch(rawUrl, opts = {}) {
|
|
402
|
+
const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
|
|
403
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
404
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
405
|
+
const allowPrivate = opts.allowPrivateNetwork ?? false;
|
|
406
|
+
const safe = assertSafeHttpUrl(rawUrl, { allowPrivateNetwork: allowPrivate });
|
|
407
|
+
if (!safe.ok) {
|
|
408
|
+
return toWebError(safe.code, safe.message);
|
|
409
|
+
}
|
|
410
|
+
const canonical = safe.url.toString();
|
|
411
|
+
if (!opts.forceRefresh && opts.cache) {
|
|
412
|
+
const hit = opts.cache.get(canonical);
|
|
413
|
+
if (hit) {
|
|
414
|
+
return {
|
|
415
|
+
ok: true,
|
|
416
|
+
url: hit.url,
|
|
417
|
+
finalUrl: hit.finalUrl,
|
|
418
|
+
title: hit.title,
|
|
419
|
+
markdown: hit.markdown,
|
|
420
|
+
contentType: hit.contentType,
|
|
421
|
+
httpStatus: hit.httpStatus,
|
|
422
|
+
fromCache: true,
|
|
423
|
+
fetchedAt: hit.fetchedAt,
|
|
424
|
+
contentHash: hit.contentHash
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
let current = safe.url;
|
|
429
|
+
let redirects = 0;
|
|
430
|
+
let res;
|
|
431
|
+
const skipDns = opts.fetchImpl != null && opts.fetchImpl !== fetch;
|
|
432
|
+
while (redirects <= MAX_REDIRECTS) {
|
|
433
|
+
if (!skipDns) {
|
|
434
|
+
const dnsErr = await resolveAndCheckHost(current.hostname, allowPrivate);
|
|
435
|
+
if (dnsErr)
|
|
436
|
+
return dnsErr;
|
|
437
|
+
}
|
|
438
|
+
const hopSafe = assertSafeHttpUrl(current.toString(), {
|
|
439
|
+
allowPrivateNetwork: allowPrivate
|
|
440
|
+
});
|
|
441
|
+
if (!hopSafe.ok) {
|
|
442
|
+
return toWebError(hopSafe.code, hopSafe.message);
|
|
443
|
+
}
|
|
444
|
+
const auth = resolveAuth({
|
|
445
|
+
useAuth: opts.useAuth,
|
|
446
|
+
authStatePath: opts.authStatePath,
|
|
447
|
+
host: current.hostname
|
|
448
|
+
});
|
|
449
|
+
const headers = applyAuthHeaders({
|
|
450
|
+
Accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.1",
|
|
451
|
+
"User-Agent": "phneakngar-web-brain/0.0.2 (+https://github.com/silence-guy/phneakngar)",
|
|
452
|
+
...opts.headers
|
|
453
|
+
}, auth);
|
|
454
|
+
try {
|
|
455
|
+
res = await fetchImpl(current.toString(), {
|
|
456
|
+
method: "GET",
|
|
457
|
+
redirect: "manual",
|
|
458
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
459
|
+
headers
|
|
460
|
+
});
|
|
461
|
+
} catch (err) {
|
|
462
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
463
|
+
if (/timeout|aborted/i.test(msg)) {
|
|
464
|
+
return toWebError("timeout", `Request timed out after ${timeoutMs}ms`);
|
|
465
|
+
}
|
|
466
|
+
return toWebError("network_error", msg);
|
|
467
|
+
}
|
|
468
|
+
if (res.status >= 300 && res.status < 400) {
|
|
469
|
+
const loc = res.headers.get("location");
|
|
470
|
+
if (!loc) {
|
|
471
|
+
return toWebError("http_error", `Redirect ${res.status} without Location`);
|
|
472
|
+
}
|
|
473
|
+
let next;
|
|
474
|
+
try {
|
|
475
|
+
next = new URL(loc, current);
|
|
476
|
+
} catch {
|
|
477
|
+
return toWebError("invalid_url", `Invalid redirect location: ${loc}`);
|
|
478
|
+
}
|
|
479
|
+
const nextSafe = assertSafeHttpUrl(next.toString(), {
|
|
480
|
+
allowPrivateNetwork: allowPrivate
|
|
481
|
+
});
|
|
482
|
+
if (!nextSafe.ok) {
|
|
483
|
+
return toWebError(nextSafe.code, `Redirect blocked: ${nextSafe.message}`);
|
|
484
|
+
}
|
|
485
|
+
current = nextSafe.url;
|
|
486
|
+
redirects += 1;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
if (!res) {
|
|
492
|
+
return toWebError("network_error", "No response");
|
|
493
|
+
}
|
|
494
|
+
if (res.status === 403 || res.status === 401) {
|
|
495
|
+
return toWebError("http_error", `HTTP ${res.status} from ${current.toString()} (blocked or unauthorized)`);
|
|
496
|
+
}
|
|
497
|
+
if (!res.ok) {
|
|
498
|
+
return toWebError("http_error", `HTTP ${res.status} from ${current.toString()}`);
|
|
499
|
+
}
|
|
500
|
+
const ct = res.headers.get("content-type");
|
|
501
|
+
if (!contentTypeOk(ct)) {
|
|
502
|
+
return toWebError("unsupported_content", `Unsupported content-type: ${ct ?? "unknown"}`);
|
|
503
|
+
}
|
|
504
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
505
|
+
if (buf.byteLength > MAX_BYTES) {
|
|
506
|
+
return toWebError("too_large", `Response exceeds ${MAX_BYTES} bytes`);
|
|
507
|
+
}
|
|
508
|
+
const text = buf.toString("utf-8");
|
|
509
|
+
if (!text.trim()) {
|
|
510
|
+
return toWebError("empty_content", "Empty response body");
|
|
511
|
+
}
|
|
512
|
+
const lowerCt = (ct ?? "").toLowerCase();
|
|
513
|
+
let title = "";
|
|
514
|
+
let markdown = "";
|
|
515
|
+
if (lowerCt.includes("json") || text.trimStart().startsWith("{") || text.trimStart().startsWith("[")) {
|
|
516
|
+
title = current.hostname;
|
|
517
|
+
markdown = "```json\n" + text.slice(0, maxChars) + (text.length > maxChars ? `
|
|
518
|
+
/* truncated */` : "") + "\n```";
|
|
519
|
+
if (text.length > maxChars) {
|
|
520
|
+
markdown += `
|
|
521
|
+
|
|
522
|
+
[... content truncated]`;
|
|
523
|
+
}
|
|
524
|
+
} else if (lowerCt.includes("html") || /<html[\s>]/i.test(text) || /<body[\s>]/i.test(text)) {
|
|
525
|
+
const extracted = extractFromHtml(text, maxChars);
|
|
526
|
+
title = extracted.title || current.hostname;
|
|
527
|
+
markdown = extracted.markdown;
|
|
528
|
+
} else {
|
|
529
|
+
title = current.hostname;
|
|
530
|
+
markdown = text.length > maxChars ? text.slice(0, maxChars) + `
|
|
531
|
+
|
|
532
|
+
[... content truncated]` : text;
|
|
533
|
+
}
|
|
534
|
+
if (!markdown.trim()) {
|
|
535
|
+
return toWebError("empty_content", "No extractable text content");
|
|
536
|
+
}
|
|
537
|
+
const fetchedAt = new Date().toISOString();
|
|
538
|
+
const contentHash = hashContent(markdown);
|
|
539
|
+
const entry = {
|
|
540
|
+
url: canonical,
|
|
541
|
+
finalUrl: current.toString(),
|
|
542
|
+
title,
|
|
543
|
+
markdown,
|
|
544
|
+
contentType: ct ?? "text/html",
|
|
545
|
+
httpStatus: res.status,
|
|
546
|
+
fetchedAt,
|
|
547
|
+
contentHash
|
|
548
|
+
};
|
|
549
|
+
if (opts.cache) {
|
|
550
|
+
opts.cache.put(entry);
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
ok: true,
|
|
554
|
+
url: canonical,
|
|
555
|
+
finalUrl: entry.finalUrl,
|
|
556
|
+
title,
|
|
557
|
+
markdown,
|
|
558
|
+
contentType: entry.contentType,
|
|
559
|
+
httpStatus: entry.httpStatus,
|
|
560
|
+
fromCache: false,
|
|
561
|
+
fetchedAt,
|
|
562
|
+
contentHash
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ../web-brain/src/search.ts
|
|
567
|
+
var UA = "phneakngar-web-brain/0.0.2 (+https://github.com/silence-guy/phneakngar)";
|
|
568
|
+
function ddgTimeParam(timeRange) {
|
|
569
|
+
if (!timeRange)
|
|
570
|
+
return;
|
|
571
|
+
switch (timeRange) {
|
|
572
|
+
case "day":
|
|
573
|
+
return "d";
|
|
574
|
+
case "week":
|
|
575
|
+
return "w";
|
|
576
|
+
case "month":
|
|
577
|
+
return "m";
|
|
578
|
+
case "year":
|
|
579
|
+
return "y";
|
|
580
|
+
default:
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
function decodeHtmlEntities(s) {
|
|
585
|
+
return s.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(Number.parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d))).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'").replace(/ /g, " ");
|
|
586
|
+
}
|
|
587
|
+
function stripTags2(s) {
|
|
588
|
+
return decodeHtmlEntities(s.replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim();
|
|
589
|
+
}
|
|
590
|
+
function resolveDdgResultHref(href) {
|
|
591
|
+
let raw = href.trim();
|
|
592
|
+
if (!raw)
|
|
593
|
+
return null;
|
|
594
|
+
raw = decodeHtmlEntities(raw);
|
|
595
|
+
if (raw.startsWith("//"))
|
|
596
|
+
raw = `https:${raw}`;
|
|
597
|
+
try {
|
|
598
|
+
const u = new URL(raw);
|
|
599
|
+
const uddg = u.searchParams.get("uddg");
|
|
600
|
+
if (uddg) {
|
|
601
|
+
const dest = decodeURIComponent(uddg);
|
|
602
|
+
if (/^https?:\/\//i.test(dest))
|
|
603
|
+
return dest;
|
|
604
|
+
}
|
|
605
|
+
if (u.hostname.includes("duckduckgo.com"))
|
|
606
|
+
return null;
|
|
607
|
+
if (u.protocol === "http:" || u.protocol === "https:")
|
|
608
|
+
return u.toString();
|
|
609
|
+
} catch {}
|
|
610
|
+
const m = raw.match(/[?&]uddg=([^&]+)/i);
|
|
611
|
+
if (m?.[1]) {
|
|
612
|
+
try {
|
|
613
|
+
const dest = decodeURIComponent(m[1]);
|
|
614
|
+
if (/^https?:\/\//i.test(dest))
|
|
615
|
+
return dest;
|
|
616
|
+
} catch {}
|
|
617
|
+
}
|
|
618
|
+
if (/^https?:\/\//i.test(raw) && !raw.includes("duckduckgo.com"))
|
|
619
|
+
return raw;
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
function detectBlockedHint(html) {
|
|
623
|
+
const lower = html.toLowerCase();
|
|
624
|
+
if (lower.includes("anomaly-modal") || lower.includes("anomaly")) {
|
|
625
|
+
return "anomaly";
|
|
626
|
+
}
|
|
627
|
+
if (lower.includes("captcha") || lower.includes("challenge-form")) {
|
|
628
|
+
return "captcha";
|
|
629
|
+
}
|
|
630
|
+
if (lower.includes("unfortunately, bots use duckduckgo too")) {
|
|
631
|
+
return "bot_block";
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
function parseDdgLiteHtml(html, maxResults) {
|
|
636
|
+
const results = [];
|
|
637
|
+
const seen = new Set;
|
|
638
|
+
const push = (url, titleRaw, snippet = "") => {
|
|
639
|
+
if (!url || seen.has(url) || results.length >= maxResults)
|
|
640
|
+
return;
|
|
641
|
+
if (url.includes("duckduckgo.com"))
|
|
642
|
+
return;
|
|
643
|
+
const title = stripTags2(titleRaw);
|
|
644
|
+
if (!title)
|
|
645
|
+
return;
|
|
646
|
+
seen.add(url);
|
|
647
|
+
results.push({ title, url, snippet: stripTags2(snippet) });
|
|
648
|
+
};
|
|
649
|
+
const anchorRe = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi;
|
|
650
|
+
let m;
|
|
651
|
+
while ((m = anchorRe.exec(html)) !== null && results.length < maxResults) {
|
|
652
|
+
const attrs = m[1] ?? "";
|
|
653
|
+
const inner = m[2] ?? "";
|
|
654
|
+
const hrefM = attrs.match(/\bhref\s*=\s*["']([^"']+)["']/i);
|
|
655
|
+
if (!hrefM?.[1])
|
|
656
|
+
continue;
|
|
657
|
+
const href = hrefM[1];
|
|
658
|
+
const isResultClass = /class\s*=\s*["'][^"']*result(?:__a|-link)/i.test(attrs) || /rel\s*=\s*["'][^"']*nofollow/i.test(attrs) || /uddg=/i.test(href);
|
|
659
|
+
if (!isResultClass)
|
|
660
|
+
continue;
|
|
661
|
+
push(resolveDdgResultHref(href), inner);
|
|
662
|
+
}
|
|
663
|
+
if (results.length === 0) {
|
|
664
|
+
const uddgRe = /[?&]uddg=([^&"'>\s]+)/gi;
|
|
665
|
+
let um;
|
|
666
|
+
while ((um = uddgRe.exec(html)) !== null && results.length < maxResults) {
|
|
667
|
+
try {
|
|
668
|
+
const dest = decodeURIComponent(um[1]);
|
|
669
|
+
if (/^https?:\/\//i.test(dest) && !dest.includes("duckduckgo.com")) {
|
|
670
|
+
push(dest, dest);
|
|
671
|
+
}
|
|
672
|
+
} catch {}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
const snippetRe = /class=["'][^"']*result(?:__|-)?snippet[^"']*["'][^>]*>([\s\S]*?)<\//gi;
|
|
676
|
+
const snippets = [];
|
|
677
|
+
let sm;
|
|
678
|
+
while ((sm = snippetRe.exec(html)) !== null) {
|
|
679
|
+
snippets.push(stripTags2(sm[1] ?? ""));
|
|
680
|
+
}
|
|
681
|
+
for (let i = 0;i < results.length; i++) {
|
|
682
|
+
if (snippets[i])
|
|
683
|
+
results[i].snippet = snippets[i];
|
|
684
|
+
}
|
|
685
|
+
return results;
|
|
686
|
+
}
|
|
687
|
+
async function runHtmlProvider(name, url, maxResults, fetchImpl) {
|
|
688
|
+
const t0 = Date.now();
|
|
689
|
+
try {
|
|
690
|
+
const res = await fetchImpl(url.toString(), {
|
|
691
|
+
method: "GET",
|
|
692
|
+
signal: AbortSignal.timeout(12000),
|
|
693
|
+
headers: {
|
|
694
|
+
Accept: "text/html,application/xhtml+xml",
|
|
695
|
+
"User-Agent": UA,
|
|
696
|
+
"Accept-Language": "en-US,en;q=0.9"
|
|
697
|
+
},
|
|
698
|
+
redirect: "follow"
|
|
699
|
+
});
|
|
700
|
+
const html = await res.text();
|
|
701
|
+
const blockedHint = detectBlockedHint(html);
|
|
702
|
+
const results = res.ok ? parseDdgLiteHtml(html, maxResults) : [];
|
|
703
|
+
return {
|
|
704
|
+
results,
|
|
705
|
+
telemetry: {
|
|
706
|
+
provider: name,
|
|
707
|
+
ok: res.ok,
|
|
708
|
+
httpStatus: res.status,
|
|
709
|
+
latencyMs: Date.now() - t0,
|
|
710
|
+
rawHtmlBytes: html.length,
|
|
711
|
+
parseCount: results.length,
|
|
712
|
+
blockedHint,
|
|
713
|
+
error: res.ok ? undefined : `Search HTTP ${res.status}`
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
} catch (err) {
|
|
717
|
+
return {
|
|
718
|
+
results: [],
|
|
719
|
+
telemetry: {
|
|
720
|
+
provider: name,
|
|
721
|
+
ok: false,
|
|
722
|
+
latencyMs: Date.now() - t0,
|
|
723
|
+
parseCount: 0,
|
|
724
|
+
error: err instanceof Error ? err.message : String(err)
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
function applyDdgParams(u, query, timeRange) {
|
|
730
|
+
u.searchParams.set("q", query);
|
|
731
|
+
const df = ddgTimeParam(timeRange);
|
|
732
|
+
if (df)
|
|
733
|
+
u.searchParams.set("df", df);
|
|
734
|
+
}
|
|
735
|
+
var ddgLiteProvider = {
|
|
736
|
+
name: "ddg-lite",
|
|
737
|
+
async search(query, { maxResults, fetchImpl, timeRange }) {
|
|
738
|
+
const u = new URL("https://lite.duckduckgo.com/lite/");
|
|
739
|
+
applyDdgParams(u, query, timeRange);
|
|
740
|
+
const run = await runHtmlProvider("ddg-lite", u, maxResults, fetchImpl);
|
|
741
|
+
if (run.telemetry.error && run.results.length === 0) {
|
|
742
|
+
throw new Error(run.telemetry.error);
|
|
743
|
+
}
|
|
744
|
+
return run.results;
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
var ddgHtmlProvider = {
|
|
748
|
+
name: "ddg-html",
|
|
749
|
+
async search(query, { maxResults, fetchImpl, timeRange }) {
|
|
750
|
+
const u = new URL("https://html.duckduckgo.com/html/");
|
|
751
|
+
applyDdgParams(u, query, timeRange);
|
|
752
|
+
const run = await runHtmlProvider("ddg-html", u, maxResults, fetchImpl);
|
|
753
|
+
if (run.telemetry.error && run.results.length === 0) {
|
|
754
|
+
throw new Error(run.telemetry.error);
|
|
755
|
+
}
|
|
756
|
+
return run.results;
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
async function runProvider(provider, query, ctx) {
|
|
760
|
+
if (provider.name === "ddg-lite") {
|
|
761
|
+
const u = new URL("https://lite.duckduckgo.com/lite/");
|
|
762
|
+
applyDdgParams(u, query, ctx.timeRange);
|
|
763
|
+
return runHtmlProvider("ddg-lite", u, ctx.maxResults, ctx.fetchImpl);
|
|
764
|
+
}
|
|
765
|
+
if (provider.name === "ddg-html") {
|
|
766
|
+
const u = new URL("https://html.duckduckgo.com/html/");
|
|
767
|
+
applyDdgParams(u, query, ctx.timeRange);
|
|
768
|
+
return runHtmlProvider("ddg-html", u, ctx.maxResults, ctx.fetchImpl);
|
|
769
|
+
}
|
|
770
|
+
const t0 = Date.now();
|
|
771
|
+
try {
|
|
772
|
+
const results = await provider.search(query, ctx);
|
|
773
|
+
return {
|
|
774
|
+
results,
|
|
775
|
+
telemetry: {
|
|
776
|
+
provider: provider.name,
|
|
777
|
+
ok: true,
|
|
778
|
+
latencyMs: Date.now() - t0,
|
|
779
|
+
parseCount: results.length
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
} catch (err) {
|
|
783
|
+
return {
|
|
784
|
+
results: [],
|
|
785
|
+
telemetry: {
|
|
786
|
+
provider: provider.name,
|
|
787
|
+
ok: false,
|
|
788
|
+
latencyMs: Date.now() - t0,
|
|
789
|
+
parseCount: 0,
|
|
790
|
+
error: err instanceof Error ? err.message : String(err)
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
async function webSearch(query, opts = {}) {
|
|
796
|
+
const q = query?.trim() ?? "";
|
|
797
|
+
if (!q) {
|
|
798
|
+
return toWebError("invalid_url", "Search query is required");
|
|
799
|
+
}
|
|
800
|
+
const maxResults = Math.min(Math.max(opts.maxResults ?? 5, 1), 20);
|
|
801
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
802
|
+
const timeRange = opts.timeRange;
|
|
803
|
+
const ctx = { maxResults, fetchImpl, timeRange };
|
|
804
|
+
if (opts.provider) {
|
|
805
|
+
const run = await runProvider(opts.provider, q, ctx);
|
|
806
|
+
if (run.results.length > 0) {
|
|
807
|
+
return {
|
|
808
|
+
ok: true,
|
|
809
|
+
query: q,
|
|
810
|
+
results: run.results.slice(0, maxResults),
|
|
811
|
+
provider: opts.provider.name,
|
|
812
|
+
providersTried: [opts.provider.name],
|
|
813
|
+
telemetry: [run.telemetry],
|
|
814
|
+
timeRange
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
return {
|
|
818
|
+
ok: true,
|
|
819
|
+
query: q,
|
|
820
|
+
results: [],
|
|
821
|
+
provider: opts.provider.name,
|
|
822
|
+
degraded: true,
|
|
823
|
+
error: {
|
|
824
|
+
code: "empty_provider_results",
|
|
825
|
+
message: run.telemetry.error ? `Provider ${opts.provider.name} failed: ${run.telemetry.error}` : `Provider ${opts.provider.name} returned 0 results (parse/engine empty)`
|
|
826
|
+
},
|
|
827
|
+
providersTried: [opts.provider.name],
|
|
828
|
+
telemetry: [run.telemetry],
|
|
829
|
+
timeRange
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
const chain = opts.noFallback ? [{ name: "ddg-lite", search: ddgLiteProvider.search }] : [
|
|
833
|
+
{ name: "ddg-lite", search: ddgLiteProvider.search },
|
|
834
|
+
{ name: "ddg-html", search: ddgHtmlProvider.search }
|
|
835
|
+
];
|
|
836
|
+
const telemetry = [];
|
|
837
|
+
const tried = [];
|
|
838
|
+
let lastProvider = chain[0].name;
|
|
839
|
+
for (const provider of chain) {
|
|
840
|
+
tried.push(provider.name);
|
|
841
|
+
lastProvider = provider.name;
|
|
842
|
+
const run = await runProvider(provider, q, ctx);
|
|
843
|
+
telemetry.push(run.telemetry);
|
|
844
|
+
if (run.results.length > 0) {
|
|
845
|
+
return {
|
|
846
|
+
ok: true,
|
|
847
|
+
query: q,
|
|
848
|
+
results: run.results.slice(0, maxResults),
|
|
849
|
+
provider: provider.name,
|
|
850
|
+
degraded: provider.name !== chain[0].name,
|
|
851
|
+
providersTried: tried,
|
|
852
|
+
telemetry,
|
|
853
|
+
timeRange
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
const blocked = telemetry.map((t) => t.blockedHint).find(Boolean);
|
|
858
|
+
const lastErr = [...telemetry].reverse().find((t) => t.error)?.error;
|
|
859
|
+
return {
|
|
860
|
+
ok: true,
|
|
861
|
+
query: q,
|
|
862
|
+
results: [],
|
|
863
|
+
provider: lastProvider,
|
|
864
|
+
degraded: true,
|
|
865
|
+
error: {
|
|
866
|
+
code: "empty_provider_results",
|
|
867
|
+
message: blocked ? `All search providers returned 0 results (blocked: ${blocked})` : lastErr ? `All search providers returned 0 results (${lastErr})` : "All search providers returned 0 results — engine empty, parse miss, or soft-block"
|
|
868
|
+
},
|
|
869
|
+
providersTried: tried,
|
|
870
|
+
telemetry,
|
|
871
|
+
timeRange
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
function createMockSearchProvider(results, name = "mock") {
|
|
875
|
+
return {
|
|
876
|
+
name,
|
|
877
|
+
async search(_query, { maxResults }) {
|
|
878
|
+
return results.slice(0, maxResults);
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ../web-brain/src/cache.ts
|
|
884
|
+
import {
|
|
885
|
+
existsSync as existsSync2,
|
|
886
|
+
mkdirSync,
|
|
887
|
+
readFileSync as readFileSync2,
|
|
888
|
+
writeFileSync,
|
|
889
|
+
readdirSync,
|
|
890
|
+
unlinkSync,
|
|
891
|
+
rmSync
|
|
892
|
+
} from "node:fs";
|
|
893
|
+
import { join as join2 } from "node:path";
|
|
894
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
895
|
+
function hashUrl(url) {
|
|
896
|
+
return createHash2("sha256").update(url).digest("hex").slice(0, 32);
|
|
897
|
+
}
|
|
898
|
+
function tokenize(q) {
|
|
899
|
+
return q.toLowerCase().split(/[^a-z0-9\u1780-\u17ff]+/i).map((t) => t.trim()).filter((t) => t.length >= 2);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
class WebCache {
|
|
903
|
+
dir;
|
|
904
|
+
pagesDir;
|
|
905
|
+
constructor(opts) {
|
|
906
|
+
this.dir = opts.dir;
|
|
907
|
+
this.pagesDir = join2(this.dir, "pages");
|
|
908
|
+
mkdirSync(this.pagesDir, { recursive: true, mode: 448 });
|
|
909
|
+
}
|
|
910
|
+
pathFor(url) {
|
|
911
|
+
return join2(this.pagesDir, `${hashUrl(url)}.json`);
|
|
912
|
+
}
|
|
913
|
+
get(url) {
|
|
914
|
+
const p = this.pathFor(url);
|
|
915
|
+
if (!existsSync2(p))
|
|
916
|
+
return null;
|
|
917
|
+
try {
|
|
918
|
+
const raw = readFileSync2(p, "utf-8");
|
|
919
|
+
const entry = JSON.parse(raw);
|
|
920
|
+
if (!entry?.url || typeof entry.markdown !== "string")
|
|
921
|
+
return null;
|
|
922
|
+
return entry;
|
|
923
|
+
} catch {
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
put(entry) {
|
|
928
|
+
const p = this.pathFor(entry.url);
|
|
929
|
+
writeFileSync(p, JSON.stringify(entry), { mode: 384 });
|
|
930
|
+
if (entry.finalUrl && entry.finalUrl !== entry.url) {
|
|
931
|
+
const p2 = this.pathFor(entry.finalUrl);
|
|
932
|
+
writeFileSync(p2, JSON.stringify(entry), { mode: 384 });
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
search(query, limit = 20) {
|
|
936
|
+
const tokens = tokenize(query);
|
|
937
|
+
if (!tokens.length)
|
|
938
|
+
return [];
|
|
939
|
+
const out = [];
|
|
940
|
+
let names;
|
|
941
|
+
try {
|
|
942
|
+
names = readdirSync(this.pagesDir).filter((n) => n.endsWith(".json"));
|
|
943
|
+
} catch {
|
|
944
|
+
return [];
|
|
945
|
+
}
|
|
946
|
+
const seen = new Set;
|
|
947
|
+
for (const name of names) {
|
|
948
|
+
try {
|
|
949
|
+
const entry = JSON.parse(readFileSync2(join2(this.pagesDir, name), "utf-8"));
|
|
950
|
+
if (!entry?.url || seen.has(entry.url))
|
|
951
|
+
continue;
|
|
952
|
+
seen.add(entry.url);
|
|
953
|
+
const hay = `${entry.title}
|
|
954
|
+
${entry.markdown}`.toLowerCase();
|
|
955
|
+
let score = 0;
|
|
956
|
+
for (const t of tokens) {
|
|
957
|
+
if (hay.includes(t))
|
|
958
|
+
score += 1;
|
|
959
|
+
}
|
|
960
|
+
if (score > 0)
|
|
961
|
+
out.push({ entry, score });
|
|
962
|
+
} catch {}
|
|
963
|
+
}
|
|
964
|
+
out.sort((a, b) => b.score - a.score);
|
|
965
|
+
return out.slice(0, limit).map((x) => x.entry);
|
|
966
|
+
}
|
|
967
|
+
stats() {
|
|
968
|
+
let entries = 0;
|
|
969
|
+
try {
|
|
970
|
+
const names = readdirSync(this.pagesDir).filter((n) => n.endsWith(".json"));
|
|
971
|
+
entries = names.length;
|
|
972
|
+
} catch {
|
|
973
|
+
entries = 0;
|
|
974
|
+
}
|
|
975
|
+
return { entries, path: this.dir };
|
|
976
|
+
}
|
|
977
|
+
clear() {
|
|
978
|
+
try {
|
|
979
|
+
rmSync(this.pagesDir, { recursive: true, force: true });
|
|
980
|
+
} catch {}
|
|
981
|
+
mkdirSync(this.pagesDir, { recursive: true, mode: 448 });
|
|
982
|
+
}
|
|
983
|
+
delete(url) {
|
|
984
|
+
const p = this.pathFor(url);
|
|
985
|
+
if (existsSync2(p))
|
|
986
|
+
unlinkSync(p);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
function defaultCacheDir(configRoot) {
|
|
990
|
+
return join2(configRoot, "web-cache");
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// ../web-brain/src/structured-extract.ts
|
|
994
|
+
import { lookup as lookup2 } from "node:dns/promises";
|
|
995
|
+
import { isIP as isIP2 } from "node:net";
|
|
996
|
+
function decodeEntities2(s) {
|
|
997
|
+
return s.replace(/ /gi, " ").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/"/gi, '"').replace(/'/gi, "'").replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)));
|
|
998
|
+
}
|
|
999
|
+
function cellText(html) {
|
|
1000
|
+
return decodeEntities2(html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim());
|
|
1001
|
+
}
|
|
1002
|
+
function metaContent(html, nameOrProp) {
|
|
1003
|
+
const re = new RegExp(`<meta[^>]+(?:name|property)=["']${nameOrProp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["'][^>]+content=["']([^"']*)["']`, "i");
|
|
1004
|
+
const re2 = new RegExp(`<meta[^>]+content=["']([^"']*)["'][^>]+(?:name|property)=["']${nameOrProp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`, "i");
|
|
1005
|
+
const m = html.match(re) || html.match(re2);
|
|
1006
|
+
return m?.[1] ? decodeEntities2(m[1]) : "";
|
|
1007
|
+
}
|
|
1008
|
+
function extractMetadata(html) {
|
|
1009
|
+
const og = {};
|
|
1010
|
+
const ogRe = /<meta[^>]+property=["'](og:[^"']+)["'][^>]+content=["']([^"']*)["']/gi;
|
|
1011
|
+
let m;
|
|
1012
|
+
while ((m = ogRe.exec(html)) !== null) {
|
|
1013
|
+
og[m[1]] = decodeEntities2(m[2]);
|
|
1014
|
+
}
|
|
1015
|
+
const ogRe2 = /<meta[^>]+content=["']([^"']*)["'][^>]+property=["'](og:[^"']+)["']/gi;
|
|
1016
|
+
while ((m = ogRe2.exec(html)) !== null) {
|
|
1017
|
+
og[m[2]] = decodeEntities2(m[1]);
|
|
1018
|
+
}
|
|
1019
|
+
const canonical = html.match(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i)?.[1] || html.match(/<link[^>]+href=["']([^"']+)["'][^>]+rel=["']canonical["']/i)?.[1] || "";
|
|
1020
|
+
const lang = html.match(/<html[^>]+lang=["']([^"']+)["']/i)?.[1] || "";
|
|
1021
|
+
return {
|
|
1022
|
+
title: extractTitle(html),
|
|
1023
|
+
description: metaContent(html, "description") || og["og:description"] || "",
|
|
1024
|
+
canonical: decodeEntities2(canonical),
|
|
1025
|
+
og,
|
|
1026
|
+
lang
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
function extractTables(html, maxTables = 20, maxRows = 200) {
|
|
1030
|
+
const cleaned = stripBoilerplate(html);
|
|
1031
|
+
const tables = [];
|
|
1032
|
+
const tableRe = /<table\b[^>]*>([\s\S]*?)<\/table>/gi;
|
|
1033
|
+
let tm;
|
|
1034
|
+
while ((tm = tableRe.exec(cleaned)) !== null && tables.length < maxTables) {
|
|
1035
|
+
const body = tm[1];
|
|
1036
|
+
const rowsHtml = [...body.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map((x) => x[1]);
|
|
1037
|
+
if (!rowsHtml.length)
|
|
1038
|
+
continue;
|
|
1039
|
+
let headers = [];
|
|
1040
|
+
const dataRows = [];
|
|
1041
|
+
for (let i = 0;i < rowsHtml.length && dataRows.length < maxRows; i++) {
|
|
1042
|
+
const cells = [
|
|
1043
|
+
...rowsHtml[i].matchAll(/<t[hd]\b[^>]*>([\s\S]*?)<\/t[hd]>/gi)
|
|
1044
|
+
].map((c) => cellText(c[1]));
|
|
1045
|
+
if (!cells.length)
|
|
1046
|
+
continue;
|
|
1047
|
+
if (i === 0 && /<th\b/i.test(rowsHtml[i])) {
|
|
1048
|
+
headers = cells;
|
|
1049
|
+
} else {
|
|
1050
|
+
dataRows.push(cells);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
if (headers.length || dataRows.length) {
|
|
1054
|
+
tables.push({ headers, rows: dataRows });
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
return tables;
|
|
1058
|
+
}
|
|
1059
|
+
function extractJsonLd(html) {
|
|
1060
|
+
const out = [];
|
|
1061
|
+
const re = /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
1062
|
+
let m;
|
|
1063
|
+
while ((m = re.exec(html)) !== null) {
|
|
1064
|
+
const raw = m[1].trim();
|
|
1065
|
+
if (!raw)
|
|
1066
|
+
continue;
|
|
1067
|
+
try {
|
|
1068
|
+
const parsed = JSON.parse(raw);
|
|
1069
|
+
if (Array.isArray(parsed))
|
|
1070
|
+
out.push(...parsed);
|
|
1071
|
+
else
|
|
1072
|
+
out.push(parsed);
|
|
1073
|
+
} catch {}
|
|
1074
|
+
}
|
|
1075
|
+
return out;
|
|
1076
|
+
}
|
|
1077
|
+
async function fetchHtml(rawUrl, opts = {}) {
|
|
1078
|
+
const allowPrivate = opts.allowPrivateNetwork ?? false;
|
|
1079
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
1080
|
+
const timeoutMs = opts.timeoutMs ?? 15000;
|
|
1081
|
+
const skipDns = opts.fetchImpl != null && opts.fetchImpl !== fetch;
|
|
1082
|
+
const safe = assertSafeHttpUrl(rawUrl, { allowPrivateNetwork: allowPrivate });
|
|
1083
|
+
if (!safe.ok)
|
|
1084
|
+
return toWebError(safe.code, safe.message);
|
|
1085
|
+
let current = safe.url;
|
|
1086
|
+
for (let redirects = 0;redirects <= 5; redirects++) {
|
|
1087
|
+
if (!skipDns) {
|
|
1088
|
+
const host = current.hostname.replace(/^\[|\]$/g, "");
|
|
1089
|
+
if (!isIP2(host)) {
|
|
1090
|
+
try {
|
|
1091
|
+
const results = await lookup2(host, { all: true });
|
|
1092
|
+
const check = assertResolvedAddressesSafe(results.map((r) => r.address), { allowPrivateNetwork: allowPrivate });
|
|
1093
|
+
if (!check.ok)
|
|
1094
|
+
return toWebError(check.code, check.message);
|
|
1095
|
+
} catch (err) {
|
|
1096
|
+
return toWebError("dns_failed", err instanceof Error ? err.message : String(err));
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
const auth = resolveAuth({
|
|
1101
|
+
useAuth: opts.useAuth,
|
|
1102
|
+
authStatePath: opts.authStatePath,
|
|
1103
|
+
host: current.hostname
|
|
1104
|
+
});
|
|
1105
|
+
const headers = applyAuthHeaders({
|
|
1106
|
+
Accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.1",
|
|
1107
|
+
"User-Agent": "phneakngar-web-brain/0.0.2",
|
|
1108
|
+
...opts.headers
|
|
1109
|
+
}, auth);
|
|
1110
|
+
let res;
|
|
1111
|
+
try {
|
|
1112
|
+
res = await fetchImpl(current.toString(), {
|
|
1113
|
+
method: "GET",
|
|
1114
|
+
redirect: "manual",
|
|
1115
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
1116
|
+
headers
|
|
1117
|
+
});
|
|
1118
|
+
} catch (err) {
|
|
1119
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1120
|
+
if (/timeout|aborted/i.test(msg))
|
|
1121
|
+
return toWebError("timeout", msg);
|
|
1122
|
+
return toWebError("network_error", msg);
|
|
1123
|
+
}
|
|
1124
|
+
if (res.status >= 300 && res.status < 400) {
|
|
1125
|
+
const loc = res.headers.get("location");
|
|
1126
|
+
if (!loc)
|
|
1127
|
+
return toWebError("http_error", `Redirect ${res.status} without Location`);
|
|
1128
|
+
let next;
|
|
1129
|
+
try {
|
|
1130
|
+
next = new URL(loc, current);
|
|
1131
|
+
} catch {
|
|
1132
|
+
return toWebError("invalid_url", `Invalid redirect: ${loc}`);
|
|
1133
|
+
}
|
|
1134
|
+
const hop = assertSafeHttpUrl(next.toString(), {
|
|
1135
|
+
allowPrivateNetwork: allowPrivate
|
|
1136
|
+
});
|
|
1137
|
+
if (!hop.ok)
|
|
1138
|
+
return toWebError(hop.code, hop.message);
|
|
1139
|
+
current = hop.url;
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
if (!res.ok)
|
|
1143
|
+
return toWebError("http_error", `HTTP ${res.status}`);
|
|
1144
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1145
|
+
if (buf.byteLength > 2000000) {
|
|
1146
|
+
return toWebError("too_large", "HTML exceeds 2MB");
|
|
1147
|
+
}
|
|
1148
|
+
const html = buf.toString("utf-8");
|
|
1149
|
+
if (!html.trim())
|
|
1150
|
+
return toWebError("empty_content", "Empty HTML body");
|
|
1151
|
+
return { ok: true, html, finalUrl: current.toString() };
|
|
1152
|
+
}
|
|
1153
|
+
return toWebError("http_error", "Too many redirects");
|
|
1154
|
+
}
|
|
1155
|
+
async function structuredExtract(opts) {
|
|
1156
|
+
const mode = opts.mode ?? "all";
|
|
1157
|
+
let html = opts.html;
|
|
1158
|
+
let url = opts.url ?? null;
|
|
1159
|
+
if (!html) {
|
|
1160
|
+
if (!opts.url) {
|
|
1161
|
+
return toWebError("invalid_url", "url or html is required");
|
|
1162
|
+
}
|
|
1163
|
+
const fetched = await fetchHtml(opts.url, opts.fetchOpts);
|
|
1164
|
+
if (!fetched.ok)
|
|
1165
|
+
return fetched;
|
|
1166
|
+
html = fetched.html;
|
|
1167
|
+
url = fetched.finalUrl;
|
|
1168
|
+
}
|
|
1169
|
+
const result = {
|
|
1170
|
+
ok: true,
|
|
1171
|
+
url,
|
|
1172
|
+
mode
|
|
1173
|
+
};
|
|
1174
|
+
if (mode === "metadata" || mode === "all") {
|
|
1175
|
+
result.metadata = extractMetadata(html);
|
|
1176
|
+
}
|
|
1177
|
+
if (mode === "tables" || mode === "all") {
|
|
1178
|
+
result.tables = extractTables(html);
|
|
1179
|
+
}
|
|
1180
|
+
if (mode === "jsonld" || mode === "all") {
|
|
1181
|
+
result.jsonld = extractJsonLd(html);
|
|
1182
|
+
}
|
|
1183
|
+
return result;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// ../web-brain/src/crawl.ts
|
|
1187
|
+
import { createHash as createHash5 } from "node:crypto";
|
|
1188
|
+
|
|
1189
|
+
// ../web-brain/src/robots.ts
|
|
1190
|
+
var DEFAULT_UA = "phneakngar-web-brain";
|
|
1191
|
+
function parseRobotsTxt(text, userAgent = DEFAULT_UA) {
|
|
1192
|
+
const lines = text.split(/\r?\n/).map((l) => l.replace(/#.*$/, "").trim());
|
|
1193
|
+
const groups = [];
|
|
1194
|
+
let current = null;
|
|
1195
|
+
const flush = () => {
|
|
1196
|
+
if (current && current.agents.length)
|
|
1197
|
+
groups.push(current);
|
|
1198
|
+
current = null;
|
|
1199
|
+
};
|
|
1200
|
+
for (const line of lines) {
|
|
1201
|
+
if (!line)
|
|
1202
|
+
continue;
|
|
1203
|
+
const idx = line.indexOf(":");
|
|
1204
|
+
if (idx < 0)
|
|
1205
|
+
continue;
|
|
1206
|
+
const key = line.slice(0, idx).trim().toLowerCase();
|
|
1207
|
+
const value = line.slice(idx + 1).trim();
|
|
1208
|
+
if (key === "user-agent") {
|
|
1209
|
+
if (!current || current.disallows.length || current.allows.length) {
|
|
1210
|
+
flush();
|
|
1211
|
+
current = { agents: [value.toLowerCase()], disallows: [], allows: [], delay: null };
|
|
1212
|
+
} else {
|
|
1213
|
+
current.agents.push(value.toLowerCase());
|
|
1214
|
+
}
|
|
1215
|
+
} else if (current) {
|
|
1216
|
+
if (key === "disallow")
|
|
1217
|
+
current.disallows.push(value);
|
|
1218
|
+
else if (key === "allow")
|
|
1219
|
+
current.allows.push(value);
|
|
1220
|
+
else if (key === "crawl-delay") {
|
|
1221
|
+
const n = Number(value);
|
|
1222
|
+
if (Number.isFinite(n) && n >= 0)
|
|
1223
|
+
current.delay = Math.round(n * 1000);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
flush();
|
|
1228
|
+
const ua = userAgent.toLowerCase();
|
|
1229
|
+
const match = groups.find((g) => g.agents.some((a) => a !== "*" && (ua === a || ua.startsWith(a)))) || groups.find((g) => g.agents.includes("*")) || null;
|
|
1230
|
+
if (!match) {
|
|
1231
|
+
return { disallows: [], allows: [], crawlDelayMs: null };
|
|
1232
|
+
}
|
|
1233
|
+
return {
|
|
1234
|
+
disallows: match.disallows.filter((p) => p.length > 0),
|
|
1235
|
+
allows: match.allows.filter((p) => p.length > 0),
|
|
1236
|
+
crawlDelayMs: match.delay
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
function isPathAllowed(pathname, rules) {
|
|
1240
|
+
const path = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
1241
|
+
let bestAllow = -1;
|
|
1242
|
+
for (const a of rules.allows) {
|
|
1243
|
+
if (path.startsWith(a) && a.length > bestAllow)
|
|
1244
|
+
bestAllow = a.length;
|
|
1245
|
+
}
|
|
1246
|
+
let bestDisallow = -1;
|
|
1247
|
+
for (const d of rules.disallows) {
|
|
1248
|
+
if (d === "")
|
|
1249
|
+
continue;
|
|
1250
|
+
if (path.startsWith(d) && d.length > bestDisallow)
|
|
1251
|
+
bestDisallow = d.length;
|
|
1252
|
+
}
|
|
1253
|
+
if (bestDisallow < 0)
|
|
1254
|
+
return true;
|
|
1255
|
+
if (bestAllow > bestDisallow)
|
|
1256
|
+
return true;
|
|
1257
|
+
return false;
|
|
1258
|
+
}
|
|
1259
|
+
function isUrlAllowedByRobots(url, rules) {
|
|
1260
|
+
try {
|
|
1261
|
+
const u = new URL(url);
|
|
1262
|
+
return isPathAllowed(u.pathname || "/", rules);
|
|
1263
|
+
} catch {
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// ../web-brain/src/sitemap.ts
|
|
1269
|
+
function extractSitemapUrlFromRobots(robotsTxt) {
|
|
1270
|
+
const urls = [];
|
|
1271
|
+
for (const line of robotsTxt.split(/\r?\n/)) {
|
|
1272
|
+
const m = line.match(/^sitemap:\s*(.+)/i);
|
|
1273
|
+
if (m?.[1])
|
|
1274
|
+
urls.push(m[1].trim());
|
|
1275
|
+
}
|
|
1276
|
+
return urls;
|
|
1277
|
+
}
|
|
1278
|
+
function parseSitemapIndex(xml) {
|
|
1279
|
+
if (!xml.includes("<sitemapindex"))
|
|
1280
|
+
return [];
|
|
1281
|
+
const urls = [];
|
|
1282
|
+
for (const m of xml.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/gi)) {
|
|
1283
|
+
const u = m[1]?.trim();
|
|
1284
|
+
if (u)
|
|
1285
|
+
urls.push(u);
|
|
1286
|
+
}
|
|
1287
|
+
return urls;
|
|
1288
|
+
}
|
|
1289
|
+
function parseSitemapEntries(xml) {
|
|
1290
|
+
if (xml.includes("<sitemapindex"))
|
|
1291
|
+
return [];
|
|
1292
|
+
if (!xml.includes("<urlset") && !xml.includes("<loc>"))
|
|
1293
|
+
return [];
|
|
1294
|
+
const entries = [];
|
|
1295
|
+
for (const block of xml.matchAll(/<url\b[^>]*>([\s\S]*?)<\/url>/gi)) {
|
|
1296
|
+
const body = block[1] ?? "";
|
|
1297
|
+
const loc = body.match(/<loc>\s*([^<]+?)\s*<\/loc>/i)?.[1]?.trim();
|
|
1298
|
+
if (!loc)
|
|
1299
|
+
continue;
|
|
1300
|
+
const entry = { url: loc };
|
|
1301
|
+
const lastmod = body.match(/<lastmod>\s*([^<]+?)\s*<\/lastmod>/i)?.[1]?.trim();
|
|
1302
|
+
if (lastmod)
|
|
1303
|
+
entry.lastmod = lastmod;
|
|
1304
|
+
const pr = body.match(/<priority>\s*([^<]+?)\s*<\/priority>/i)?.[1]?.trim();
|
|
1305
|
+
if (pr != null) {
|
|
1306
|
+
const p = Number.parseFloat(pr);
|
|
1307
|
+
if (Number.isFinite(p))
|
|
1308
|
+
entry.priority = p;
|
|
1309
|
+
}
|
|
1310
|
+
entries.push(entry);
|
|
1311
|
+
}
|
|
1312
|
+
return entries;
|
|
1313
|
+
}
|
|
1314
|
+
function sortSitemapEntries(entries) {
|
|
1315
|
+
return entries.map((entry, index) => ({ entry, index })).sort((a, b) => {
|
|
1316
|
+
const aLm = a.entry.lastmod ? Date.parse(a.entry.lastmod) : NaN;
|
|
1317
|
+
const bLm = b.entry.lastmod ? Date.parse(b.entry.lastmod) : NaN;
|
|
1318
|
+
const aOk = Number.isFinite(aLm);
|
|
1319
|
+
const bOk = Number.isFinite(bLm);
|
|
1320
|
+
if (aOk !== bOk)
|
|
1321
|
+
return aOk ? -1 : 1;
|
|
1322
|
+
if (aOk && bOk && aLm !== bLm)
|
|
1323
|
+
return bLm - aLm;
|
|
1324
|
+
const ap = a.entry.priority;
|
|
1325
|
+
const bp = b.entry.priority;
|
|
1326
|
+
const aHas = typeof ap === "number";
|
|
1327
|
+
const bHas = typeof bp === "number";
|
|
1328
|
+
if (aHas !== bHas)
|
|
1329
|
+
return aHas ? -1 : 1;
|
|
1330
|
+
if (aHas && bHas && ap !== bp)
|
|
1331
|
+
return bp - ap;
|
|
1332
|
+
return a.index - b.index;
|
|
1333
|
+
}).map((x) => x.entry);
|
|
1334
|
+
}
|
|
1335
|
+
var MAX_INDEX_CHILDREN = 5;
|
|
1336
|
+
async function discoverSitemapUrls(origin, lightFetch, robotsTxt) {
|
|
1337
|
+
const locations = [];
|
|
1338
|
+
if (robotsTxt)
|
|
1339
|
+
locations.push(...extractSitemapUrlFromRobots(robotsTxt));
|
|
1340
|
+
if (locations.length === 0) {
|
|
1341
|
+
locations.push(`${origin}/sitemap.xml`, `${origin}/sitemap_index.xml`);
|
|
1342
|
+
}
|
|
1343
|
+
const all = [];
|
|
1344
|
+
const seenLoc = new Set;
|
|
1345
|
+
async function loadSitemap(url, depth = 0) {
|
|
1346
|
+
if (seenLoc.has(url) || depth > 2)
|
|
1347
|
+
return;
|
|
1348
|
+
seenLoc.add(url);
|
|
1349
|
+
const res = await lightFetch(url);
|
|
1350
|
+
if (!res.ok || res.status >= 400 || !res.body)
|
|
1351
|
+
return;
|
|
1352
|
+
const xml = res.body;
|
|
1353
|
+
if (xml.includes("<sitemapindex")) {
|
|
1354
|
+
for (const child of parseSitemapIndex(xml).slice(0, MAX_INDEX_CHILDREN)) {
|
|
1355
|
+
await loadSitemap(child, depth + 1);
|
|
1356
|
+
}
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
all.push(...parseSitemapEntries(xml));
|
|
1360
|
+
}
|
|
1361
|
+
for (const loc of locations) {
|
|
1362
|
+
await loadSitemap(loc);
|
|
1363
|
+
}
|
|
1364
|
+
return sortSitemapEntries(all).map((e) => e.url);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// ../web-brain/src/url-utils.ts
|
|
1368
|
+
function stripFragment(url) {
|
|
1369
|
+
try {
|
|
1370
|
+
const u = new URL(url);
|
|
1371
|
+
u.hash = "";
|
|
1372
|
+
return u.toString();
|
|
1373
|
+
} catch {
|
|
1374
|
+
return url;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
function canonicalForCrawl(url) {
|
|
1378
|
+
try {
|
|
1379
|
+
const u = new URL(url);
|
|
1380
|
+
u.hash = "";
|
|
1381
|
+
let pathname = u.pathname;
|
|
1382
|
+
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
1383
|
+
pathname = pathname.slice(0, -1);
|
|
1384
|
+
}
|
|
1385
|
+
u.pathname = pathname;
|
|
1386
|
+
return u.toString();
|
|
1387
|
+
} catch {
|
|
1388
|
+
return url;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
function canonicalForOutput(url) {
|
|
1392
|
+
try {
|
|
1393
|
+
const u = new URL(url);
|
|
1394
|
+
let path = u.pathname;
|
|
1395
|
+
if (path === "/")
|
|
1396
|
+
path = "";
|
|
1397
|
+
else if (path.length > 1 && path.endsWith("/"))
|
|
1398
|
+
path = path.slice(0, -1);
|
|
1399
|
+
return `${u.origin}${path}${u.search}`;
|
|
1400
|
+
} catch {
|
|
1401
|
+
return url;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
function matchesPatterns(url, includePatterns, excludePatterns) {
|
|
1405
|
+
if (includePatterns && includePatterns.length > 0) {
|
|
1406
|
+
const ok = includePatterns.some((p) => {
|
|
1407
|
+
try {
|
|
1408
|
+
return new RegExp(p).test(url);
|
|
1409
|
+
} catch {
|
|
1410
|
+
return false;
|
|
1411
|
+
}
|
|
1412
|
+
});
|
|
1413
|
+
if (!ok)
|
|
1414
|
+
return false;
|
|
1415
|
+
}
|
|
1416
|
+
if (excludePatterns && excludePatterns.length > 0) {
|
|
1417
|
+
const blocked = excludePatterns.some((p) => {
|
|
1418
|
+
try {
|
|
1419
|
+
return new RegExp(p).test(url);
|
|
1420
|
+
} catch {
|
|
1421
|
+
return false;
|
|
1422
|
+
}
|
|
1423
|
+
});
|
|
1424
|
+
if (blocked)
|
|
1425
|
+
return false;
|
|
1426
|
+
}
|
|
1427
|
+
return true;
|
|
1428
|
+
}
|
|
1429
|
+
var DOC_PATH_HINTS = ["/docs/", "/guide/", "/api/", "/reference/", "/learn/"];
|
|
1430
|
+
function isDocPathUrl(url) {
|
|
1431
|
+
try {
|
|
1432
|
+
const path = new URL(url).pathname.toLowerCase();
|
|
1433
|
+
return DOC_PATH_HINTS.some((p) => path.includes(p));
|
|
1434
|
+
} catch {
|
|
1435
|
+
return false;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
function prioritizeDocLinks(urls) {
|
|
1439
|
+
return [...urls].sort((a, b) => {
|
|
1440
|
+
const aDoc = isDocPathUrl(a) ? 0 : 1;
|
|
1441
|
+
const bDoc = isDocPathUrl(b) ? 0 : 1;
|
|
1442
|
+
return aDoc - bDoc;
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// ../web-brain/src/dedup.ts
|
|
1447
|
+
import {
|
|
1448
|
+
existsSync as existsSync3,
|
|
1449
|
+
mkdirSync as mkdirSync2,
|
|
1450
|
+
readFileSync as readFileSync3,
|
|
1451
|
+
writeFileSync as writeFileSync2
|
|
1452
|
+
} from "node:fs";
|
|
1453
|
+
import { join as join3 } from "node:path";
|
|
1454
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
1455
|
+
import { homedir as homedir2 } from "node:os";
|
|
1456
|
+
function splitIntoBlocks(markdown) {
|
|
1457
|
+
if (!markdown.trim())
|
|
1458
|
+
return [];
|
|
1459
|
+
const lines = markdown.split(`
|
|
1460
|
+
`);
|
|
1461
|
+
const headingIndices = [];
|
|
1462
|
+
for (let i = 0;i < lines.length; i++) {
|
|
1463
|
+
if (/^#{1,6}\s+/.test(lines[i])) {
|
|
1464
|
+
headingIndices.push({ lineIdx: i });
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
if (headingIndices.length === 0) {
|
|
1468
|
+
return markdown.split(/\n\n+/).map((b) => b.trim()).filter(Boolean);
|
|
1469
|
+
}
|
|
1470
|
+
const blocks = [];
|
|
1471
|
+
for (let i = 0;i < headingIndices.length; i++) {
|
|
1472
|
+
const start = headingIndices[i].lineIdx;
|
|
1473
|
+
const end = i + 1 < headingIndices.length ? headingIndices[i + 1].lineIdx : lines.length;
|
|
1474
|
+
blocks.push(lines.slice(start, end).join(`
|
|
1475
|
+
`).trim());
|
|
1476
|
+
}
|
|
1477
|
+
return blocks.filter(Boolean);
|
|
1478
|
+
}
|
|
1479
|
+
function normalizeBlockText(text) {
|
|
1480
|
+
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
1481
|
+
}
|
|
1482
|
+
function hashBlock(text) {
|
|
1483
|
+
return createHash3("sha256").update(normalizeBlockText(text)).digest("hex");
|
|
1484
|
+
}
|
|
1485
|
+
function lineHash(line) {
|
|
1486
|
+
return createHash3("sha1").update(line.trim().toLowerCase()).digest("hex");
|
|
1487
|
+
}
|
|
1488
|
+
var NAV_DEDUPE_THRESHOLD = 0.6;
|
|
1489
|
+
var MAX_LEADING_LINES = 30;
|
|
1490
|
+
var MAX_TRAILING_LINES = 20;
|
|
1491
|
+
var MIN_CORPUS = 4;
|
|
1492
|
+
function stripRepeatedNavigationLines(pages) {
|
|
1493
|
+
if (pages.length < MIN_CORPUS)
|
|
1494
|
+
return pages;
|
|
1495
|
+
const lineSets = pages.map((p) => p.markdown.split(`
|
|
1496
|
+
`));
|
|
1497
|
+
const countLeading = new Map;
|
|
1498
|
+
const countTrailing = new Map;
|
|
1499
|
+
for (const lines of lineSets) {
|
|
1500
|
+
const seenL = new Set;
|
|
1501
|
+
for (let i = 0;i < Math.min(MAX_LEADING_LINES, lines.length); i++) {
|
|
1502
|
+
const h = lineHash(lines[i]);
|
|
1503
|
+
if (!seenL.has(h)) {
|
|
1504
|
+
seenL.add(h);
|
|
1505
|
+
countLeading.set(h, (countLeading.get(h) ?? 0) + 1);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
const seenT = new Set;
|
|
1509
|
+
for (let i = lines.length - 1;i >= Math.max(lines.length - MAX_TRAILING_LINES, 0); i--) {
|
|
1510
|
+
const h = lineHash(lines[i]);
|
|
1511
|
+
if (!seenT.has(h)) {
|
|
1512
|
+
seenT.add(h);
|
|
1513
|
+
countTrailing.set(h, (countTrailing.get(h) ?? 0) + 1);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
const threshold = pages.length * NAV_DEDUPE_THRESHOLD;
|
|
1518
|
+
const navLeading = new Set([...countLeading].filter(([, c]) => c >= threshold).map(([h]) => h));
|
|
1519
|
+
const navTrailing = new Set([...countTrailing].filter(([, c]) => c >= threshold).map(([h]) => h));
|
|
1520
|
+
return pages.map((page, i) => {
|
|
1521
|
+
const lines = lineSets[i];
|
|
1522
|
+
let head = 0;
|
|
1523
|
+
while (head < lines.length && (lines[head].trim() === "" || navLeading.has(lineHash(lines[head])))) {
|
|
1524
|
+
head++;
|
|
1525
|
+
}
|
|
1526
|
+
let tail = lines.length;
|
|
1527
|
+
while (tail > head && (lines[tail - 1].trim() === "" || navTrailing.has(lineHash(lines[tail - 1])))) {
|
|
1528
|
+
tail--;
|
|
1529
|
+
}
|
|
1530
|
+
return { url: page.url, markdown: lines.slice(head, tail).join(`
|
|
1531
|
+
`) };
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
function defaultBoilerplatePath() {
|
|
1535
|
+
return join3(process.env.PHNEAKNGAR_WEB_CACHE_DIR || join3(homedir2(), ".phneakngar", "web-cache"), "boilerplate");
|
|
1536
|
+
}
|
|
1537
|
+
function getStoredBoilerplate(domain, storeDir = defaultBoilerplatePath()) {
|
|
1538
|
+
const p = join3(storeDir, `${domain.replace(/[^a-z0-9.-]/gi, "_")}.json`);
|
|
1539
|
+
if (!existsSync3(p))
|
|
1540
|
+
return [];
|
|
1541
|
+
try {
|
|
1542
|
+
const raw = JSON.parse(readFileSync3(p, "utf-8"));
|
|
1543
|
+
return Array.isArray(raw.hashes) ? raw.hashes : [];
|
|
1544
|
+
} catch {
|
|
1545
|
+
return [];
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
function storeBoilerplate(domain, hashes, storeDir = defaultBoilerplatePath()) {
|
|
1549
|
+
mkdirSync2(storeDir, { recursive: true, mode: 448 });
|
|
1550
|
+
const p = join3(storeDir, `${domain.replace(/[^a-z0-9.-]/gi, "_")}.json`);
|
|
1551
|
+
writeFileSync2(p, JSON.stringify({ domain, hashes, updatedAt: new Date().toISOString() }), { mode: 384 });
|
|
1552
|
+
}
|
|
1553
|
+
function deduplicatePages(pages, domain, storeDir) {
|
|
1554
|
+
if (pages.length <= 1) {
|
|
1555
|
+
return pages.map((p) => ({ url: p.url, markdown: p.markdown }));
|
|
1556
|
+
}
|
|
1557
|
+
const stripped = stripRepeatedNavigationLines(pages);
|
|
1558
|
+
const dir = storeDir ?? defaultBoilerplatePath();
|
|
1559
|
+
const boilerplateHashes = new Set(domain ? getStoredBoilerplate(domain, dir) : []);
|
|
1560
|
+
const pageBlocks = stripped.map((page) => ({
|
|
1561
|
+
url: page.url,
|
|
1562
|
+
blocks: splitIntoBlocks(page.markdown)
|
|
1563
|
+
}));
|
|
1564
|
+
const hashPageCount = new Map;
|
|
1565
|
+
for (const page of pageBlocks) {
|
|
1566
|
+
const seenHashes = new Set;
|
|
1567
|
+
for (const block of page.blocks) {
|
|
1568
|
+
const h = hashBlock(block);
|
|
1569
|
+
if (!seenHashes.has(h)) {
|
|
1570
|
+
seenHashes.add(h);
|
|
1571
|
+
hashPageCount.set(h, (hashPageCount.get(h) ?? 0) + 1);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
const threshold = pages.length / 2;
|
|
1576
|
+
for (const [hash, count] of hashPageCount) {
|
|
1577
|
+
if (count > threshold)
|
|
1578
|
+
boilerplateHashes.add(hash);
|
|
1579
|
+
}
|
|
1580
|
+
if (domain) {
|
|
1581
|
+
storeBoilerplate(domain, [...boilerplateHashes], dir);
|
|
1582
|
+
}
|
|
1583
|
+
return pageBlocks.map((page) => {
|
|
1584
|
+
const filtered = page.blocks.filter((block) => !boilerplateHashes.has(hashBlock(block)));
|
|
1585
|
+
return {
|
|
1586
|
+
url: page.url,
|
|
1587
|
+
markdown: filtered.join(`
|
|
1588
|
+
|
|
1589
|
+
`)
|
|
1590
|
+
};
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// ../web-brain/src/embed.ts
|
|
1595
|
+
import {
|
|
1596
|
+
existsSync as existsSync4,
|
|
1597
|
+
mkdirSync as mkdirSync3,
|
|
1598
|
+
readFileSync as readFileSync4,
|
|
1599
|
+
writeFileSync as writeFileSync3,
|
|
1600
|
+
readdirSync as readdirSync2,
|
|
1601
|
+
unlinkSync as unlinkSync2
|
|
1602
|
+
} from "node:fs";
|
|
1603
|
+
import { join as join4 } from "node:path";
|
|
1604
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
1605
|
+
import { homedir as homedir3 } from "node:os";
|
|
1606
|
+
var EMBED_DIM = 384;
|
|
1607
|
+
var SUMMARY_CHARS = 500;
|
|
1608
|
+
var MIN_TEXT_LEN = 20;
|
|
1609
|
+
function isIndexingEnabled() {
|
|
1610
|
+
return process.env.PHNEAKNGAR_CRAWL_INDEX === "1" || process.env.PHNEAKNGAR_CRAWL_INDEX === "true";
|
|
1611
|
+
}
|
|
1612
|
+
function tokenize2(text) {
|
|
1613
|
+
return text.toLowerCase().split(/[^a-z0-9\u1780-\u17ff]+/i).filter((t) => t.length >= 2);
|
|
1614
|
+
}
|
|
1615
|
+
function hashEmbed(text, dim = EMBED_DIM) {
|
|
1616
|
+
const vec = new Float32Array(dim);
|
|
1617
|
+
const tokens = tokenize2(text);
|
|
1618
|
+
if (!tokens.length)
|
|
1619
|
+
return vec;
|
|
1620
|
+
const tf = new Map;
|
|
1621
|
+
for (const t of tokens)
|
|
1622
|
+
tf.set(t, (tf.get(t) ?? 0) + 1);
|
|
1623
|
+
for (const [tok, count] of tf) {
|
|
1624
|
+
const h = createHash4("sha256").update(tok).digest();
|
|
1625
|
+
const idx = h.readUInt32BE(0) % dim;
|
|
1626
|
+
const sign = h[4] & 1 ? 1 : -1;
|
|
1627
|
+
vec[idx] += sign * (1 + Math.log(count));
|
|
1628
|
+
}
|
|
1629
|
+
let norm = 0;
|
|
1630
|
+
for (let i = 0;i < dim; i++)
|
|
1631
|
+
norm += vec[i] * vec[i];
|
|
1632
|
+
norm = Math.sqrt(norm) || 1;
|
|
1633
|
+
for (let i = 0;i < dim; i++)
|
|
1634
|
+
vec[i] /= norm;
|
|
1635
|
+
return vec;
|
|
1636
|
+
}
|
|
1637
|
+
function cosineSimilarity(a, b) {
|
|
1638
|
+
const n = Math.min(a.length, b.length);
|
|
1639
|
+
let dot = 0;
|
|
1640
|
+
for (let i = 0;i < n; i++)
|
|
1641
|
+
dot += a[i] * b[i];
|
|
1642
|
+
return dot;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
class VectorStore {
|
|
1646
|
+
dir;
|
|
1647
|
+
constructor(dir) {
|
|
1648
|
+
this.dir = dir ?? join4(process.env.PHNEAKNGAR_WEB_CACHE_DIR || join4(homedir3(), ".phneakngar", "web-cache"), "vectors");
|
|
1649
|
+
mkdirSync3(this.dir, { recursive: true, mode: 448 });
|
|
1650
|
+
}
|
|
1651
|
+
pathFor(url) {
|
|
1652
|
+
const id = createHash4("sha256").update(url).digest("hex").slice(0, 32);
|
|
1653
|
+
return join4(this.dir, `${id}.json`);
|
|
1654
|
+
}
|
|
1655
|
+
upsert(rec) {
|
|
1656
|
+
writeFileSync3(this.pathFor(rec.url), JSON.stringify(rec), { mode: 384 });
|
|
1657
|
+
}
|
|
1658
|
+
get(url) {
|
|
1659
|
+
const p = this.pathFor(url);
|
|
1660
|
+
if (!existsSync4(p))
|
|
1661
|
+
return null;
|
|
1662
|
+
try {
|
|
1663
|
+
return JSON.parse(readFileSync4(p, "utf-8"));
|
|
1664
|
+
} catch {
|
|
1665
|
+
return null;
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
list() {
|
|
1669
|
+
const out = [];
|
|
1670
|
+
for (const name of readdirSync2(this.dir).filter((n) => n.endsWith(".json"))) {
|
|
1671
|
+
try {
|
|
1672
|
+
out.push(JSON.parse(readFileSync4(join4(this.dir, name), "utf-8")));
|
|
1673
|
+
} catch {}
|
|
1674
|
+
}
|
|
1675
|
+
return out;
|
|
1676
|
+
}
|
|
1677
|
+
clear() {
|
|
1678
|
+
for (const name of readdirSync2(this.dir).filter((n) => n.endsWith(".json"))) {
|
|
1679
|
+
unlinkSync2(join4(this.dir, name));
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
size() {
|
|
1683
|
+
try {
|
|
1684
|
+
return readdirSync2(this.dir).filter((n) => n.endsWith(".json")).length;
|
|
1685
|
+
} catch {
|
|
1686
|
+
return 0;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
async function indexCrawlResult(item, store) {
|
|
1691
|
+
if (!isIndexingEnabled())
|
|
1692
|
+
return false;
|
|
1693
|
+
try {
|
|
1694
|
+
const summary = (item.markdown ?? "").slice(0, SUMMARY_CHARS);
|
|
1695
|
+
const text = `${item.title ?? ""}
|
|
1696
|
+
${summary}`.trim();
|
|
1697
|
+
if (text.length < MIN_TEXT_LEN)
|
|
1698
|
+
return false;
|
|
1699
|
+
const vector = hashEmbed(text);
|
|
1700
|
+
const contentHash = createHash4("sha256").update(item.markdown ?? "").digest("hex");
|
|
1701
|
+
const vs = store ?? new VectorStore;
|
|
1702
|
+
vs.upsert({
|
|
1703
|
+
url: item.url,
|
|
1704
|
+
contentHash,
|
|
1705
|
+
modelId: "hash-embed-v1",
|
|
1706
|
+
dims: EMBED_DIM,
|
|
1707
|
+
vector: Array.from(vector),
|
|
1708
|
+
textPreview: text.slice(0, 200),
|
|
1709
|
+
updatedAt: new Date().toISOString()
|
|
1710
|
+
});
|
|
1711
|
+
return true;
|
|
1712
|
+
} catch {
|
|
1713
|
+
return false;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
function findSimilar(queryOrUrl, opts = {}) {
|
|
1717
|
+
const store = opts.store ?? new VectorStore;
|
|
1718
|
+
const limit = opts.limit ?? 5;
|
|
1719
|
+
const minScore = opts.minScore ?? 0.05;
|
|
1720
|
+
let queryVec;
|
|
1721
|
+
const existing = store.get(queryOrUrl);
|
|
1722
|
+
if (existing) {
|
|
1723
|
+
queryVec = Float32Array.from(existing.vector);
|
|
1724
|
+
} else {
|
|
1725
|
+
queryVec = hashEmbed(queryOrUrl);
|
|
1726
|
+
}
|
|
1727
|
+
const hits = [];
|
|
1728
|
+
for (const rec of store.list()) {
|
|
1729
|
+
if (rec.url === queryOrUrl)
|
|
1730
|
+
continue;
|
|
1731
|
+
const score = cosineSimilarity(queryVec, Float32Array.from(rec.vector));
|
|
1732
|
+
if (score >= minScore) {
|
|
1733
|
+
hits.push({ url: rec.url, score, textPreview: rec.textPreview });
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
hits.sort((a, b) => b.score - a.score);
|
|
1737
|
+
return hits.slice(0, limit);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// ../web-brain/src/budget.ts
|
|
1741
|
+
var DEFAULT_MAX_TOTAL_CHARS = 1e5;
|
|
1742
|
+
var DEFAULT_MAX_TOKENS_OUT = 4000;
|
|
1743
|
+
var PER_PAGE_TOKENS = 2000;
|
|
1744
|
+
var MAX_TOKENS_OUT_CEILING = 60000;
|
|
1745
|
+
var MIN_TOKENS_PER_PAGE = 256;
|
|
1746
|
+
var TRUNC_MARKER = `
|
|
1747
|
+
|
|
1748
|
+
[... content truncated]`;
|
|
1749
|
+
function countTokens(text) {
|
|
1750
|
+
if (!text)
|
|
1751
|
+
return 0;
|
|
1752
|
+
return Math.ceil(text.length / 4);
|
|
1753
|
+
}
|
|
1754
|
+
function truncateByTokens(text, maxTokens) {
|
|
1755
|
+
if (maxTokens <= 0)
|
|
1756
|
+
return TRUNC_MARKER.trim();
|
|
1757
|
+
if (!text)
|
|
1758
|
+
return "";
|
|
1759
|
+
if (countTokens(text) <= maxTokens)
|
|
1760
|
+
return text;
|
|
1761
|
+
const maxChars = Math.max(0, maxTokens * 4 - 40);
|
|
1762
|
+
const head = text.slice(0, maxChars);
|
|
1763
|
+
const threshold = head.length * 0.7;
|
|
1764
|
+
const lastSentence = Math.max(head.lastIndexOf(". "), head.lastIndexOf(`.
|
|
1765
|
+
`), head.lastIndexOf("? "), head.lastIndexOf("! "));
|
|
1766
|
+
if (lastSentence > threshold) {
|
|
1767
|
+
return head.slice(0, lastSentence + 1) + TRUNC_MARKER;
|
|
1768
|
+
}
|
|
1769
|
+
const lastPara = head.lastIndexOf(`
|
|
1770
|
+
|
|
1771
|
+
`);
|
|
1772
|
+
if (lastPara > threshold)
|
|
1773
|
+
return head.slice(0, lastPara) + TRUNC_MARKER;
|
|
1774
|
+
return head + TRUNC_MARKER;
|
|
1775
|
+
}
|
|
1776
|
+
function truncateByChars(text, maxChars) {
|
|
1777
|
+
if (maxChars <= 0)
|
|
1778
|
+
return "";
|
|
1779
|
+
if (text.length <= maxChars)
|
|
1780
|
+
return text;
|
|
1781
|
+
return text.slice(0, Math.max(0, maxChars - 20)) + TRUNC_MARKER;
|
|
1782
|
+
}
|
|
1783
|
+
function applyAggregateMarkdownBudget(items, getBody, setBody, opts) {
|
|
1784
|
+
let used = 0;
|
|
1785
|
+
const minFloor = opts.minTokensPerItem ?? 0;
|
|
1786
|
+
for (const item of items) {
|
|
1787
|
+
const body = getBody(item);
|
|
1788
|
+
if (!body)
|
|
1789
|
+
continue;
|
|
1790
|
+
const remaining = opts.maxTokensOut - used;
|
|
1791
|
+
if (remaining <= 0) {
|
|
1792
|
+
if (minFloor > 0) {
|
|
1793
|
+
setBody(item, truncateByTokens(body, minFloor));
|
|
1794
|
+
used += Math.min(minFloor, countTokens(body));
|
|
1795
|
+
} else {
|
|
1796
|
+
setBody(item, "");
|
|
1797
|
+
}
|
|
1798
|
+
continue;
|
|
1799
|
+
}
|
|
1800
|
+
const budget = Math.max(remaining, minFloor);
|
|
1801
|
+
const next = truncateByTokens(body, budget);
|
|
1802
|
+
setBody(item, next);
|
|
1803
|
+
used += countTokens(next);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
function buildEvidenceFromMarkdown(query, title, url, markdown, opts = {}) {
|
|
1807
|
+
if (!markdown)
|
|
1808
|
+
return [];
|
|
1809
|
+
const maxItems = opts.maxItems ?? 1;
|
|
1810
|
+
const budget = opts.maxTokensOut ?? 400;
|
|
1811
|
+
const paras = markdown.split(/\n\n+/).map((p) => p.trim()).filter((p) => p.length >= 40);
|
|
1812
|
+
const qTokens = query.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
|
|
1813
|
+
const scored = paras.map((p) => {
|
|
1814
|
+
const low = p.toLowerCase();
|
|
1815
|
+
let score = 0.1;
|
|
1816
|
+
for (const t of qTokens)
|
|
1817
|
+
if (low.includes(t))
|
|
1818
|
+
score += 1;
|
|
1819
|
+
return { p, score };
|
|
1820
|
+
});
|
|
1821
|
+
scored.sort((a, b) => b.score - a.score);
|
|
1822
|
+
const out = [];
|
|
1823
|
+
let used = 0;
|
|
1824
|
+
for (const s of scored.slice(0, maxItems * 2)) {
|
|
1825
|
+
if (out.length >= maxItems)
|
|
1826
|
+
break;
|
|
1827
|
+
const remaining = budget - used;
|
|
1828
|
+
if (remaining <= 0)
|
|
1829
|
+
break;
|
|
1830
|
+
const excerpt = truncateByTokens(s.p, remaining);
|
|
1831
|
+
if (excerpt.length < 20)
|
|
1832
|
+
continue;
|
|
1833
|
+
out.push({ title, url, excerpt, score: s.score });
|
|
1834
|
+
used += countTokens(excerpt);
|
|
1835
|
+
}
|
|
1836
|
+
if (!out.length && paras[0]) {
|
|
1837
|
+
out.push({
|
|
1838
|
+
title,
|
|
1839
|
+
url,
|
|
1840
|
+
excerpt: truncateByTokens(paras[0], budget),
|
|
1841
|
+
score: 0.1
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
return out;
|
|
1845
|
+
}
|
|
1846
|
+
function scaleDefaultTokens(pageCount) {
|
|
1847
|
+
return Math.min(MAX_TOKENS_OUT_CEILING, Math.max(DEFAULT_MAX_TOKENS_OUT, PER_PAGE_TOKENS * Math.max(1, pageCount)));
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
// ../web-brain/src/crawl.ts
|
|
1851
|
+
function sleep(ms) {
|
|
1852
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
1853
|
+
}
|
|
1854
|
+
function extractLinks(html, baseUrl) {
|
|
1855
|
+
const out = [];
|
|
1856
|
+
const re = /<a\b[^>]+href=["']([^"'#][^"']*)["']/gi;
|
|
1857
|
+
let m;
|
|
1858
|
+
while ((m = re.exec(html)) !== null) {
|
|
1859
|
+
const href = m[1].trim();
|
|
1860
|
+
if (!href || href.startsWith("mailto:") || href.startsWith("javascript:")) {
|
|
1861
|
+
continue;
|
|
1862
|
+
}
|
|
1863
|
+
if (href.startsWith("tel:") || href.startsWith("data:"))
|
|
1864
|
+
continue;
|
|
1865
|
+
try {
|
|
1866
|
+
const abs = new URL(href, baseUrl);
|
|
1867
|
+
if (abs.protocol !== "http:" && abs.protocol !== "https:")
|
|
1868
|
+
continue;
|
|
1869
|
+
abs.hash = "";
|
|
1870
|
+
out.push(abs.toString());
|
|
1871
|
+
} catch {}
|
|
1872
|
+
}
|
|
1873
|
+
return [...new Set(out)];
|
|
1874
|
+
}
|
|
1875
|
+
async function loadRobots(origin, opts) {
|
|
1876
|
+
const robotsUrl = `${origin}/robots.txt`;
|
|
1877
|
+
const res = await fetchHtml(robotsUrl, { ...opts, forceRefresh: true });
|
|
1878
|
+
if (!res.ok) {
|
|
1879
|
+
return {
|
|
1880
|
+
rules: { disallows: [], allows: [], crawlDelayMs: null },
|
|
1881
|
+
raw: null
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
return { rules: parseRobotsTxt(res.html), raw: res.html };
|
|
1885
|
+
}
|
|
1886
|
+
function hashContent2(s) {
|
|
1887
|
+
return createHash5("sha256").update(s).digest("hex");
|
|
1888
|
+
}
|
|
1889
|
+
async function fetchPage(url, opts) {
|
|
1890
|
+
const cacheKey = url;
|
|
1891
|
+
if (opts.cache && !opts.fetchOpts.forceRefresh) {
|
|
1892
|
+
const hit = opts.cache.get(cacheKey);
|
|
1893
|
+
if (hit) {
|
|
1894
|
+
return {
|
|
1895
|
+
ok: true,
|
|
1896
|
+
url: hit.url,
|
|
1897
|
+
finalUrl: hit.finalUrl,
|
|
1898
|
+
title: hit.title,
|
|
1899
|
+
markdown: hit.markdown,
|
|
1900
|
+
html: "",
|
|
1901
|
+
fromCache: true,
|
|
1902
|
+
links: []
|
|
1903
|
+
};
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
const htmlRes = await fetchHtml(url, opts.fetchOpts);
|
|
1907
|
+
if (!htmlRes.ok) {
|
|
1908
|
+
return { ok: false, reason: htmlRes.error.message };
|
|
1909
|
+
}
|
|
1910
|
+
const extracted = extractFromHtml(htmlRes.html, opts.maxChars);
|
|
1911
|
+
const links = extractLinks(htmlRes.html, htmlRes.finalUrl);
|
|
1912
|
+
const fetchedAt = new Date().toISOString();
|
|
1913
|
+
const contentHash = hashContent2(extracted.markdown);
|
|
1914
|
+
if (opts.cache) {
|
|
1915
|
+
opts.cache.put({
|
|
1916
|
+
url: cacheKey,
|
|
1917
|
+
finalUrl: htmlRes.finalUrl,
|
|
1918
|
+
title: extracted.title,
|
|
1919
|
+
markdown: extracted.markdown,
|
|
1920
|
+
contentType: "text/html",
|
|
1921
|
+
httpStatus: 200,
|
|
1922
|
+
fetchedAt,
|
|
1923
|
+
contentHash
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
return {
|
|
1927
|
+
ok: true,
|
|
1928
|
+
url: cacheKey,
|
|
1929
|
+
finalUrl: htmlRes.finalUrl,
|
|
1930
|
+
title: extracted.title,
|
|
1931
|
+
markdown: extracted.markdown,
|
|
1932
|
+
html: htmlRes.html,
|
|
1933
|
+
fromCache: false,
|
|
1934
|
+
links
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
async function webCrawl(seedUrl, opts = {}) {
|
|
1938
|
+
const strategy = opts.strategy ?? "bfs";
|
|
1939
|
+
const maxDepth = Math.min(Math.max(opts.maxDepth ?? 2, 0), 3);
|
|
1940
|
+
const maxPages = Math.min(Math.max(opts.maxPages ?? 20, 1), 50);
|
|
1941
|
+
const sameHost = opts.sameHost !== false;
|
|
1942
|
+
const respectRobots = opts.respectRobots !== false;
|
|
1943
|
+
const minDelayMs = Math.max(opts.minDelayMs ?? 250, 0);
|
|
1944
|
+
const allowPrivate = opts.fetchOpts?.allowPrivateNetwork ?? false;
|
|
1945
|
+
const fetchImpl = opts.fetchImpl ?? opts.fetchOpts?.fetchImpl ?? fetch;
|
|
1946
|
+
const maxChars = opts.maxChars ?? 30000;
|
|
1947
|
+
const includePatterns = opts.includePatterns;
|
|
1948
|
+
const excludePatterns = opts.excludePatterns;
|
|
1949
|
+
const extractLinksGraph = opts.extractLinksGraph === true;
|
|
1950
|
+
const useAuth = opts.useAuth === true || process.env.PHNEAKNGAR_USE_AUTH === "1";
|
|
1951
|
+
const dedupeBoilerplate = opts.dedupeBoilerplate !== false;
|
|
1952
|
+
const maxTotalChars = opts.maxTotalChars ?? DEFAULT_MAX_TOTAL_CHARS;
|
|
1953
|
+
const includeFullMarkdown = opts.includeFullMarkdown !== false;
|
|
1954
|
+
const doIndex = opts.indexPages === true || isIndexingEnabled();
|
|
1955
|
+
const seedSafe = assertSafeHttpUrl(seedUrl, {
|
|
1956
|
+
allowPrivateNetwork: allowPrivate
|
|
1957
|
+
});
|
|
1958
|
+
if (!seedSafe.ok)
|
|
1959
|
+
return toWebError(seedSafe.code, seedSafe.message);
|
|
1960
|
+
const seed = seedSafe.url;
|
|
1961
|
+
const origin = seed.origin;
|
|
1962
|
+
const host = seed.hostname.toLowerCase();
|
|
1963
|
+
const fetchOpts = {
|
|
1964
|
+
...opts.fetchOpts,
|
|
1965
|
+
fetchImpl,
|
|
1966
|
+
cache: opts.cache ?? opts.fetchOpts?.cache,
|
|
1967
|
+
allowPrivateNetwork: allowPrivate,
|
|
1968
|
+
useAuth,
|
|
1969
|
+
authStatePath: opts.authStatePath ?? opts.fetchOpts?.authStatePath
|
|
1970
|
+
};
|
|
1971
|
+
const cache = opts.cache ?? opts.fetchOpts?.cache ?? null;
|
|
1972
|
+
let robots = { disallows: [], allows: [], crawlDelayMs: null };
|
|
1973
|
+
let robotsRaw = null;
|
|
1974
|
+
let robotsFetched = false;
|
|
1975
|
+
if (respectRobots) {
|
|
1976
|
+
const loaded = await loadRobots(origin, fetchOpts);
|
|
1977
|
+
robots = loaded.rules;
|
|
1978
|
+
robotsRaw = loaded.raw;
|
|
1979
|
+
robotsFetched = true;
|
|
1980
|
+
}
|
|
1981
|
+
const delayMs = Math.max(minDelayMs, robots.crawlDelayMs ?? 0);
|
|
1982
|
+
const lightFetch = async (url) => {
|
|
1983
|
+
const r = await fetchHtml(url, { ...fetchOpts, forceRefresh: true });
|
|
1984
|
+
if (!r.ok)
|
|
1985
|
+
return { ok: false };
|
|
1986
|
+
return { ok: true, body: r.html, status: 200 };
|
|
1987
|
+
};
|
|
1988
|
+
if (strategy === "map") {
|
|
1989
|
+
return runMapStrategy({
|
|
1990
|
+
seed: seed.toString(),
|
|
1991
|
+
origin,
|
|
1992
|
+
host,
|
|
1993
|
+
sameHost,
|
|
1994
|
+
maxDepth,
|
|
1995
|
+
maxPages,
|
|
1996
|
+
includePatterns,
|
|
1997
|
+
excludePatterns,
|
|
1998
|
+
respectRobots,
|
|
1999
|
+
robots,
|
|
2000
|
+
robotsRaw,
|
|
2001
|
+
robotsFetched,
|
|
2002
|
+
delayMs,
|
|
2003
|
+
lightFetch,
|
|
2004
|
+
fetchOpts,
|
|
2005
|
+
allowPrivate
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
let strategyUsed = strategy;
|
|
2009
|
+
let sitemapUrls = [];
|
|
2010
|
+
let sitemapFound = false;
|
|
2011
|
+
if (strategy === "sitemap" || strategy === "auto") {
|
|
2012
|
+
sitemapUrls = await discoverSitemapUrls(origin, lightFetch, robotsRaw);
|
|
2013
|
+
sitemapFound = sitemapUrls.length > 0;
|
|
2014
|
+
if (sitemapFound) {
|
|
2015
|
+
strategyUsed = "sitemap";
|
|
2016
|
+
} else if (strategy === "sitemap") {
|
|
2017
|
+
strategyUsed = "bfs";
|
|
2018
|
+
} else {
|
|
2019
|
+
strategyUsed = "bfs";
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
const pages = [];
|
|
2023
|
+
const skipped = [];
|
|
2024
|
+
const linkEdges = [];
|
|
2025
|
+
const seenEdges = new Set;
|
|
2026
|
+
let lastFetchAt = 0;
|
|
2027
|
+
const seedCanon = canonicalForCrawl(seed.toString());
|
|
2028
|
+
const allowUrl = (url, opts2) => {
|
|
2029
|
+
const safe = assertSafeHttpUrl(url, { allowPrivateNetwork: allowPrivate });
|
|
2030
|
+
if (!safe.ok)
|
|
2031
|
+
return safe.message;
|
|
2032
|
+
if (sameHost && safe.url.hostname.toLowerCase() !== host)
|
|
2033
|
+
return "off-host";
|
|
2034
|
+
const isSeed = opts2?.isSeed === true || canonicalForCrawl(url) === seedCanon;
|
|
2035
|
+
if (!isSeed && !matchesPatterns(url, includePatterns, excludePatterns)) {
|
|
2036
|
+
return "pattern-filter";
|
|
2037
|
+
}
|
|
2038
|
+
if (isSeed && excludePatterns?.length && !matchesPatterns(url, undefined, excludePatterns)) {
|
|
2039
|
+
return "pattern-filter";
|
|
2040
|
+
}
|
|
2041
|
+
if (respectRobots && !isUrlAllowedByRobots(url, robots)) {
|
|
2042
|
+
return "robots-disallow";
|
|
2043
|
+
}
|
|
2044
|
+
return null;
|
|
2045
|
+
};
|
|
2046
|
+
const pace = async () => {
|
|
2047
|
+
const wait = delayMs - (Date.now() - lastFetchAt);
|
|
2048
|
+
if (wait > 0)
|
|
2049
|
+
await sleep(wait);
|
|
2050
|
+
};
|
|
2051
|
+
const recordEdge = (from, to) => {
|
|
2052
|
+
if (!extractLinksGraph)
|
|
2053
|
+
return;
|
|
2054
|
+
const key = `${from}\x00${stripFragment(to)}`;
|
|
2055
|
+
if (seenEdges.has(key))
|
|
2056
|
+
return;
|
|
2057
|
+
seenEdges.add(key);
|
|
2058
|
+
linkEdges.push({ from, to: stripFragment(to) });
|
|
2059
|
+
};
|
|
2060
|
+
if (strategyUsed === "sitemap") {
|
|
2061
|
+
const seenCanon = new Set;
|
|
2062
|
+
const ordered = [];
|
|
2063
|
+
for (const u of sitemapUrls) {
|
|
2064
|
+
const c = canonicalForCrawl(u);
|
|
2065
|
+
if (seenCanon.has(c))
|
|
2066
|
+
continue;
|
|
2067
|
+
seenCanon.add(c);
|
|
2068
|
+
if (allowUrl(u))
|
|
2069
|
+
continue;
|
|
2070
|
+
ordered.push(u);
|
|
2071
|
+
}
|
|
2072
|
+
const totalFound = ordered.length;
|
|
2073
|
+
for (const url of ordered.slice(0, maxPages)) {
|
|
2074
|
+
await pace();
|
|
2075
|
+
const page = await fetchPage(url, { fetchOpts, maxChars, cache });
|
|
2076
|
+
lastFetchAt = Date.now();
|
|
2077
|
+
if (!page.ok) {
|
|
2078
|
+
skipped.push({ url, reason: page.reason });
|
|
2079
|
+
continue;
|
|
2080
|
+
}
|
|
2081
|
+
pages.push({
|
|
2082
|
+
url: canonicalForOutput(page.finalUrl),
|
|
2083
|
+
finalUrl: page.finalUrl,
|
|
2084
|
+
title: page.title,
|
|
2085
|
+
markdown: page.markdown,
|
|
2086
|
+
depth: 0,
|
|
2087
|
+
fromCache: page.fromCache
|
|
2088
|
+
});
|
|
2089
|
+
if (extractLinksGraph) {
|
|
2090
|
+
for (const l of page.links)
|
|
2091
|
+
recordEdge(url, l);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
return finalizeCrawl({
|
|
2095
|
+
seed: seed.toString(),
|
|
2096
|
+
strategyUsed,
|
|
2097
|
+
pages,
|
|
2098
|
+
totalFound,
|
|
2099
|
+
skipped,
|
|
2100
|
+
robotsFetched,
|
|
2101
|
+
sitemapFound: true,
|
|
2102
|
+
links: extractLinksGraph ? linkEdges : undefined,
|
|
2103
|
+
dedupeBoilerplate,
|
|
2104
|
+
maxTotalChars,
|
|
2105
|
+
maxTokensOut: opts.maxTokensOut,
|
|
2106
|
+
includeFullMarkdown,
|
|
2107
|
+
doIndex,
|
|
2108
|
+
useAuth
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
const traversal = strategyUsed === "dfs" ? "dfs" : "bfs";
|
|
2112
|
+
const queue = [
|
|
2113
|
+
{ url: seed.toString(), depth: 0 }
|
|
2114
|
+
];
|
|
2115
|
+
const visited = new Set([canonicalForCrawl(seed.toString())]);
|
|
2116
|
+
while (queue.length && pages.length < maxPages) {
|
|
2117
|
+
const item = traversal === "dfs" ? queue.pop() : queue.shift();
|
|
2118
|
+
const deny = allowUrl(item.url);
|
|
2119
|
+
if (deny) {
|
|
2120
|
+
skipped.push({ url: item.url, reason: deny });
|
|
2121
|
+
continue;
|
|
2122
|
+
}
|
|
2123
|
+
await pace();
|
|
2124
|
+
const page = await fetchPage(item.url, { fetchOpts, maxChars, cache });
|
|
2125
|
+
lastFetchAt = Date.now();
|
|
2126
|
+
if (!page.ok) {
|
|
2127
|
+
skipped.push({ url: item.url, reason: page.reason });
|
|
2128
|
+
continue;
|
|
2129
|
+
}
|
|
2130
|
+
let links = page.links;
|
|
2131
|
+
if (page.fromCache && item.depth < maxDepth && pages.length < maxPages && links.length === 0) {
|
|
2132
|
+
const htmlRes = await fetchHtml(item.url, {
|
|
2133
|
+
...fetchOpts,
|
|
2134
|
+
forceRefresh: true
|
|
2135
|
+
});
|
|
2136
|
+
lastFetchAt = Date.now();
|
|
2137
|
+
if (htmlRes.ok) {
|
|
2138
|
+
links = extractLinks(htmlRes.html, htmlRes.finalUrl);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
pages.push({
|
|
2142
|
+
url: canonicalForOutput(page.finalUrl),
|
|
2143
|
+
finalUrl: page.finalUrl,
|
|
2144
|
+
title: page.title,
|
|
2145
|
+
markdown: page.markdown,
|
|
2146
|
+
depth: item.depth,
|
|
2147
|
+
fromCache: page.fromCache
|
|
2148
|
+
});
|
|
2149
|
+
if (item.depth >= maxDepth || pages.length >= maxPages)
|
|
2150
|
+
continue;
|
|
2151
|
+
const filtered = prioritizeDocLinks(links.filter((link) => {
|
|
2152
|
+
if (visited.has(canonicalForCrawl(link)))
|
|
2153
|
+
return false;
|
|
2154
|
+
if (allowUrl(link))
|
|
2155
|
+
return false;
|
|
2156
|
+
return true;
|
|
2157
|
+
}));
|
|
2158
|
+
for (const link of filtered) {
|
|
2159
|
+
const c = canonicalForCrawl(link);
|
|
2160
|
+
if (visited.has(c))
|
|
2161
|
+
continue;
|
|
2162
|
+
visited.add(c);
|
|
2163
|
+
queue.push({ url: link, depth: item.depth + 1 });
|
|
2164
|
+
recordEdge(item.url, link);
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
return finalizeCrawl({
|
|
2168
|
+
seed: seed.toString(),
|
|
2169
|
+
strategyUsed: traversal,
|
|
2170
|
+
pages,
|
|
2171
|
+
totalFound: visited.size,
|
|
2172
|
+
skipped,
|
|
2173
|
+
robotsFetched,
|
|
2174
|
+
sitemapFound,
|
|
2175
|
+
links: extractLinksGraph ? linkEdges : undefined,
|
|
2176
|
+
dedupeBoilerplate,
|
|
2177
|
+
maxTotalChars,
|
|
2178
|
+
maxTokensOut: opts.maxTokensOut,
|
|
2179
|
+
includeFullMarkdown,
|
|
2180
|
+
doIndex,
|
|
2181
|
+
useAuth
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
async function finalizeCrawl(args) {
|
|
2185
|
+
let pages = args.pages;
|
|
2186
|
+
if (args.dedupeBoilerplate && pages.length > 1) {
|
|
2187
|
+
let domain;
|
|
2188
|
+
try {
|
|
2189
|
+
domain = new URL(args.seed).hostname;
|
|
2190
|
+
} catch {
|
|
2191
|
+
domain = undefined;
|
|
2192
|
+
}
|
|
2193
|
+
const deduped = deduplicatePages(pages.map((p) => ({ url: p.url, markdown: p.markdown })), domain);
|
|
2194
|
+
pages = pages.map((p, i) => ({
|
|
2195
|
+
...p,
|
|
2196
|
+
markdown: deduped[i]?.markdown ?? p.markdown
|
|
2197
|
+
}));
|
|
2198
|
+
}
|
|
2199
|
+
let droppedOverBudget = 0;
|
|
2200
|
+
{
|
|
2201
|
+
const budgeted = [];
|
|
2202
|
+
let charCount = 0;
|
|
2203
|
+
for (const page of pages) {
|
|
2204
|
+
if (charCount + page.markdown.length > args.maxTotalChars && budgeted.length > 0) {
|
|
2205
|
+
droppedOverBudget += 1;
|
|
2206
|
+
continue;
|
|
2207
|
+
}
|
|
2208
|
+
let md = page.markdown;
|
|
2209
|
+
if (charCount + md.length > args.maxTotalChars) {
|
|
2210
|
+
md = truncateByChars(md, Math.max(0, args.maxTotalChars - charCount));
|
|
2211
|
+
}
|
|
2212
|
+
budgeted.push({ ...page, markdown: md });
|
|
2213
|
+
charCount += md.length;
|
|
2214
|
+
}
|
|
2215
|
+
pages = budgeted;
|
|
2216
|
+
}
|
|
2217
|
+
const maxTokensOut = args.maxTokensOut ?? scaleDefaultTokens(pages.length);
|
|
2218
|
+
for (const page of pages) {
|
|
2219
|
+
if (!page.markdown)
|
|
2220
|
+
continue;
|
|
2221
|
+
page.evidence = buildEvidenceFromMarkdown(args.seed, page.title || page.url, page.url, page.markdown, { maxTokensOut: Math.min(400, maxTokensOut), maxItems: 1 });
|
|
2222
|
+
}
|
|
2223
|
+
let indexed = 0;
|
|
2224
|
+
if (args.doIndex) {
|
|
2225
|
+
for (const page of pages) {
|
|
2226
|
+
const ok = await indexCrawlResult({
|
|
2227
|
+
url: page.url,
|
|
2228
|
+
title: page.title,
|
|
2229
|
+
markdown: page.markdown
|
|
2230
|
+
});
|
|
2231
|
+
if (ok)
|
|
2232
|
+
indexed += 1;
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
if (!args.includeFullMarkdown) {
|
|
2236
|
+
for (const page of pages) {
|
|
2237
|
+
if (!page.evidence?.length && page.markdown) {
|
|
2238
|
+
page.excerpt = page.markdown.slice(0, 600);
|
|
2239
|
+
}
|
|
2240
|
+
page.markdown = "";
|
|
2241
|
+
}
|
|
2242
|
+
} else {
|
|
2243
|
+
applyAggregateMarkdownBudget(pages, (p) => p.markdown, (p, body) => {
|
|
2244
|
+
p.markdown = body;
|
|
2245
|
+
}, { maxTokensOut, minTokensPerItem: MIN_TOKENS_PER_PAGE });
|
|
2246
|
+
}
|
|
2247
|
+
return {
|
|
2248
|
+
ok: true,
|
|
2249
|
+
seed: args.seed,
|
|
2250
|
+
strategyUsed: args.strategyUsed,
|
|
2251
|
+
pages,
|
|
2252
|
+
totalFound: args.totalFound,
|
|
2253
|
+
crawled: pages.length,
|
|
2254
|
+
skipped: args.skipped,
|
|
2255
|
+
robotsFetched: args.robotsFetched,
|
|
2256
|
+
sitemapFound: args.sitemapFound,
|
|
2257
|
+
...args.links ? { links: args.links } : {},
|
|
2258
|
+
...droppedOverBudget > 0 ? { droppedOverBudget } : {},
|
|
2259
|
+
...indexed > 0 ? { indexed } : {},
|
|
2260
|
+
authUsed: args.useAuth
|
|
2261
|
+
};
|
|
2262
|
+
}
|
|
2263
|
+
async function runMapStrategy(ctx) {
|
|
2264
|
+
const discovered = [];
|
|
2265
|
+
const seen = new Set;
|
|
2266
|
+
let sitemapFound = false;
|
|
2267
|
+
const pushUrl = (url) => {
|
|
2268
|
+
if (discovered.length >= ctx.maxPages)
|
|
2269
|
+
return;
|
|
2270
|
+
const c = canonicalForCrawl(url);
|
|
2271
|
+
if (seen.has(c))
|
|
2272
|
+
return;
|
|
2273
|
+
if (ctx.sameHost) {
|
|
2274
|
+
try {
|
|
2275
|
+
if (new URL(url).hostname.toLowerCase() !== ctx.host)
|
|
2276
|
+
return;
|
|
2277
|
+
} catch {
|
|
2278
|
+
return;
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
if (!matchesPatterns(url, ctx.includePatterns, ctx.excludePatterns))
|
|
2282
|
+
return;
|
|
2283
|
+
if (ctx.respectRobots && !isUrlAllowedByRobots(url, ctx.robots))
|
|
2284
|
+
return;
|
|
2285
|
+
const safe = assertSafeHttpUrl(url, {
|
|
2286
|
+
allowPrivateNetwork: ctx.allowPrivate
|
|
2287
|
+
});
|
|
2288
|
+
if (!safe.ok)
|
|
2289
|
+
return;
|
|
2290
|
+
seen.add(c);
|
|
2291
|
+
discovered.push(canonicalForOutput(url));
|
|
2292
|
+
};
|
|
2293
|
+
pushUrl(ctx.seed);
|
|
2294
|
+
const sm = await discoverSitemapUrls(ctx.origin, ctx.lightFetch, ctx.robotsRaw);
|
|
2295
|
+
if (sm.length > 0) {
|
|
2296
|
+
sitemapFound = true;
|
|
2297
|
+
for (const u of sm)
|
|
2298
|
+
pushUrl(u);
|
|
2299
|
+
}
|
|
2300
|
+
const queue = [{ url: ctx.seed, depth: 0 }];
|
|
2301
|
+
const queued = new Set([canonicalForCrawl(ctx.seed)]);
|
|
2302
|
+
let last = 0;
|
|
2303
|
+
while (queue.length && discovered.length < ctx.maxPages) {
|
|
2304
|
+
const item = queue.shift();
|
|
2305
|
+
if (item.depth >= ctx.maxDepth)
|
|
2306
|
+
continue;
|
|
2307
|
+
const wait = ctx.delayMs - (Date.now() - last);
|
|
2308
|
+
if (wait > 0)
|
|
2309
|
+
await sleep(wait);
|
|
2310
|
+
const htmlRes = await fetchHtml(item.url, {
|
|
2311
|
+
...ctx.fetchOpts,
|
|
2312
|
+
forceRefresh: item.depth === 0
|
|
2313
|
+
});
|
|
2314
|
+
last = Date.now();
|
|
2315
|
+
if (!htmlRes.ok)
|
|
2316
|
+
continue;
|
|
2317
|
+
for (const link of extractLinks(htmlRes.html, htmlRes.finalUrl)) {
|
|
2318
|
+
pushUrl(link);
|
|
2319
|
+
const c = canonicalForCrawl(link);
|
|
2320
|
+
if (queued.has(c))
|
|
2321
|
+
continue;
|
|
2322
|
+
if (item.depth + 1 > ctx.maxDepth)
|
|
2323
|
+
continue;
|
|
2324
|
+
if (ctx.sameHost) {
|
|
2325
|
+
try {
|
|
2326
|
+
if (new URL(link).hostname.toLowerCase() !== ctx.host)
|
|
2327
|
+
continue;
|
|
2328
|
+
} catch {
|
|
2329
|
+
continue;
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
if (ctx.respectRobots && !isPathAllowed(new URL(link).pathname, ctx.robots)) {
|
|
2333
|
+
continue;
|
|
2334
|
+
}
|
|
2335
|
+
queued.add(c);
|
|
2336
|
+
queue.push({ url: link, depth: item.depth + 1 });
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
return {
|
|
2340
|
+
ok: true,
|
|
2341
|
+
seed: ctx.seed,
|
|
2342
|
+
strategyUsed: "map",
|
|
2343
|
+
pages: [],
|
|
2344
|
+
totalFound: discovered.length,
|
|
2345
|
+
crawled: 0,
|
|
2346
|
+
skipped: [],
|
|
2347
|
+
robotsFetched: ctx.robotsFetched,
|
|
2348
|
+
sitemapFound,
|
|
2349
|
+
urls: discovered
|
|
2350
|
+
};
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
// ../web-brain/src/diff.ts
|
|
2354
|
+
function diffLines(oldText, newText) {
|
|
2355
|
+
const a = oldText.replace(/\r\n/g, `
|
|
2356
|
+
`).split(`
|
|
2357
|
+
`);
|
|
2358
|
+
const b = newText.replace(/\r\n/g, `
|
|
2359
|
+
`).split(`
|
|
2360
|
+
`);
|
|
2361
|
+
const n = a.length;
|
|
2362
|
+
const m = b.length;
|
|
2363
|
+
if (n * m > 2000000) {
|
|
2364
|
+
return roughDiff(a, b);
|
|
2365
|
+
}
|
|
2366
|
+
const dp = Array.from({ length: n + 1 }, () => Array.from({ length: m + 1 }, () => 0));
|
|
2367
|
+
for (let i2 = n - 1;i2 >= 0; i2--) {
|
|
2368
|
+
for (let j2 = m - 1;j2 >= 0; j2--) {
|
|
2369
|
+
if (a[i2] === b[j2])
|
|
2370
|
+
dp[i2][j2] = dp[i2 + 1][j2 + 1] + 1;
|
|
2371
|
+
else
|
|
2372
|
+
dp[i2][j2] = Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
const ops = [];
|
|
2376
|
+
let i = 0;
|
|
2377
|
+
let j = 0;
|
|
2378
|
+
while (i < n && j < m) {
|
|
2379
|
+
if (a[i] === b[j]) {
|
|
2380
|
+
ops.push({ t: "eq", line: a[i] });
|
|
2381
|
+
i++;
|
|
2382
|
+
j++;
|
|
2383
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
2384
|
+
ops.push({ t: "del", line: a[i] });
|
|
2385
|
+
i++;
|
|
2386
|
+
} else {
|
|
2387
|
+
ops.push({ t: "add", line: b[j] });
|
|
2388
|
+
j++;
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
while (i < n) {
|
|
2392
|
+
ops.push({ t: "del", line: a[i++] });
|
|
2393
|
+
}
|
|
2394
|
+
while (j < m) {
|
|
2395
|
+
ops.push({ t: "add", line: b[j++] });
|
|
2396
|
+
}
|
|
2397
|
+
return coalesceOps(ops);
|
|
2398
|
+
}
|
|
2399
|
+
function roughDiff(a, b) {
|
|
2400
|
+
const setA = new Set(a);
|
|
2401
|
+
const setB = new Set(b);
|
|
2402
|
+
const removed = a.filter((l) => !setB.has(l));
|
|
2403
|
+
const added = b.filter((l) => !setA.has(l));
|
|
2404
|
+
const hunks = [];
|
|
2405
|
+
if (removed.length)
|
|
2406
|
+
hunks.push({ type: "remove", lines: removed });
|
|
2407
|
+
if (added.length)
|
|
2408
|
+
hunks.push({ type: "add", lines: added });
|
|
2409
|
+
return hunks;
|
|
2410
|
+
}
|
|
2411
|
+
function coalesceOps(ops) {
|
|
2412
|
+
const hunks = [];
|
|
2413
|
+
for (const op of ops) {
|
|
2414
|
+
const type = op.t === "eq" ? "equal" : op.t === "add" ? "add" : "remove";
|
|
2415
|
+
const last = hunks[hunks.length - 1];
|
|
2416
|
+
if (last && last.type === type)
|
|
2417
|
+
last.lines.push(op.line);
|
|
2418
|
+
else
|
|
2419
|
+
hunks.push({ type, lines: [op.line] });
|
|
2420
|
+
}
|
|
2421
|
+
return hunks;
|
|
2422
|
+
}
|
|
2423
|
+
function summarizeHunks(hunks, oldHash, newHash, opts = {}) {
|
|
2424
|
+
const maxUnifiedChars = opts.maxUnifiedChars ?? 12000;
|
|
2425
|
+
const maxHunks = opts.maxHunks ?? 40;
|
|
2426
|
+
let addedLines = 0;
|
|
2427
|
+
let removedLines = 0;
|
|
2428
|
+
for (const h of hunks) {
|
|
2429
|
+
if (h.type === "add")
|
|
2430
|
+
addedLines += h.lines.length;
|
|
2431
|
+
if (h.type === "remove")
|
|
2432
|
+
removedLines += h.lines.length;
|
|
2433
|
+
}
|
|
2434
|
+
const changed = oldHash !== newHash;
|
|
2435
|
+
const outHunks = hunks.filter((h) => h.type !== "equal").slice(0, maxHunks);
|
|
2436
|
+
const unifiedParts = [];
|
|
2437
|
+
for (const h of hunks) {
|
|
2438
|
+
if (h.type === "equal") {
|
|
2439
|
+
continue;
|
|
2440
|
+
}
|
|
2441
|
+
const prefix = h.type === "add" ? "+" : "-";
|
|
2442
|
+
for (const line of h.lines) {
|
|
2443
|
+
unifiedParts.push(`${prefix}${line}`);
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
let unified = unifiedParts.join(`
|
|
2447
|
+
`);
|
|
2448
|
+
if (unified.length > maxUnifiedChars) {
|
|
2449
|
+
unified = unified.slice(0, maxUnifiedChars) + `
|
|
2450
|
+
… [unified diff truncated]`;
|
|
2451
|
+
}
|
|
2452
|
+
return {
|
|
2453
|
+
changed: changed || addedLines > 0 || removedLines > 0,
|
|
2454
|
+
oldHash,
|
|
2455
|
+
newHash,
|
|
2456
|
+
addedLines,
|
|
2457
|
+
removedLines,
|
|
2458
|
+
unified,
|
|
2459
|
+
hunks: outHunks
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
async function webDiff(opts) {
|
|
2463
|
+
let oldMd = opts.oldMarkdown;
|
|
2464
|
+
let oldTitle = opts.oldTitle ?? "";
|
|
2465
|
+
let oldFetchedAt = null;
|
|
2466
|
+
let oldFromCache = false;
|
|
2467
|
+
let newMd = opts.newMarkdown;
|
|
2468
|
+
let newTitle = opts.newTitle ?? "";
|
|
2469
|
+
let newFetchedAt = new Date().toISOString();
|
|
2470
|
+
let newFromCache = false;
|
|
2471
|
+
let url = opts.url ?? null;
|
|
2472
|
+
if (opts.url && oldMd == null) {
|
|
2473
|
+
const cached = opts.cache?.get(opts.url) ?? null;
|
|
2474
|
+
if (!cached) {
|
|
2475
|
+
const first = await webFetch(opts.url, {
|
|
2476
|
+
...opts.fetchOpts,
|
|
2477
|
+
cache: opts.cache ?? opts.fetchOpts?.cache,
|
|
2478
|
+
forceRefresh: true
|
|
2479
|
+
});
|
|
2480
|
+
if (!first.ok)
|
|
2481
|
+
return first;
|
|
2482
|
+
return {
|
|
2483
|
+
ok: true,
|
|
2484
|
+
url: opts.url,
|
|
2485
|
+
summary: {
|
|
2486
|
+
changed: false,
|
|
2487
|
+
oldHash: "",
|
|
2488
|
+
newHash: first.contentHash,
|
|
2489
|
+
addedLines: 0,
|
|
2490
|
+
removedLines: 0,
|
|
2491
|
+
unified: "",
|
|
2492
|
+
hunks: []
|
|
2493
|
+
},
|
|
2494
|
+
old: { title: "", fetchedAt: null, fromCache: false },
|
|
2495
|
+
new: {
|
|
2496
|
+
title: first.title,
|
|
2497
|
+
fetchedAt: first.fetchedAt,
|
|
2498
|
+
fromCache: false
|
|
2499
|
+
}
|
|
2500
|
+
};
|
|
2501
|
+
}
|
|
2502
|
+
oldMd = cached.markdown;
|
|
2503
|
+
oldTitle = cached.title;
|
|
2504
|
+
oldFetchedAt = cached.fetchedAt;
|
|
2505
|
+
oldFromCache = true;
|
|
2506
|
+
url = cached.url;
|
|
2507
|
+
}
|
|
2508
|
+
if (opts.url && newMd == null) {
|
|
2509
|
+
const fresh = await webFetch(opts.url, {
|
|
2510
|
+
...opts.fetchOpts,
|
|
2511
|
+
cache: opts.cache ?? opts.fetchOpts?.cache,
|
|
2512
|
+
forceRefresh: true
|
|
2513
|
+
});
|
|
2514
|
+
if (!fresh.ok)
|
|
2515
|
+
return fresh;
|
|
2516
|
+
newMd = fresh.markdown;
|
|
2517
|
+
newTitle = fresh.title;
|
|
2518
|
+
newFetchedAt = fresh.fetchedAt;
|
|
2519
|
+
newFromCache = false;
|
|
2520
|
+
url = fresh.url;
|
|
2521
|
+
}
|
|
2522
|
+
if (oldMd == null || newMd == null) {
|
|
2523
|
+
return toWebError("invalid_url", "Provide url (with cache) or both oldMarkdown and newMarkdown");
|
|
2524
|
+
}
|
|
2525
|
+
const { createHash: createHash6 } = await import("node:crypto");
|
|
2526
|
+
const oldHash = createHash6("sha256").update(oldMd).digest("hex");
|
|
2527
|
+
const newHash = createHash6("sha256").update(newMd).digest("hex");
|
|
2528
|
+
if (oldHash === newHash) {
|
|
2529
|
+
return {
|
|
2530
|
+
ok: true,
|
|
2531
|
+
url,
|
|
2532
|
+
summary: {
|
|
2533
|
+
changed: false,
|
|
2534
|
+
oldHash,
|
|
2535
|
+
newHash,
|
|
2536
|
+
addedLines: 0,
|
|
2537
|
+
removedLines: 0,
|
|
2538
|
+
unified: "",
|
|
2539
|
+
hunks: []
|
|
2540
|
+
},
|
|
2541
|
+
old: { title: oldTitle, fetchedAt: oldFetchedAt, fromCache: oldFromCache },
|
|
2542
|
+
new: { title: newTitle, fetchedAt: newFetchedAt, fromCache: newFromCache }
|
|
2543
|
+
};
|
|
2544
|
+
}
|
|
2545
|
+
const hunks = diffLines(oldMd, newMd);
|
|
2546
|
+
const summary = summarizeHunks(hunks, oldHash, newHash, {
|
|
2547
|
+
maxUnifiedChars: opts.maxUnifiedChars,
|
|
2548
|
+
maxHunks: opts.maxHunks
|
|
2549
|
+
});
|
|
2550
|
+
return {
|
|
2551
|
+
ok: true,
|
|
2552
|
+
url,
|
|
2553
|
+
summary,
|
|
2554
|
+
old: { title: oldTitle, fetchedAt: oldFetchedAt, fromCache: oldFromCache },
|
|
2555
|
+
new: { title: newTitle, fetchedAt: newFetchedAt, fromCache: newFromCache }
|
|
2556
|
+
};
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
// ../web-brain/src/mcp-server.ts
|
|
2560
|
+
function openCache() {
|
|
2561
|
+
if (process.env.PHNEAKNGAR_WEB_CACHE_DIR) {
|
|
2562
|
+
return new WebCache({ dir: process.env.PHNEAKNGAR_WEB_CACHE_DIR });
|
|
2563
|
+
}
|
|
2564
|
+
const root = process.env.PHNEAKNGAR_PROJECT_ROOT || join5(homedir4(), ".phneakngar");
|
|
2565
|
+
return new WebCache({ dir: defaultCacheDir(root) });
|
|
2566
|
+
}
|
|
2567
|
+
var TOOLS = [
|
|
2568
|
+
{
|
|
2569
|
+
name: "web_search",
|
|
2570
|
+
description: "Search the public web (DDG lite → HTML fallback). Returns title/url/snippet plus provider telemetry. Empty results set degraded=true and error.code=empty_provider_results (not silent success). Prefer over inventing URLs.",
|
|
2571
|
+
inputSchema: {
|
|
2572
|
+
type: "object",
|
|
2573
|
+
properties: {
|
|
2574
|
+
query: { type: "string" },
|
|
2575
|
+
max_results: { type: "number" },
|
|
2576
|
+
time_range: {
|
|
2577
|
+
type: "string",
|
|
2578
|
+
enum: ["day", "week", "month", "year"],
|
|
2579
|
+
description: "Recency filter (DDG df=) when supported"
|
|
2580
|
+
},
|
|
2581
|
+
mock: { type: "boolean", description: "Offline mock results" }
|
|
2582
|
+
},
|
|
2583
|
+
required: ["query"]
|
|
2584
|
+
}
|
|
2585
|
+
},
|
|
2586
|
+
{
|
|
2587
|
+
name: "web_fetch",
|
|
2588
|
+
description: "Fetch a public http(s) URL as clean markdown. SSRF-guarded. Uses local cache unless force_refresh.",
|
|
2589
|
+
inputSchema: {
|
|
2590
|
+
type: "object",
|
|
2591
|
+
properties: {
|
|
2592
|
+
url: { type: "string" },
|
|
2593
|
+
force_refresh: { type: "boolean" },
|
|
2594
|
+
max_chars: { type: "number" }
|
|
2595
|
+
},
|
|
2596
|
+
required: ["url"]
|
|
2597
|
+
}
|
|
2598
|
+
},
|
|
2599
|
+
{
|
|
2600
|
+
name: "web_cache",
|
|
2601
|
+
description: "Search or stats on the local web-brain cache (no network).",
|
|
2602
|
+
inputSchema: {
|
|
2603
|
+
type: "object",
|
|
2604
|
+
properties: {
|
|
2605
|
+
action: { type: "string", enum: ["search", "stats", "clear"] },
|
|
2606
|
+
query: { type: "string" },
|
|
2607
|
+
limit: { type: "number" }
|
|
2608
|
+
},
|
|
2609
|
+
required: ["action"]
|
|
2610
|
+
}
|
|
2611
|
+
},
|
|
2612
|
+
{
|
|
2613
|
+
name: "web_extract",
|
|
2614
|
+
description: "Structured extract: metadata, HTML tables, and/or JSON-LD from a URL or raw html.",
|
|
2615
|
+
inputSchema: {
|
|
2616
|
+
type: "object",
|
|
2617
|
+
properties: {
|
|
2618
|
+
url: { type: "string" },
|
|
2619
|
+
html: { type: "string" },
|
|
2620
|
+
mode: {
|
|
2621
|
+
type: "string",
|
|
2622
|
+
enum: ["metadata", "tables", "jsonld", "all"]
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
},
|
|
2627
|
+
{
|
|
2628
|
+
name: "web_crawl",
|
|
2629
|
+
description: "Multi-page crawl: strategy bfs|dfs|sitemap|auto|map. Sitemap-first for docs, pattern filters, robots.txt, canonical URL dedup. Caps: depth≤3, pages≤50. Prefer sitemap/auto for doc sites; map for URL discovery only.",
|
|
2630
|
+
inputSchema: {
|
|
2631
|
+
type: "object",
|
|
2632
|
+
properties: {
|
|
2633
|
+
url: { type: "string" },
|
|
2634
|
+
strategy: {
|
|
2635
|
+
type: "string",
|
|
2636
|
+
enum: ["bfs", "dfs", "sitemap", "auto", "map"],
|
|
2637
|
+
description: "bfs (default) | dfs | sitemap | auto (sitemap if found else bfs) | map (URLs only)"
|
|
2638
|
+
},
|
|
2639
|
+
max_depth: { type: "number" },
|
|
2640
|
+
max_pages: { type: "number" },
|
|
2641
|
+
same_host: { type: "boolean" },
|
|
2642
|
+
include_patterns: {
|
|
2643
|
+
type: "array",
|
|
2644
|
+
items: { type: "string" },
|
|
2645
|
+
description: "Regex whitelist on full URL"
|
|
2646
|
+
},
|
|
2647
|
+
exclude_patterns: {
|
|
2648
|
+
type: "array",
|
|
2649
|
+
items: { type: "string" },
|
|
2650
|
+
description: "Regex blacklist on full URL"
|
|
2651
|
+
},
|
|
2652
|
+
extract_links: {
|
|
2653
|
+
type: "boolean",
|
|
2654
|
+
description: "Include inter-page link graph"
|
|
2655
|
+
},
|
|
2656
|
+
use_auth: {
|
|
2657
|
+
type: "boolean",
|
|
2658
|
+
description: "Send cookies from auth state / env"
|
|
2659
|
+
},
|
|
2660
|
+
max_total_chars: { type: "number" },
|
|
2661
|
+
max_tokens_out: { type: "number" },
|
|
2662
|
+
include_full_markdown: { type: "boolean" },
|
|
2663
|
+
index_pages: {
|
|
2664
|
+
type: "boolean",
|
|
2665
|
+
description: "Embed pages into local vector store (or PHNEAKNGAR_CRAWL_INDEX=1)"
|
|
2666
|
+
},
|
|
2667
|
+
dedupe_boilerplate: { type: "boolean" }
|
|
2668
|
+
},
|
|
2669
|
+
required: ["url"]
|
|
2670
|
+
}
|
|
2671
|
+
},
|
|
2672
|
+
{
|
|
2673
|
+
name: "web_find_similar",
|
|
2674
|
+
description: "Find similar pages from the local crawl vector index (hash embeddings). Prefer after crawl with index_pages / PHNEAKNGAR_CRAWL_INDEX=1.",
|
|
2675
|
+
inputSchema: {
|
|
2676
|
+
type: "object",
|
|
2677
|
+
properties: {
|
|
2678
|
+
query: {
|
|
2679
|
+
type: "string",
|
|
2680
|
+
description: "URL previously indexed or free-text concept"
|
|
2681
|
+
},
|
|
2682
|
+
limit: { type: "number" }
|
|
2683
|
+
},
|
|
2684
|
+
required: ["query"]
|
|
2685
|
+
}
|
|
2686
|
+
},
|
|
2687
|
+
{
|
|
2688
|
+
name: "web_diff",
|
|
2689
|
+
description: "Diff a URL's cached snapshot against a fresh fetch (or two markdown strings). Reports changed, line counts, unified patch.",
|
|
2690
|
+
inputSchema: {
|
|
2691
|
+
type: "object",
|
|
2692
|
+
properties: {
|
|
2693
|
+
url: { type: "string" },
|
|
2694
|
+
old_markdown: { type: "string" },
|
|
2695
|
+
new_markdown: { type: "string" }
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
];
|
|
2700
|
+
var frameMode = "ndjson";
|
|
2701
|
+
function send(msg) {
|
|
2702
|
+
const body = JSON.stringify(msg);
|
|
2703
|
+
if (frameMode === "ndjson") {
|
|
2704
|
+
writeSync(1, `${body}
|
|
2705
|
+
`);
|
|
2706
|
+
return;
|
|
2707
|
+
}
|
|
2708
|
+
const frame = `Content-Length: ${Buffer.byteLength(body, "utf-8")}\r
|
|
2709
|
+
\r
|
|
2710
|
+
${body}`;
|
|
2711
|
+
writeSync(1, frame);
|
|
2712
|
+
}
|
|
2713
|
+
var SUPPORTED_PROTOCOL = "2025-06-18";
|
|
2714
|
+
async function callTool(name, args) {
|
|
2715
|
+
const cache = openCache();
|
|
2716
|
+
switch (name) {
|
|
2717
|
+
case "web_search": {
|
|
2718
|
+
const query = String(args.query ?? "");
|
|
2719
|
+
const maxResults = Number(args.max_results) || 5;
|
|
2720
|
+
const tr = args.time_range != null ? String(args.time_range) : undefined;
|
|
2721
|
+
const timeRange = tr === "day" || tr === "week" || tr === "month" || tr === "year" ? tr : undefined;
|
|
2722
|
+
const provider = args.mock ? createMockSearchProvider([
|
|
2723
|
+
{
|
|
2724
|
+
title: `Mock: ${query}`,
|
|
2725
|
+
url: "https://example.com/",
|
|
2726
|
+
snippet: "mock"
|
|
2727
|
+
}
|
|
2728
|
+
]) : undefined;
|
|
2729
|
+
return webSearch(query, { maxResults, provider, timeRange });
|
|
2730
|
+
}
|
|
2731
|
+
case "web_fetch": {
|
|
2732
|
+
return webFetch(String(args.url ?? ""), {
|
|
2733
|
+
forceRefresh: !!args.force_refresh,
|
|
2734
|
+
maxChars: Number(args.max_chars) || 30000,
|
|
2735
|
+
cache
|
|
2736
|
+
});
|
|
2737
|
+
}
|
|
2738
|
+
case "web_cache": {
|
|
2739
|
+
const action = String(args.action ?? "stats");
|
|
2740
|
+
if (action === "clear") {
|
|
2741
|
+
cache.clear();
|
|
2742
|
+
return { ok: true, cleared: true, path: cache.dir };
|
|
2743
|
+
}
|
|
2744
|
+
if (action === "search") {
|
|
2745
|
+
return {
|
|
2746
|
+
ok: true,
|
|
2747
|
+
hits: cache.search(String(args.query ?? ""), Number(args.limit) || 20)
|
|
2748
|
+
};
|
|
2749
|
+
}
|
|
2750
|
+
return { ok: true, ...cache.stats() };
|
|
2751
|
+
}
|
|
2752
|
+
case "web_extract": {
|
|
2753
|
+
return structuredExtract({
|
|
2754
|
+
url: args.url != null ? String(args.url) : undefined,
|
|
2755
|
+
html: args.html != null ? String(args.html) : undefined,
|
|
2756
|
+
mode: args.mode || "all",
|
|
2757
|
+
fetchOpts: { cache }
|
|
2758
|
+
});
|
|
2759
|
+
}
|
|
2760
|
+
case "web_crawl": {
|
|
2761
|
+
const strat = args.strategy != null ? String(args.strategy) : "bfs";
|
|
2762
|
+
const strategy = strat === "dfs" || strat === "sitemap" || strat === "auto" || strat === "map" || strat === "bfs" ? strat : "bfs";
|
|
2763
|
+
const include = Array.isArray(args.include_patterns) ? args.include_patterns.map(String) : undefined;
|
|
2764
|
+
const exclude = Array.isArray(args.exclude_patterns) ? args.exclude_patterns.map(String) : undefined;
|
|
2765
|
+
return webCrawl(String(args.url ?? ""), {
|
|
2766
|
+
strategy,
|
|
2767
|
+
maxDepth: args.max_depth != null ? Number(args.max_depth) : 2,
|
|
2768
|
+
maxPages: args.max_pages != null ? Number(args.max_pages) : 20,
|
|
2769
|
+
sameHost: args.same_host !== false,
|
|
2770
|
+
includePatterns: include,
|
|
2771
|
+
excludePatterns: exclude,
|
|
2772
|
+
extractLinksGraph: args.extract_links === true,
|
|
2773
|
+
useAuth: args.use_auth === true,
|
|
2774
|
+
maxTotalChars: args.max_total_chars != null ? Number(args.max_total_chars) : undefined,
|
|
2775
|
+
maxTokensOut: args.max_tokens_out != null ? Number(args.max_tokens_out) : undefined,
|
|
2776
|
+
includeFullMarkdown: args.include_full_markdown !== false,
|
|
2777
|
+
indexPages: args.index_pages === true || isIndexingEnabled(),
|
|
2778
|
+
dedupeBoilerplate: args.dedupe_boilerplate !== false,
|
|
2779
|
+
cache
|
|
2780
|
+
});
|
|
2781
|
+
}
|
|
2782
|
+
case "web_find_similar": {
|
|
2783
|
+
return {
|
|
2784
|
+
ok: true,
|
|
2785
|
+
indexingEnabled: isIndexingEnabled(),
|
|
2786
|
+
results: findSimilar(String(args.query ?? ""), {
|
|
2787
|
+
limit: Number(args.limit) || 5
|
|
2788
|
+
})
|
|
2789
|
+
};
|
|
2790
|
+
}
|
|
2791
|
+
case "web_diff": {
|
|
2792
|
+
return webDiff({
|
|
2793
|
+
url: args.url != null ? String(args.url) : undefined,
|
|
2794
|
+
oldMarkdown: args.old_markdown != null ? String(args.old_markdown) : undefined,
|
|
2795
|
+
newMarkdown: args.new_markdown != null ? String(args.new_markdown) : undefined,
|
|
2796
|
+
cache
|
|
2797
|
+
});
|
|
2798
|
+
}
|
|
2799
|
+
default:
|
|
2800
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
async function handleMcpMessage(msg) {
|
|
2804
|
+
const id = msg.id ?? null;
|
|
2805
|
+
const method = msg.method ?? "";
|
|
2806
|
+
if (method === "initialize") {
|
|
2807
|
+
const requested = typeof msg.params?.protocolVersion === "string" ? msg.params.protocolVersion : SUPPORTED_PROTOCOL;
|
|
2808
|
+
const protocolVersion = requested === "2024-11-05" || requested === "2025-03-26" || requested === "2025-06-18" ? requested : SUPPORTED_PROTOCOL;
|
|
2809
|
+
return {
|
|
2810
|
+
jsonrpc: "2.0",
|
|
2811
|
+
id,
|
|
2812
|
+
result: {
|
|
2813
|
+
protocolVersion,
|
|
2814
|
+
capabilities: { tools: {} },
|
|
2815
|
+
serverInfo: { name: "phneakngar-web-brain", version: "0.0.2" }
|
|
2816
|
+
}
|
|
2817
|
+
};
|
|
2818
|
+
}
|
|
2819
|
+
if (method === "notifications/initialized" || method === "initialized") {
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
if (method === "ping") {
|
|
2823
|
+
return { jsonrpc: "2.0", id, result: {} };
|
|
2824
|
+
}
|
|
2825
|
+
if (method === "tools/list") {
|
|
2826
|
+
return { jsonrpc: "2.0", id, result: { tools: TOOLS } };
|
|
2827
|
+
}
|
|
2828
|
+
if (method === "tools/call") {
|
|
2829
|
+
const params = msg.params ?? {};
|
|
2830
|
+
const name = String(params.name ?? "");
|
|
2831
|
+
const args = params.arguments ?? {};
|
|
2832
|
+
try {
|
|
2833
|
+
const result = await callTool(name, args);
|
|
2834
|
+
const isError = typeof result === "object" && result != null && "ok" in result && result.ok === false;
|
|
2835
|
+
return {
|
|
2836
|
+
jsonrpc: "2.0",
|
|
2837
|
+
id,
|
|
2838
|
+
result: {
|
|
2839
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
2840
|
+
structuredContent: result,
|
|
2841
|
+
isError
|
|
2842
|
+
}
|
|
2843
|
+
};
|
|
2844
|
+
} catch (e) {
|
|
2845
|
+
return {
|
|
2846
|
+
jsonrpc: "2.0",
|
|
2847
|
+
id,
|
|
2848
|
+
error: {
|
|
2849
|
+
code: -32000,
|
|
2850
|
+
message: e instanceof Error ? e.message : String(e)
|
|
2851
|
+
}
|
|
2852
|
+
};
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
if (id !== null && id !== undefined) {
|
|
2856
|
+
return {
|
|
2857
|
+
jsonrpc: "2.0",
|
|
2858
|
+
id,
|
|
2859
|
+
error: { code: -32601, message: `Method not found: ${method}` }
|
|
2860
|
+
};
|
|
2861
|
+
}
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
function takeJsonObject(text) {
|
|
2865
|
+
const start = text.search(/\{/);
|
|
2866
|
+
if (start < 0)
|
|
2867
|
+
return null;
|
|
2868
|
+
let depth = 0;
|
|
2869
|
+
let inString = false;
|
|
2870
|
+
let escape = false;
|
|
2871
|
+
for (let i = start;i < text.length; i++) {
|
|
2872
|
+
const ch = text[i];
|
|
2873
|
+
if (inString) {
|
|
2874
|
+
if (escape) {
|
|
2875
|
+
escape = false;
|
|
2876
|
+
continue;
|
|
2877
|
+
}
|
|
2878
|
+
if (ch === "\\") {
|
|
2879
|
+
escape = true;
|
|
2880
|
+
continue;
|
|
2881
|
+
}
|
|
2882
|
+
if (ch === '"')
|
|
2883
|
+
inString = false;
|
|
2884
|
+
continue;
|
|
2885
|
+
}
|
|
2886
|
+
if (ch === '"') {
|
|
2887
|
+
inString = true;
|
|
2888
|
+
continue;
|
|
2889
|
+
}
|
|
2890
|
+
if (ch === "{")
|
|
2891
|
+
depth++;
|
|
2892
|
+
else if (ch === "}") {
|
|
2893
|
+
depth--;
|
|
2894
|
+
if (depth === 0) {
|
|
2895
|
+
return {
|
|
2896
|
+
json: text.slice(start, i + 1),
|
|
2897
|
+
rest: text.slice(i + 1)
|
|
2898
|
+
};
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
return null;
|
|
2903
|
+
}
|
|
2904
|
+
async function runMcpStdio() {
|
|
2905
|
+
let buffer = Buffer.alloc(0);
|
|
2906
|
+
let draining = false;
|
|
2907
|
+
const onData = (chunk) => {
|
|
2908
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
2909
|
+
drain();
|
|
2910
|
+
};
|
|
2911
|
+
async function drain() {
|
|
2912
|
+
if (draining)
|
|
2913
|
+
return;
|
|
2914
|
+
draining = true;
|
|
2915
|
+
try {
|
|
2916
|
+
while (true) {
|
|
2917
|
+
const asText = buffer.toString("utf-8");
|
|
2918
|
+
const trimmedStart = asText.replace(/^\s+/, "");
|
|
2919
|
+
if (/^Content-Length\s*:/i.test(trimmedStart)) {
|
|
2920
|
+
const headerEnd = buffer.indexOf(`\r
|
|
2921
|
+
\r
|
|
2922
|
+
`);
|
|
2923
|
+
if (headerEnd < 0)
|
|
2924
|
+
return;
|
|
2925
|
+
const header = buffer.slice(0, headerEnd).toString("utf-8");
|
|
2926
|
+
const match = header.match(/Content-Length:\s*(\d+)/i);
|
|
2927
|
+
if (!match) {
|
|
2928
|
+
buffer = buffer.slice(headerEnd + 4);
|
|
2929
|
+
continue;
|
|
2930
|
+
}
|
|
2931
|
+
frameMode = "content-length";
|
|
2932
|
+
const len = Number(match[1]);
|
|
2933
|
+
const bodyStart = headerEnd + 4;
|
|
2934
|
+
if (buffer.length < bodyStart + len)
|
|
2935
|
+
return;
|
|
2936
|
+
const body = buffer.slice(bodyStart, bodyStart + len).toString("utf-8");
|
|
2937
|
+
buffer = buffer.slice(bodyStart + len);
|
|
2938
|
+
try {
|
|
2939
|
+
const msg = JSON.parse(body);
|
|
2940
|
+
const resp = await handleMcpMessage(msg);
|
|
2941
|
+
if (resp !== undefined)
|
|
2942
|
+
send(resp);
|
|
2943
|
+
} catch {}
|
|
2944
|
+
continue;
|
|
2945
|
+
}
|
|
2946
|
+
const nl = buffer.indexOf(`
|
|
2947
|
+
`);
|
|
2948
|
+
if (nl >= 0) {
|
|
2949
|
+
const line = buffer.slice(0, nl).toString("utf-8").trim();
|
|
2950
|
+
buffer = buffer.slice(nl + 1);
|
|
2951
|
+
if (line.startsWith("{")) {
|
|
2952
|
+
frameMode = "ndjson";
|
|
2953
|
+
try {
|
|
2954
|
+
const msg = JSON.parse(line);
|
|
2955
|
+
const resp = await handleMcpMessage(msg);
|
|
2956
|
+
if (resp !== undefined)
|
|
2957
|
+
send(resp);
|
|
2958
|
+
} catch {}
|
|
2959
|
+
}
|
|
2960
|
+
continue;
|
|
2961
|
+
}
|
|
2962
|
+
const text = buffer.toString("utf-8");
|
|
2963
|
+
const taken = takeJsonObject(text.trimStart());
|
|
2964
|
+
if (!taken)
|
|
2965
|
+
return;
|
|
2966
|
+
frameMode = "ndjson";
|
|
2967
|
+
buffer = Buffer.from(taken.rest, "utf-8");
|
|
2968
|
+
try {
|
|
2969
|
+
const msg = JSON.parse(taken.json);
|
|
2970
|
+
const resp = await handleMcpMessage(msg);
|
|
2971
|
+
if (resp !== undefined)
|
|
2972
|
+
send(resp);
|
|
2973
|
+
} catch {}
|
|
2974
|
+
}
|
|
2975
|
+
} finally {
|
|
2976
|
+
draining = false;
|
|
2977
|
+
if (buffer.length > 0)
|
|
2978
|
+
drain();
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
if (typeof process.stdin.resume === "function")
|
|
2982
|
+
process.stdin.resume();
|
|
2983
|
+
process.stdin.on("data", onData);
|
|
2984
|
+
await new Promise((resolve) => {
|
|
2985
|
+
process.stdin.on("end", () => resolve());
|
|
2986
|
+
process.stdin.on("close", () => resolve());
|
|
2987
|
+
});
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
// ../web-brain/src/bin-mcp.ts
|
|
2991
|
+
runMcpStdio();
|