src / research / web.ts
src / research / web.ts
import { createHash } from "node:crypto";
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { AgenticError } from "../core/errors";
export type SearchProvider = "auto" | "duckduckgo" | "wikipedia" | "searxng";
export interface WebSearchResult {
rank: number;
title: string;
url: string;
snippet: string;
provider: Exclude<SearchProvider, "auto">;
}
export interface WebSearchResponse {
query: string;
provider: SearchProvider;
results: WebSearchResult[];
fetchedAt: string;
}
export interface FetchedWebPage {
requestedUrl: string;
url: string;
status: number;
contentType: string;
title?: string;
text: string;
links: Array<{ text: string; url: string }>;
bytes: number;
truncated: boolean;
sha256: string;
fetchedAt: string;
}
export interface WebServiceOptions {
timeoutMs: number;
maxResponseBytes: number;
maxTextChars: number;
maxLinks: number;
userAgent: string;
allowPrivateNetwork: boolean;
allowedDomains: string[];
searxngBaseUrl?: string;
}
const DEFAULT_OPTIONS: WebServiceOptions = {
timeoutMs: 20_000,
maxResponseBytes: 4_000_000,
maxTextChars: 80_000,
maxLinks: 200,
userAgent:
"Mozilla/5.0 (compatible; LMStudio-Agentic-Workspace/0.2; +https://lmstudio.ai/)",
allowPrivateNetwork: false,
allowedDomains: [],
};
function decodeHtml(value: string): string {
const named: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
ndash: "-",
mdash: "-",
hellip: "...",
};
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (_match, entity: string) => {
if (entity.startsWith("#x") || entity.startsWith("#X")) {
const code = Number.parseInt(entity.slice(2), 16);
return Number.isFinite(code) ? String.fromCodePoint(code) : "";
}
if (entity.startsWith("#")) {
const code = Number.parseInt(entity.slice(1), 10);
return Number.isFinite(code) ? String.fromCodePoint(code) : "";
}
return named[entity.toLowerCase()] ?? `&${entity};`;
});
}
function stripTags(value: string): string {
return decodeHtml(value.replace(/<[^>]+>/g, " "))
.replace(/\s+/g, " ")
.trim();
}
function normalizedHostname(hostname: string): string {
const lower = hostname.toLowerCase().replace(/\.$/, "");
return lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
}
function hostAllowed(hostname: string, allowedDomains: string[]): boolean {
if (allowedDomains.length === 0) return true;
const host = normalizedHostname(hostname);
return allowedDomains.some((entry) => {
const allowed = entry.toLowerCase().trim().replace(/^\*\./, "").replace(/\.$/, "");
return allowed !== "" && (host === allowed || host.endsWith(`.${allowed}`));
});
}
function isPrivateIpv4(address: string): boolean {
const parts = address.split(".").map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return true;
}
const [a, b] = parts;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 198 && (b === 18 || b === 19)) ||
a >= 224
);
}
function isPrivateIp(address: string): boolean {
const normalized = address.toLowerCase().split("%")[0];
if (normalized.startsWith("::ffff:")) {
return isPrivateIpv4(normalized.slice("::ffff:".length));
}
const kind = isIP(normalized);
if (kind === 4) return isPrivateIpv4(normalized);
if (kind !== 6) return true;
return (
normalized === "::" ||
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
normalized.startsWith("fe8") ||
normalized.startsWith("fe9") ||
normalized.startsWith("fea") ||
normalized.startsWith("feb") ||
normalized.startsWith("ff")
);
}
function safeUrl(raw: string): URL {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new AgenticError("INVALID_INPUT", `Invalid URL: ${raw}`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new AgenticError("PROTECTED_PATH", "Only http:// and https:// URLs are allowed.");
}
if (url.username || url.password) {
throw new AgenticError("PROTECTED_PATH", "URLs containing credentials are not allowed.");
}
return url;
}
async function assertNetworkTarget(url: URL, options: WebServiceOptions): Promise<void> {
if (!hostAllowed(url.hostname, options.allowedDomains)) {
throw new AgenticError(
"PROTECTED_PATH",
`Domain is not in the configured web allowlist: ${url.hostname}`,
);
}
if (options.allowPrivateNetwork) return;
const host = normalizedHostname(url.hostname);
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) {
throw new AgenticError("PROTECTED_PATH", `Private/local network target rejected: ${host}`);
}
if (isIP(host)) {
if (isPrivateIp(host)) {
throw new AgenticError("PROTECTED_PATH", `Private network target rejected: ${host}`);
}
return;
}
let addresses: Array<{ address: string }>;
try {
addresses = await lookup(host, { all: true, verbatim: true });
} catch (error) {
throw new AgenticError(
"NOT_FOUND",
`DNS lookup failed for ${host}: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (addresses.length === 0 || addresses.some((entry) => isPrivateIp(entry.address))) {
throw new AgenticError("PROTECTED_PATH", `Private network resolution rejected: ${host}`);
}
}
function absolutizeLink(base: string, raw: string): string | undefined {
try {
const url = new URL(decodeHtml(raw), base);
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
url.hash = "";
return url.toString();
} catch {
return undefined;
}
}
function htmlToText(html: string, baseUrl: string, maxLinks: number): {
title?: string;
text: string;
links: Array<{ text: string; url: string }>;
} {
const titleMatch = html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
const title = titleMatch ? stripTags(titleMatch[1]) : undefined;
const links: Array<{ text: string; url: string }> = [];
const seen = new Set<string>();
const anchorPattern = /<a\b[^>]*href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>([\s\S]*?)<\/a>/gi;
for (const match of html.matchAll(anchorPattern)) {
if (links.length >= maxLinks) break;
const url = absolutizeLink(baseUrl, match[1] ?? match[2] ?? match[3] ?? "");
if (!url || seen.has(url)) continue;
seen.add(url);
links.push({ text: stripTags(match[4]).slice(0, 300), url });
}
const cleaned = html
.replace(/<!--[\s\S]*?-->/g, " ")
.replace(/<(script|style|noscript|svg|canvas|template)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
.replace(/<(br|hr)\b[^>]*>/gi, "\n")
.replace(/<\/(p|div|section|article|main|header|footer|li|h[1-6]|tr|table|blockquote)>/gi, "\n")
.replace(/<li\b[^>]*>/gi, "\n- ")
.replace(/<[^>]+>/g, " ");
const text = decodeHtml(cleaned)
.replace(/\r/g, "")
.replace(/[\t ]+/g, " ")
.replace(/ *\n */g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
return { ...(title ? { title } : {}), text, links };
}
function duckDuckGoRedirect(raw: string): string | undefined {
const absolute = raw.startsWith("//") ? `https:${raw}` : raw;
try {
const url = new URL(decodeHtml(absolute), "https://duckduckgo.com/");
const redirected = url.searchParams.get("uddg");
return redirected ? decodeURIComponent(redirected) : url.toString();
} catch {
return undefined;
}
}
function parseDuckDuckGo(html: string, limit: number): WebSearchResult[] {
const anchors = [
...html.matchAll(
/<a\b[^>]*class=(?:"[^"]*result__a[^"]*"|'[^']*result__a[^']*')[^>]*href=(?:"([^"]+)"|'([^']+)')[^>]*>([\s\S]*?)<\/a>/gi,
),
];
const snippets = [
...html.matchAll(
/<(?:a|div|span)\b[^>]*class=(?:"[^"]*result__snippet[^"]*"|'[^']*result__snippet[^']*')[^>]*>([\s\S]*?)<\/(?:a|div|span)>/gi,
),
].map((match) => stripTags(match[1]));
const results: WebSearchResult[] = [];
const seen = new Set<string>();
for (const [index, match] of anchors.entries()) {
if (results.length >= limit) break;
const url = duckDuckGoRedirect(match[1] ?? match[2] ?? "");
if (!url || seen.has(url)) continue;
seen.add(url);
results.push({
rank: results.length + 1,
title: stripTags(match[3]).slice(0, 500),
url,
snippet: (snippets[index] ?? "").slice(0, 1200),
provider: "duckduckgo",
});
}
return results;
}
export class WebResearchService {
public readonly options: WebServiceOptions;
public constructor(options: Partial<WebServiceOptions> = {}) {
this.options = { ...DEFAULT_OPTIONS, ...options };
}
public async validateUrl(rawUrl: string): Promise<string> {
const url = safeUrl(rawUrl);
await assertNetworkTarget(url, this.options);
return url.toString();
}
public async search(
query: string,
input: { provider?: SearchProvider; maxResults?: number } = {},
): Promise<WebSearchResponse> {
const cleaned = query.trim();
if (!cleaned) throw new AgenticError("INVALID_INPUT", "Search query may not be empty.");
if (cleaned.length > 2000) {
throw new AgenticError("INVALID_INPUT", "Search query is limited to 2,000 characters.");
}
const provider = input.provider ?? "auto";
const limit = Math.max(1, Math.min(input.maxResults ?? 10, 30));
let results: WebSearchResult[] = [];
if (provider === "wikipedia") {
results = await this.searchWikipedia(cleaned, limit);
} else if (provider === "searxng") {
results = await this.searchSearxng(cleaned, limit);
} else if (provider === "duckduckgo") {
results = await this.searchDuckDuckGo(cleaned, limit);
} else {
try {
results = this.options.searxngBaseUrl
? await this.searchSearxng(cleaned, limit)
: await this.searchDuckDuckGo(cleaned, limit);
} catch {
results = [];
}
if (results.length === 0) results = await this.searchWikipedia(cleaned, limit);
}
return {
query: cleaned,
provider,
results: results.slice(0, limit).map((result, index) => ({ ...result, rank: index + 1 })),
fetchedAt: new Date().toISOString(),
};
}
public async fetchPage(
rawUrl: string,
input: { maxTextChars?: number; maxResponseBytes?: number } = {},
): Promise<FetchedWebPage> {
const response = await this.request(rawUrl, {
maxBytes: input.maxResponseBytes ?? this.options.maxResponseBytes,
accept: "text/html,application/xhtml+xml,text/plain,application/json,application/xml;q=0.8,*/*;q=0.2",
});
const contentType = response.contentType;
const raw = response.body.toString("utf8");
let title: string | undefined;
let text: string;
let links: Array<{ text: string; url: string }> = [];
if (contentType.includes("html") || /<html\b/i.test(raw.slice(0, 1000))) {
const parsed = htmlToText(raw, response.url, this.options.maxLinks);
title = parsed.title;
text = parsed.text;
links = parsed.links;
} else if (contentType.includes("json")) {
try {
text = JSON.stringify(JSON.parse(raw), null, 2);
} catch {
text = raw;
}
} else {
text = raw.replace(/\u0000/g, "").trim();
}
const maxText = Math.max(1000, Math.min(input.maxTextChars ?? this.options.maxTextChars, 500_000));
const truncated = response.truncated || text.length > maxText;
if (text.length > maxText) text = text.slice(0, maxText);
return {
requestedUrl: rawUrl,
url: response.url,
status: response.status,
contentType,
...(title ? { title } : {}),
text,
links,
bytes: response.body.length,
truncated,
sha256: createHash("sha256").update(response.body).digest("hex"),
fetchedAt: new Date().toISOString(),
};
}
private async searchDuckDuckGo(query: string, limit: number): Promise<WebSearchResult[]> {
const target = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
const response = await this.request(target, {
maxBytes: 2_000_000,
accept: "text/html",
method: "POST",
body: new URLSearchParams({ q: query }).toString(),
contentType: "application/x-www-form-urlencoded",
});
const results = parseDuckDuckGo(response.body.toString("utf8"), limit);
if (results.length === 0) {
throw new AgenticError("NOT_FOUND", "DuckDuckGo returned no parseable results.");
}
return results;
}
private async searchWikipedia(query: string, limit: number): Promise<WebSearchResult[]> {
const target = new URL("https://en.wikipedia.org/w/api.php");
target.searchParams.set("action", "query");
target.searchParams.set("list", "search");
target.searchParams.set("srsearch", query);
target.searchParams.set("srlimit", String(limit));
target.searchParams.set("format", "json");
target.searchParams.set("utf8", "1");
const response = await this.request(target.toString(), {
maxBytes: 2_000_000,
accept: "application/json",
});
const payload = JSON.parse(response.body.toString("utf8")) as {
query?: { search?: Array<{ title?: string; snippet?: string }> };
};
return (payload.query?.search ?? []).slice(0, limit).map((item, index) => ({
rank: index + 1,
title: item.title ?? "Untitled Wikipedia result",
url: `https://en.wikipedia.org/wiki/${encodeURIComponent((item.title ?? "").replaceAll(" ", "_"))}`,
snippet: stripTags(item.snippet ?? ""),
provider: "wikipedia",
}));
}
private async searchSearxng(query: string, limit: number): Promise<WebSearchResult[]> {
if (!this.options.searxngBaseUrl) {
throw new AgenticError("INVALID_INPUT", "No SearXNG base URL is configured.");
}
const target = new URL("search", this.options.searxngBaseUrl.endsWith("/")
? this.options.searxngBaseUrl
: `${this.options.searxngBaseUrl}/`);
target.searchParams.set("q", query);
target.searchParams.set("format", "json");
const response = await this.request(target.toString(), {
maxBytes: 3_000_000,
accept: "application/json",
});
const payload = JSON.parse(response.body.toString("utf8")) as {
results?: Array<{ title?: string; url?: string; content?: string }>;
};
return (payload.results ?? [])
.filter((item) => typeof item.url === "string" && item.url !== "")
.slice(0, limit)
.map((item, index) => ({
rank: index + 1,
title: item.title?.trim() || item.url || "Untitled result",
url: item.url as string,
snippet: item.content?.trim() || "",
provider: "searxng",
}));
}
private async request(
rawUrl: string,
input: {
maxBytes: number;
accept: string;
method?: "GET" | "POST";
body?: string;
contentType?: string;
},
): Promise<{
url: string;
status: number;
contentType: string;
body: Buffer;
truncated: boolean;
}> {
let url = safeUrl(rawUrl);
const maxBytes = Math.max(10_000, Math.min(input.maxBytes, 50_000_000));
for (let redirects = 0; redirects <= 5; redirects++) {
await assertNetworkTarget(url, this.options);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.options.timeoutMs);
let response: Response;
try {
response = await fetch(url, {
method: input.method ?? "GET",
body: input.body,
headers: {
"user-agent": this.options.userAgent,
accept: input.accept,
...(input.contentType ? { "content-type": input.contentType } : {}),
},
redirect: "manual",
signal: controller.signal,
});
} catch (error) {
if (controller.signal.aborted) {
throw new AgenticError("TIMEOUT", `Web request timed out: ${url.toString()}`);
}
throw new AgenticError(
"INTERNAL",
`Web request failed for ${url.toString()}: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
clearTimeout(timer);
}
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get("location");
if (!location) throw new AgenticError("INTERNAL", "Redirect response omitted Location.");
if (redirects === 5) throw new AgenticError("INTERNAL", "Too many web redirects.");
url = safeUrl(new URL(location, url).toString());
continue;
}
const chunks: Buffer[] = [];
let bytes = 0;
let truncated = false;
if (response.body) {
const reader = response.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = Buffer.from(value);
const remaining = maxBytes - bytes;
if (remaining <= 0) {
truncated = true;
await reader.cancel();
break;
}
if (chunk.length > remaining) {
chunks.push(chunk.subarray(0, remaining));
bytes += remaining;
truncated = true;
await reader.cancel();
break;
}
chunks.push(chunk);
bytes += chunk.length;
}
} finally {
reader.releaseLock();
}
}
if (!response.ok) {
const preview = Buffer.concat(chunks).toString("utf8").slice(0, 500);
throw new AgenticError(
"NOT_FOUND",
`HTTP ${response.status} for ${url.toString()}${preview ? `: ${preview}` : ""}`,
);
}
return {
url: response.url || url.toString(),
status: response.status,
contentType: response.headers.get("content-type")?.toLowerCase() ?? "application/octet-stream",
body: Buffer.concat(chunks),
truncated,
};
}
throw new AgenticError("INTERNAL", "Web request redirect loop.");
}
}
import { createHash } from "node:crypto";
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { AgenticError } from "../core/errors";
export type SearchProvider = "auto" | "duckduckgo" | "wikipedia" | "searxng";
export interface WebSearchResult {
rank: number;
title: string;
url: string;
snippet: string;
provider: Exclude<SearchProvider, "auto">;
}
export interface WebSearchResponse {
query: string;
provider: SearchProvider;
results: WebSearchResult[];
fetchedAt: string;
}
export interface FetchedWebPage {
requestedUrl: string;
url: string;
status: number;
contentType: string;
title?: string;
text: string;
links: Array<{ text: string; url: string }>;
bytes: number;
truncated: boolean;
sha256: string;
fetchedAt: string;
}
export interface WebServiceOptions {
timeoutMs: number;
maxResponseBytes: number;
maxTextChars: number;
maxLinks: number;
userAgent: string;
allowPrivateNetwork: boolean;
allowedDomains: string[];
searxngBaseUrl?: string;
}
const DEFAULT_OPTIONS: WebServiceOptions = {
timeoutMs: 20_000,
maxResponseBytes: 4_000_000,
maxTextChars: 80_000,
maxLinks: 200,
userAgent:
"Mozilla/5.0 (compatible; LMStudio-Agentic-Workspace/0.2; +https://lmstudio.ai/)",
allowPrivateNetwork: false,
allowedDomains: [],
};
function decodeHtml(value: string): string {
const named: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
ndash: "-",
mdash: "-",
hellip: "...",
};
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (_match, entity: string) => {
if (entity.startsWith("#x") || entity.startsWith("#X")) {
const code = Number.parseInt(entity.slice(2), 16);
return Number.isFinite(code) ? String.fromCodePoint(code) : "";
}
if (entity.startsWith("#")) {
const code = Number.parseInt(entity.slice(1), 10);
return Number.isFinite(code) ? String.fromCodePoint(code) : "";
}
return named[entity.toLowerCase()] ?? `&${entity};`;
});
}
function stripTags(value: string): string {
return decodeHtml(value.replace(/<[^>]+>/g, " "))
.replace(/\s+/g, " ")
.trim();
}
function normalizedHostname(hostname: string): string {
const lower = hostname.toLowerCase().replace(/\.$/, "");
return lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
}
function hostAllowed(hostname: string, allowedDomains: string[]): boolean {
if (allowedDomains.length === 0) return true;
const host = normalizedHostname(hostname);
return allowedDomains.some((entry) => {
const allowed = entry.toLowerCase().trim().replace(/^\*\./, "").replace(/\.$/, "");
return allowed !== "" && (host === allowed || host.endsWith(`.${allowed}`));
});
}
function isPrivateIpv4(address: string): boolean {
const parts = address.split(".").map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return true;
}
const [a, b] = parts;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 198 && (b === 18 || b === 19)) ||
a >= 224
);
}
function isPrivateIp(address: string): boolean {
const normalized = address.toLowerCase().split("%")[0];
if (normalized.startsWith("::ffff:")) {
return isPrivateIpv4(normalized.slice("::ffff:".length));
}
const kind = isIP(normalized);
if (kind === 4) return isPrivateIpv4(normalized);
if (kind !== 6) return true;
return (
normalized === "::" ||
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
normalized.startsWith("fe8") ||
normalized.startsWith("fe9") ||
normalized.startsWith("fea") ||
normalized.startsWith("feb") ||
normalized.startsWith("ff")
);
}
function safeUrl(raw: string): URL {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new AgenticError("INVALID_INPUT", `Invalid URL: ${raw}`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new AgenticError("PROTECTED_PATH", "Only http:// and https:// URLs are allowed.");
}
if (url.username || url.password) {
throw new AgenticError("PROTECTED_PATH", "URLs containing credentials are not allowed.");
}
return url;
}
async function assertNetworkTarget(url: URL, options: WebServiceOptions): Promise<void> {
if (!hostAllowed(url.hostname, options.allowedDomains)) {
throw new AgenticError(
"PROTECTED_PATH",
`Domain is not in the configured web allowlist: ${url.hostname}`,
);
}
if (options.allowPrivateNetwork) return;
const host = normalizedHostname(url.hostname);
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) {
throw new AgenticError("PROTECTED_PATH", `Private/local network target rejected: ${host}`);
}
if (isIP(host)) {
if (isPrivateIp(host)) {
throw new AgenticError("PROTECTED_PATH", `Private network target rejected: ${host}`);
}
return;
}
let addresses: Array<{ address: string }>;
try {
addresses = await lookup(host, { all: true, verbatim: true });
} catch (error) {
throw new AgenticError(
"NOT_FOUND",
`DNS lookup failed for ${host}: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (addresses.length === 0 || addresses.some((entry) => isPrivateIp(entry.address))) {
throw new AgenticError("PROTECTED_PATH", `Private network resolution rejected: ${host}`);
}
}
function absolutizeLink(base: string, raw: string): string | undefined {
try {
const url = new URL(decodeHtml(raw), base);
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
url.hash = "";
return url.toString();
} catch {
return undefined;
}
}
function htmlToText(html: string, baseUrl: string, maxLinks: number): {
title?: string;
text: string;
links: Array<{ text: string; url: string }>;
} {
const titleMatch = html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
const title = titleMatch ? stripTags(titleMatch[1]) : undefined;
const links: Array<{ text: string; url: string }> = [];
const seen = new Set<string>();
const anchorPattern = /<a\b[^>]*href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>([\s\S]*?)<\/a>/gi;
for (const match of html.matchAll(anchorPattern)) {
if (links.length >= maxLinks) break;
const url = absolutizeLink(baseUrl, match[1] ?? match[2] ?? match[3] ?? "");
if (!url || seen.has(url)) continue;
seen.add(url);
links.push({ text: stripTags(match[4]).slice(0, 300), url });
}
const cleaned = html
.replace(/<!--[\s\S]*?-->/g, " ")
.replace(/<(script|style|noscript|svg|canvas|template)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
.replace(/<(br|hr)\b[^>]*>/gi, "\n")
.replace(/<\/(p|div|section|article|main|header|footer|li|h[1-6]|tr|table|blockquote)>/gi, "\n")
.replace(/<li\b[^>]*>/gi, "\n- ")
.replace(/<[^>]+>/g, " ");
const text = decodeHtml(cleaned)
.replace(/\r/g, "")
.replace(/[\t ]+/g, " ")
.replace(/ *\n */g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
return { ...(title ? { title } : {}), text, links };
}
function duckDuckGoRedirect(raw: string): string | undefined {
const absolute = raw.startsWith("//") ? `https:${raw}` : raw;
try {
const url = new URL(decodeHtml(absolute), "https://duckduckgo.com/");
const redirected = url.searchParams.get("uddg");
return redirected ? decodeURIComponent(redirected) : url.toString();
} catch {
return undefined;
}
}
function parseDuckDuckGo(html: string, limit: number): WebSearchResult[] {
const anchors = [
...html.matchAll(
/<a\b[^>]*class=(?:"[^"]*result__a[^"]*"|'[^']*result__a[^']*')[^>]*href=(?:"([^"]+)"|'([^']+)')[^>]*>([\s\S]*?)<\/a>/gi,
),
];
const snippets = [
...html.matchAll(
/<(?:a|div|span)\b[^>]*class=(?:"[^"]*result__snippet[^"]*"|'[^']*result__snippet[^']*')[^>]*>([\s\S]*?)<\/(?:a|div|span)>/gi,
),
].map((match) => stripTags(match[1]));
const results: WebSearchResult[] = [];
const seen = new Set<string>();
for (const [index, match] of anchors.entries()) {
if (results.length >= limit) break;
const url = duckDuckGoRedirect(match[1] ?? match[2] ?? "");
if (!url || seen.has(url)) continue;
seen.add(url);
results.push({
rank: results.length + 1,
title: stripTags(match[3]).slice(0, 500),
url,
snippet: (snippets[index] ?? "").slice(0, 1200),
provider: "duckduckgo",
});
}
return results;
}
export class WebResearchService {
public readonly options: WebServiceOptions;
public constructor(options: Partial<WebServiceOptions> = {}) {
this.options = { ...DEFAULT_OPTIONS, ...options };
}
public async validateUrl(rawUrl: string): Promise<string> {
const url = safeUrl(rawUrl);
await assertNetworkTarget(url, this.options);
return url.toString();
}
public async search(
query: string,
input: { provider?: SearchProvider; maxResults?: number } = {},
): Promise<WebSearchResponse> {
const cleaned = query.trim();
if (!cleaned) throw new AgenticError("INVALID_INPUT", "Search query may not be empty.");
if (cleaned.length > 2000) {
throw new AgenticError("INVALID_INPUT", "Search query is limited to 2,000 characters.");
}
const provider = input.provider ?? "auto";
const limit = Math.max(1, Math.min(input.maxResults ?? 10, 30));
let results: WebSearchResult[] = [];
if (provider === "wikipedia") {
results = await this.searchWikipedia(cleaned, limit);
} else if (provider === "searxng") {
results = await this.searchSearxng(cleaned, limit);
} else if (provider === "duckduckgo") {
results = await this.searchDuckDuckGo(cleaned, limit);
} else {
try {
results = this.options.searxngBaseUrl
? await this.searchSearxng(cleaned, limit)
: await this.searchDuckDuckGo(cleaned, limit);
} catch {
results = [];
}
if (results.length === 0) results = await this.searchWikipedia(cleaned, limit);
}
return {
query: cleaned,
provider,
results: results.slice(0, limit).map((result, index) => ({ ...result, rank: index + 1 })),
fetchedAt: new Date().toISOString(),
};
}
public async fetchPage(
rawUrl: string,
input: { maxTextChars?: number; maxResponseBytes?: number } = {},
): Promise<FetchedWebPage> {
const response = await this.request(rawUrl, {
maxBytes: input.maxResponseBytes ?? this.options.maxResponseBytes,
accept: "text/html,application/xhtml+xml,text/plain,application/json,application/xml;q=0.8,*/*;q=0.2",
});
const contentType = response.contentType;
const raw = response.body.toString("utf8");
let title: string | undefined;
let text: string;
let links: Array<{ text: string; url: string }> = [];
if (contentType.includes("html") || /<html\b/i.test(raw.slice(0, 1000))) {
const parsed = htmlToText(raw, response.url, this.options.maxLinks);
title = parsed.title;
text = parsed.text;
links = parsed.links;
} else if (contentType.includes("json")) {
try {
text = JSON.stringify(JSON.parse(raw), null, 2);
} catch {
text = raw;
}
} else {
text = raw.replace(/\u0000/g, "").trim();
}
const maxText = Math.max(1000, Math.min(input.maxTextChars ?? this.options.maxTextChars, 500_000));
const truncated = response.truncated || text.length > maxText;
if (text.length > maxText) text = text.slice(0, maxText);
return {
requestedUrl: rawUrl,
url: response.url,
status: response.status,
contentType,
...(title ? { title } : {}),
text,
links,
bytes: response.body.length,
truncated,
sha256: createHash("sha256").update(response.body).digest("hex"),
fetchedAt: new Date().toISOString(),
};
}
private async searchDuckDuckGo(query: string, limit: number): Promise<WebSearchResult[]> {
const target = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
const response = await this.request(target, {
maxBytes: 2_000_000,
accept: "text/html",
method: "POST",
body: new URLSearchParams({ q: query }).toString(),
contentType: "application/x-www-form-urlencoded",
});
const results = parseDuckDuckGo(response.body.toString("utf8"), limit);
if (results.length === 0) {
throw new AgenticError("NOT_FOUND", "DuckDuckGo returned no parseable results.");
}
return results;
}
private async searchWikipedia(query: string, limit: number): Promise<WebSearchResult[]> {
const target = new URL("https://en.wikipedia.org/w/api.php");
target.searchParams.set("action", "query");
target.searchParams.set("list", "search");
target.searchParams.set("srsearch", query);
target.searchParams.set("srlimit", String(limit));
target.searchParams.set("format", "json");
target.searchParams.set("utf8", "1");
const response = await this.request(target.toString(), {
maxBytes: 2_000_000,
accept: "application/json",
});
const payload = JSON.parse(response.body.toString("utf8")) as {
query?: { search?: Array<{ title?: string; snippet?: string }> };
};
return (payload.query?.search ?? []).slice(0, limit).map((item, index) => ({
rank: index + 1,
title: item.title ?? "Untitled Wikipedia result",
url: `https://en.wikipedia.org/wiki/${encodeURIComponent((item.title ?? "").replaceAll(" ", "_"))}`,
snippet: stripTags(item.snippet ?? ""),
provider: "wikipedia",
}));
}
private async searchSearxng(query: string, limit: number): Promise<WebSearchResult[]> {
if (!this.options.searxngBaseUrl) {
throw new AgenticError("INVALID_INPUT", "No SearXNG base URL is configured.");
}
const target = new URL("search", this.options.searxngBaseUrl.endsWith("/")
? this.options.searxngBaseUrl
: `${this.options.searxngBaseUrl}/`);
target.searchParams.set("q", query);
target.searchParams.set("format", "json");
const response = await this.request(target.toString(), {
maxBytes: 3_000_000,
accept: "application/json",
});
const payload = JSON.parse(response.body.toString("utf8")) as {
results?: Array<{ title?: string; url?: string; content?: string }>;
};
return (payload.results ?? [])
.filter((item) => typeof item.url === "string" && item.url !== "")
.slice(0, limit)
.map((item, index) => ({
rank: index + 1,
title: item.title?.trim() || item.url || "Untitled result",
url: item.url as string,
snippet: item.content?.trim() || "",
provider: "searxng",
}));
}
private async request(
rawUrl: string,
input: {
maxBytes: number;
accept: string;
method?: "GET" | "POST";
body?: string;
contentType?: string;
},
): Promise<{
url: string;
status: number;
contentType: string;
body: Buffer;
truncated: boolean;
}> {
let url = safeUrl(rawUrl);
const maxBytes = Math.max(10_000, Math.min(input.maxBytes, 50_000_000));
for (let redirects = 0; redirects <= 5; redirects++) {
await assertNetworkTarget(url, this.options);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.options.timeoutMs);
let response: Response;
try {
response = await fetch(url, {
method: input.method ?? "GET",
body: input.body,
headers: {
"user-agent": this.options.userAgent,
accept: input.accept,
...(input.contentType ? { "content-type": input.contentType } : {}),
},
redirect: "manual",
signal: controller.signal,
});
} catch (error) {
if (controller.signal.aborted) {
throw new AgenticError("TIMEOUT", `Web request timed out: ${url.toString()}`);
}
throw new AgenticError(
"INTERNAL",
`Web request failed for ${url.toString()}: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
clearTimeout(timer);
}
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get("location");
if (!location) throw new AgenticError("INTERNAL", "Redirect response omitted Location.");
if (redirects === 5) throw new AgenticError("INTERNAL", "Too many web redirects.");
url = safeUrl(new URL(location, url).toString());
continue;
}
const chunks: Buffer[] = [];
let bytes = 0;
let truncated = false;
if (response.body) {
const reader = response.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = Buffer.from(value);
const remaining = maxBytes - bytes;
if (remaining <= 0) {
truncated = true;
await reader.cancel();
break;
}
if (chunk.length > remaining) {
chunks.push(chunk.subarray(0, remaining));
bytes += remaining;
truncated = true;
await reader.cancel();
break;
}
chunks.push(chunk);
bytes += chunk.length;
}
} finally {
reader.releaseLock();
}
}
if (!response.ok) {
const preview = Buffer.concat(chunks).toString("utf8").slice(0, 500);
throw new AgenticError(
"NOT_FOUND",
`HTTP ${response.status} for ${url.toString()}${preview ? `: ${preview}` : ""}`,
);
}
return {
url: response.url || url.toString(),
status: response.status,
contentType: response.headers.get("content-type")?.toLowerCase() ?? "application/octet-stream",
body: Buffer.concat(chunks),
truncated,
};
}
throw new AgenticError("INTERNAL", "Web request redirect loop.");
}
}