import { JSDOM } from "jsdom" export interface FetchResult { url: string title: string | null author: string | null description: string | null domain: string | null published: string | null wordCount: number | null content: string } const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" /** * Browser-like request headers to reduce 403 blocks from bot detection. */ const BROWSER_HEADERS: Record = { "User-Agent": USER_AGENT, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate, br", "Cache-Control": "no-cache", "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Sec-Fetch-User": "?1", "Upgrade-Insecure-Requests": "1", } /** * Fetch a URL and extract its main content as Markdown via Defuddle. * * Uses native fetch + JSDOM + Defuddle (no headless browser) to * strip navigation, ads, sidebars, and clutter. * * JSDOM provides full DOM API support (including getComputedStyle) * which Defuddle needs for accurate hidden-element detection. */ export async function fetchPage( url: string, signal?: AbortSignal ): Promise { const { Defuddle } = await import("defuddle/node") let html: string try { let response = await fetch(url, { signal, redirect: "follow", headers: BROWSER_HEADERS, }) // Cloudflare blocks requests that fake a browser UA but fail the TLS // fingerprint check. Retrying with an honest UA often passes through. if ( response.status === 403 && response.headers.get("cf-mitigated") === "challenge" ) { response = await fetch(url, { signal, redirect: "follow", headers: { ...BROWSER_HEADERS, "User-Agent": "pi-fetch" }, }) } if (!response.ok) { throw new Error(`HTTP ${response.status} ${response.statusText}`) } html = await response.text() } catch (err: any) { if (err?.name === "AbortError") throw err throw new Error(`Failed to fetch ${url}: ${err?.message ?? err}`) } const dom = new JSDOM(html, { url, pretendToBeVisual: true }) const result = await Defuddle(dom.window.document, url, { markdown: true }) return { url, title: result.title ?? null, author: result.author ?? null, description: result.description ?? null, domain: result.domain ?? null, published: result.published ?? null, wordCount: result.wordCount ?? null, content: result.content ?? "", } } /** * Format a FetchResult into a human/LLM-readable Markdown string. */ export function formatResult(result: FetchResult): string { const lines: string[] = [] if (result.title) lines.push(`# ${result.title}`) const meta: string[] = [] if (result.author) meta.push(`**Author:** ${result.author}`) if (result.domain) meta.push(`**Domain:** ${result.domain}`) if (result.published) meta.push(`**Published:** ${result.published}`) if (result.wordCount) meta.push(`**Words:** ${result.wordCount}`) if (meta.length > 0) { lines.push("") lines.push(meta.join(" ยท ")) } if (result.description) { lines.push("") lines.push(`> ${result.description}`) } if (lines.length > 0) { lines.push("") lines.push("---") } lines.push("") lines.push(result.content || "(no content extracted)") return lines.join("\n") }