Mozilla's Readability는 Firefox の Reader View를 뒷받침하는 추출 엔진을 단독 JavaScript로 옮겨 놓은 것입니다. Apache-2.0 패키지 @mozilla/readability로 공개되어 있고, 라이브 DOM에서 기사 본문을 골라냅니다. 그래서 Node에서 쓰려면 jsdom 같은 DOM 구현이 필요합니다. 페이지 가져오기, JavaScript 실행, 스키마 추출은 하지 않습니다.
이번에 검증한 건 npm의 latest 버전 0.6.0입니다. 2025년 3월 3일 공개되었고, 2026년 7월 27일 확인 시점 기준으로 저장소 스타 수는 11,361개, 마지막 push는 2026년 7월 9일이었습니다. 즉 main은 공개된 패키지보다 훨씬 앞서 있습니다. 저는 jsdom 29.1.1과 Node v22.22.3, macOS arm64 환경에서, 이 목적에 맞게 만든 라벨링된 HTML 픽스처 22개에 대해 돌려봤습니다. 여기 나온 수치는 전부 그 세팅을 기준으로 합니다. 직접 써보면 이 카테고리에서는 꽤 다루기 쉬운 도구입니다. 설치는 2분이면 끝나고, 바이너리도 필요 없고, 브라우저 캐시도 안 들고 다녀도 되고, 다시 실행해도 결과가 매번 같습니다. 재미있는 건 운영 자체가 아니라, 소스 안의 몇몇 상수만 봐도 실패 방식이 꽤 예측된다는 점, 그리고 그중 하나의 상수가 문서 설명보다 훨씬 크게 작동한다는 점입니다.
22개의 제어된 합성 픽스처에서는 Readability가 라벨링된 기사 블록 74개를 전부 회수했습니다. 이 결과는 상한이 있는 값이지만, 그렇다고 해서 본문을 절대 놓치지 않는다는 뜻은 아닙니다. 공개된 실제 페이지 benchmark에서는 recall이 0.982였고, 알려진 실패 패턴은 여기서는 재현되지 않았습니다. 이번에 가장 눈에 띄었던 실패는 소스 수준의 링크 밀도 게이트 0.25 때문에 주변의 불필요한 요소가 같이 딸려 들어온 경우였습니다. 겉으로 보이는 심각도는 테스트 환경에 따라 달라집니다.
readability.js는 실제로 무엇이고, 무엇이 아닌가
Readability는 DOM에 대한 규칙 기반 스코어링 처리입니다. 후보 요소를 순회하면서 각각에 콘텐츠 스코어를 매기고, 그 점수를 조상 요소로 전파한 뒤, 가장 점수가 높은 서브트리를 고르고, 마지막으로 페이지의 장식처럼 보이는 것들을 정리합니다. 기사 추출 전략은 이게 전부입니다. 모델도, 학습 데이터도, 사이트별 룰도 없습니다. 그래서 처음 보는 페이지에서도 잘 동작합니다. 그리고 실패가 소스만 봐도 예측되는 것도 이 설계 덕분입니다. 그 점이 꽤 흥미롭습니다.
자주 오해되는 부분이 3가지 있습니다.
- fetcher가 아닙니다. URL이 아니라
document를 받습니다. 가져오기, 재시도, 봇 차단 대응, 헤더 설정은 직접 해줘야 합니다. - renderer가 아닙니다. JavaScript를 실행하지 않습니다. 넘겨준 DOM에 들어 있는 것만 봅니다.
- 구조화 추출기가 아닙니다. 반환값은
title,byline,excerpt,content(HTML),textContent,length,siteName입니다. 스키마도, 타입이 붙은 행도,{name, price}같은 형태도 없습니다.
실제 처리를 거의 다 맡는 4개의 상수

node_modules 안의 Readability.js를 읽어보면 문서보다 동작이 더 잘 보입니다. 라이브러리 작동의 대부분은 다음 4가지로 설명됩니다.
- 스코어가 붙은 문단별 콘텐츠 스코어:
1 + (commaCount + 1) + min(floor(len / 100), 3)입니다. 25자 미만의 문단은 아예 세지 않습니다. 스코어는 조상에게 전파되고, 부모는 전부 받으며, 조부모는 절반, 그보다 위의 조상은level · 3으로 나뉩니다. DEFAULT_CHAR_THRESHOLD = 500— “성공한” 파싱으로 볼 최소 기사 길이입니다. 이 값을 밑돌면 제거 조건을 줄여 다시 잡는 처리가 들어갑니다.unlikelyCandidates정규식 —comment,footer,menu,related,sidebar,social,sponsor같은 class / id 부분 문자열에 맞습니다. 맞는 노드는 스코어링 전에 떨어집니다.grabArticle의 sibling 추가 게이트 — 톱 후보를 고른 뒤, 그 형제 요소를 같이 넣을지 판단합니다. 형제는 자신의 스코어가 임계값을 넘거나, 또는nodeLength > 80 && linkDensity < 0.25, 또는nodeLength < 80 && nodeLength > 0 && linkDensity === 0 && 문장부호를 포함할 때 같이 들어갑니다.
링크 밀도는 Σ(linkText.length · coef) / textLength이고, #만 있는 href는 coef = 0.3, 그 외에는 1입니다. 이번에 관찰한 sibling 혼입은 이 게이트로 설명할 수 있습니다. 물론 이게 추출 알고리즘 전체는 아닙니다.
세팅과, 설명서에는 안 나오는 의존성
npm install @mozilla/readability jsdom만 하면 바로 됩니다. 2분, 바이너리 없음, 설치 후 추가 다운로드 없음, 캐시에 브라우저도 필요 없음. 설치 관점에서는 거의 이상적입니다.
다만 “의존성이 없다”는 말은 알고리즘 기준이지, 실행 환경 기준은 아닙니다. Readability는 생 document 위에서 동작하고, Node에서는 DOM 구현을 직접 붙여줘야 합니다. 이번에는 jsdom 29.1.1을 썼습니다. jsdom은 가볍지 않고, 많은 파이프라인에서는 추출 자체보다 루프 안의 주요 비용이 됩니다. 그건 감안해야 합니다.
또 하나, 재실행을 유발하는 함정이 있습니다. Readability.parse()는 넘겨받은 DOM을 파괴적으로 바꿉니다. 같은 jsdom document를 두 번 parse하면, 두 번째는 이미 1차로 손상된 document를 보게 됩니다. 제 하니스에서는 parse마다 새 jsdom을 만듭니다. 페이지를 루프 돌면서 document를 재사용해 시간을 아끼려 한다면, 나중에 버그로 돌아옵니다.
어떻게 테스트했는가
실제 뉴스 사이트에는 돌리지 않았습니다. 라이브 페이지에서는 점수는 보이지만, 왜 그런지는 잘 안 보입니다. 휴리스틱에서는 “왜”가 절반 이상이기 때문입니다. 그래서 저는 **22개의 HTML 픽스처에 91개 라벨링 블록(기사 74개, 장식 요소 17개)**을 넣어 생성했습니다. 각 블록 안의 모든 단어에는 그 블록 전용 식별자 문자열을 붙였습니다. 블록끼리 어휘가 겹치지 않기 때문에, 추출된 토큰은 정확히 하나의 블록에만 대응하고, “회수됨”인지 “누락됨”인지는 애매한 매치가 아니라 정확한 포함 판단으로 갈립니다.
추출 단계와 채점 단계는 의도적으로 분리했습니다. Node 러너가 내보내는 건 raw 추출 텍스트, isProbablyReaderable의 true/false, 그리고 측정한 링크 밀도뿐입니다. precision과 recall은 별도 스크립트에서 그 raw 텍스트를 라벨과 맞춰 나중에 계산합니다. 하니스 안에는 손으로 박아 넣은 지표 상수가 하나도 없습니다. 자기 수치를 믿으려면 그게 제일 낫습니다.
그다음 같은 바이트열을 trafilatura 2.1.0에도 넣어, 같은 테스트베드에서 비교했습니다. 각 픽스처는 3번씩 파싱했고, 22개 전부 매번 byte-identical한 텍스트를 반환했습니다.
요약 표만 믿지 말고 각 단계를 직접 확인할 수 있습니다. tests/build_fixtures.mjs는 주석이 달린 HTML과 정답 데이터를 만들고, tests/run_readability.mjs는 추출 결과와 예측기 출력을 기록하며, tests/metrics.py는 그 기록을 나중에 채점합니다. raw Readability 출력, 계산된 지표, 동일 입력 비교는 artifacts/raw/에 남겨뒀습니다. 이 분리는 결과가 수상해 보일 때 중요합니다. 파서가 예상 밖 텍스트를 뱉은 건지, 라벨셋이 틀린 건지, 채점 코드가 오분류한 건지 가릴 수 있기 때문입니다. 이 패키지로 재현 가능한 건 여기 적은 주장 확인까지이고, 실운영 페이지 섞임에서도 같은 실패 분포가 나온다는 뜻은 아닙니다.
범위의 한계는 분명하고 중요합니다. 여기에 있는 건 합성 제어 페이지이지, 실데이터 코퍼스가 아닙니다. 공개된 실제 페이지 지표는 article-extraction-benchmark가 기준입니다. 거기서 readability_js 0.6.0(이번에 테스트한 것과 같은 버전)은 약 181개의 실제 페이지에 대해 **word-F1 0.947 ± 0.005(precision 0.914 ± 0.008, recall 0.982 ± 0.003)**를 보였습니다. 저는 이 수치를 인용만 했고, 다시 측정하진 않았습니다.
여기서는 현재 릴리스의 benchmark 행을 사용했고, 대체된 과거 행은 제외했습니다. 제어된 픽스처는 공개 실제 페이지 코퍼스를 대체하는 게 아니라, 어떤 콘텐츠 형태가 어떤 룰을 발동시키는지를 블록 단위로 쪼개 보여주는 용도입니다.
합성 픽스처 팩에서는 recall이 완벽했다
74 / 74입니다. 22개 모든 픽스처에서 Readability는 라벨링된 기사 블록을 하나도 놓치지 않았습니다. 기사와 장식 요소가 섞인 11개의 깨끗한 합성 픽스처에서는 micro-average token recall이 1.000이었습니다. 기사 문장이 단 한 줄도 빠지지 않았습니다.
다만 주의할 점이 2개 있습니다.
이것들은 깨끗한 단일 컬럼 합성 페이지입니다. 실제 기사들은 더 깊게 중첩되고, 본문 중간에 광고가 끼어들고, 첫 문단이 점수상 우연으로 떨어지기도 합니다. 그런 누락은 트래커에 보고돼 있습니다(#437, #901, 그리고 #922에서 표 앞에서 떨어지는 케이스). 제 픽스처에서는 하나도 발동하지 않았습니다. 그래서 고쳐졌다고 말하는 건 아닙니다. 제 테스트가 거기까지 닿지 않았을 뿐입니다. 실제 페이지에서는 이 버전의 benchmark recall이 1.000이 아니라 0.982입니다.
그래도 결과의 방향은 중요합니다. Readability의 문제는 기사를 버려버리는 게 아닙니다. 같이 가져오는 불필요한 요소 쪽입니다.
precision 수치와, 3개의 라벨이 필요한 이유

숫자 하나는 인용하긴 쉽고, 변명하긴 어렵습니다. 11개의 혼합 픽스처에서 Readability는 17개 장식 블록 중 5개를 유지했습니다. 즉 leak rate는 0.294입니다.
하지만 이건 실세계 leak rate가 아닙니다. 3가지 서로 다른 세팅은 3가지 서로 다른 걸 측정하고, 그중 평범한 페이지를 대표하는 건 하나뿐입니다.
| 숫자가 측정하는 것 | 결과 |
|---|---|
| 의도적으로 어렵게 만든 픽스처 집합 — 11개의 혼합 페이지 중 6개는 sibling 게이트를 깨도록 설계 | 17개 중 5개 장식 블록 유지(0.294) |
현실적인 1페이지 — <article> 본문 주위에 nav, 광고 배너, sidebar, comments, footer, 그리고 중립 클래스 프로모션 1개 | 6개 중 5개 외곽 블록 제거, 1개 유지 |
| 약 181개의 실제 페이지, 공개 benchmark(이번 실행은 아님) | precision 0.914, recall 0.982, word-F1 0.947 — readability_js 0.6.0 |
1행은 예측이 아니라 스트레스 테스트로 읽어야 합니다. Readability가 야생 페이지에서 장식 요소의 29%를 새는 건 아닙니다. 현실적인 페이지에서는 unlikelyCandidates 정규식에 맞는 클래스의 nav-menu, ad-banner, sidebar, comments, site-footer가 모두 깔끔하게 제거됐습니다. 마지막에 남은 1개는 제가 그 정규식을 피하도록 일부러 심어둔 블록입니다.
0.25 게이트: 장식 제거가 멈추는 지점
sibling 추가 룰은 소스에 그대로 적혀 있습니다. 제가 확인한 범위에서는 어디서 딱 전환되는지 정확히 잰 사례는 없었습니다. 그래서 그라데이션을 만들었습니다. <article> 바깥의 중립 클래스 <p class="teaser-block">, 톱 후보로 확실히 이기는 4문단짜리 명확한 기사, 그리고 바꾸는 것은 프로모션의 길이와 링크 밀도뿐. 밀도는 Readability 자체 공식을 써서 계산했고, 가정이 아니라 실측입니다.
| 프로모션 블록 | 본문 길이 | 80자 초과 | 실측 링크 밀도 | 결과 |
|---|---|---|---|---|
| 링크 없음 | 126 | yes | 0.000 | 유지 |
| 짧은 링크 1개 | 126 | yes | 0.143 | 유지 |
| 조금 긴 링크 1개 | 126 | yes | 0.278 | 제외 |
| 문장의 절반이 링크 | 126 | yes | 0.476 | 제외 |
| 1문장, 마침표로 끝남 | 60 | no | 0.000 | 유지 |
| 같은 문장, 마침표 없음 | 59 | no | 0.000 | 제외 |
소스의 조건은 0.25를 임계값으로 둡니다. 실측 샘플은 그 양쪽에 있었고, 0.143은 유지, 0.278은 제외였습니다. 다른 분기에서는 60자짜리 마침표 있는 문장은 유지됐고, 59자짜리 마침표 없는 버전은 떨어졌습니다. 기사 recall은 어떤 조건에서도 4/4였고, 이 샘플들은 precision 차이만 분리해서 보여줍니다.
테스트 하니스 밖에서 이 게이트는 사실상 이렇게 말합니다. 기사 옆에 있는, 길고 링크가 적은 중립적인 문장은 기사다. 문제는 그 안에 기사 아닌 것도 엄청 많이 섞인다는 겁니다. 문단 형태로 쓴 “관련 기사” 설명, 뉴스레터 안내, 편집자 메모, 추적용 링크를 빼고 마케팅 담당이 문장으로 써버린 스폰서 티저 같은 것들입니다.
RAG 인덱스에서는 링크가 적은 프로모션 문단이 추출 청크로 들어가고, 검색이나 생성이 그걸 본문처럼 취급할 위험이 있습니다. 소스의 룰은 그 실패 모드를 충분히 현실적으로 만듭니다. 이 리뷰에서는 검색이나 모델 인용의 end-to-end 평가는 하지 않았습니다.
사이트별 엄격한 필터를 만들려면, 이미 알려진 source-DOM 컨테이너를 미리 제외하고, 직렬화 전에 source 노드의 조상을 보존해 비교하거나, 후단에서 신중하게 검증한 텍스트 패턴 필터를 적용하세요. 반환된 HTML만으로는, 노드가 원래 primary container 밖에 있었는지 보장하지 못할 수도 있습니다. sibling 게이트는 공개 옵션으로 조정할 수 없습니다.
픽스처가 뒤집은 3가지 전제
픽스처는 charThreshold가 짧은 기사를 거른다, 의미 태그가 필요하다, 짧은 비본문 콘텐츠는 떨어진다, 이 3가지 전제를 뒤집었습니다. 아래 증거가 중요합니다. 사전등록 같은 주장은 필요 없습니다.
charThreshold = 500은 절벽이 아니다
흔한 이해는 500자 미만의 기사는 null이 된다는 겁니다. 실제로는 그렇지 않습니다. 본문 길이를 120자부터 1500자까지 바꾸고, charThreshold를 200, 500, 1000으로 바꿔가며 확인했습니다.
| 본문 길이 | 모든 임계값에서 parse 성공 | 추출 후 길이 |
|---|---|---|
| 120 | yes | 161 |
| 300 | yes | 342 |
| 460 | yes | 509 |
| 520 | yes | 569 |
| 800 | yes | 841 |
| 1500 | yes | 1555 |
깔끔하게 나란히입니다. 본문 길이가 무엇이든, 3가지 임계값 설정에서 추출 길이는 완전히 같았습니다. 임계값은 반환값 유무를 제어하는 게 아니라, 정리 플래그를 빼고 다시 잡을지 여부를 정합니다. 깨끗한 페이지에서는 뺄 것이 없으니 어느 경로를 타도 결과가 같습니다. 진짜 null 경계는 “추출 가능한 텍스트가 전혀 없음”입니다.
그리고 여기서 실제로 나온 실패는 가짜 null이 아니라 더 골치 아픈 거였습니다. 거의 비어 있는 페이지, 즉 nav 바와 4단어짜리 짧은 설명문을 넣었더니 성공으로 돌아왔고, 그 “기사”에는 nav가 포함돼 있었습니다. 진짜 기사가 없으면 Readability는 장식 요소를 기사처럼 돌려줍니다. 대규모 크롤링에서 비-null을 “이 페이지에 콘텐츠가 있었다”는 뜻으로 보면 그 전제는 틀립니다.
시맨틱 태그가 본질은 아니다
겉 구조를 빼면 recall이 떨어질 거라 생각했습니다. 같은 기사 본문으로 두 가지 외형을 만들었습니다. 하나는 <main><article><h1>에 설명적인 class 이름이 붙은 형태, 다른 하나는 <div class="x1">와 그냥 <div>로만 된 문단 형태였습니다. 결과는 둘 다 기사 블록 4/4를 회수했고, 장식 요소 혼입은 0이었습니다. 기사 블록이 페이지에서 가장 밀도 높은 텍스트 블록으로 분명하게 보이면, 길이와 쉼표 수만으로도 충분히 찾을 수 있습니다. <article> 태그가 없으면 Readability를 못 쓴다는 말은 속설입니다.
이 주장에는 솔직한 한계도 있습니다. 이번 페이지에는 분명한 콘텐츠 블록이 하나뿐이었습니다. 시맨틱이 진짜 영향을 주는 건, 두 개의 밀도 높은 서브트리가 경쟁하는 페이지입니다. 그 타이브레이커는 테스트하지 않았습니다.
비본문 콘텐츠는 그대로 남는다
“25자 미만 문단은 세지 않는다”는 규칙 때문에 표나 캡션은 빠질 거라고 생각했습니다. 이 역시 틀렸습니다. 이 규칙은 후보의 스코어링에만 영향을 주고, 보존에는 영향을 주지 않습니다. 컨테이너가 이기면, 안에 있는 내용은 전부 함께 딸려 옵니다.
| 기사 안의 콘텐츠 종류 | Readability | trafilatura |
|---|---|---|
| 본문 문단(×2) | 유지 | 유지 |
| 데이터 표 셀(×2) | 유지 | 유지 |
<pre> 코드 블록 | 유지 | 유지 |
25자 미만의 1줄 <p>(×2) | 유지 | 유지 |
<figcaption> | 유지 | 제외 |
| 합계 | 8/8 | 7/8 |
여기서는 더 강한 정리 쪽이 졌습니다. 페이지가 문서, 튜토리얼, 또는 코드 블록과 캡션이 있는 무언가라면, Readability의 “이긴 서브트리를 통째로 남기는” 동작은 오히려 장점입니다.
isProbablyReaderable은 parse()가 성공해도 false를 반환한다

README는 본격적인 parse 전에 가벼운 사전 판정으로 isProbablyReaderable(doc)을 호출하라고 권합니다. 제 테스트에서는 이 게이트가 3가지 페이지 형태를 false로 판단했지만, 뒤이어 나온 parse()는 문제없이 성공했습니다.
| 페이지 형태 | 예측기 판단 | parse() | 무엇을 바꾸면 고쳐지나 |
|---|---|---|---|
콘텐츠가 <li> 요소에만 있는 경우 | false | 성공 | 없음 — minScore 1–80, minContentLength 40–200 모두 false 유지 |
| 140자 미만 문단이 10개 있는 경우 | false | 성공 | minContentLength ≤ 100 (minScore는 무효) |
| 408자짜리 문단 1개만 있는 경우 | false | 성공 | minScore ≤ 10 (스코어가 약 16.4) |
| 일반 기사(대조군) | true | 성공 | — |
이 3가지 실패는 각각 원인이 다르고, 조정 가능한 것도 2개뿐입니다. <li> 케이스는 구조 문제입니다. 예측기가 스코어를 매기는 건 p, pre, article 노드(그리고 div > br의 부모)뿐이라, 콘텐츠가 리스트 항목에 있는 페이지는 아무것에도 매칭되지 않아 스코어가 0이 됩니다. 임계값을 아무리 바꿔도 살릴 수 없습니다. 이 형태는 이미 issue #662로 보고돼 있습니다. 짧은 문단이 많은 경우는 minContentLength 게이트 문제로, 각 문단이 스코어 전에 날아가서, 10개의 충분히 내용 있는 문단도 합치면 0이 됩니다. 이 값을 낮추면 고쳐지고, minScore를 바꿔도 소용없습니다. 하나의 긴 문단 케이스는 산술 문제입니다. 스코어는 sqrt(408 − 140) ≈ 16.4로 기본 minScore 20보다 낮습니다. 단 하나의 문단이 스스로 임계값을 넘으려면 140 + 20² = 540자가 필요합니다.
README도 예측기가 false negative를 낼 수 있다고 경고합니다. 실무적으로 덧붙이자면, 이걸 유일한 컷오프로 쓰지 마세요. 페이지가 중요하면 parse해서 결과 길이를 확인해야 합니다. 이미 jsdom을 만드는 비용을 냈다면, parse 자체는 그리 비싸지 않습니다.
같은 바이트열, 2개의 추출기
같은 픽스처에 trafilatura 2.1.0을 돌리면, 서로 다른 두 테스트베드에서 얻은 두 숫자를 나란히 보는 것보다 훨씬 직관적으로 비교할 수 있습니다. 입력이 byte-for-byte 동일하기 때문입니다.
| 지표(11개 혼합 픽스처) | @mozilla/readability | trafilatura |
|---|---|---|
| 기사 블록 recall | 1.000 | 1.000 |
| 유지된 장식 블록 수 | 5/17 (0.294) | 1/17 (0.059) |
| Token F1(micro) | 0.948 | 0.969 |
| 비본문 recall | 8/8 | 7/8 |
| 아주 짧은 기사(120자), token F1 | 0.800 | 0.571 |
이 픽스처에서는 어느 쪽도 완승은 아니었습니다. trafilatura는 sibling 블록을 덜 유지했고, Readability는 짧은 문장과 비본문을 더 많이 유지했습니다. 두 도구 모두 라벨되지 않은 제목 텍스트 때문에 절대 precision이 조금씩 깎였습니다. 그래서 블록 단위 누락 수가 더 직접적인 신호로 보입니다. 공개 benchmark에서는 우연히 word-F1 순위도 비슷하지만, 코퍼스와 지표가 다르기 때문에 cross-testbed 검증은 아닙니다.
견고성에 대해서도 한마디 하자면, 일부러 망가뜨린 canonical 페이지의 쌍둥이 버전(닫지 않은 <p>, 잘못 중첩된 <b>/<i>, 불필요한 </div>)을 흘려보내도 recall은 3/3, 누락 0이었고, 정리된 버전과 같았습니다. 이건 Readability 덕분이 아니라, 먼저 깨진 HTML을 복구하는 jsdom의 HTML5 트리 빌더 덕분입니다. 어떤 픽스처에서도 파서는 떨어지지 않았습니다.
장점과 단점
장점
- 기사 recall이 강합니다. 22개 합성 픽스처에서 74/74 라벨 블록을 회수했고, 혼합 세트에서는 token recall 1.000이었습니다.
unlikelyCandidates클래스에 걸리는 페이지 장식을 잘 제거합니다. 현실적인 페이지에서는 nav, 광고 배너, sidebar, comments, footer가 모두 사라졌습니다(6개 중 5개 제거).- 비본문 콘텐츠를 통째로 유지합니다. 표,
<pre>코드, 그림 캡션, 25자 미만 행이 모두 살아남았고(8/8), trafilatura는 캡션을 놓쳤습니다. - 시맨틱 마크업에 의존하지 않습니다. 중립화한
<div>기사도<article>/<main>버전과 같은 점수였습니다. - 짧은 기사를 잘못 거절하지 않습니다. 깨끗한 콘텐츠는 120자까지 회수됐고,
charThreshold200 / 500 / 1000에서 결과는 같았습니다. - 완전히 deterministic합니다. 22개 전부에서 3회 실행 결과가 동일한 텍스트였습니다.
- 설치는 2분, Apache-2.0, npm 버전이 그대로 검증 버전(0.6.0)이라 여기엔 오래된 정보가 없습니다.
단점
- sibling 추가 게이트는 악용하기 쉽습니다. 길고, 링크가 적고, 중립 클래스인 프로모션 문장은 기사와 구분이 잘 안 되고,
linkDensity < 0.25면 그대로 따라붙습니다. - 콘텐츠가 적은 페이지에서는
null대신 장식 요소를 기사로 돌려줍니다. 거의 빈 픽스처에서는 nav 바가 본문처럼 반환됐습니다. isProbablyReaderable은 3가지 페이지 형태에서 false negative를 냈고, 그중 1개는 조정으로도 못 고칩니다.- 실행 시 완전한 DOM이 필요합니다. “의존성 없음”이라는 설명 뒤에 jsdom 비용이 숨어 있고, 루프 안에서는 그게 지배적입니다.
parse()는 입력 document를 파괴적으로 바꾸므로, 페이지마다 DOM을 새로 만들어야 합니다.- fetch도 없고, JavaScript 렌더링도 없고, 구조화 출력도 없습니다. 이것은 파이프라인의 한 단계이지, 전체가 아닙니다.
- 트래커에 보고된 실제 페이지 누락(첫 문단이나 표 앞에서 떨어지는 케이스)은 이번 픽스처에서 재현되지 않았기 때문에, 드문 건지 아니면 제 페이지가 거기까지 닿지 못한 건지 알 수 없습니다.
누가 써야 하고, 누가 피해야 하나
이미 HTML이 있고, Node 서비스 안에서 순수 JavaScript로 기사만 뽑고 싶다면 Readability는 꽤 강력합니다. Python 의존성을 더하기 어려운 환경에서도 쓰기 편합니다. (타이밍 분포는 따로 잡지 않았기 때문에 속도에 대해 말할 수 있는 건 “추출보다 jsdom 구성 비용이 루프를 지배한다” 정도입니다.) Reader 모드, 오프라인 기사 저장, 이메일 뉴스레터, 클린 뷰 버튼, 브라우저 확장, 코드 블록이나 캡션이 포함된 문서 처리 같은 용도에 잘 맞습니다. 게다가 소스를 읽으면 동작이 보이니, 왜 특정 블록이 남았는지 동료에게 설명할 때도 유용합니다.
반대로, 가장 중요한 지표가 장식 제거 precision이라면, 특히 LLM 인덱스로 흘려보내서 섞인 프로모션 문단이 나중에 참조 가능한 chunk가 되는 용도라면 피하는 편이 낫습니다. 페이지가 클라이언트 사이드 렌더링이라면 피하세요. 넘겨준 DOM만 보고 JavaScript는 실행하지 않습니다. 필요한 게 {title, price, sku} 같은 구조화 데이터라면 피하세요. 어떤 설정을 해도 콘텐츠 추출기가 스키마 기반 도구가 되지는 않습니다. 그리고 “이 페이지에 정말 기사였는가”가 중요한 처리라면, 비-null 반환을 답으로 믿지 마세요.
대안과 Thunderbit 스택에서의 위치
여기서 말하는 건 Mozilla가 유지하는 무료 Apache-2.0 라이브러리를 깎아내리려는 게 아닙니다. Readability는 인프라이고, Firefox 안에서 오랫동안 돌아온 것이며, Reader 모드 추출의 레퍼런스 구현으로 남은 데는 이유가 있습니다. 더 넓은 선택지를 보고 싶다면, 저는 오픈소스 스크래퍼 총정리와 더 넓은 조사인 best web scraping tools에 정리해뒀습니다.
같은 픽스처를 6개 추출기 전부로 비교한 결과는 6 라이브러리 추출 비교를 보세요.
저자 주: Thunderbit은 URL을 넣어 렌더링과 추출을 관리하는 옵션입니다. 이번 픽스처에서는 실행하지 않았기 때문에, 동등한 품질 주장은 포함하지 않았습니다. 여기서 핵심은 이미 DOM이 있는 상태에서 로컬 추출을 할 것이냐, 아니면 fetch·render·structure를 서비스로 맡길 것이냐입니다. 셀프 호스트는 벤더 비용을 피할 수 있지만, 인프라와 유지보수 비용은 남습니다.
솔직한 트레이드오프는 이렇습니다. Readability는 무료이고, 투명하고, 내가 직접 돌릴 수 있습니다. 출력을 결정한 정확한 게이트를 읽을 수 있다는 건 매니지드 API에서 얻기 어려운 장점입니다. 매니지드 스택은 비용이 들고 내부 메커니즘은 감춰지지만, 원래 직접 조립했어야 할 fetch·render·structure 단계를 대신해줍니다. 그 spectrum의 AI 지원 쪽을 원한다면, 저는 다른 글에서 AI로 어떤 웹사이트든 스크래핑하는 방법과 AI 크롤러에 대해 썼습니다. 실제로 어느 단계를 직접 가져갈지에 따라 고르세요.
결론
이미 DOM이 있고, JavaScript / Node에서 돌리고, 약간의 불필요한 sibling 요소를 감수하는 대신 과도한 누락을 피하고 싶다면 Readability는 꽤 유력한 선택입니다. 이 픽스처 팩에서는 74개의 라벨링된 기사 블록을 전부 회수했고, 표, 코드, 캡션도 유지했습니다. 다만 이 결과는 단일 컬럼 합성 페이지라는 조건에 한정됩니다. 공개 실제 페이지에서는 recall이 0.982였고, 첫 문단이나 표 앞뒤의 알려진 누락은 재현되지 않았으며, 콘텐츠가 적은 페이지에서는 장식 요소를 기사로 돌려줄 수도 있습니다.
약점의 크기는 정확히 봐야 합니다. 이 픽스처에서의 핵심 약점은 precision이고, 소스의 특정 한 줄에 있습니다. 80자를 넘고 링크 밀도가 0.25 미만인 sibling은, 그것이 기사 일부인지 아닌지와 관계없이 기사에 추가됩니다. 같은 텍스트로 0.143과 0.278을 비교하면 그 전환점을 볼 수 있었습니다. 실제 페이지 benchmark에서는 precision 0.914, recall 0.982입니다. 추출 텍스트를 인덱스에 넣고 그다음 모델이 인용하게 할 거라면, 유지된 장식 요소와 놓친 본문 둘 다 확인하세요. 둘 중 어느 오류도 없다고 가정하면 안 됩니다.
Thunderbit로 웹 데이터를 추출하기 Get Started Free
FAQ
Mozilla Readability는 장식 요소를 전부 제거할 수 있나요?
아니요. 게다가 그 수치는 무엇을 재느냐에 따라 꽤 달라집니다. 공개 실제 페이지 benchmark에서는 readability_js 0.6.0의 precision이 0.914였고, 반환 내용의 약 8.6%는 본문이 아니었습니다. 제가 만든 현실적인 테스트 페이지에서는 6개의 외곽 블록 중 5개(nav, 광고, sidebar, comments, footer)를 제거하고, 중립 클래스 프로모션 문단 1개만 남겼습니다. 일부러 휴리스틱을 깨도록 만든 블록을 강조한 픽스처 집합에서는 17개 중 5개를 유지했지만, 마지막 수치는 실세계 비율이 아니라 스트레스 테스트입니다.
Node에서 readability.js를 쓰려면 jsdom이 꼭 필요한가요?
네. 적어도 어떤 형태로든 DOM 구현은 필요합니다. Readability 자체는 순수 JavaScript지만, live document 객체 위에서 동작하므로 Node에서는 DOM을 직접 준비해야 합니다. 이번 세팅에서는 jsdom 29.1.1을 사용했습니다. “의존성 없음”은 알고리즘 기준이고, 실행 환경 기준은 아닙니다. 또 parse()가 넘겨받은 document를 바꾸기 때문에, 하나의 DOM을 재사용하지 말고 페이지마다 새로 만들어야 합니다.
charThreshold 옵션은 실제로 뭘 하나요?
많은 사람이 생각하는 것과 다릅니다. 짧은 기사를 null로 바꾸지 않습니다. 저는 120자짜리 깨끗한 기사까지 회수할 수 있었고, charThreshold를 200, 500, 1000으로 바꿔도 추출 길이는 같았습니다. 이 임계값은 정리 플래그를 빼고 다시 grab할지 결정합니다. 깨끗한 페이지는 지울 게 없으니 어느 경로든 결과가 같습니다. 진짜 null이 되는 경우는 추출 가능한 텍스트가 전혀 없는 페이지입니다. nav만 있는 페이지조차 null이 아니라 nav를 기사처럼 돌려줬습니다.
parse() 전에 isProbablyReaderable을 호출해야 하나요?
컷오프가 아니라 힌트로 쓰세요. parse()는 성공하는데 isProbablyReaderable이 false를 반환한 페이지 형태가 3가지 있었습니다. <li> 요소 안에만 콘텐츠가 있는 경우, 140자 미만 문단이 10개 있는 경우, 그리고 408자짜리 문단 1개만 있는 경우입니다. <li> 케이스는 조정으로 못 고칩니다. 예측기가 점수를 매기는 건 p, pre, article 노드뿐이기 때문입니다. 짧은 문단이 많은 경우는 minContentLength를 낮춰야 하고, 단일 긴 문단 케이스는 minScore를 낮춰야 합니다. 기본값에서는 하나뿐인 문단이 스스로 임계값을 넘으려면 540자가 필요합니다. 페이지가 중요하다면 parse해서 결과를 확인하세요.
기사 추출에는 Readability와 trafilatura 중 어느 쪽이 더 좋나요?
같은 바이트 픽스처에서는 trafilatura가 장식 요소를 더 적게 유지했고(17개 중 1개, Readability는 5개), 반대로 Readability는 짧은 문장과 <figcaption>을 더 많이 유지했습니다. 무엇을 고를지는 허용 가능한 오류와 실행 환경에 달려 있습니다. 공개 benchmark는 다른 맥락이며, 이 픽스처 결과를 검증하는 용도는 아닙니다.


