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개였고, 마지막 푸시는 2026년 7월 9일이라 main 브랜치가 배포 버전보다 한참 앞서 있었습니다. 저는 Node v22.22.3, macOS arm64, jsdom 29.1.1 환경에서, 목적에 맞게 만든 22개의 라벨링된 HTML fixture를 대상으로 돌렸고, 이 글의 수치는 모두 그 환경에서 나온 결과입니다. 직접 써보면 이 도구는 이 카테고리에서 가장 손이 덜 갑니다. 설치는 2분이면 끝나고, 바이너리도 없고, 캐시에 브라우저를 올려둘 필요도 없고, 다시 돌려도 결과가 똑같습니다. 흥미로운 지점은 사용 난이도가 아니라, 실패 양상이 소스 코드의 몇 개 상수만 봐도 꽤 예측 가능하다는 점입니다. 그리고 그중 하나는 문서가 말해주는 것보다 더 많은 걸 결정합니다.
22개의 통제된 synthetic fixture 전반에서 Readability는 라벨링된 기사 블록 74개를 전부 회수했습니다. 다만 이 제한된 결과가 곧 본문을 절대 놓치지 않는다는 뜻은 아닙니다. 공개 real-page 벤치마크에서는 recall이 0.982였고, 알려진 오류 유형은 여기서 재현되지 않았습니다. 가장 눈에 띄는 fixture 실패는 소스 코드의 0.25 링크 밀도 게이트 때문에 형제 콘텐츠가 추가로 포함된 사례였습니다. 그 체감 심각도는 테스트베드에 따라 달라집니다.
readability.js는 정확히 무엇이고, 무엇이 아닌가
Readability는 DOM 위에서 규칙 기반으로 점수를 매기는 과정입니다. 후보 요소를 훑고, 각 요소에 콘텐츠 점수를 부여한 뒤, 그 점수를 조상 노드로 전파하고, 가장 점수가 높은 서브트리를 고른 다음, 페이지의 장식 요소처럼 보이는 것들을 정리합니다. 이게 기사 추출의 전부입니다. 모델도 없고, 학습 데이터도 없고, 사이트별 규칙도 없습니다. 처음 본 페이지에서도 잘 동작하는 이유가 바로 이것이고, 실패가 소스에서 예측 가능하다는 점이 재미있는 이유이기도 합니다.
사람들이 자주 헷갈리는, 하지만 이 도구가 아닌 것 세 가지가 있습니다.
- 가져오는 도구가 아닙니다. URL이 아니라
document를 받습니다. 페이지 요청, 재시도, 봇 차단 대응, 헤더 처리는 사용자의 몫입니다. - 렌더링 도구가 아닙니다. JavaScript를 실행하지 않습니다. 넘겨준 DOM에 들어 있는 것만 봅니다.
- 구조화 추출 도구가 아닙니다. 결과는
title,byline,excerpt,content(HTML),textContent,length,siteName정도입니다. 스키마도 없고, 타입화된 행도 없고,{name, price}같은 구조도 없습니다.
대부분의 동작을 좌우하는 네 개의 상수

node_modules 안의 Readability.js를 읽어보면, 문서보다 훨씬 많은 걸 알 수 있습니다. 라이브러리 동작의 대부분은 아래 네 가지 메커니즘이 담당합니다.
- 점수 대상 문단 1개당 콘텐츠 점수:
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-append 게이트 — 최상위 후보를 고른 뒤, 그 형제 노드들도 포함할지 판단합니다. 형제가 자기 점수로 기준을 넘기거나,nodeLength > 80 && linkDensity < 0.25, 또는 **nodeLength < 80 && nodeLength > 0 && linkDensity === 0 && 마침표가 포함됨**이면 함께 들어옵니다.
링크 밀도는 Σ(linkText.length · coef) / textLength로 계산하며, #만 들어간 href는 coef = 0.3, 그 외는 1입니다. 이 게이트가 여기서 관찰된 형제 누수를 설명해 줍니다. 다만 이것이 추출 알고리즘 전체는 아닙니다.
세팅과, 소개에는 안 나오는 의존성
npm install @mozilla/readability jsdom만 하면 바로 실행됩니다. 2분이면 끝나고, 바이너리도 없고, 설치 후 다운로드도 없고, 브라우저가 캐시에 대기하고 있을 필요도 없습니다. 설치 축만 보면 거의 이상적입니다.
하지만 “의존성 없음”은 알고리즘에 대한 말이지, 런타임에 대한 말은 아닙니다. Readability는 살아 있는 document 위에서 동작하고, Node에서는 DOM 구현체를 직접 넣어줘야 합니다. 여기서는 jsdom 29.1.1을 썼습니다. jsdom은 가볍지 않고, 대부분의 파이프라인에서는 추출 자체보다 루프 전체 비용을 더 많이 차지합니다. 그 점은 예산에 넣어야 합니다.
제가 다시 실행하게 만든 또 하나의 함정은 Readability.parse()가 입력 DOM을 수정한다는 것이었습니다. 같은 jsdom document를 두 번 파싱하면 두 번째 호출은 첫 번째가 이미 뜯어놓은 문서를 보게 됩니다. 제 테스트 하네스는 매 파싱마다 새 jsdom을 만듭니다. 페이지를 돌면서 문서 객체를 재사용해 시간을 아끼려 한다면, 바로 그게 버그입니다.
어떻게 테스트했나
실제 뉴스 사이트를 대상으로 돌리지는 않았습니다. 실페이지는 점수는 주지만 왜 그런지는 보기 어렵고, 휴리스틱에서는 그 “왜”가 핵심입니다. 대신 저는 91개 라벨 블록(기사 74, boilerplate 17)을 포함한 22개 HTML fixture를 만들었습니다. 각 블록의 모든 단어 앞에는 그 블록만의 고유한 sentinel 문자열을 붙였습니다. 블록 간 어휘는 서로 겹치지 않으므로, 추출된 토큰이 어느 블록에서 왔는지 정확히 하나로 매핑됩니다. 따라서 “회수됨”과 “누수됨”은 애매한 유사도 비교가 아니라 정확한 포함 여부 테스트가 됩니다.
추출 단계와 채점 단계도 의도적으로 분리했습니다. Node 러너는 원본 추출 텍스트, isProbablyReaderable의 불리언 결과, 측정된 링크 밀도만 출력합니다. precision과 recall은 그 원본 텍스트와 라벨을 대조하는 별도 스크립트가 사후 계산합니다. 하네스 어디에도 지표 상수를 손으로 적어두지 않았습니다. 제 숫자를 믿는 거의 유일한 방식이기 때문입니다.
그 다음에는 동일한 바이트를 trafilatura 2.1.0에도 넣어 같은 테스트베드에서 비교했습니다. 각 fixture는 세 번씩 파싱했고, 22개 전부 매번 바이트 동일한 텍스트를 반환했습니다.
요약표만 믿지 말고 각 단계를 직접 확인할 수도 있습니다. tests/build_fixtures.mjs는 주석이 달린 HTML과 정답 데이터를 만들고, tests/run_readability.mjs는 추출 결과와 예측기 출력을 기록하며, tests/metrics.py는 그 기록을 나중에 채점합니다. 원본 Readability 출력, 계산된 지표, 동일 입력 비교 결과는 artifacts/raw/에 보관했습니다. 결과가 수상해 보일 때 이 분리가 중요합니다. 파서가 예상 밖 텍스트를 돌려줬는지, 라벨셋이 잘못됐는지, 채점 코드가 오분류했는지를 구분할 수 있기 때문입니다. 이 패키지에서의 재현은 여기서 말한 주장을 검증해 주지만, 배포 환경의 실제 페이지 분포가 같은 실패 양상을 가진다는 증거는 아닙니다.
적용 범위의 한계는 분명하고 중요합니다. 이건 현실 코퍼스가 아니라 synthetic 통제 페이지입니다. 권위 있는 real-page 수치는 공개 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)**를 기록합니다. 저는 그 수치를 인용했을 뿐, 재현하지는 않았습니다.
여기서는 현재 릴리스 벤치마크 행만 사용했고, 대체된 과거 행은 제외했습니다. 통제 fixture는 공개 real-page 코퍼스를 대체하는 것이 아니라, 어떤 콘텐츠 형태가 어떤 규칙을 자극하는지 블록 단위로 분해해 보여줍니다.
synthetic fixture 팩에서는 recall이 완벽했다
74개 중 74개. 22개 모든 fixture에서 Readability는 라벨링된 기사 블록을 하나도 놓치지 않았습니다. 그리고 기사와 boilerplate가 섞인 11개의 깨끗한 synthetic fixture에서는 micro-averaged token recall이 1.000이었습니다. 기사 문장 하나도 사라지지 않았습니다.
다만 여기에는 두 가지 단서가 붙습니다.
이 페이지들은 단일 컬럼의 깔끔한 synthetic 페이지입니다. 실제 기사들은 더 깊게 중첩되고, 중간에 광고가 끼고, 어떤 경우에는 첫 문단을 점수 산물 때문에 잃기도 합니다. 이런 누락은 트래커(#437, #901, 그리고 표 앞 콘텐츠가 떨어지는 #922)에 보고돼 있습니다. 제 fixture는 그 어느 것도 유발하지 못했습니다. 따라서 제가 그것들이 해결됐다고 말하는 건 아닙니다. 제 테스트가 거기까지 도달하지 못했다는 뜻입니다. 실제 페이지에서 이 버전의 benchmark recall은 1.000이 아니라 0.982입니다.
그래도 방향성은 중요합니다. Readability의 문제는 기사를 통째로 버리는 쪽이 아닙니다. 대신 무엇을 같이 데려오느냐에 있습니다.
precision 수치와, 그것을 이해하려면 필요한 세 가지 라벨

숫자 하나는 말하기 쉽고, 방어하기는 어렵습니다. 11개의 혼합 fixture에서 Readability는 17개의 boilerplate 블록 중 5개를 유지했으며, 누수율은 0.294였습니다.
하지만 이 값은 실제 누수율이 아닙니다. 세 가지 다른 세팅은 세 가지 다른 것을 측정하고, 그중 실제 페이지를 설명하는 것은 하나뿐입니다.
| 수치가 측정하는 것 | 결과 |
|---|---|
| 적대적으로 가중한 fixture 집합 — 섞인 11개 페이지 중 6개는 형제 게이트를 깨도록 의도적으로 설계됨 | boilerplate 17개 중 5개 유지 (0.294) |
현실적인 단일 페이지 — <article> 본문 주위에 nav, 광고 배너, 사이드바, 댓글, footer, 그리고 중립 class의 프로모션 1개가 둘러싼 형태 | chrome 블록 6개 중 5개 제거, 1개 유지 |
| 약 181개 실제 페이지의 공개 벤치마크(제가 직접 실행한 결과는 아님) | precision 0.914, recall 0.982, word-F1 0.947 — readability_js 0.6.0 |
첫 번째 행은 예측이 아니라 스트레스 테스트로 읽어야 합니다. Readability가 실전에서 boilerplate의 29%를 누수시키는 것은 아닙니다. 현실적인 페이지에서는 unlikelyCandidates 정규식이 매칭하는 class를 가진 요소들 — nav-menu, ad-banner, sidebar, comments, site-footer — 이 모두 깔끔하게 제거됐고, 5개 전부 사라졌습니다. 유일하게 살아남은 것은 제가 그 정규식을 피하도록 의도적으로 만든 블록 하나였습니다.
0.25 게이트: boilerplate 제거가 멈추는 지점
sibling-append 규칙 자체는 소스에 문서화돼 있습니다. 하지만 제가 찾은 한, 정확히 어디에서 뒤집히는지는 측정된 적이 없었습니다. 그래서 저는 그라디언트를 만들었습니다. <article> 밖에 중립 class의 <p class="teaser-block"> 하나를 두고, 확실하게 top candidate가 될 네 문단짜리 기사 본문을 준비한 뒤, 프로모션의 길이와 링크 밀도만 바꿨습니다. 링크 밀도는 Readability 자신이 쓰는 공식으로, 추정이 아니라 실행 시점에 측정했습니다.
| 프로모션 블록 | 내부 텍스트 길이 | 80자 초과 여부 | 측정된 링크 밀도 | 결과 |
|---|---|---|---|---|
| 링크 없음 | 126 | yes | 0.000 | 유지 |
| 짧은 링크 1개 | 126 | yes | 0.143 | 유지 |
| 더 긴 링크 1개 | 126 | yes | 0.278 | 제외 |
| 텍스트의 절반이 링크 | 126 | yes | 0.476 | 제외 |
| 한 문장, 마침표로 끝남 | 60 | no | 0.000 | 유지 |
| 같은 텍스트, 마침표 없음 | 59 | no | 0.000 | 제외 |
소스 조건은 0.25 임계값을 사용합니다. 측정 샘플은 그 지점을 정확히 양쪽에서 끼워 넣었고, 0.143은 유지, 0.278은 제외됐습니다. 별도의 분기에서는 60자짜리 마침표 포함 문장은 유지됐고, 59자짜리 마침표 없는 버전은 제외됐습니다. 기사 recall은 각 분기에서 모두 4/4였으므로, 이 샘플들은 precision 효과만 분리해서 보여줍니다.
테스트 하네스 밖에서 이 게이트가 사실상 말하는 것은 이겁니다. 기사 옆에 붙은 길고 링크가 적고 중립적인 문장은 기사다. 그런데 그건 기사 아닌 것들에도 너무 많이 들어맞습니다. 문단 형식의 “관련 읽을거리” 안내, 뉴스레터 홍보 문구, 에디터 노트, 추적용 이유로 링크만 제거된 스폰서성 티저 같은 것들이 그렇습니다.
RAG 인덱스에서는 링크가 적은 홍보 문단이 추출된 chunk가 되어, 검색이나 생성 단계가 그것을 기사 본문으로 취급할 위험이 생깁니다. 소스 규칙만 봐도 그 실패 모드는 충분히 가능성이 있습니다. 다만 이 리뷰에서는 end-to-end 검색이나 모델 인용 평가까지는 하지 않았습니다.
사이트별 강한 필터가 필요하다면, 먼저 알려진 source-DOM 컨테이너를 전처리로 제외하고, 직렬화 전에 원본 노드의 상위 관계를 유지해 비교하거나, 이후에 검증된 텍스트 패턴 필터를 적용하세요. 반환된 HTML만으로는 원래 노드가 primary container 밖에 있었는지 더 이상 보장되지 않을 수 있습니다. sibling 게이트는 공개 옵션으로 조정할 수 없습니다.
fixture가 뒤집은 세 가지 가정
fixture는 세 가지 가정을 반박했습니다. charThreshold가 짧은 기사를 거른다, semantic tag가 반드시 필요하다, 짧은 비서사 콘텐츠는 버려진다는 가정입니다. 아래 증거가 핵심이며, 사전 등록 같은 건 필요 없습니다.
charThreshold = 500은 절벽이 아니다
흔히들 기사 길이가 500자 미만이면 null을 돌려준다고 생각합니다. 그렇지 않습니다. 본문 길이를 120자에서 1500자까지 바꾸고, charThreshold를 200, 500, 1000으로 달리해 돌렸습니다.
| 본문 길이 | 모든 임계값에서 파싱 성공 | 추출 길이 |
|---|---|---|
| 120 | yes | 161 |
| 300 | yes | 342 |
| 460 | yes | 509 |
| 520 | yes | 569 |
| 800 | yes | 841 |
| 1500 | yes | 1555 |
완전히 평평합니다. 본문 길이가 어느 수준이든 세 threshold 설정에서 추출 길이는 동일했습니다. 이 임계값은 반환값을 막는 장치가 아닙니다. 정리 플래그를 뺀 상태로 다시 grab할지 말지를 정할 뿐입니다. 깨끗한 페이지에서는 제거할 게 없으니 어느 쪽이든 같은 내용이 나옵니다. 진짜 null 경계는 “추출 가능한 텍스트가 전혀 없음”입니다.
그리고 이 테스트에서 실제 실패는 더 불쾌한 형태로 나타났습니다. 거의 비어 있는 페이지 — nav bar와 네 단어짜리 소개 문구 — 를 넣었더니, 성공으로 처리되면서 반환된 “기사”에 nav가 포함됐습니다. 진짜 기사가 없으면 Readability는 boilerplate를 기사로 돌려줍니다. 대규모 크롤링에서 non-null을 “이 페이지에 내용이 있다”로 취급하면, 그 가정은 틀립니다.
semantic tag가 핵심은 아니다
사다리를 치우면 recall이 떨어질 거라고 예상했습니다. 같은 기사 텍스트를 두 가지 피부로 감쌌습니다. 하나는 <main><article><h1>와 설명적인 class 이름이 있는 버전, 다른 하나는 <div class="x1">와 bare <div> 문단만 있는 버전입니다. 결과는 둘 다 기사 블록 4/4 회수, boilerplate 누수 0였습니다. 기사가 페이지에서 가장 밀도 높은 텍스트 블록으로 분명하게 보이면, 길이와 쉼표 기반 점수만으로도 semantic 도움 없이 찾아냅니다. “Readability는 <article> 태그가 있어야 한다”는 건 folklore입니다.
그 주장에 대한 솔직한 한계도 있습니다. 제 페이지에는 눈에 띄는 콘텐츠 블록이 하나뿐이었습니다. semantic이 진가를 발휘할 법한 경우는 밀집 서브트리가 둘 이상 경쟁하는 페이지인데, 그 타이브레이크는 테스트하지 않았습니다.
비서사 콘텐츠도 그대로 남는다
“25자 미만 문단은 계산하지 않는다”는 규칙 때문에 표나 캡션이 빠질 거라고 생각했습니다. 또 틀렸습니다. 그 규칙은 유지 여부가 아니라 후보 점수 계산에만 영향을 줍니다. 컨테이너가 이기기만 하면 그 안의 내용은 전부 따라옵니다.
| 기사 내부 콘텐츠 유형 | Readability | trafilatura |
|---|---|---|
| 산문 문단(×2) | 유지 | 유지 |
| 데이터 표 셀(×2) | 유지 | 유지 |
<pre> 코드 블록 | 유지 | 유지 |
25자 미만 한 줄 <p>(×2) | 유지 | 유지 |
<figcaption> | 유지 | 제거 |
| 합계 | 8/8 | 7/8 |
이건 더 강하게 정리하는 도구가 손해를 보는 축입니다. 페이지가 문서, 튜토리얼, 코드 블록과 캡션이 있는 자료라면, Readability의 “이긴 서브트리 전체를 유지”하는 방식은 장점입니다.
parse()는 yes인데 isProbablyReaderable은 no라고 할 때

README는 본격 파싱 전에 isProbablyReaderable(doc)를 저렴한 사전 점검으로 쓰라고 권합니다. 그런데 제 테스트에서는 이 게이트가, 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 | 성공 | — |
세 실패는 원인도 다르고, 조정 가능한 것도 두 개뿐입니다. <li> 사례는 구조적 문제입니다. 예측기는 p, pre, article 노드(그리고 div > br 부모)만 점수화하므로, 콘텐츠가 리스트 항목에만 있는 페이지는 아무것도 매칭되지 않고 점수 0이 됩니다. 임계값을 아무리 조정해도 복구되지 않습니다. 이 형태는 이미 issue #662에 보고돼 있습니다. 많은 짧은 문단 사례는 minContentLength 게이트 때문에 각 문단이 점수화 전에 건너뛰어져, 열 개의 충분히 긴 문단도 합치면 0이 됩니다. 이 값은 낮추면 해결되고, minScore 조정은 도움이 안 됩니다. 한 개 긴 문단 사례는 산술 문제입니다. 점수는 sqrt(408 − 140) ≈ 16.4인데, 기본 minScore 20보다 낮습니다. 단독 문단이 스스로 기준을 넘으려면 140 + 20² = 540자가 필요합니다.
README도 예측기가 false negative를 낼 수 있다고 경고합니다. 제가 덧붙이고 싶은 실무 규칙은 이겁니다. 이걸 유일한 게이트로 쓰지 마세요. 페이지가 중요하다면 파싱하고 결과 길이를 확인하세요. 이미 jsdom 빌드 비용을 지불했다면, 파싱 비용은 그에 비해 그리 크지 않습니다.
같은 바이트, 두 개의 추출기
같은 fixture를 trafilatura 2.1.0에 돌리면, 서로 다른 테스트베드에서 얻은 숫자 두 개를 나란히 놓는 것보다 훨씬 깔끔하게 비교할 수 있습니다. 입력이 바이트 단위로 완전히 같기 때문입니다.
| 측정치(혼합 fixture 11개) | @mozilla/readability | trafilatura |
|---|---|---|
| 기사 블록 recall | 1.000 | 1.000 |
| 유지된 boilerplate 블록 | 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 |
이 fixture들에서 어느 쪽이 절대적으로 우세하다고 말할 수는 없습니다. trafilatura는 형제 블록을 덜 남겼고, Readability는 짧은 콘텐츠와 비서사 콘텐츠를 더 많이 살렸습니다. 두 도구 모두 라벨이 없는 제목 텍스트 때문에 절대적인 token precision이 낮아져 보이므로, 블록 단위 누수 개수가 더 직접적인 신호입니다. 공개 real-page benchmark는 우연히 word-F1 순서를 비슷하게 보여주지만, 코퍼스와 지표가 다르므로 이것을 cross-testbed 검증으로 볼 수는 없습니다.
견고성도 간단히 확인했습니다. 정석 페이지를 일부러 망가뜨린 변형본(닫히지 않은 <p>, 잘못 중첩된 <b>/<i>, 튀어나온 </div>)에서도 recall은 3/3, 누수 0이었고, 정상 페이지와 같았습니다. 그 공은 Readability가 아니라 jsdom의 HTML5 트리 빌더에 있습니다. Readability가 보기 전에 이미 HTML을 복구해 주기 때문입니다. 어떤 fixture도 파서를 크래시시키지 않았습니다.
장점과 단점
장점
- 기사 recall이 강합니다. 22개 synthetic fixture에서 라벨 블록 74/74 회수, 혼합 세트 token recall 1.000.
- 정규식으로 분류되는 페이지 장식이 안정적으로 제거됩니다. 현실적인 페이지에서는 nav, 광고 배너, 사이드바, 댓글, footer가 모두 사라졌습니다(6개 중 5개 제거).
- 비서사 콘텐츠를 온전히 유지합니다. 표,
<pre>코드, figure 캡션, 25자 미만 줄까지 모두 살아남았습니다(8/8). 반면 trafilatura는 캡션 하나를 제거했습니다. - semantic 마크업에 의존하지 않습니다. 중립적인
<div>기사와<article>/<main>버전의 점수가 동일했습니다. - 짧은 기사를 잘못 거르지 않습니다. 깨끗한 콘텐츠는 120자까지 회수됐고,
charThreshold200/500/1000에서도 결과가 같았습니다. - 완전히 결정적입니다. 22개 fixture 모두 세 번의 실행에서 동일한 텍스트를 반환했습니다.
- 설치가 2분이면 끝나고, Apache-2.0이며, npm 버전이 곧 제가 테스트한 버전(0.6.0)이라 이 결과가 낡지도 않았습니다.
단점
- sibling-append 게이트는 악용될 수 있습니다. 길고 링크가 적고 중립 class를 가진 홍보성 문장은 기사 텍스트와 구분되지 않고
linkDensity < 0.25에서 그대로 따라옵니다. - 콘텐츠가 빈약한 페이지에서는
null대신 boilerplate를 기사로 반환합니다. 거의 비어 있는 fixture에서는 nav bar가 본문으로 돌아왔습니다. isProbablyReaderable은 서로 다른 세 가지 페이지 형태에서 false negative를 냈고, 그중 하나는 어떤 조정도 통하지 않습니다.- 런타임에 전체 DOM이 필요합니다. “의존성 없음”이라는 표현 뒤에 jsdom 비용이 숨겨져 있고, 이 비용이 루프를 지배합니다.
parse()가 입력 문서를 수정하므로, 페이지마다 DOM을 새로 만들어야 합니다.- 가져오기, JavaScript 렌더링, 구조화 출력이 없습니다. 파이프라인의 한 단계일 뿐, 파이프라인 전체는 아닙니다.
- 트래커에 보고된 실페이지 누락(첫 문단 손실, 표 앞 콘텐츠 누락)은 제 fixture에서 재현되지 않았습니다. 그래서 그게 희귀한지, 아니면 제 페이지가 그 지점까지 도달하지 못했는지는 말할 수 없습니다.
누가 쓰고, 누가 건너뛰어야 하나
이미 HTML을 갖고 있고, Python 의존성을 추가하기 애매한 Node 서비스 안에서, pure JavaScript로 기사만 뽑아내고 싶다면 Readability가 맞습니다. (속도 분포를 제대로 수집한 것은 아니므로, 제가 말할 수 있는 속도 평가는 “jsdom 빌드가 추출보다 루프 전체를 지배한다” 정도뿐입니다.) Reader mode 기능, 오프라인 기사 아카이빙, 이메일 뉴스레터, “clean view” 버튼, 브라우저 확장, 코드 블록과 캡션이 있는 문서 파이프라인에는 잘 맞습니다. 그리고 소스만 봐도 동작이 읽히기 때문에, 왜 특정 블록이 통과했는지 동료에게 설명해야 할 때 생각보다 큰 가치가 있습니다.
반대로, boilerplate 제거 precision이 평가 지표라면, 특히 작은 홍보 문단 하나가 검색 가능한 chunk가 되는 LLM 인덱스에 넣는다면 건너뛰세요. 페이지가 클라이언트 사이드에서 렌더링된다면 건너뛰세요. 이 도구는 넘겨준 DOM만 읽고 JavaScript는 실행하지 않습니다. 필요한 것이 서사형 텍스트가 아니라 {title, price, sku} 같은 구조라면 건너뛰세요. 어떤 설정도 콘텐츠 추출기를 스키마 기반 추출기로 바꿔주지 않습니다. 그리고 “이 페이지에 기사 자체가 있었나”가 진짜 질문인 경우라면, non-null 반환을 답으로 믿지 마세요.
대안과 Thunderbit 스택의 위치
이건 Mozilla가 유지하는 무료 Apache-2.0 라이브러리를 깎아내리는 이야기가 아닙니다. Readability는 인프라이고, Firefox 안에서 수년째 돌아왔으며, reader-mode 추출에서는 기준 구현체로 불릴 만한 이유가 있습니다. 더 넓은 시장을 보고 싶다면 open-source scrapers roundup과 the best web scraping tools에서 계속 비교를 정리하고 있습니다.
같은 fixture를 여섯 개 추출기 전부에 적용한 비교는 six-library extraction comparison에서 보실 수 있습니다.
작성자 메모: Thunderbit는 URL 입력 기반 렌더링과 추출을 위한 관리형 옵션입니다. 이 fixture에는 실행하지 않았으므로, 품질이 동일하다는 뜻은 아닙니다. 핵심 경계는 이미 DOM을 갖고 로컬 추출만 원하는지, 아니면 가져오기/렌더링/구조화 출력을 서비스로 맡기고 싶은지입니다. 셀프 호스팅은 벤더 사용료를 피할 수 있지만, 인프라와 유지보수 비용은 여전히 듭니다.
솔직한 트레이드오프는 이렇습니다. Readability는 무료이고, 투명하고, 직접 운영할 수 있습니다. 출력이 왜 그렇게 나왔는지 결정한 정확한 게이트를 읽을 수 있는데, 관리형 API에서는 그런 걸 보기 어렵습니다. 관리형 스택은 돈이 들고 내부 메커니즘은 숨기지만, 대신 fetch-render-structure 단계를 직접 조립하지 않아도 됩니다. AI가 보조하는 쪽이 궁금하다면, 저는 다른 글에서 AI로 어떤 웹사이트든 스크래핑하는 방법과 AI 크롤러를 다뤘습니다. 실제로 어떤 단계를 직접 소유하고 싶은지에 따라 고르시면 됩니다.
결론
이미 DOM이 있고, JavaScript/Node 환경에서 돌리며, 과도한 누락보다 가끔의 추가 형제 콘텐츠를 더 낫다고 보는 경우라면 Readability는 유력한 선택입니다. 이 fixture 팩에서는 라벨된 기사 블록 74개를 전부 회수했고, 표·코드·캡션도 유지했습니다. 다만 이 결과는 단일 컬럼 synthetic 페이지에 한정됩니다. 공개 real-page recall은 0.982였고, 알려진 첫 문단/표 인접 누락은 재현되지 않았으며, 콘텐츠가 빈약한 페이지는 boilerplate를 기사로 돌려줄 수 있습니다.
약점의 크기를 정확히 잡아야 합니다. 이 fixture에서 가장 큰 문제는 precision이며, 소스의 정확히 문서화된 한 줄에 있습니다. 80자보다 길고 링크 밀도가 0.25 미만인 sibling은 기사에 그대로 추가됩니다. 그 경계는 동일한 텍스트에서 0.143일 때는 유지되고 0.278일 때는 제외되는 것으로 확인했습니다. 공개 real-page benchmark는 precision 0.914, recall 0.982를 보고합니다. 추출한 텍스트를 나중에 모델이 인용할 인덱스에 넣는다면, 유지된 boilerplate와 누락된 본문을 둘 다 살펴보세요. 둘 중 하나가 없을 거라고 가정하지 마십시오.
웹 데이터 추출용 Thunderbit 체험하기 Get Started Free
자주 묻는 질문
Mozilla Readability는 boilerplate를 전부 제거하나요?
아니요. 그리고 그 수치는 무엇을 측정하느냐에 크게 좌우됩니다. 공개 real-page benchmark에서 readability_js 0.6.0의 precision은 0.914로, 반환된 내용 중 약 8.6%는 본문이 아닙니다. 제가 만든 현실적인 테스트 페이지에서는 chrome 블록 6개 중 5개를 제거했으며(nav, ad, sidebar, comments, footer 모두 제거), 중립 class의 홍보 문단 하나만 남겼습니다. 반대로 heuristic을 깨도록 설계한 블록을 의도적으로 많이 넣은 fixture 집합에서는 17개 중 5개를 유지했습니다. 마지막 수치는 실전 누수율이 아니라 스트레스 테스트입니다.
Node에서 readability.js를 쓰려면 jsdom이 필요한가요?
네, 아니면 다른 DOM 구현이 필요합니다. Readability는 순수 JavaScript지만 살아 있는 document 객체 위에서 동작하므로, Node에서는 DOM을 직접 제공해야 합니다. 제 환경에서는 jsdom 29.1.1을 썼습니다. “의존성 없음”이라는 표현은 런타임이 아니라 알고리즘에 대한 말입니다. 또한 parse()는 넘겨받은 문서를 수정하므로, 하나의 DOM을 재사용하지 말고 페이지마다 새 DOM을 만드세요.
charThreshold 옵션은 실제로 무엇을 하나요?
대부분이 생각하는 것과는 다릅니다. 짧은 기사를 null로 만들지는 않습니다. 저는 120자짜리 깨끗한 기사도 회수했고, charThreshold를 200, 500, 1000으로 바꿔도 추출 길이는 같았습니다. 이 임계값은 정리 플래그를 뺀 상태로 다시 grab할지 결정할 뿐입니다. 깨끗한 페이지라면 지울 게 없으니 결과도 같습니다. 진짜 null은 추출 가능한 텍스트가 전혀 없는 페이지입니다. 심지어 nav만 있는 페이지도 non-null로 돌아왔고, nav 자체가 기사로 반환됐습니다.
parse() 전에 isProbablyReaderable을 호출해야 하나요?
게이트가 아니라 힌트로 쓰세요. 이 예측기는 <li> 요소 안에만 콘텐츠가 있는 페이지, 140자 미만 문단 10개, 408자짜리 문단 하나에 대해 false를 반환했지만, parse()는 모두 성공했습니다. <li> 사례는 예측기가 p, pre, article 노드만 점수화하기 때문에 조정으로도 해결되지 않습니다. 많은 짧은 문단 사례는 더 낮은 minContentLength가 필요합니다. 한 개 긴 문단 사례는 더 낮은 minScore가 필요합니다. 기본값을 넘기려면 단독 문단이 540자에 도달해야 하기 때문입니다. 페이지가 중요하다면 그냥 파싱하고 결과를 확인하세요.
기사 추출에는 Readability와 trafilatura 중 무엇이 더 좋은가요?
같은 fixture 바이트 기준으로 보면 trafilatura가 boilerplate를 더 적게 남겼고(17개 중 1개 vs 5개), Readability는 더 짧은 콘텐츠를 잘 살렸고 trafilatura가 제거한 <figcaption>도 유지했습니다. 에러 허용 범위와 런타임 기준으로 고르세요. 공개 benchmark는 별개의 맥락이지, 이 fixture 결과의 검증은 아닙니다.


