가장 긴 문단이 151자뿐인 문서에서, jusText를 기본 설정으로 돌리면 0자를 반환한다. 일부만 추출되는 수준이 아니라, 완전히 빈 문자열이다. 임계값을 하나 낮춰 딱 한 문단만 기준을 넘기면 같은 문서가 832자를 돌려준다. 더 낮춰도 결과는 여전히 832자다.
이 테스트 문서에서는 동작이 완만한 경사가 아니라 절벽처럼 끊어진다. 기본값이 맞는지 틀리는지는 대상 코퍼스의 문단 길이와 보일러플레이트 분포에 달려 있다.
jusText란 무엇이며, 왜 어휘 밀도가 중요한가
이 비교군의 다른 추출기들과 달리, jusText는 언어별 불용어 밀도에 특히 강하게 의존한다. the, and, of, was 같은 기능어 비중이 높은 블록일수록 산문일 가능성이 높다. 하지만 이 어휘 신호가 분류기의 전부는 아니다. 문단 길이, 링크 밀도, HTML 기반 블록 경계, 제목과의 거리, 주변 클래스, 그리고 문맥을 반영하는 후처리도 결과에 영향을 준다.
공식 참고 자료: jusText 공식 저장소.

이 분류기는 언어별 불용어 목록을 사용하기 때문에, jusText는 100개 언어용 목록을 제공한다. 이 언어별 어휘 표면이 이번 비교에서 가장 분명한 차별점이다. 다만 다국어 품질은 테스트하지 않았다.
테스트 버전: 3.0.2, BSD 2-Clause, GitHub 스타 822개. Python 3.14.2.
절벽의 실측 결과

jusText의 분류기는 두 단계로 돌아간다. 문맥을 보지 않는 1차 분류에서 각 문단을 good, bad, short, neargood 중 하나로 나눈다. 그다음 문맥을 반영하는 2차 분류에서 neargood를 good으로 승격시키는데, 이미 good인 블록 옆에 있을 때만 가능하다. 그리고 문단이 단독으로 good 판정을 받으려면 length_high를 넘어야 하는데, 기본값은 200자다.
어느 문단도 200자를 넘지 않으면 승격의 시드가 생기지 않고, 모든 neargood 블록은 보일러플레이트로 떨어진다. 결국 페이지 전체가 비어 버린다.
가장 긴 문단이 151자인 테스트 문서에서 임계값을 바꿔가며 실험했다:
length_high | good으로 분류된 문단 수 | 반환된 문자 수 |
|---|---|---|
| 200(기본값) | 0 | 0 |
| 150 | 8 | 832 |
| 120 | 8 | 832 |
| 100 | 8 | 832 |
| 80 | 8 | 832 |
justext-length-threshold.json.
이 임계값 테스트 문서에서는, 한 문단이 기준선을 넘는 순간 출력 대상 문단 8개가 모두 살아났고, 그보다 더 낮춰도 추가로 늘어나는 것은 없었다. 이 흔적은 기존 good 블록 주변의 적격 neargood 이웃을 문맥 단계가 승격시키는 패턴과 일치한다. 다만 임의의 페이지에서 모든 이웃이 무조건 승격된다는 뜻은 아니다.
length_high의 영향으로 보기 전에, length_low 네 가지 값과 max_link_density 두 가지 값을 교차해 훑어봤다. 총 8가지 조합 모두 결과는 0이었다. 이 두 설정을 바꿔도 이 문서에서는 출력을 되살릴 수 없었다.
전체 22개 테스트 문서에서도 같은 흐름이 이어진다. length_high=200에서는 22개 중 2개, 150에서는 22개 중 9개, 120에서는 22개 중 15개에서만 출력이 나왔다.
이 결과가 말하지 않는 것도 두 가지 있다. jusText가 엉망이라는 뜻은 아니다. 실제 자연어 기사 페이지에서 기본값으로 돌리면 1,190자의 깔끔한 본문 텍스트를 추출했다. 진짜 뉴스 문단은 첫 시도부터 200자를 넘기기 때문이다. 또 기본값이 잘못됐다는 뜻도 아니다. 기본값은 긴 문단을 전제로 한다는 뜻이며, 본인 코퍼스에 그런 문단이 있는지 먼저 판단하라는 신호다.
이 테스트 세트의 반대편: 기본값에서 더 높은 보일러플레이트 누수
라벨이 붙은 이 테스트 세트에서 기본값 기준으로 보면, jusText의 보일러플레이트 누수가 가장 높다.
| 라이브러리 | 기사 재현율(22개 전체) | 보일러플레이트 누수 | 콘텐츠 토큰 정밀도 | 오염 토큰 수 |
|---|---|---|---|---|
| Readability | 1.0000 | 0.2353 | 0.9109 | 35 |
| trafilatura | 0.9865 | 0.0588 | 0.9411 | 4 |
| newspaper4k | 0.9865 | 0.0000 | 0.9452 | 0 |
| resiliparse | 0.9054 | 0.0588 | 0.9381 | 7 |
| jusText | 0.8378 | 0.4706 | 0.8760 | 74 |
| goose3 | 0.8243 | 0.0000 | 1.0000 | 0 |
sixway-scores.json. 하나의 테스트 세트, 하나의 스코어러, 그리고 모든 단위에는 고유한 센티넬이 붙어 있어 복구 여부를 정확한 부분 문자열 포함으로 판정했다.

47%의 보일러플레이트 누수와 74개의 오염 토큰 — 이 라벨된 합성 세트에서는 Readability보다 누수율이 두 배, 오염도는 두 배 이상 높다. 다만 이는 기본값에서의 진단 결과일 뿐, 전체 제품군을 대표하는 고정 순위는 아니다.
관찰된 누수는 절벽 현상에 관여한 것과 같은 문맥 단계와 일치한다. 주변 블록의 클래스와 거리 규칙이 허용하면 적격 neargood 블록이 승격될 수 있고, 그래서 기사 텍스트 옆의 산문성 프로모션이나 댓글이 경계를 넘어갈 수 있다. 이 테스트 결과는 어떤 라벨 블록이 새어 나왔는지 보여주지만, 단순화한 메커니즘 자체는 여전히 jusText의 정확한 문맥 규칙에 따라 조건적으로 작동한다.
재현율은 0.8378로 아래에서 세 번째였고, 손실의 전부는 임계값 때문이다. 129자짜리 단일 기사에서는 아무것도 못 건졌고, 짧은 문단 10개로 된 페이지에서도 못 건졌으며, 거의 비어 있는 문서에서도 못 건졌다.
언어별 불용어 목록 인벤토리
다른 불용어 목록은 99개.
justext.get_stoplists()는 100개 언어를 반환한다. 이 분류기는 영어 휴리스틱을 번역한 것이 아니라, 설계 자체가 언어별 파라미터를 받도록 되어 있다. 언어를 바꾸는 일은 인자 하나를 넘기는 것으로 끝난다:
import justext
paragraphs = justext.justext(html, justext.get_stoplist("Czech"))
text = "\n".join(p.text for p in paragraphs if not p.is_boilerplate)
trafilatura와 goose3도 언어 관련 동작을 제공하지만, 이번 리뷰에서는 어떤 추출기도 비영어 정답 기준으로 채점하지 않았다. jusText가 번들로 제공하는 100개의 불용어 목록은 다국어 평가 후보로서 분명한 장점이지만, 그 목록 수만으로 해당 언어들에서의 추출 품질을 입증할 수는 없고 경쟁 도구들이 그 언어들을 덜 효과적으로 지원한다고도 말할 수 없다.
API는 두 개의 함수와 아홉 개의 조정 가능한 상수로 이루어진다. length_low 70, length_high 200, stopwords_low 0.30, stopwords_high 0.32, max_link_density 0.20, max_heading_distance 200, 그리고 인코딩 처리 옵션이 있다. 작고 읽기 쉽고, 시그니처에 다 드러난다.
설치와 속도
pip install justext를 실행하면 3개 패키지가 설치된다. 비교군 중 가장 적다. 용량은 22.4 MiB이고, 2초도 안 걸린다.
공식 참고 자료: PyPI의 jusText.
| 라이브러리 | 패키지 수 | site-packages | 콜드 임포트 | 추출 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 |
install-and-import.json. 각 라이브러리는 빈 가상환경에 따로 설치했다.
3개 패키지와 22.4 MiB는 이 비교에서 상당히 가벼운 의존성이며, 중앙값 0.56ms의 추출 속도는 이 벤치마크에서 trafilatura의 0.51ms와 가깝다. 다만 환경 측정은 패키지 용량을 파일 유형별로 분해하지 않기 때문에, 디스크 사용량 중 얼마나 많은 부분이 불용어 목록 때문인지까지는 말해주지 못한다.
유지보수 상태, 조심스럽게 읽기
여기서 사용한 유지보수 시점 지표는 저장소의 마지막 푸시와 PyPI의 마지막 릴리스이며, 둘 다 2025-02-25다. 테스트보다 17개월 전이다. 전체 릴리스는 8개, 포크는 91개, 열려 있는 이슈는 9개, 아카이브는 아니다.
PyPI 분류기에는 Python 지원이 3.9까지로 적혀 있다. 그런데 나는 이걸 3.14.2에서 실행했고, 설치도 됐고, 0.777초 만에 import됐으며, 22개 중 19개 테스트 문서에서 예외 한 번 없이 추출했다.
즉, 메타데이터는 실제보다 Python 버전 다섯 단계 뒤에 있고, 현실에서는 잘 동작한다. 이 차이가 중요하다. 조용한 저장소는 기능이 아니라 지원 상태를 말해주는 신호다. 공개된 2011년 방식과 단어 목록 집합이 전부인 라이브러리라면, “완성됐다”는 상태가 꽤 그럴듯하다. 더 바꿀 것도 별로 없고, 불용어 목록은 안티봇 우회 코드처럼 쉽게 낡아버리지도 않는다.
조용한 저장소가 의미하는 바도 분명하다. 버그를 만나면 스스로 고치거나 포크해야 한다는 뜻이다. 다만 열려 있는 이슈가 9개뿐이라면, 감당 못 할 정도로 미해결 문제가 쌓인 라이브러리라고 보기도 어렵다.
메모리와 깨진 HTML이 끼치는 영향
운영 관점의 두 질문은 여기서 따로 측정했다.
더 넓은 스트레스 테스트 맥락은 10개 라이브러리의 메모리 및 잘못된 HTML 비교에 있다.
최대 상주 메모리는 /usr/bin/time -l로 측정했으며, 각 셀마다 새 프로세스를 하나씩 띄웠다. import floor는 라이브러리를 로드한 뒤 대기 상태로 둘 때 드는 비용이고, peak에는 문서 처리 비용이 포함된다.
| 라이브러리 | 실행 환경 | Import floor | 226 KB peak | 10 MB peak |
|---|---|---|---|---|
| html2text | python3.14 | 18.7 | 19.9 | 71.2 |
| pyquery | python3.14 | 30.3 | 33.9 | 172.5 |
| resiliparse | python3.14 | 20.5 | 25.1 | 225.1 |
| markdownify | python3.14 | 23.9 | 28.9 | 278.5 |
| goose3 | python3.14 | 44.1 | 52.4 | 398.5 |
| cheerio | node22 | 66.8 | 76.5 | 398.5 |
| justext | python3.14 | 30.3 | 36.6 | 431.2 |
| newspaper4k | python3.14 | 52.6 | 61.8 | 668.5 |
| trafilatura | python3.14 | 52.5 | 64.8 | 927.1 |
| turndown | node22 | 47.8 | 68.4 | 2947.1 |
memory-results.json. Python과 Node의 기준값은 서로 직접 비교할 수 없다. 인터프리터가 둘 다 안에 들어 있기 때문이다.
jusText의 floor는 30.3 MiB이고, 이번 10MB 테스트 문서에서 peak은 431.2 MiB였다. import floor의 약 14.2배, 입력 크기의 절대 RSS 기준으로는 43.1배다. 한 개의 테스트 문서와 한 개의 프로세스만으로 일반적인 스케일링 곡선을 말할 수는 없다. Python과 Node 기준값도 여전히 직접 비교 불가다.
깨진 HTML. 정확히 한 가지씩만 망가뜨린 12개 문서 — 닫히지 않은 태그, 잘못 중첩된 인라인 요소, 공백이 포함된 따옴표 없는 속성, 남아도는 닫는 태그, <html> 자체가 없는 문서, 중복 속성, 태그 중간에서 잘린 문서, 잘못된 엔티티, 닫히지 않은 <script>, 거짓말하는 charset 선언, 마크업이 들어 있는 주석, 그리고 600단계 중첩 — 에 더해, 같은 크기의 잘 형성된 제어 문서 2개를 포함했다. 왜냐하면 “아무것도 못 읽었다”는 결과는, 같은 크기의 깨끗한 문서에서도 역시 아무것도 못 읽는 라이브러리라면 잘못된 HTML 때문이라고 말할 수 없기 때문이다.
justext는 14개 중 0개에서 예외를 던졌고, 13개에서 아무것도 반환하지 않았다. 깨진 문서 전반에서 점수화된 센티넬 33개 중 0개만 복구했다(malformed-results.json). 잘 형성된 짧은 제어 문서도 0자를 반환했고, 잘 형성된 긴 제어 문서만 1,333자로 비어 있지 않았다. 12개 깨진 문서는 모두 빈 상태로 남았고, 닫히지 않은 <script> 사례는 센티넬 생존 채점에서 제외했다. 따라서 이 묶음은 파서의 관용성 — 예외 없음 — 은 보여주지만, 침묵이 잘못된 HTML 때문인지 아니면 이미 측정된 크기 임계값 때문인지는 구분해주지 못한다.
장점과 단점
장점. 100개의 언어별 불용어 목록, 그리고 그것을 번역한 게 아니라 실제로 그 목록을 중심으로 만든 분류기. 비교군 중 패키지 수가 가장 적은 3개 패키지. 중앙값 추출 0.56ms. 문서화된 조정 상수 9개. BSD 2-Clause. 분류기는 3.9까지만 적혀 있는데도 Python 3.14에서 문제 없이 동작.
단점. 이 합성 테스트의 기본값 기준 보일러플레이트 누수가 가장 높다: 47%, 오염 토큰 74개. 짧은 문단 테스트 문서에서는 어떤 문단도 length_high를 넘기 못해 빈 문자열을 돌려줬다. 이 세트에서 재현율은 0.8378이었다. 마지막 저장소 푸시와 릴리스가 테스트보다 17개월 앞선 시점에 멈춰 있어, 유지보수 책임을 따져봐야 한다.
누가 써야 하고, 누가 피해야 하나
jusText를 검토할 만한 경우는 코퍼스가 다국어이거나, 명시적인 문단 길이 임계값 조정이 이득이 되는 경우다. 이번 테스트에서는 불용어 목록 100개를 인벤토리로만 확인했고, 다국어 품질은 측정하지 않았다. 패키지 3개짜리 의존성과 명시적인 임계값 제어가 배포 요건에 맞을 때도 후보가 된다.
피해야 할 경우는 토큰 단위로 모델에 넣는 작업이다. 이런 상황에서는 goose3와 newspaper4k가 0개였던 오염 토큰 74개가 곧바로 비용이 된다. 제품 설명, 목록 페이지, 변경 로그, FAQ처럼 짧은 문단이 많은 페이지도 length_high를 의도적으로 조정하지 않았다면 피하는 편이 좋다. 그리고 규정이나 조달 이유로 활발히 유지보수되는 의존성이 필요하다면 역시 피해야 한다. 코드가 돌아가더라도 그건 현실적인 제약이다.
쓴다면 이렇게 하라. 기본값이나 분위수 규칙을 그대로 복사하지 말고, 라벨이 있는 검증 샘플에 맞춰 length_high를 조정하라. 가능한 임계값을 훑으면서 기사 재현율과 보일러플레이트 정밀도를 함께 측정하라. 게이트를 낮추면 짧은 산문을 살릴 수 있지만, 원치 않는 인접 블록도 함께 승격될 수 있다.
관리형 API가 들어갈 자리
jusText는 이 비교군의 다른 도구들과 마찬가지로, 이미 확보한 HTML을 입력으로 받는다. 이들 라이브러리 중 어느 것도 페이지를 직접 가져오거나, JavaScript를 렌더링하거나, 안티봇 계층을 처리하지 않는다.
같은 6개 추출기 기준 비교는 6개 라이브러리 추출 비교를 참고하면 된다.
자체 서비스인 Thunderbit을 포함한 호스티드 가져오기/렌더링/추출 서비스는 책임 경계가 다르다. Thunderbit은 이번 벤치마크에 포함하지 않았다. 여기서의 차이는 “제공받은 HTML의 산문 분류”와 “URL을 수집하고 처리하는 서비스”의 차이다. 이 글은 동일 지표 기준의 품질 비교를 제공하지 않는다.
정직하게 말하면, jusText의 다국어 불용어 목록은 실제로 유용하고 무료다. 문제의 본질이 여러 언어의 페이지를 가져오는 것이지 그 안의 산문을 분류하는 것이 아니라면, 구매해야 할 것은 다른 제품이다.
더 넓은 영역에서는 웹 스크래핑 API 정리에서 호스티드 옵션을, 오픈소스 스크래퍼 가이드에서 자체 호스팅 옵션을 다룬다. 출력이 모델로 들어갈 예정이라면, Python에서 HTML을 Markdown으로 변환하기에서 가장 많은 보존 손실이 일어난다.
jusText를 써야 할까?
코퍼스가 다국어이거나, 문단 길이 분포상 명시적인 임계값 조정이 유리하다면 후보가 된다. 배포 전에 이 두 가지를 모두 검증하라.
번들된 불용어 목록 100개는 분명한 설계 요소지만, 다국어 정확도는 테스트하지 않았다. 임계값 절벽은 조정 가능하다. 더 낮은 설정이 괜찮은지는 라벨이 있는 샘플에서 측정한 재현율과 보일러플레이트 정밀도에 달려 있다.
이 영어 합성 세트의 기본값 기준으로는 newspaper4k가 라벨된 보일러플레이트 단위를 하나도 누수시키지 않았고, 기사 단위의 0.9865를 회수했다. 즉, 이 작업 부류에서의 비교 후보일 뿐, 보편적인 대체재 추천은 아니다.
웹 데이터 추출용 Thunderbit 체험하기 Get Started Free
자주 묻는 질문
왜 jusText는 일부만 추출하지 않고 빈 문자열을 반환하나요?
분류기가 두 단계로 되어 있기 때문이다. 문단이 단독으로 good을 받으려면 length_high(기본 200자)를 넘어야 하고, 그 뒤에 2차 분류가 인접한 neargood 블록을 승격시킨다. 기준을 넘는 문단이 하나도 없으면 시드가 없으므로 모든 후보가 보일러플레이트로 떨어지고 결과가 비게 된다. 부분 답을 못 찾는 실패가 아니라, 구조적으로 전부 아니면 전무인 방식이다.
jusText는 방치된 프로젝트인가요? 마지막 저장소 푸시와 마지막 PyPI 릴리스가 둘 다 2025-02-25였고, 테스트 시점 기준으로 17개월 전이다. PyPI 분류기는 Python 3.9에서 멈춰 있지만, Python 3.14.2에서 예외 없이 설치되고 실행됐다. 열려 있는 이슈 9개도 엄청난 적체는 아니다. 날짜는 고장 증거라기보다 유지보수 위험으로 읽어야 한다. 실무적으로는 버그를 직접 고쳐야 할 가능성이 있다는 뜻이다.
왜 Readability보다 보일러플레이트를 더 많이 새게 하나요? 이번 테스트 세트에서는 기본 문맥 규칙 아래에서 산문처럼 보이는 인접 블록이 출력 경계를 넘었다. 승격은 모든 이웃에 자동 적용되는 게 아니라, 블록 클래스·거리·문맥에 따라 조건적으로 이뤄진다. 측정 결과는 기본값에서 보일러플레이트 단위 누수 47%, 오염 토큰 74개였다.
다른 언어에서 쓰려면 어떻게 하나요?
justext.justext(html, justext.get_stoplist("German"))처럼 쓰면 된다. get_stoplists()는 사용 가능한 100개를 모두 반환한다. 불용어 목록은 문단 길이, 링크 밀도, HTML 기반 세분화, 제목 거리, 주변 블록 문맥을 함께 쓰는 분류기의 언어별 어휘 입력이다. 즉, 언어 입력만 바꾸는 것이지, 그것만으로 독일어 추출 품질이 검증되는 것은 아니다.
이번 리뷰에서 무엇을 테스트하지 않았나요?
실제 웹페이지는 전혀 테스트하지 않았다. 여기서는 라벨이 붙은 단위로 통제된 테스트 문서만 사용했다. 이 라이브러리의 핵심 장점인 다국어 기능은 불용어 목록 100개를 인벤토리로만 확인했고, 비영어 텍스트로 채점하지 않았다. 인코딩 엣지 케이스도, jusText가 encoding, default_encoding, enc_errors 파라미터를 제공함에도 불구하고 다루지 않았다. 그리고 max_heading_distance와 두 개의 불용어 비율 임계값은 전부 기본값으로 두었다.


