import { Readability } from "@mozilla/readability"; import { parseHTML } from "linkedom"; import TurndownService from "turndown"; import { activityMonitor } from "../observability/activity.ts"; import { fetchRemoteUrl, loadFetchContentDomainPolicy, loadSsrfConfig } from "../net/ssrf.ts"; import type { ProxiedRequestInit } from "../net/proxy.ts"; import type { FetchOptions, FetchResult } from "./types.ts"; const DEFAULT_TIMEOUT_MS = 30_000; const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; const MIN_READABLE_CHARS = 100; const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" }); function requestSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal { const timeout = AbortSignal.timeout(timeoutMs); return signal ? AbortSignal.any([signal, timeout]) : timeout; } async function readBuffer(response: Response): Promise { const declared = Number(response.headers.get("content-length")); if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) throw new Error("Response too large (5MB)"); const reader = response.body?.getReader(); if (!reader) { const buffer = await response.arrayBuffer(); if (buffer.byteLength > MAX_RESPONSE_BYTES) throw new Error("Response too large (5MB)"); return buffer; } const chunks: Uint8Array[] = []; let size = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; if (!value) continue; size += value.byteLength; if (size > MAX_RESPONSE_BYTES) { await reader.cancel(); throw new Error("Response too large (5MB)"); } chunks.push(value); } } finally { reader.releaseLock(); } const output = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { output.set(chunk, offset); offset += chunk.byteLength; } return output.buffer; } async function readText(response: Response): Promise { const buffer = await readBuffer(response); const charset = response.headers.get("content-type")?.match(/charset\s*=\s*["']?([^;"'\s]+)/i)?.[1]; try { return new TextDecoder(charset || "utf-8").decode(buffer); } catch { return new TextDecoder().decode(buffer); } } function isTextType(contentType: string): boolean { const mime = contentType.split(";", 1)[0].trim().toLowerCase(); return !mime || mime.startsWith("text/") || mime === "application/json" || mime === "application/ld+json" || mime === "application/xml" || mime === "application/xhtml+xml" || mime.endsWith("+json") || mime.endsWith("+xml"); } export function extractHeadingTitle(text: string): string | null { const match = text.match(/^#{1,2}\s+(.+)/m); return match?.[1]?.replace(/\*+/g, "").trim() || null; } function textTitle(text: string, url: string): string { return extractHeadingTitle(text) ?? new URL(url).pathname.split("/").filter(Boolean).at(-1) ?? url; } function htmlToMarkdown(html: string, url: string): { title: string; content: string } { const { document } = parseHTML(html); const title = document.title?.trim() || new URL(url).hostname; const article = new Readability(document as unknown as Document).parse(); const source = typeof article?.content === "string" && article.content.trim() ? article.content : document.body?.innerHTML || ""; return { title: article?.title?.trim() || title, content: turndown.turndown(source).trim() }; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } export async function fetchWithHttp(url: string, signal?: AbortSignal, options: FetchOptions = {}): Promise { const activityId = activityMonitor.logStart({ type: "fetch", url }); try { const ssrf = loadSsrfConfig(); const init: ProxiedRequestInit = { signal: requestSignal(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, signal), __proxy: options.proxy, headers: { "User-Agent": "OpenAI File Downloader, XaiImageApiFetch/1.0", Accept: "text/html,application/xhtml+xml,application/json,text/plain,text/markdown,application/xml;q=0.9,*/*;q=0.1", }, }; const response = await fetchRemoteUrl(url, init, { allowRanges: ssrf.allowRanges, trustEnvProxy: options.proxy === undefined && ssrf.trustEnvProxy, domainPolicy: loadFetchContentDomainPolicy(), ...(options.lookup ? { lookup: options.lookup } : {}), }); const contentType = response.headers.get("content-type") || ""; const mimeType = contentType.split(";", 1)[0].trim().toLowerCase(); if (!isTextType(contentType)) { activityMonitor.logComplete(activityId, response.status); return { url, title: "", content: "", error: `${options.mode === "raw" ? "Unsupported content type in raw mode" : "Unsupported content type"}: ${mimeType || "unknown"}`, mimeType, status: response.status }; } const text = await readText(response); if (options.mode === "raw") { activityMonitor.logComplete(activityId, response.status); return { url, title: textTitle(text, url), content: text, error: null, mimeType, status: response.status }; } if (!response.ok) { activityMonitor.logComplete(activityId, response.status); return { url, title: "", content: "", error: `HTTP ${response.status}: ${response.statusText}`, mimeType, status: response.status }; } const isHtml = contentType.includes("text/html") || contentType.includes("application/xhtml+xml"); const extracted = isHtml ? htmlToMarkdown(text, response.url || url) : { title: textTitle(text, url), content: text }; activityMonitor.logComplete(activityId, response.status); if (!extracted.content || (isHtml && extracted.content.length < MIN_READABLE_CHARS)) { return { url, title: extracted.title, content: extracted.content, error: "Extracted HTML content appears incomplete", mimeType, status: response.status }; } return { url, ...extracted, error: null, mimeType, status: response.status }; } catch (error) { const message = errorMessage(error).toLowerCase().includes("abort") ? "Aborted" : errorMessage(error); if (message === "Aborted") activityMonitor.logComplete(activityId, 0); else activityMonitor.logError(activityId, message); return { url, title: "", content: "", error: message }; } }