6개의 라이브러리, 하나의 주석 처리된 fixture 세트, 하나의 스코어러. 이 22개의 합성 fixture에서 보일러플레이트 누수가 가장 컸던 라이브러리 — Mozilla Readability, 23.5% — 는 동시에 라벨링된 모든 article 단위를 복원한 유일한 도구였다.
이 한 문장이 바로 핵심적인 트레이드오프다. 그런데 이 주제에 대한 대부분의 글은 precision만 계산하고 끝내기 때문에, 이 사실을 거의 드러내지 않는다.
실제로 무엇을 측정했나
이 세트의 모든 fixture에는 단위별 정답이 들어 있다. 페이지의 각 블록 — article 문단, nav, 광고, 사이드바, 댓글 스레드, 프로모션 영역 — 은 article 또는 boilerplate로 라벨링되어 있고, 고유한 센티널 토큰이 붙어 있다. 따라서 “추출기가 이 단위를 복원했는가”는 유사도 점수가 아니라 정확한 부분 문자열 포함 여부다. 센티널은 출력에 살아남거나, 그렇지 않거나 둘 중 하나다.
총 22개 fixture, 91개 단위. 사용한 추출기는 6개다: Mozilla Readability 0.6.0(jsdom 30.0.1 경유), trafilatura 2.2.0, resiliparse 1.0.9, newspaper4k 0.9.6, goose3 3.1.22, jusText 3.0.2. Python 3.14.2와 Node 22는 같은 머신에서 실행했다. 다만 이 글은 OS/CPU, 정확한 실행 명령, 반복 횟수, 워밍업 정책은 보존하지 않으므로, 시간 컬럼은 이식 가능한 벤치마크가 아니라 로컬 관찰치다.
실행 전에 스스로에게 걸어둔 규칙도 두 가지였다. 각 Python 라이브러리는 서로 비어 있는 독립 virtualenv에만 설치해서, 다른 라이브러리가 끌어온 의존성이 섞이지 않도록 했다. 또 어떤 러너도 메트릭을 계산하지 않게 했다. 모든 도구는 원시 추출 텍스트만 내보내고, 단 하나의 스코어러가 모든 수치를 산출했다. 즉, 여섯 도구를 여섯 가지 비슷해 보이는 "precision" 정의가 아니라 동일한 산술로 비교한 셈이다.
한눈에 보는 표
| 라이브러리 | article 재현율(22개 전체) | 보일러플레이트 누수 | 콘텐츠 토큰 precision | precision 산출 fixture 수 | 오염 토큰 수 |
|---|---|---|---|---|---|
| Readability | 1.0000 | 0.2353 | 0.9109 | 11/11 | 35 |
| trafilatura | 0.9865 | 0.0588 | 0.9411 | 11/11 | 4 |
| newspaper4k | 0.9865 | 0.0000 | 0.9452 | 11/11 | 0 |
| resiliparse | 0.9054 | 0.0588 | 0.9381 | 11/11 | 7 |
| jusText | 0.8378 | 0.4706 | 0.8760 | 10/11 | 74 |
| goose3 | 0.8243 | 0.0000 | 1.0000 | 10/11 | 0 |
재현율은 22개 전체 fixture를 합산한 값이다. 누수율, 전체 콘텐츠 토큰 precision, 오염 수치는 article 단위와 boilerplate 단위가 모두 있는 11개 fixture를 기준으로 계산했다. “answered”는 그중 출력이 나온 fixture 수를 뜻한다. 전체 fixture별 수치는 sixway-scores.json에 있다.
이 표의 한 행은 기본값이 아니다. resiliparse의 extract_plain_text는 기본이 main_content=False인데, 나는 이를 main_content=True로 호출했다. 차이는 작지 않다. 기본값으로 두면 세트 전체에서 보일러플레이트 단위 17개 중 17개를 누출한다 — 모든 nav, 광고, 사이드바, 댓글 스레드, 프로모션 영역을 그대로 끌고 온다는 뜻이다. 반면 이 플래그를 켜면 17개 중 1개만 누출한다. 위의 다른 라이브러리들은 모두 기본값으로 호출했다. 따라서 resiliparse의 0.0588 누수율은 main content를 요청했을 때의 성능이고, extract_plain_text(html)만 호출하면 다른 제품이라고 봐야 한다(default-vs-main-content.json).
첫 번째 열과 두 번째 열을 함께 봐야 한다. 둘 중 하나만 읽으면 엉뚱한 도구를 고르게 된다.
Readability는 절대 놓치지 않는다. 22개 fixture 모두에서 완벽한 재현율을 기록했고, 이 점에서는 혼자다. 대신 대가를 치른다. 17개 보일러플레이트 단위 중 4개가 유출됐고, 오염 토큰은 35개였다. trafilatura의 누수율보다 4배다. 이 네 번의 누수 중 세 번은 같은 패턴이다. article의 형제 요소로 붙어 있는 중립 클래스의 프로모션 블록을, 형제 요소를 덧붙이는 휴리스틱이 함께 삼켜 버린 것이다. 이 출력을 모델에 넣는다면, 그 토큰 비용을 그대로 내야 하고 모델은 그것을 article로 읽게 된다.
newspaper4k는 균형형이다. 누수 0, 오염 토큰 0, 재현율 0.9865, 그리고 22개 fixture 전부에서 출력이 나왔다. 워크로드를 모른 상태에서 하나만 골라야 한다면 이쪽이다. 다만 사람들이 가장 먼저 집는 도구는 아니다.
goose3는 precision은 완벽하지만 이번 테스트에서 재현율이 가장 나빴다. 반환한 콘텐츠 단어는 전부 article 내용이었다. 하지만 두 fixture에서는 아예 아무것도 복원하지 못했고, 그 두 fixture에서는 출력도 없었다. 거절할 수 있다면 완벽한 precision은 값싸다.
두 라이브러리를 과하게 좋게 보이게 만든 precision 숫자
이 마지막 지점은 구체적으로 짚을 가치가 있다. 나도 한 번은 이 함정에 빠질 뻔했기 때문이다.
여기서 precision과 F1은 출력이 나온 경우에만 계산된다. 어떤 fixture에서 빈 문자열을 반환한 라이브러리는 분자에도 분모에도 기여하지 않는다. 즉, 응답을 거절하는 것은 공짜이고, 보수적인 추출기의 precision은 단지 침묵했기 때문에 철저한 추출기보다 좋아 보일 수 있다.
goose3의 전체 precision은 출력이 있었던 10개 scored fixture에서 1.0000이었다. jusText는 11개 중 10개에서 0.8760이었다. Readability, trafilatura, resiliparse, newspaper4k는 11개 전부에 응답했다. 이제 표는 precision 옆에 분모를 함께 보여주므로, 거절이 보기 좋은 비율 뒤에 숨어버릴 수 없다.
더 나쁜 버전도 있었다. 처음 만든 스코어러는 article fidelity를 측정하는 11개 fixture만 대상으로 article 재현율을 평균냈다. 이 세트는 누수가 없는 fixture를 제외하는데, 그건 leak 측정에는 맞는 방식이다. 그런데 그 결과 resiliparse가 재현율 1.0000으로 나왔다. 하지만 22개 전체로 보면 resiliparse는 0.9054다. article이 <p> 하나 없이 <li> 요소 안에만 들어 있는 fixture에서는 출력은 냈지만 article 단위 6개 중 0개만 복원했기 때문이다. 그 fixture에는 boilerplate가 없어서 평균에서 빠졌고, 완벽한 점수 뒤에 실제 실패가 숨어 버렸다.
각 도구가 실제로 어디서 깨지는가
| Fixture | 테스트 내용 | 아무것도 복원하지 못한 도구 |
|---|---|---|
article이 전부 <li> 안에 있고 <p>가 없음 | 구조 가정 | resiliparse (0/6), goose3 (출력 없음) |
| 129자짜리 짧은 article 단위 1개 | 짧은 콘텐츠 임계값 | jusText |
| 짧은 문단 10개, 긴 문단 없음 | 짧은 콘텐츠 임계값 | jusText |
| 거의 비어 있는 문서 | 진짜 null 경계 | goose3, jusText |
이것들은 모두 일반적인 “추출을 더 못한다”가 아니라, 재현 가능한 구체적 동작이다.
- resiliparse와 goose3는 둘 다 문단을 전제로 한다. 본문이 리스트로만 이루어진 페이지 — 변경 로그, 스펙, FAQ, 레시피 — 에 이 둘을 던지면, resiliparse는 리스트 내용을 하나도 담지 않은 텍스트를 돌려주고, goose3는 아무것도 돌려주지 않는다. 여기서 더 위험한 쪽은 resiliparse다. 뭔가를 반환했다는 사실이 성공처럼 보이기 때문이다.
- jusText에는 길이 절벽이 있고, 그 경사는 매우 가파르다. 아래에서 더 설명한다.
- 거의 비어 있는 문서는 아무것도 돌려주는 것이 오히려 맞는 경우이므로, 이건 두 라이브러리의 문제라고 보지 않겠다.
jusText: 완만한 경사가 아니라 절벽
jusText는 22개 fixture 중 19개에서 출력을 냈고, 보일러플레이트의 47%를 누수했다. 테스트 내에서 가장 높은 수치라, 보통의 평판과는 정반대다. 하지만 진짜 흥미로운 수치는 내가 모든 걸 다시 돌리게 만든 숫자다.
jusText는 각 블록을 언어별 stoplist에 대한 stopword 밀도로 분류한 뒤, 이미 good인 블록 옆에 있을 때만 neargood를 good으로 승격하는 문맥 인식 단계를 거친다. 블록이 스스로 good이 되려면 length_high를 넘어야 하는데, 기본값은 200자다. 이 선을 넘는 문장이 하나도 없는 문서에서는 승격을 시작할 씨앗이 없어서, 페이지 전체가 boilerplate로 무너진다.
가장 긴 문단이 151자인 문서에서 이를 시험해 봤다.
length_high | good 문단 수 | 반환된 글자 수 |
|---|---|---|
| 200(기본값) | 0 | 0 |
| 150 | 8 | 832 |
| 120 | 8 | 832 |
| 100 | 8 | 832 |
| 80 | 8 | 832 |
문단 하나만 임계값을 넘기면 0자에서 832자로 곧바로 점프한다. 그리고 그 뒤로는 더 느슨하게 만들어도 아무것도 달라지지 않는다. 한 문단이 선을 넘는 순간 전체 문서가 열리는 셈이다.
그 결론을 내리기 전에 length_low는 네 가지 값으로, max_link_density는 두 가지 값으로 각각 스윕했다. 총 8가지 조합이었고, 전부 0을 반환했다. 이 프로젝트의 규칙은 부정적 능력에 대한 주장은 최소 3가지 파라미터 형태를 확인하거나, 아니면 벤더가 필드를 지목한 자체 오류를 제시해야 한다는 것이다. 파라미터 하나가 작동하지 않는다고 해서 라이브러리 전체에 대한 결론이 되지는 않는다. 수치는 justext-length-threshold.json에 있다.
이게 jusText가 형편없다는 뜻은 아니다. 실제 자연어 페이지에서 기본값으로 돌렸을 때는 깨끗한 article 텍스트 1,190자를 반환했다. 뜻하는 바는 다르다. 문서화된 조정값 하나가 사실상 스위치처럼 작동하며, 짧은 문단 문서에서는 그 스위치의 기본 위치가 맞지 않는다는 것이다.
무엇을 설치하게 되고, import에는 얼마가 드는가

같은 fixture, 같은 머신, 각 라이브러리는 비어 있는 독립 virtualenv에 설치.
| 라이브러리 | 패키지 수 | site-packages | cold import | 추출 p50 |
|---|---|---|---|---|
| resiliparse | 5 | 21.0 MiB | 0.015 s | 0.06 ms |
| jusText | 3 | 22.4 MiB | 0.777 s | 0.56 ms |
| goose3 | 16 | 44.3 MiB | 2.181 s | 1.85 ms |
| newspaper4k | 22 | 47.5 MiB | 2.812 s | 2.69 ms |
| trafilatura | 17 | 69.9 MiB | 1.584 s | 0.51 ms |
| Readability + jsdom | 32 (npm) | 26 MiB | 0.473 s | 6.37 ms |
이번 실행에서 resiliparse는 cold import와 median extraction 값이 가장 낮았다. 각각 15 ms와 0.06 ms다. 다만 불완전한 프로토콜로는 런타임 간 정확한 비율을 말할 수 없는데, 특히 가장 느린 단일 추출이 1,098 ms였기 때문이다. 서버리스 크기 산정에 이 수치를 쓰려면, 시작 시점, 첫 호출, 안정 상태의 분포를 따로 봐야 한다.
trafilatura와 resiliparse는 품질 면에서 거의 동률이다. content-token F1이 각각 0.9697 대 0.9681, 누수율도 둘 다 0.0588로 같다. 이 정도 차이를 승자라고 부르진 않겠다. 하지만 용량은 다르다. 21.0 MiB 대 69.9 MiB, 5개 패키지 대 17개 패키지다. 실제로 치르는 비용은 resiliparse의 리스트 인식 부족과 trafilatura의 추가 선행조건 3개 사이의 선택이다.
공개 전에 내가 먼저 찾아낸 테스트베드의 버그 2개
위의 비교는 거의 성립하지 못할 뻔했다. 그 이유는 어떤 개별 행보다도 더 중요하다.
fixture 세트는 6개 라이브러리 중 2개를 제대로 볼 수 없었다. 원래 fixture는 각 단위를 zzart01vf64 zzart01v56i 같은 유일한 난수 토큰 열로 만든다. 정확한 재현율을 만들기 위한 방법이면서도, 그 결과 fixture 안에는 영어 기능어가 하나도 없다. Readability, trafilatura, resiliparse는 DOM 구조로 판단하므로 영향이 없었다. 하지만 goose3와 jusText는 stopword를 세어 구조를 판단하는데, 셀 수 있는 것이 없었다. 그래서 둘 다 22개 fixture 전부에서 빈 문자열을 반환했다.
2개 라이브러리를 전부 0점으로 놓은 표는 권위 있어 보일 수는 있어도 아무 의미가 없다. 나는 표를 쓰기 전에 실제 페이지에서 먼저 확인했다. goose3는 1,017자를, jusText는 1,190자를 반환했다. 라이브러리는 멀쩡했다. 테스트베드가 그들을 표현하지 못했을 뿐이다.
그래서 fixture를 영어 산문에 센티널을 얹는 방식으로 다시 만들었다. 구조, 클래스, DOM 위치, 단위 경계, 센티널은 그대로 두고, 토큰 1,568개를 일대일로 교체했다. goose3는 0에서 22개 중 20개로 올라갔다.
그런데 재구성이 스스로 만든 문제도 두 개 있었고, 둘 다 내 실수였다. 영어 단어는 대략 6자이고, zzart01vf64는 대략 12자다. 1:1 교체는 각 단위를 절반으로 줄였다. 단위 텍스트 21,646자가 10,986자로 바뀌었고, 가장 긴 단위는 1,513자에서 622자로 줄었다. 길이가 핵심인 fixture를 조용히 다시 써 버린 셈이다. 길이 절벽처럼 동작하는 jusText는 그것만으로 22개 중 19개에서 6개로 떨어졌다. 절반 길이 버전을 그대로 냈다면, jusText 수치는 실제보다 3배나 나쁘게 보였을 것이다.
두 번째 문제: 하나의 공통 코퍼스에서 모든 단위를 뽑다 보니 stopword 밀도는 돌아왔지만, 토큰 수준 스코어링이 전제하는 성질은 무너졌다. article과 boilerplate의 어휘는 서로 겹치면 안 된다. 그렇지 않으면 “추출된 boilerplate 토큰”을 셀 때 the 같은 단어까지 세게 된다. 22개 fixture 중 10개가 어휘 중복을 가지게 됐고, 원본에서는 0개였다. 해결책은 각 단위의 content word에는 접미사를 붙이고 기능어는 그대로 두는 것이었다. lexical 라이브러리에게는 실제 stopword를 세게 하고, 스코어러에게는 분리된 content vocabulary를 주는 방식이다.
그래서 이 글의 token-level 컬럼은 content_token_*라는 이름을 쓰며, 공개된 Readability 대 trafilatura 수치와 재사용하지 않았다. 그것은 다른 지표이고, content word만 대상으로 측정한 값이다. 하나를 다른 것으로 말하면 틀린다.
재구성 중에 또 하나 드러난 건 내 문제가 아니었다. 링크 밀도 fixture 3개는 </a>를 단어 내부에 넣는다 — <a href="/x">zzsibp015qlhf zzsi</a>bp015qbht 같은 식이다. 정확한 비율을 맞추려고 anchor를 문자 오프셋으로 배치했기 때문이다. 렌더링된 텍스트는 변하지 않으므로 원래 스코어링은 이를 알아채지 못했다. 하지만 요소 단위로 처리하는 추출기는 다른 도구들이 하나의 단어로 보는 것을 두 조각으로 본다. 이 문제도 고쳤고, 링크된 문자 차이는 조용히 흡수하지 않고 기록으로 남겼다.
누구에게 무엇이 맞는가
모델에 넣고 토큰당 비용을 내는가? newspaper4k 또는 goose3. 둘 다 보일러플레이트 단위도, 오염 토큰도 0이었다. 모든 페이지에서 무조건 답이 필요하면 newspaper4k, 추측보다 침묵이 낫고 페이지에 문단이 있는 편이면 goose3.
지연 시간에 민감한 Python 경로를 최적화하는가? bake-off에 resiliparse를 넣어라. 이번 테스트에서는 import와 median extraction이 가장 낮았고, 품질도 trafilatura와 거의 맞먹었다. 다만 기본값이 아닌 main_content=True일 때만 그렇다. 리스트가 많은 레이아웃부터 먼저 확인하고, 이 로컬 시간을 런타임 간 정확한 속도 비율로 바꾸지는 마라.
아카이빙, 또는 extra content보다 missing content가 더 나쁜 모든 경우? Readability. 모든 fixture에서 article 단위를 전부 복원한 유일한 도구다. 35개의 불필요한 토큰은, 문단 하나를 잃는 대가보다는 싸다.
다국어 작업인가? jusText는 언어별 stoplist를 제공하므로 후보에 넣을 만하다. 다만 이 연구는 다국어 추출을 시험하지 않았다. 그러니 그 기능은 평가할 이유이지, 승리의 증거가 아니다. 대표적인 문단 길이와 length_high를 함께 시험해라.
article이 아닌 페이지인가? 이들 중 어느 것도 아니다. 전부 “페이지에 하나의 본문 산문이 있다”는 가정을 바탕으로 만들어졌고, 상품 목록, 검색 결과 페이지, 대시보드는 그 가정을 파라미터로는 고칠 수 없는 방식으로 깨뜨린다.
관리형 API는 어디에 맞나
위의 모든 것은 직접 실행하는 라이브러리다. HTML을 넣으면 텍스트를 돌려준다. 관찰된 실패 모드는 페이지 형태에 따라 달라지므로, 선택한 기본값이 자신의 코퍼스에 맞는지 검증해야 한다. 구조화 필드 추출과 가져오기/렌더링은 이번 비교 범위 밖이다.
작성자 메모: Thunderbit은 URL 입력 워크플로와 구조화 출력용 관리형 서비스다. 이 fixture로는 실행하지 않았으므로, 품질 비교를 시사하는 뜻은 아니다. 핵심 판단 기준은 이미 HTML을 갖고 로컬 텍스트 추출기만 있으면 되는지, 아니면 가져오기/렌더링과 운영까지 서비스에 맡길지 여부다.
솔직하게 말하면 이렇다. 이미 HTML을 가지고 있고 텍스트가 필요하다면, 이 6개 중 하나는 공짜에 가깝고 충분히 좋다. 그리고 이 표가 어떤 것이 좋은지 알려준다. 반대로 페이지를 대규모로 가져와야 하거나, 산문이 아니라 행(row)이 필요하다면 그건 다른 구매다.
반대로 호스티드 fetcher를 고르는 중이라면, 우리의 웹 스크래핑 API 총정리가 그 분야를 다루고, SEO 및 데이터 API 비용 비교가 가격을 정리해준다. 셀프호스티드 쪽은 오픈소스 스크래퍼 종합 가이드가 더 넓은 시야를 제공하고, 실제로 필요한 것이 일반 텍스트가 아니라 Markdown이라면 Python에서 HTML을 Markdown으로 변환하기에서 손실이 가장 많이 발생한다.
결론
승자는 없다. 그리고 승자를 하나로 정해 버리는 표는 실제 트레이드오프를 왜곡하는 것이다.
선택하기 전에 작은 수용 테스트 코퍼스를 만들어라. 리스트만 있는 article, 짧은 문단, 프로모션 형제 요소, 거의 비어 있는 페이지, 그리고 오염보다 출력 거절이 더 나은 예시를 포함시켜라. article 복원, 보일러플레이트 누수, 거절을 각각 따로 점수화하라. 이 fixture들에서는 Readability가 재현율을 가장 우선했고, newspaper4k가 가장 균형 잡힌 행을 보였으며, resiliparse는 리스트 콘텐츠를 못 보는 대신 지연 시간 후보였다. 이 라벨들은 검증 없이 테스트된 형태를 넘어가면 안 된다.
내가 실제로 당신에게 말할 것은 이보다 더 좁다. 도구를 고르기 전에, 당신의 페이지 형태에 대해 직접 fixture를 돌려보라는 것이다. 6개 중 2개는 내가 시작했던 테스트베드를 보지 못했고, 그중 하나는 완벽한 재현율처럼 보이는 점수 뒤에 완전한 실패를 숨기고 있었다. 비교 표는 그 출발점일 뿐, 대체재가 아니다.
웹 데이터 추출용 Thunderbit 체험하기 Get Started Free
자주 묻는 질문
이 수치는 이 라이브러리들의 공개 벤치마크와 비교해도 되나? 아니다. 그렇게 인용하지 않겠다. 이건 synthetic하지만 라벨이 붙은 단위를 가진 통제된 fixture라서, 6개 모두 동일한 바이트를 봤고 서로 비교는 공정하다. scrapinghub의 article-extraction 벤치마크 같은 공개 수치는 실제 코퍼스를 사용하며, 그건 다른 더 어려운 문제를 측정한다. 이 표는 이 6개를 서로 비교할 때만 써야지, 논문 숫자와 맞대지 마라.
둘 다 DOM 기반인데 왜 Readability의 누수율이 trafilatura보다 훨씬 높나? 각자가 경계를 긋는 방식이 다르기 때문이다. Readability의 누수 4건 중 3건은 article 옆에 붙어 있는 중립 클래스의 프로모션 블록이다. 이 휴리스틱은 인접한, 링크 밀도가 낮은 긴 콘텐츠는 이야기의 일부일 가능성이 높다고 보고 함께 끌어온다. 실제로 그런 경우도 많다. 하지만 이 fixture들에서는 프로모션이었다. trafilatura는 덧붙이는 범위가 더 엄격해서 같은 단위 중 1개만 누수했다.
goose3와 jusText의 precision 수치를 믿어도 되나? 샘플 수와 함께 볼 때만 그렇다. 둘 다 article과 boilerplate가 모두 있는 11개 fixture 중 10개에서만 점수가 매겨졌다. 한 fixture에서는 아무것도 반환하지 않았기 때문이다. 출력이 없는 fixture는 비율의 어느 쪽에도 기여하지 않는다. goose3의 1.0000 precision은 응답한 페이지에 한해서는 진짜다. 전체 22개 fixture에서의 0.8243 재현율은 같은 사실의 다른 반쪽이다.
jusText의 길이 임계값은 실제 페이지에서도 중요하나?
전적으로 문단 길이에 달려 있다. 문단이 300자 정도인 뉴스 기사라면 첫 문단에서 length_high를 넘고 정상적으로 동작할 것이다. 그래서 기본값에서 실제 페이지에 대해 jusText는 깨끗한 텍스트 1,190자를 반환했다. 반대로 짧은 문단, 리스트 항목, 상품 설명 위주의 페이지는 임계값을 한 번도 넘지 못할 수 있고, 그 경우 jusText는 부분 답변 대신 빈 문자열을 반환한다. 프로덕션에서 알아내지 말고, 명시적으로 설정해라.
여기서 테스트하지 않은 것은 무엇인가? 실제 웹 페이지 전부다. multilingual extraction도 마찬가지인데, 그럼에도 jusText의 주요 장점은 언어별 stoplist다. 부하가 걸렸을 때의 메모리도 시험하지 않았다. article이 아닌 어떤 페이지도 포함하지 않았다 — 상품 목록, 검색 결과, 대시보드 모두 제외다. 인코딩 엣지 케이스도 제외다. 그리고 Node와 Python 생태계는 런타임 성능이 아니라 라이브러리 동작만 비교했으므로, 경계를 넘는 밀리초 수치는 정확한 비율이 아니라 대략적인 규모 차이로 읽어야 한다.


