// Copyright (C) 2026 Rob Colbert // Licensed under the Apache License, Version 2.0 import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { Readability } from "@mozilla/readability"; import { JSDOM } from "jsdom"; import { chromium, type Browser, type Page } from "playwright"; import TurndownService from "turndown"; export interface FetchResult { url: string; title: string; markdown: string; totalLineCount: number; shownLineCount: number; cacheStatus: "hit" | "miss" | "disabled"; } export class WebFetcher { private turndown: TurndownService; constructor(private cacheDir: string) { this.turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", hr: "---", }); this.turndown.remove([ "script", "style", "noscript", "nav", "footer", "header", "button", "input", "form", ]); } async fetchUrlWithRange( url: string, startLine: number = 1, endLine?: number, useCache: boolean = true, ): Promise { const result = await this.fetchUrl(url, useCache); if (startLine === 1 && endLine === undefined) { return result; } const lines = result.markdown.split("\n"); const startIdx = Math.max(0, startLine - 1); const endIdx = endLine !== undefined ? Math.min(endLine, lines.length) : lines.length; const selectedLines = lines.slice(startIdx, endIdx); return { ...result, markdown: selectedLines.join("\n"), shownLineCount: selectedLines.length, }; } private async fetchUrl(url: string, useCache: boolean): Promise { if (useCache) { const cached = await this.readFromCache(url); if (cached) { return { ...cached, cacheStatus: "hit" }; } } const fetched = await this.fetchFresh(url, useCache ? "miss" : "disabled"); if (useCache) { await this.writeToCache(url, fetched.title, fetched.markdown); } return fetched; } private async fetchFresh( url: string, cacheStatus: FetchResult["cacheStatus"], ): Promise { const browser: Browser = await chromium.launch({ headless: true }); const context = await browser.newContext(); const page: Page = await context.newPage(); try { await page.goto(url, { waitUntil: "networkidle", timeout: 30000 }); const title = await page.title(); const rawHtml = await page.content(); const doc = new JSDOM(rawHtml, { url }); const article = new Readability(doc.window.document).parse(); let htmlContent: string; let extractedTitle = title; if (article) { htmlContent = article.content || ""; extractedTitle = article.title || title; } else { htmlContent = await page.evaluate(() => { const main = document.querySelector("main") || document.body; return main.innerHTML; }); } const markdown = this.addLineNumbers(this.turndown.turndown(htmlContent)); const lineCount = markdown.split("\n").length; return { url, title: extractedTitle, markdown, totalLineCount: lineCount, shownLineCount: lineCount, cacheStatus, }; } finally { await browser.close(); } } private getCacheFilePath(url: string): string { const hash = crypto.createHash("sha256").update(url).digest("hex"); return path.join(this.cacheDir, `fetch-url-${hash}.md`); } private async ensureCacheDir(): Promise { await fs.mkdir(this.cacheDir, { recursive: true }); } private async readFromCache(url: string): Promise | null> { try { const content = await fs.readFile(this.getCacheFilePath(url), "utf-8"); const lines = content.split("\n"); const titleLine = lines.find((line) => line.startsWith("## Title:")); const fetchedLineIndex = lines.findIndex((line) => line.startsWith("## Fetched:")); const markdownStartIndex = fetchedLineIndex === -1 ? 0 : fetchedLineIndex + 2; const markdown = lines.slice(markdownStartIndex).join("\n"); const lineCount = markdown.split("\n").length; return { url, title: titleLine?.replace("## Title:", "").trim() || "Unknown", markdown, totalLineCount: lineCount, shownLineCount: lineCount, }; } catch { return null; } } private async writeToCache(url: string, title: string, markdown: string): Promise { await this.ensureCacheDir(); const content = [ `## URL: ${url}`, `## Title: ${title}`, `## Fetched: ${new Date().toISOString()}`, "", markdown, ].join("\n"); await fs.writeFile(this.getCacheFilePath(url), content, "utf-8"); } private addLineNumbers(text: string): string { return text .split("\n") .map((line, index) => `${index + 1}: ${line}`) .join("\n"); } }