This site is not affiliated with or endorsed by Cloudflare, Inc. It showcases deployable experiments across most Cloudflare products.
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_URL
  • 502 - 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 --remote for 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

Click the deploy button

Deploy to Cloudflare Workers

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 dev

Configuration

Browser binding BROWSER and nodejs_compat_v2 in wrangler.json.

Cloudflare Features Used

On this page