누출을 정의할 수 있는 fixture들만 놓고 보면, goose3는 content-token 정밀도 1.0000과 오염 토큰 0개를 기록했다. 네비게이션, 광고, 사이드바, 댓글, 프로모션의 어떤 단어도 결과물에 섞이지 않았다. 6개 라이브러리를 비교한 다른 어떤 도구도 이 수준을 따라오지 못했다.
하지만 전체 22개 fixture 기준으로는 기사 재현율이 가장 낮다. Mozilla Readability의 1.0000에 비해 goose3는 0.8243에 그쳤는데, 그 이유는 두 개의 fixture에서 빈 문자열을 반환했기 때문이다.
이 두 지표는 채점 규칙상 서로 연결되어 있다. 결과가 비어 있으면 조건부 정밀도에는 아무 기여도 하지 못하고, 재현율에는 그 누락이 그대로 반영된다.
goose3란 무엇인가
goose3는 Scala 기반 Gravity Labs' Goose와 python-goose 계보를 잇는 Python 3 버전이다. 단순한 텍스트 덤퍼가 아니라 메타데이터를 함께 뽑아내는 기사 추출기다. Goose를 생성한 뒤 extract()를 호출하면, 정제된 본문 텍스트, 제목, 작성자, 발행일, 대표 이미지, 메타 설명, 태그, 링크, 트윗 등 약 28개 필드에 접근할 수 있는 Article 객체가 반환된다.
공식 참고: goose3 공식 저장소.

테스트 버전: 3.1.22, Apache 라이선스, GitHub stars 912개, 마지막 푸시 2026-07-23 — 테스트 시점 기준으로 활발히 유지 관리되고 있었다. Python 3.14.2.
API 사용은 두 번의 호출과 하나의 약속으로 정리된다:
from goose3 import Goose
g = Goose()
try:
article = g.extract(raw_html=html)
text = article.cleaned_text
finally:
g.close() # 사용 후 명시적으로 닫기
여기서 close()를 꼭 짚고 넘어가야 하는 이유는, 잊기 쉽지만 아무 경고도 없기 때문이다. 이번 리뷰에서는 닫기를 생략했을 때 세션, 연결, 메모리가 실제로 얼마나 남는지 루프 테스트로 측정하지 않았으므로, “리소스 누수”라고 단정하기엔 근거가 부족하다. 다만 API 사용 예시가 요구하는 생명주기 규칙으로서 명시적 종료는 반드시 지켜야 한다고 보는 것이 맞다.
측정된 트레이드오프
6개 추출기, 1개 주석 달린 fixture 세트, 1개의 채점기. 모든 fixture의 각 블록은 article 또는 boilerplate로 라벨링되어 있고 고유한 sentinel 토큰을 갖기 때문에, “이 단위를 복원했는가”는 유사도 점수가 아니라 정확한 부분 문자열 포함 여부로 판정된다.
| 라이브러리 | 기사 재현율(전체 22개) | 보일러플레이트 누출 | content-token 정밀도 | 오염 토큰 수 | 출력 생성 |
|---|---|---|---|---|---|
| Readability | 1.0000 | 0.2353 | 0.9109 | 35 | 22/22 |
| trafilatura | 0.9865 | 0.0588 | 0.9411 | 4 | 22/22 |
| newspaper4k | 0.9865 | 0.0000 | 0.9452 | 0 | 22/22 |
| resiliparse | 0.9054 | 0.0588 | 0.9381 | 7 | 22/22 |
| jusText | 0.8378 | 0.4706 | 0.8760 | 74 | 19/22 |
| goose3 | 0.8243 | 0.0000 | 1.0000 | 0 | 20/22 |
sixway-scores.json. 재현율은 22개 전체 fixture 기준이고, 누출률과 정밀도는 기사 단위와 보일러플레이트 단위가 모두 있는 11개 fixture 기준이다.
정밀도 열은 마지막 열과 함께 읽어야 한다. 여기서 정밀도는 출력을 생성한 경우에만 계산된다. 즉, 어떤 라이브러리가 fixture에서 빈 문자열을 반환하면 분자에도 분모에도 아무 영향이 없다. 이 평균에서는 “포기”가 공짜인 셈이다. 분자는 추출된 불용어 제거 토큰과 라벨링된 기사 토큰의 멀티셋 중복이고, 분모는 추출된 모든 불용어 제거 토큰이다. “오염 토큰”은 더 좁게, 라벨링된 보일러플레이트 토큰과의 중복만 센다. 라벨링된 기사에도 보일러플레이트에도 속하지 않는 추가 토큰은, 오염 토큰 수를 늘리지 않으면서 정밀도를 떨어뜨릴 수 있다. 반복 토큰이 기사 멀티셋을 넘어서는 경우도 마찬가지다. 그래서 newspaper4k는 오염 토큰 0개인데도 정밀도가 1.0000 아래로 나올 수 있다. goose3의 1.0000은 11개 중 10개 fixture에서 채점된 값이고, Readability, trafilatura, newspaper4k, resiliparse는 11개 중 11개 모두에서 채점됐다.
이 합성 데이터셋 안에서는 1.0000이 여전히 의미가 있다. 채점된 10개 fixture에서 goose3는 라벨링된 보일러플레이트 토큰을 하나도 내보내지 않았다. 같은 페이지들에서 Readability는 35개를 내보냈다. 모델이 이 출력을 소비한다면, 해당 10개 fixture에서는 라벨링된 보일러플레이트에 대해 토큰 비용이 전혀 들지 않았다는 뜻이다. 하지만 실제 페이지에서 낭비가 0이라고 입증하는 것은 아니며, 빈 결과는 파이프라인의 다른 지점에서 fallback이나 재시도 비용을 유발할 수 있다.
두 번의 무응답, 그리고 그 의미
goose3는 정확히 두 개의 fixture에서 아무 것도 반환하지 않았다. 그중 하나는 이해할 만하지만, 다른 하나는 분명한 한계다.
거의 비어 있는 문서. 기사 단위가 32자 하나뿐인 페이지다. goose3는 거절한다. jusText도 마찬가지다. 이번 fixture 세트에서는 Readability가 22개 전부에서 출력을 냈으므로, 이 경우 Readability의 결과는 goose3의 무응답을 정당화해주지 않는다. 이처럼 작은 문서를 받아들이는지 여부는 호출 측의 최소 콘텐츠 계약에 달려 있다.

모든 본문이 <li> 요소로만 이루어진 기사. 기사 단위는 6개지만, 그중 어느 것도 <p> 태그 안에 없다. goose3는 빈 문자열을 반환한다.
두 번째 사례가 특히 의아했던 이유는, goose3의 기본 설정에 parse_lists=True가 들어 있기 때문이다. 그래서 나는 working control과 함께 세 가지 설정을 직접 확인했다. 라이브러리에 대한 판단은 한 번의 비생산적 실행만으로 내릴 수 없기 때문이다:
| 설정 | 리스트 전용 페이지 | <p> control |
|---|---|---|
| 기본값 | 0자 | 937자 |
strict=False | 0자 | 937자 |
parse_lists=True (명시) | 0자 | 937자 |
세 설정 모두 결과는 0자였고, control은 세 경우 모두 937자를 반환했다. 즉 parse_lists=True는 goose3가 이미 찾아낸 기사 안에서 리스트를 유지할지를 결정할 뿐, 리스트 자체를 기사로 보도록 후보 점수기를 바꾸지는 못한다. goose3의 노드 스코어링은 본문을 찾기 위해 paragraph 형태의 블록을 필요로 하며, 본문이 리스트로만 구성된 페이지는 그런 블록이 없다.
확인된 결과의 범위는 더 좁다. 이 합성 fixture처럼 본문이 전부 <li>이고 paragraph 후보가 전혀 없는 구조에서는 빈 문자열이 반환됐다. changelog, API 문서, 레시피, FAQ 페이지, 비교형 포스트는 리스트 비중이 높을 수 있어 실제 페이지 재현에서 위험 샘플로 볼 만하지만, 이 한 개의 fixture만으로 그런 페이지 유형 전반이 실패한다고 말할 수는 없다.
빈 문자열은 호출 측이 비어 있지 않은 출력인지 검증할 때만 기계적으로 감지할 수 있다. 기사와 아무 관련 없는 그럴듯한 텍스트보다 다루기는 쉽지만, 예외만 감시하는 구조에서는 여전히 조용한 실패다. 프로덕션 호출 측에는 최소 출력 길이 체크와 fallback 또는 명시적인 실패 기록이 필요하다.
설정 현실
pip install goose3는 약 6~9초 동안 16개 패키지와 44.3 MiB를 가져온다. 새 서브프로세스에서 측정한 cold import는 2.181초였다.
공식 참고: PyPI의 goose3.
| 라이브러리 | 패키지 수 | 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 |
install-and-import.json. 각 라이브러리는 서로 독립된 빈 virtualenv에서 실행했으므로, 다른 라이브러리의 footprint를 물려받지 않는다.
무게는 중간, 속도도 중간. Python 3.14.2에서 설치와 import가 문제없이 되었는데, 이 카테고리에서는 그 자체로 보편적이지 않다.
배포 전에 알아둘 만한 기본값 3가지

문서 대신 배포된 Configuration 객체를 읽어보면 19개 설정이 나온다. 그중 세 가지는 누군가를 놀라게 할 수 있다.
스스로 정체를 밝힌다. browser_user_agent의 기본값은 Goose/3.1.22다. goose3가 직접 가져오도록 두면, 접속하는 모든 서버 로그에 라이브러리 이름과 정확한 버전이 남는다. 솔직한 방식이지만 동시에 지문이기도 하다. 명시적으로 바꾸거나, HTML을 직접 가져와서 raw_html로 넘겨라.
MacPorts 경로를 가리킨다. imagemagick_convert_path의 기본값은 /opt/local/bin/convert, imagemagick_identify_path는 /opt/local/bin/identify다. 내 환경에서는 둘 다 없다. /opt/local은 MacPorts 경로인데, 대부분의 사람들은 쓰지 않는다. Homebrew는 /opt/homebrew에 바이너리를 둔다. 기본값은 이미지 fetching을 켜지 않으면(enable_image_fetching은 합리적으로도 기본값이 False) 사실상 동작하지 않는다. 하지만 대표 이미지 추출을 기대하며 이 기능을 켜면, 조용히 작동하지 않을 수 있는 지점이 바로 여기다.
영어를 전제로 한다. target_language 기본값은 en이고 use_meta_language=True다. 즉 페이지가 자체 언어 선언을 제공하면 그 값을 따르고, 없으면 영어로 돌아간다. 영어 작업에는 무난하지만, 그 외 언어라면 명시적으로 설정하는 편이 좋다.
나머지는 무난하다. parser_class는 lxml, http_timeout은 30초, strict는 켜짐, log_level은 ERROR, parse_headers와 keep_footnotes는 켜짐, images_min_bytes는 4,000이다.
메모리와 잘못된 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의 baseline은 서로 직접 비교할 수 없다. 둘 다 내부에 인터프리터가 포함되기 때문이다.
goose3의 floor는 44.1 MiB이고, 10 MB 문서에서는 398.5 MiB까지 치솟는다. 표시된 Python 라이브러리 중 import floor가 세 번째로 높아서, cold-start 민감 배포라면 알아둘 가치가 있다.
깨진 HTML. 각각 하나씩만 망가뜨린 12개 문서 — 닫히지 않은 태그, 잘못 중첩된 인라인 요소, 공백이 있는 따옴표 없는 속성, 불쑥 닫히는 태그, <html>이 전혀 없는 문서, 중복 속성, 태그 중간에서 잘린 문서, 잘못된 엔티티, 닫히지 않은 <script>, 거짓말하는 charset 선언, 마크업을 포함한 주석, 600단계 중첩 — 에 더해, 크기가 맞는 정상 control 2개를 포함했다. “아무 것도 반환하지 않았다”는 말이, 같은 크기의 깨끗한 문서에서도 라이브러리가 역시 침묵할 때는 malformedness에 대해 아무 말도 해주지 못하기 때문이다.
goose3는 14개 중 0개에서 예외를 던졌고, 10개에서 아무 것도 반환하지 않았으며, 깨진 fixture들에서 sentinel 33개 중 2개를 복구했다(malformed-results.json). 이 집계에서 한 fixture는 제외됐다. HTML5 규칙상 닫히지 않은 <script> 이후의 모든 내용은 script content이므로, 그 내용을 잃는 것은 정상이고 복구하는 쪽이 오히려 규칙에서 벗어난다.
장단점
장점. 출력이 있는 10개의 content-fidelity fixture에서 라벨링된 보일러플레이트 토큰을 하나도 남기지 않았다. 약 28개의 기사 필드를 사용할 수 있지만, 정확도는 점수화가 아니라 목록화만 했다. 이미지 fetch 기본값이 꺼져 있는 것은 합리적이다. Python 3.14에서 설치가 깔끔했다. 활발히 유지 관리된다. Apache-2.0. 호출 측이 명시적으로 검사하면 빈 결과를 쉽게 차단할 수 있다.
단점. 전체 세트에서 기사 재현율이 0.8243으로 가장 낮았고, 이는 잘못된 것을 반환해서가 아니라 아예 아무 것도 반환하지 않았기 때문이다. 기사 본문이 리스트로만 이뤄진 페이지는 설정과 무관하게 빈 문자열을 낸다. 44.3 MiB와 2.2초의 cold import는 resiliparse의 21.0 MiB와 15ms에 비하면 무겁다. close()가 필요하다. 두 기본값이 대부분의 머신에서 맞지 않는 것을 가리킨다.
누가 쓰고, 누가 피해야 하나
goose3를 쓰면 좋은 경우는, 추출한 텍스트가 모델이나 데이터베이스로 들어가고 라벨링된 보일러플레이트가 비싼 비용으로 이어지며, 페이지가 문단 중심의 전형적인 기사인 경우다. 이번 fixture 세트에서는 응답을 한 경우 라벨링된 보일러플레이트 토큰을 하나도 내보내지 않았다. 메타데이터 표면은 존재하지만 여기서는 검증하지 않았으므로, title, author, date, image의 정확도는 선택 기준으로 삼기 전에 별도 ground-truth fixture가 필요하다.
피해야 할 경우는 리스트 비중이 높은 말뭉치다. 설명 없이 빈 문자열이 나올 것이다. cold-start 비용이 중요하다면, resiliparse가 145배 더 빠르게 import되므로 피하는 편이 낫다. 그리고 모든 페이지에 대해 답이 필요하다면 피해야 한다. 여기서 “답 없음”은 실제 결과다. 22개 fixture 중 2개에서, 그리고 둘 다 조용히 — 빈 문자열은 예외가 아니므로 — 그렇게 나타났다.
함께 테스트해볼 만한 조합: goose3를 1차로 쓰고, cleaned_text가 비어 있거나 최소 콘텐츠 규칙보다 짧으면 fallback으로 넘기는 방식이다. Readability는 이 22개 fixture에서 goose3의 두 빈 케이스를 포함해 기사 단위를 전부 복구했다. 이 합성 결과는 아키텍처 패턴을 뒷받침할 뿐, 실제 페이지에서 fallback이 절대 놓치지 않는다는 보장은 아니다.
관리형 API가 들어갈 자리
이 벤치마크는 goose3의 raw_html 추출 경로를 테스트했다. 즉, goose3가 보기 전에 HTML은 이미 확보된 상태였다. goose3는 User-Agent 설정에서 드러나듯 자체 네트워크 fetch 경로도 갖고 있지만, 그 경로는 여기서 테스트하지 않았다. JavaScript 렌더링과 봇 차단 대응도 마찬가지로 테스트 대상이 아니었다.
같은 fixture를 6개 추출기 전체에 대해 보고 싶다면 6개 라이브러리 추출 비교를 참고하면 된다.
우리의 Thunderbit을 포함한 관리형 fetch/render/extraction 서비스는 다른 책임 경계를 다룬다. Thunderbit는 이번 테스트에서 벤치마크하지 않았다. 핵심 차이는 공급된 HTML을 기사로 추출하는 방식과, URL을 가져와 처리하는 호스팅 서비스 방식 사이에 있다. 이 글은 같은 지표로 비교한 성능이나 품질 비교를 제공하지 않는다.
공정하게 보면, goose3의 필드 세트는 고정적이고 기사형이다. 페이지가 기사라면 딱 맞고, 상품 목록이라면 틀린 도구다. 이미 HTML을 가지고 있고 페이지가 기사라면, goose3는 무료이고 매우 깔끔하다.
호스팅 측면의 더 넓은 비교는 웹 스크래핑 API 총정리에 있고, 자체 호스팅 대안은 오픈소스 스크래퍼 종합 가이드에 있다. 텍스트가 모델로 들어갈 예정이라면, Python에서 HTML을 Markdown으로 변환하기에서 실제로 어디서 fidelity가 깨지는지도 다룬다.
goose3를 써야 할까?
문단 형태의 기사들이 작업과 잘 맞고, 호출 측이 빈 출력을 성공이 아니라 실패한 추출로 다룬다면, 그렇다.
이번 fixture 세트에서 goose3는 응답한 경우 라벨링된 보일러플레이트 토큰을 하나도 내보내지 않았고, 빈 문자열을 두 번 반환했다. 하나는 거의 비어 있는 페이지였고, 다른 하나는 리스트 전용 합성 본문이었다. 이는 정밀도와 커버리지 사이의 트레이드오프이지, 제품 전체의 성향을 증명하는 것은 아니다.
재현율이 더 중요하다면, 명시적인 최소 출력 가드를 둔 fallback을 테스트하라. Readability는 이 22개 fixture에서 기사 단위를 전부 복구했다. newspaper4k는 22개 모두에서 출력을 생성하면서 라벨링된 보일러플레이트 누출 0, 재현율 0.9865를 보였다. 이런 결과는 이 합성 세트에서만 유효한 것이지, 알 수 없는 실제 운영 워크로드를 뜻하지는 않는다.
goose3는 잘못된 단어 하나의 비용이 페이지 하나를 놓치는 비용보다 클 때 제값을 한다.
Thunderbit로 웹 데이터 추출 체험하기 Get Started Free
자주 묻는 질문
goose3의 완벽한 정밀도는 진짜인가, 아니면 단지 포기해서 나온 값인가? 둘 다 맞고, 둘은 분리해서 봐야 한다. 보일러플레이트가 있는 11개 fixture 중 10개에서만 채점됐으므로 평균에서 한 fixture가 빠졌고, 그 부분은 포기의 결과다. 하지만 채점된 10개에서는 의도적으로 적대적인 보일러플레이트에 대해 오염 토큰을 하나도 내보내지 않았고, Readability는 35개를 누출했다. 정밀도는 응답한 페이지에서 실제이고, 포기는 재현율 열에 드러난다.
왜 goose3는 본문이 리스트로만 된 페이지에서 아무 것도 반환하지 않나?
후보 점수화가 기사 본문을 찾기 위해 paragraph 형태의 블록을 필요로 하기 때문이다. <li>만으로 구성된 페이지에는 그런 블록이 없다. 기본값인 parse_lists=True는 이 사실을 바꾸지 않는다. 나는 strict=False와 함께 명시적으로 시험했지만, 세 설정 모두 0자를 반환했고, <p> 기반 control은 세 경우 모두 937자를 반환했다. parse_lists는 이미 찾아낸 기사 안에서 리스트를 유지할지 여부를 정할 뿐이다.
close()를 꼭 호출해야 하나?
위 예시처럼 try/finally로 명시적으로 닫아라. 이번 리뷰는 닫기를 생략했을 때 무엇이 누적되는지 측정하지 않았으므로 정량적인 루프 누수는 주장하지 않는다. 다만 Goose에는 호출 측이 관리해야 하는 생명주기가 있음을 보여준다.
goose3는 User-Agent를 무엇으로 보내나?
기본값은 Goose/3.1.22다. 라이브러리 이름과 정확한 버전이 그대로 나간다. 이는 goose3가 직접 fetch할 때만 적용되며, raw_html을 넘기면 완전히 우회된다. 직접 fetch를 허용한다면 User-Agent는 의도적으로 설정하라. 기본값은 접속하는 모든 서버에 무엇이 호출했는지 정확히 알려준다.
이번 리뷰에서 테스트하지 않은 것은 무엇인가?
실제 운영 페이지 전부다. 여기서는 통제된 fixture만 사용했다. target_language가 1급 설정이지만, 다국어 추출은 테스트하지 않았다. 메타데이터 필드(title, authors, date, top image)는 목록화했을 뿐 정확도 점수는 매기지 않았다. 이미지 fetching은 기본적으로 꺼져 있고, ImageMagick 경로는 대부분의 머신에 없는 패키지 관리자를 가리킨다. 동시성이나 지속 부하에서의 메모리 동작, 그리고 부하 시 처리량도 테스트하지 않았다. 메모리 표는 새 프로세스 하나가 문서 하나를 처리하는 조건에서 측정했다.


