Browser Rendering
Readability Extractor
Extract clean article content with Browser Rendering + Mozilla Readability - main content without nav/ads for LLMs
Load a fully rendered page with Browser Rendering, then strip navigation, ads, and sidebars using readability-style heuristics. Returns title, author (when detectable), body text, word count, and estimated read time.
Features
- GET /extract - rendered DOM extraction with readability heuristics
- Returns title, author, body, wordCount, readTimeMinutes
- Uses @cloudflare/puppeteer like other Browser Rendering experiments
API Reference
GET /extract
url string (required) - Article URL (http or https).
Example Request
curl "https://your-worker.workers.dev/extract?url=https://example.com/article"Error Codes
400-INVALID_URL502-EXTRACT_ERROR
Use Cases
- Build reading-mode or newsletter digest pipelines
- Extract main content from JavaScript-heavy news sites
- Prototype RAG document ingestion from article URLs
Limitations
- Requires Browser Rendering on your account
- Heuristic extraction; not identical to Mozilla Readability
- Local dev may need
wrangler dev --remotefor browser binding
Use in your project
Copy these files into an existing Worker. Prefer Deployment to try the full experiment first. Source: apps/experiments/readability-extractor.
DependenciesNoneBindingsBrowser RenderingPlatformWorkers runtime
import puppeteer from "@cloudflare/puppeteer";import type { Page } from "@cloudflare/puppeteer";import { DEFAULT_VIEWPORT, NAVIGATION_TIMEOUT_MS } from "../constants/defaults";import { buildExtractResponse } from "./readability";import type { RawReadabilityContent } from "../types/extract";import type { ReadabilityExtractResponse } from "../types/extract";export async function withBrowserPage<T>( browserBinding: Fetcher, url: string, fn: (page: Page) => Promise<T>): Promise<T> { let browser: Awaited<ReturnType<typeof puppeteer.launch>> | null = null; try { browser = await puppeteer.launch(browserBinding); const page = await browser.newPage(); await page.setViewport(DEFAULT_VIEWPORT); await page.goto(url, { waitUntil: "networkidle0", timeout: NAVIGATION_TIMEOUT_MS, }); return await fn(page); } finally { if (browser) { try { await browser.close(); } catch { /* ignore close errors */ } } }}export async function extractReadableContent( browserBinding: Fetcher, url: string): Promise<ReadabilityExtractResponse> { const raw = await withBrowserPage(browserBinding, url, async (page) => { return page.evaluate((): RawReadabilityContent => { const removeSelectors = [ "script", "style", "noscript", "nav", "footer", "aside", "iframe", "svg", "[aria-hidden='true']", ".sidebar", ".advertisement", ".ad", ".comments", ".comment", ".social-share", ".newsletter", ".cookie-banner", ]; const authorMeta = document.querySelector('meta[name="author"]')?.getAttribute("content") ?? document.querySelector('meta[property="article:author"]')?.getAttribute("content") ?? document.querySelector('meta[property="og:article:author"]')?.getAttribute("content") ?? document.querySelector("[rel='author']")?.textContent ?? null; const title = document.querySelector('meta[property="og:title"]')?.getAttribute("content")?.trim() || document.title.trim(); const candidateSelectors = [ "article", "main", "[role='main']", ".post-content", ".article-body", ".entry-content", ".article-content", "#content", ".content", ]; let contentRoot: Element = document.body; let bestScore = 0; for (const selector of candidateSelectors) { const elements = Array.from(document.querySelectorAll(selector)); for (const element of elements) { const textLength = element.textContent?.replace(/\s+/g, " ").trim().length ?? 0; const paragraphCount = element.querySelectorAll("p").length; const score = textLength + paragraphCount * 120; if (score > bestScore) { bestScore = score; contentRoot = element; } } } if (bestScore === 0) { const paragraphs = Array.from(document.querySelectorAll("p")); let bestParagraphGroup: Element = document.body; let groupScore = 0; for (const paragraph of paragraphs) { const parent = paragraph.parentElement; if (!parent) continue; const score = parent.textContent?.replace(/\s+/g, " ").trim().length ?? 0; if (score > groupScore) { groupScore = score; bestParagraphGroup = parent; } } contentRoot = bestParagraphGroup; } const clone = contentRoot.cloneNode(true) as HTMLElement; for (const selector of removeSelectors) { clone.querySelectorAll(selector).forEach((node: Element) => node.remove()); } const body = clone.innerText.replace(/\s+/g, " ").trim(); return { title, author: authorMeta?.trim() || null, body, }; }); }); return buildExtractResponse(url, raw);}Deployment
Configure bindings
Browser binding BROWSER and nodejs_compat_v2 in wrangler.json.
Test your deployment
See the experiment README for curl examples.
Local Development
cd apps/experiments/readability-extractor
npm install
npm run devConfiguration
Browser binding BROWSER and nodejs_compat_v2 in wrangler.json.