본문 바로가기
C.W.K.
Stream
Lesson 02 of 07 · published

Readability 추출 — Mozilla의 Reader Mode를 content script에

~12 min · readability, article, extraction, mozilla

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"뉴스 기사를 가리키면 ClipDeck이 제목, 필자, 본문만 건져 내. 광고도, 관련 기사도, 쿠키 배너도 없이. 이 lesson에서 Mozilla의 Readability.js를 content script에 붙여서 '기사 통째로 저장' 을 버튼 하나로 만들 거야."

Readability.js가 뭐냐면

github.com/mozilla/readability는 Firefox의 Reader Mode를 돌리는 바로 그 JavaScript library 야. 엔진이 같고, Apache-2.0 라이선스고, 쓰는 데 Firefox가 필요하지도 않아. 생긴 건 이래:

  • 넣는 것: 복사해 둔 document. document를 그냥 넘기면 안 돼 — Readability는 받은 tree를 헤집어 놓거든. user가 읽고 있는 페이지를 헤집을 순 없잖아.
  • 나오는 것: { title, byline, dir, content, textContent, length, excerpt, siteName, lang }. content는 뽑아낸 HTML이지 sanitizer가 보증한 결과가 아니고, textContent는 순수 텍스트야.
  • 값: 최소화해서 50 KB 쯤. content script에 넣기 충분해. 보통 기사 하나 파싱에 50–100 ms 쯤 더 들고.

파일로 넣을까, 묶을까

ClipDeck 이랑 Readability를 같이 내보내는 방법은 둘 다 말이 돼:

  • 파일로 넣기: GitHub release에서 Readability.js를 받아 clipdeck/vendor/Readability.js에 두고, content_scripts.js 배열에서 content.js 보다 앞에 적어. 그러면 library가 전역 Readability를 만들어 줘. 단순하고, 눈으로 확인하기도 쉽고.
  • 묶기: npm install @mozilla/readability 하고 source에서 import 한 다음 esbuild 나 Rollup으로 content.js 하나로 묶어. 길게 보면 더 깔끔해. tree-shaking 이나 TypeScript를 쓸 거면 이 길밖에 없고.

ClipDeck v1은 파일로 넣고 갈 거야. 묶는 쪽으로 옮기는 건 Track 8에서 다뤄.

복사해서 넘기는 요령

정해진 주문처럼 외워 두면 돼:

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

document.cloneNode(true)가 깊은 복사본을 만들어. Readability는 내비게이션, 사이드바, 푸터, 댓글, 광고처럼 기사 본문이 아니라고 판단한 부분을 복사본에서 물리적으로 뜯어내. user의 페이지는 손끝 하나 안 닿고.

결과 읽기

자주 쓰는 항목들이야:

  • title — 정리된 기사 제목. document.title 뒤에 흔히 붙는 사이트 이름이 떨어져 나가 있는 경우가 많아.
  • byline — 필자. 최선을 다해 찾는 정도라 작은 블로그에선 null 인 일이 잦아.
  • content — 뽑아낸 HTML. Readability는 HTML sanitizer가 아니야. Extension page를 포함해 어디서든 innerHTML로 그리기 전에 DOMPurify처럼 유지보수되는 sanitizer를 거쳐.
  • textContentcontent에서 HTML 태그를 걷어 낸 순수 텍스트야. 깔끔한 텍스트만 저장할 거면 ClipDeck은 이걸 쓰고, 제목과 문단 구조를 살리고 싶으면 content를 써.
  • length — 추출한 본문의 글자 수를 담아. 추출이 제대로 됐나 가늠할 때 쓸모 있어 (너무 짧으면 십중팔구 실패한 거야).
  • excerpt — 첫 문단 정도. 미리보기에 좋아.
  • siteName, lang, dir — 부가 정보.

reader.parse()가 null을 돌려주면 Readability가 이 페이지는 기사 모양이 아니라고 판단한 거야 (너무 짧거나, 너무 어수선하거나, 본문이라고 할 만한 게 없거나). 그럴 땐 선택 영역이 있으면 그걸로 대신하고, 없으면 페이지 제목이랑 URL만 챙겨.

ClipDeck의 '기사 통째로 저장'

선택 영역 저장이랑은 따로 불러:

  • popup 버튼: "이 페이지의 기사 통째로 저장".
  • 남는 조합이 있으면 단축키도 하나.
  • contexts: ['page']를 준 우클릭 메뉴: "이 페이지를 ClipDeck에 저장".

handler가 content script 한테 {type: 'readArticle'}를 보내면, content script가 Readability를 돌려서 기사 object를 돌려줘. 그럼 SW가 그 텍스트랑 isArticle: true 표시, URL과 제목을 담아 clip을 만들어. 선택 영역 clip 이랑 모양은 같고 본문만 훨씬 긴 거지.

속도 얘기

Readability는 대부분의 페이지에서 수십 밀리초면 끝나. 아주 큰 것들 (긴 Wikipedia 문서, 오래된 포럼 스레드)은 수백 ms까지 가고. content script의 main thread에서 그냥 돌려도 user는 못 느껴. 파싱하는 동안에도 페이지가 반응해야 한다면 Web Worker로 넘길 수도 있는데, 클릭 한 번에 한 번 도는 일에는 과해.

document를 복사하고, 복사본을 Readability에 넘기고, 정리된 기사 object를 받아. user의 페이지는 파싱이 있었는지도 모르고, ClipDeck은 selector를 직접 깎지 않고도 깔끔한 텍스트와 제목과 필자를 얻어.
Readability가 답이 아닐 때. 포럼 스레드, 댓글 영역, GitHub 이슈, 문서가 여러 개인 SPA — 이런 건 나름의 구조가 있는데 Readability가 그걸 뭉개거나 버려. 이럴 땐 그 사이트에 맞는 selector를 직접 짜는 게 (아니면 user가 댓글을 하나씩 선택하게 두는 게) 더 잘 돼. Readability는 블로그 글, 뉴스 기사, 긴 글에 맞는 기본값이야.
Compound storage update는 원자적이지 않아. get → modify → set을 surface 둘이 동시에 돌리면 한쪽 변경이 사라질 수 있어. Service worker를 sole writer로 두고 mutation queue 하나로 직렬화하거나 record를 독립 key로 저장해. 한 번의 set은 그 안의 key를 함께 적용하지만, 앞뒤 read-modify-write 전체가 atomic인 건 아니야.

Code

manifest.json — Readability.js를 content.js 보다 먼저 올려야 전역으로 잡혀·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 — 정리된 기사 object를 돌려주는 readArticle handler·javascript
// content.js — readArticle 도구를 dispatcher 에 wire
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,
    },
  };
};

// Dispatcher 의 `{type:'tool', name:'readability'}` envelope 안 통과 하는
// 직접 caller (popup, SW) 위한 top-level legacy handler.
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type !== "readArticle") return;
  (async () => sendResponse(await CD_TOOLS.readability()))();
  return true;
});
background.js — 우클릭 메뉴에 기사 저장 붙이기·javascript
// background.js — popup 버튼이나 context menu trigger 하는 save-article flow
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(),
  };
  await mutateLocal(["clips"], ({ clips = [] }) => ({
    clips: [clip, ...clips],
  }));
});

External links

Exercise

github.com/mozilla/readability 최신 release에서 Readability.js 를 받아 clipdeck/vendor/Readability.js에 둬. manifest.json의 content_scripts.js 배열을 고쳐서 vendor/Readability.js가 content.js 보다 먼저 오게 해 (첫 번째 code block). 두 번째 code block의 dispatcher 등록을 content.js에 넣고, 세 번째 code block의 우클릭 메뉴 연결을 background.js에 넣어. Reload 하고 진짜 뉴스 기사 (큰 언론사나 Wikipedia 문서)에서 우클릭 → 'Save full article to ClipDeck' 을 눌러 봐. side panel을 열면 기사 제목이랑 본문 전체가 담긴 clip이 새로 생겨 있어야 해 (기사에 따라 몇 KB에서 십몇 KB까지). 그다음 Readability가 잘 못 다루는 페이지 (SPA 대시보드 같은 거) 에서도 똑같이 해 봐. 아무것도 안 저장되고 SW DevTools에 경고만 찍히면 제대로 물러난 거야.
Hint
content script console에 Readability is not defined 가 뜨면 vendor 파일이 안 올라온 거야. 경로가 맞는지, 그리고 content_scripts.js 배열에서 content.js 보다 앞에 있는지 봐 (배열 순서가 중요해. Chrome이 적힌 순서대로 넣거든). reader.parse() 가 늘 null 이면 페이지가 iframe 안이거나, Readability의 판단 기준에 비해 너무 짧은 거야 (기본으로 깔끔한 텍스트 140 자쯤은 있어야 해). 연결이 제대로 됐는지부터 확인하려면 긴 페이지에서 먼저 해 봐.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.