C.W.K.
Stream
Lesson 02 of 07 · published

Readability Extraction — Mozilla's Reader Mode in Your Content Script

~12 min · readability, article, extraction, mozilla

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"You point at a news article, ClipDeck pulls out the headline, byline, and body — no ads, no related posts, no cookie banners. Lesson 2 is wiring Mozilla's Readability.js into the content script so 'save the whole article' becomes a single button."

Readability.js, Plainly

github.com/mozilla/readability is the JavaScript library that powers Firefox's Reader Mode. Same engine, MPL-licensed, no Firefox required to use it. The shape:

  • Input: a cloned document. Don't pass document directly — Readability mutates the tree it's given, and you do not want to mutate the page the user is reading.
  • Output: { title, byline, dir, content, textContent, length, excerpt, siteName, lang }. content is sanitized HTML; textContent is plain text.
  • Cost: about 50 KB minified. Fits in a content script. Adds maybe 50–100 ms to parse a typical article.

Vendoring vs Bundling

Two reasonable ways to ship Readability with ClipDeck:

  • Vendor: download Readability.js from the GitHub release, drop it under clipdeck/vendor/Readability.js, declare it in content_scripts.js array BEFORE content.js. The library exposes a global Readability. Simple. Auditable.
  • Bundle: npm install @mozilla/readability, import in your TS/JS source, bundle with esbuild/Rollup into a single content.js. Cleaner long-term. Required if you want tree-shaking or TypeScript.

ClipDeck v1 vendors. We'll move to bundling in Track 8.

The Cloning Trick

The standard incantation:

const documentClone = document.cloneNode(true);
const reader = new Readability(documentClone);
const article = reader.parse();

document.cloneNode(true) creates a deep copy. Readability strips out everything it considers non-article (nav, sidebars, footer, comments, ads) by physically removing those nodes from the clone. The user's page is untouched.

Reading the Result

Common fields:

  • title — the article title, cleaned up. Often missing the site-name suffix that document.title carries.
  • byline — author. Best-effort; often null on small blogs.
  • content — sanitized HTML. Safe to render in an extension iframe; still pass through a sanitizer like DOMPurify before injecting into the user's page.
  • textContent — plain text version of content. Use this for ClipDeck if you want clean text storage; use content if you want to preserve heading/paragraph structure.
  • length — character count of textContent. Useful for sanity-checking the extraction (very short = probably failed).
  • excerpt — first paragraph or so. Good for previews.
  • siteName, lang, dir — metadata.

If reader.parse() returns null, Readability decided the page is not article-shaped (too short, too cluttered, no clear main content). Fall back to the selection if there is one, or to the page title + URL for context.

The ClipDeck 'Save Full Article' Action

Wire it as a separate trigger from selection-save:

  • Popup button: "Save full article from this page."
  • Keyboard shortcut option (if you have a free combo).
  • Context menu item with contexts: ['page']: "Save this page to ClipDeck."

The handler messages the content script with {type: 'readArticle'}; the content script runs Readability and returns the article object; the SW builds a clip with the article's text, a flag isArticle: true, and the URL + title — same shape as a selection clip, just with more body.

Performance Note

Readability runs in tens of milliseconds on most pages and up to a few hundred ms on giant ones (long Wikipedia articles, archived forum threads). Run it inside the content script's main thread; users won't notice. If you ever needed to keep the page responsive during parse, you could offload to a Web Worker, but for one-shot per click it's overkill.

Clone the document. Hand the clone to Readability. Take the structured article object. The user's page never feels the parse; ClipDeck gets clean text + title + byline without any custom selector engineering.
When Readability isn't the right tool. Forum threads, comment sections, GitHub issues, multi-document SPAs — these have intentional structure that Readability flattens or discards. For these, custom selectors per domain (or accepting the user's selection of one comment at a time) works better. Readability is the right default for blog posts, news articles, long-form pages.

Code

manifest.json — load Readability.js BEFORE content.js so the global is available·json
{
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "exclude_matches": [
        "https://accounts.google.com/*",
        "https://*.bank.com/*"
      ],
      "js": [
        "vendor/Readability.js",
        "content.js"
      ],
      "run_at": "document_idle"
    }
  ]
}
content.js — readArticle handler that returns the structured article·javascript
// content.js — wire the readArticle tool into the dispatcher
CD_TOOLS.readability = async () => {
  if (typeof Readability !== "function") {
    throw new Error("Readability not loaded");
  }
  const documentClone = document.cloneNode(true);
  const reader = new Readability(documentClone);
  const article = reader.parse();
  if (!article) return { ok: false, reason: "not-article-shaped" };
  return {
    ok: true,
    article: {
      title: article.title,
      byline: article.byline,
      excerpt: article.excerpt,
      content: article.content,
      textContent: article.textContent,
      length: article.length,
      siteName: article.siteName,
      lang: article.lang,
    },
  };
};

// Top-level legacy handler for direct callers (popup, SW) that don't go
// through the dispatcher's `{type:'tool', name:'readability'}` envelope.
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type !== "readArticle") return;
  (async () => sendResponse(await CD_TOOLS.readability()))();
  return true;
});
background.js — context-menu save-article wiring·javascript
// background.js — save-article flow triggered by popup button or context menu
chrome.contextMenus.create({
  id: "clipdeck-save-article",
  title: "Save full article to ClipDeck",
  contexts: ["page"],
});

chrome.contextMenus.onClicked.addListener(async (info, tab) => {
  if (info.menuItemId !== "clipdeck-save-article" || !tab?.id) return;
  const response = await chrome.tabs.sendMessage(tab.id, { type: "readArticle" });
  if (!response?.ok) {
    console.warn("[ClipDeck SW] article extraction failed:", response?.reason);
    return;
  }
  const a = response.article;
  const clip = {
    id: crypto.randomUUID(),
    text: a.textContent,
    url: tab.url,
    title: a.title || tab.title || "",
    siteName: a.siteName,
    byline: a.byline,
    isArticle: true,
    savedAt: Date.now(),
  };
  const { clips = [] } = await chrome.storage.local.get("clips");
  await chrome.storage.local.set({ clips: [clip, ...clips] });
});

External links

Exercise

Download Readability.js from the latest release at github.com/mozilla/readability and place it at clipdeck/vendor/Readability.js. Update manifest.json's content_scripts.js array to load vendor/Readability.js BEFORE content.js (first code block). Add the dispatcher registration from the second code block to content.js. Add the context-menu wiring from the third code block to background.js. Reload. Right-click on a real news article (any major news site or a Wikipedia article) → 'Save full article to ClipDeck'. Open the side panel — a new clip should appear with the article's title and the full body text (a few KB to tens of KB depending on the article). Try the same on a page Readability doesn't handle well (an SPA dashboard) — confirm the gentle fail-over: nothing saves and a warning logs to the SW DevTools.
Hint
If Readability is not defined appears in the content script's console, the vendor file didn't load — confirm the path is correct AND it appears in the content_scripts.js array BEFORE content.js (array order matters; Chrome injects in declared order). If reader.parse() always returns null, the page might be inside an iframe, OR the page is too small/short for Readability's heuristics (it has a default min length around 140 characters of clean text). Try on a longer page first to confirm the wiring.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.