import pLimit from "p-limit"; import { getWebSearchConfigPath } from "../config/path.ts"; import { loadSsrfConfig } from "../net/ssrf.ts"; import { sanitizeInlineDataUris } from "./data-uri-sanitize.ts"; import { extractHeadingTitle } from "./http-extractor.ts"; import { getFetchProviderDefinition, loadFetchProvider } from "./registry.ts"; import { getFetchRouting } from "./routing-config.ts"; import type { FetchOptions, FetchResult, RegisteredToolNames } from "./types.ts"; const CONFIG_PATH = getWebSearchConfigPath(); const CONCURRENCY = 3; const fetchLimit = pLimit(CONCURRENCY); export type { ExtractedContent, ExtractOptions, FetchOptions, FetchResult, RegisteredToolNames } from "./types.ts"; export { extractHeadingTitle, loadSsrfConfig }; export function loadSsrfAllowRanges(): string[] { return loadSsrfConfig().allowRanges; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function isAbortError(error: unknown): boolean { return errorMessage(error).toLowerCase().includes("abort"); } function isConfigError(error: unknown): boolean { return errorMessage(error).startsWith("Failed to parse ") || errorMessage(error).includes(" must be ") || errorMessage(error).includes(" contains an invalid provider"); } function isSafetyError(message: string): boolean { return message.startsWith("Blocked internal ") || message.startsWith("Blocked hostname ") || message.startsWith("Hostname not allowed ") || message.startsWith("Failed to resolve ") || message.startsWith("Too many redirects") || message === "Only HTTP and HTTPS URLs can be fetched remotely"; } function useful(result: FetchResult | null): result is FetchResult { return !!result && !result.error && result.content.trim().length > 0; } function notFound(result: FetchResult, toolNames?: RegisteredToolNames): FetchResult { const search = toolNames?.webSearch; return { ...result, error: `${result.error}\n\nThe origin reports that the page does not exist.${search ? ` Use ${search} to locate its current URL.` : ""}` }; } export async function extractContent(url: string, signal?: AbortSignal, options: FetchOptions = {}): Promise { let parsed: URL; try { parsed = new URL(url); } catch { return { url, title: "", content: "", error: "web_fetch requires an absolute HTTP(S) URL" }; } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return { url, title: "", content: "", error: "web_fetch only supports HTTP(S) URLs" }; if (options.mode === "raw") return (await (await loadFetchProvider("http")).fetch(url, signal, options))!; let routing; try { routing = getFetchRouting(); } catch (error) { return { url, title: "", content: "", error: errorMessage(error) }; } const providers = routing.providers.filter(id => routing.allowRemoteHostedProviders || !getFetchProviderDefinition(id).hosted); if (providers.length === 0) return { url, title: "", content: "", error: "Remote hosted fetch providers are disabled unless fetchRouting.allowRemoteHostedProviders is true" }; let httpResult: FetchResult | null = null; const errors: string[] = []; const runHttp = async (): Promise => { httpResult = await (await loadFetchProvider("http")).fetch(url, signal, options); if (!httpResult) return null; if (!httpResult.error) return httpResult; if (httpResult.status === 404 || httpResult.status === 410) return notFound(httpResult, options.toolNames); if (httpResult.error.startsWith("Unsupported content type") || httpResult.error.startsWith("Response too large") || isConfigError(httpResult.error) || isSafetyError(httpResult.error)) return httpResult; return null; }; if (providers[0] !== "http") { const gate = await runHttp(); if (gate) return gate; } for (const id of providers) { if (signal?.aborted) return { url, title: "", content: "", error: "Aborted" }; const provider = await loadFetchProvider(id); try { if (!(await provider.isAvailable())) continue; const result = id === "http" ? await runHttp() : await provider.fetch(url, signal, options); if (result && (id === "http" || useful(result))) return result; } catch (error) { if (isAbortError(error)) return { url, title: "", content: "", error: "Aborted" }; if (isConfigError(error)) return { ...(httpResult ?? { url, title: "", content: "", error: null }), error: errorMessage(error) }; errors.push(`${provider.label} fallback failed: ${errorMessage(error)}`); } } const base = httpResult ?? { url, title: "", content: "", error: "No web_fetch Provider returned content" }; let guidance = errors.length ? `${base.error}\n\nProvider errors:\n${errors.map(error => `- ${error}`).join("\n")}` : base.error; if (errors.some(error => error.startsWith("Bright Data fallback failed:"))) guidance += `\n\nSet brightdataApiKey and brightdataUnlockerZone in ${CONFIG_PATH} or BRIGHTDATA_API_KEY and BRIGHTDATA_UNLOCKER_ZONE.`; return { ...base, error: guidance }; } export async function fetchAllContent(urls: string[], signal?: AbortSignal, options?: FetchOptions): Promise { const results = await Promise.all(urls.map(url => fetchLimit(() => extractContent(url, signal, options)))); if (options?.mode === "raw") return results; return results.map((result, index) => { if (!result.content) return result; const sanitized = sanitizeInlineDataUris(result.content, `urls[${index}].content`); return sanitized.omissions.length ? { ...result, content: sanitized.text } : result; }); }