10월 15일, PyPI에 pyhtml2text를 공개했습니다. HTML을 일반 텍스트나 Markdown으로 바꿔 주는 패키지이고, Chris Emerson의 Rust 크레이트인 rust-html2text를 기반으로 합니다. Markdown 변환은 제가 측정한 페이지에서 흔히 쓰는 Python 변환기들보다 3배에서 21배 빨랐습니다. 상대 링크를 페이지 URL 기준으로 풀어 주는 기능도 있어서, 출력물이 원래 페이지를 떠나도 링크가 그대로 작동합니다.
pip install pyhtml2textfrom pyhtml2text import html2markdown
html = """<h2>Before you start</h2>
<p>Read the <a href="setup.html">setup guide</a> and <b>back up</b> your data.</p>
<img src="/img/diagram.png" alt="How it fits together">
<table><tr><th>Plan</th><th>Price</th></tr><tr><td>Basic</td><td>Free</td></tr></table>"""
print(html2markdown(html, base_url="https://example.com/docs/intro.html"))## Before you start
Read the [setup guide](https://example.com/docs/setup.html) and **back up** your data.

| Plan | Price |
| --- | --- |
| Basic | Free |시작은 rust-html2text에 보낸 풀 리퀘스트였습니다. 메인테이너는 LLM으로 작성된 코드라는 이유로 이를 닫았고, 그래서 pyhtml2text는 포크 위에서 돌아갑니다. 이 글은 그 과정과 포크·패키지에 들어 있는 것, 그리고 Markdown 출력이 맞는지 어떻게 확인했는지에 대한 이야기입니다.
왜 필요했나#
maru는 Accept: text/markdown 요청에 HTML 대신 Markdown으로 답합니다. 내비게이션 바와 푸터를 헤치고 다니고 싶지 않은 AI 에이전트와 LLM 클라이언트를 위해서입니다. 엣지 쪽 이야기는 Cloudflare, 크롤러, 캐시에 관한 글에서 다뤘습니다. 매물, 구인, 커뮤니티 페이지는 HTML이 텅 빈 Expo 셸이라 Markdown을 데이터베이스에서 바로 만듭니다. 나머지는 Hugo가 만든 진짜 HTML(마케팅, 약관, 블로그 페이지)이고, 이건 변환을 거칩니다.
변환할 때 문제는 링크입니다. Hugo 페이지에는 /en/posts/...나 ../setup/ 같은 링크가 가득합니다. 브라우저에서는 문제없습니다. 어느 페이지에서 온 링크인지 브라우저가 알고 있으니까요. 하지만 Markdown을 읽는 에이전트에게는 그런 맥락이 없어서 /login을 따라갈 수 없습니다. 필요한 건 https://example.com/login입니다.
순수 Python으로 작성된 html2text에는 이를 위한 baseurl 옵션이 있어서 처음에는 그걸 썼습니다. 하지만 느리고, 아래에서 다룰 Markdown 문제도 있었습니다. rust-html2text는 빠르고 테스트도 잘 돼 있었지만, 링크를 풀어 주는 기능이 없었습니다.
먼저 업스트림에 요청하기#
8월 29일, 사용 사례와 원하는 동작을 적어 이슈 #287을 열었습니다. RFC 3986 방식으로 풀되, 절대 URL과 mailto:, data:, #fragment 링크는 건드리지 않는 동작이었습니다. 몇 분 뒤 풀 리퀘스트 #288을 열었습니다. Config::base_url(...) 옵션을 추가하는 PR이었고, 이슈에는 이게 출발점일 뿐이며 메인테이너가 다른 방식을 원한다면 충분히 이해한다고 적었습니다.
10월 11일, 메인테이너가 PR을 닫았습니다.
Thanks for the contribution, but I’m unable to accept this as it appears to be LLM generated. (I have now added a brief policy to the README.md - it wasn’t previously stated)
(기여해 줘서 고맙지만 LLM으로 생성된 것으로 보여 받을 수 없다, 이전에는 명시돼 있지 않던 간단한 정책을 이제 README.md에 추가했다는 내용입니다.)
그리고 이슈에는 이렇게 답했습니다.
Hi - this is a reasonable feature request, thanks! I have closed the PR as it appears to be LLM-written which I can’t accept, but thanks for the attempt.
A short HTML sample and expected output would definitely help get the feature implemented!
(합리적인 기능 요청이라 고맙다, PR은 LLM으로 작성된 것으로 보여 받을 수 없어 닫았지만 시도해 줘서 고맙다, 짧은 HTML 예시와 기대하는 출력이 있으면 기능 구현에 분명 도움이 될 거라는 내용입니다.)
README에는 이제 이런 정책이 있습니다.
At this time, this project is not able to accept code contributions produced with generative AI/LLMs due to the uncertain copyright status.
(현재 이 프로젝트는 저작권 지위가 불확실하기 때문에 생성형 AI나 LLM으로 만든 코드 기여를 받을 수 없다는 내용입니다.)
판단은 정확했습니다. 그 코드는 제가 Claude Code로 작성했습니다. 저는 이 정책이 무리하다고 생각하지도 않습니다. LLM 출력물의 저작권 문제는 아직 정리되지 않았고, 다른 프로젝트들이 의존하는 MIT 크레이트에 그런 코드를 병합했을 때의 위험은 메인테이너가 떠안게 됩니다. 그분의 프로젝트이고, 답변도 정중했습니다. 기능 요청 이슈는 지금도 열려 있습니다.
그래도 저는 그 기능이 필요했고, 메인테이너와 그 프로젝트의 기여 정책을 두고 논쟁하는 건 기능을 얻는 좋은 방법이 아닙니다. 그래서 변경 사항은 포크에 두고 그 위에 Python 패키지를 만들었으며, 둘 다 업스트림이 아니라는 점을 분명히 밝혀 두었습니다.
포크에 들어 있는 것#
포크는 제 rust-html2text 포크의 pyhtml2text-perf 브랜치이고, 업스트림 위로 깔끔하게 리베이스할 수 있도록 작게 유지하고 있습니다. 추가한 커밋은 세 개입니다.
Config::base_url. PR에 있던 변경입니다. 상대 링크를 RFC 3986 §5.3에 따라 풉니다. 필요한 건 참조 해석(reference resolution)뿐이라url크레이트를 쓰지 않고 크레이트 안에 약 200줄로 구현했습니다. base URL을 주지 않으면 출력은 그대로입니다.- 스타일 규칙 공유. 이 크레이트는 문서를 렌더링할 때마다 스타일시트 데이터를 복제했고,
config::plain()은 호출될 때마다 장식 규칙 열 개를 새로 만들었습니다. 규칙 목록을Arc(copy-on-write) 뒤에 두고 한 번만 만들게 했더니, 짧은 스니펫 기준 7.3 µs가 5.6 µs가 됐습니다. 큰 페이지에서는 아무것도 아니지만, 몇천 번 호출하면 쌓입니다. - DOM 노드 타입과 URL 리졸버 공개. 아래에서 설명할 Markdown 변환기는 파싱된 DOM을 직접 순회하기 때문에 이 타입들이 필요하고, 링크도 텍스트 렌더러와 똑같은 방식으로 풀어야 합니다.
Python 패키지#
pyhtml2text는 PyO3와 maturin으로 만들었고, 함수는 두 개입니다.
from pyhtml2text import html2text, html2markdown
html2text(html, width=80, *, base_url=None, decorate=True, link_footnotes=True,
table_borders=True, wrap_links=True, max_wrap_width=None, raw=False)
html2markdown(html, *, base_url=None)html2text는 rust-html2text의 일반 텍스트 렌더러로, 터미널과 이메일의 text/plain 파트를 위해 만들어진 것입니다.
print(html2text('<p>Please <a href="/login">sign in</a> first.</p>',
base_url="https://example.com/docs/page.html"))Please [sign in][1] first.
[1]: https://example.com/login바인딩에서 잘 드러나지 않는 점 몇 가지:
- 두 함수 모두
str이나 UTF-8bytes를 받습니다. 그래서response.content를 디코딩하지 않고 바로 넘길 수 있습니다. 입력은 복사하지 않고 빌려 씁니다. - 변환하는 동안 GIL을 풉니다. 처음부터 끝까지 순수 Rust라서 스레드가 여러 코어로 확장됩니다. 모듈이 GIL이 필요 없다고 선언하기 때문에, free-threaded Python 3.14t에서 모듈을 임포트해도 GIL이 다시 켜지지 않습니다.
- 패닉은
ValueError로 돌아옵니다. PyO3는 기본적으로PanicException을 던지는데, 이건except Exception으로 잡히지 않습니다. 변환 오류를 처리했다고 생각하는 웹 워커가 여기서 죽어 버리니, 바인딩에서 잡아서ValueError로 다시 던집니다. - 휠은 30개입니다. CPython 3.10 이상용 abi3 휠과 free-threaded 3.14t 휠을, Linux(glibc와 musl), macOS, Windows에서 maturin CI가 빌드할 수 있는 모든 아키텍처에 대해 제공합니다. 그래서 설치할 때 Rust 툴체인이 필요 없습니다.
빠르게 만들기#
0.2.0은 실제 페이지에서 0.1.0보다 1.5~1.7배, 짧은 스니펫에서는 두 배 빨랐고, 출력은 바이트 단위까지 똑같았습니다. 대부분은 세 가지 변경 덕분입니다.
- 프로파일 기반 최적화(PGO). CI가 계측용 휠을 빌드해서 Python 문서 페이지와 RFC로 이뤄진 학습 세트를 돌리고, 그 프로파일로 실제 휠을 빌드합니다. 학습 세트에 없는 페이지에서 약 25% 빨라집니다. GitHub에서 계측 빌드를 네이티브로 돌릴 수 있는 곳(Linux x86_64와 aarch64, macOS, Windows x64)에서만 적용하고, 나머지 플랫폼은 같은 코드를 PGO 없이 받습니다.
- mimalloc. html2text는 작은 할당을 아주 많이 하는데, mimalloc은 이를 시스템 할당자보다 훨씬 잘 처리합니다. glibc armv7, ppc64le, s390x에서는 manylinux2014 크로스 컴파일러가 너무 오래돼서 빌드할 수 없어 뺐습니다.
- 포크의 스타일 규칙 공유.
속도를 올리면서 아무것도 바뀌지 않았는지 확인하려고, 벤치마크 스크립트가 로컬 코퍼스의 모든 페이지를 열한 가지 옵션 조합으로 변환한 출력의 해시를 기록합니다. 빈 입력, 300단계 중첩, rowspan=0 같은 엣지 케이스도 포함합니다. 모든 변경은 해시를 하나도 바꾸지 않아야 했습니다.
Apple Silicon Mac에서 Markdown으로 변환한 중앙값 시간입니다.
| 위키백과 문서 (1 MB) | BBC News 첫 페이지 (370 KB) | Python 문서 페이지 (245 KB) | |
|---|---|---|---|
| markdownify | 184 ms | 28 ms | 97 ms |
| html2text (순수 Python) | 73 ms | 13 ms | 37 ms |
| html-to-markdown | 44 ms | 5.6 ms | 15 ms |
| pyhtml2text | 14 ms | 1.7 ms | 4.5 ms |
일반 텍스트로 변환한 경우입니다.
| 위키백과 문서 (780 KB) | BBC News 첫 페이지 (370 KB) | |
|---|---|---|
| html2text (순수 Python) | 71.5 ms | 14.9 ms |
| html2text-rs | 15.2 ms | 2.5 ms |
| pyhtml2text | 10.9 ms | 1.6 ms |
깊이 중첩된 HTML, 그리고 html5ever 패치#
40,000단계로 중첩된 목록을 손으로 쓰는 사람은 없습니다. 하지만 남이 쓴 페이지를 변환하다 보면 언젠가는 악의적이거나 망가진 무언가가 그런 걸 보내옵니다. 기본 파서로는 40,000단계 중첩된 <ul><li>를 파싱하는 데 6초가 걸렸습니다. 요청 하나에 워커 하나가 그만큼 묶이는 겁니다.
원인은 rust-html2text가 쓰는 HTML5 파서인 html5ever에 있었습니다. 대부분의 블록 레벨 시작 태그는 열린 <p> 요소가 “button scope 안에” 있는지 확인해서, 그걸 닫아야 하는지 판단해야 합니다. html5ever는 이 질문에 답하려고 열린 요소 스택 전체를 훑었습니다. 그래서 중첩이 깊어질수록 태그 하나당 시간이 늘었고, 전체 시간은 깊이의 제곱에 비례해 커졌습니다.
패키지에는 패치 하나를 적용한 html5ever 0.39.0이 벤더링돼 있습니다. 이제 열린 요소 스택이 가장 위에 있는 <p>와 가장 위에 있는 button scope 경계의 위치를 기억하고, push·pop·truncate 때마다 갱신하므로 확인이 O(1)입니다. 같은 문서가 이제 0.14초에 파싱됩니다. 디버그 빌드에서는 모든 답을 예전 스택 순회 결과와도 대조합니다. 패치가 다른 것은 아무것도 바꾸지 않았는지 확인하려고, html5lib의 트리 구성 테스트 전체, 실제 페이지 약 20개, 무작위 태그 수프 문서 300,000개에서 기본 파서와 패치한 파서의 DOM을 비교했고, 전부 동일했습니다.
Markdown 변환기에도 같은 이유로 한계를 두었습니다. 트리를 재귀가 아니라 명시적인 스택으로 순회하므로 어떤 입력도 스레드 스택을 넘치게 할 수 없습니다. 인용문과 목록은 32단계를 넘으면 더 들여쓰지 않습니다. 모든 줄이 바깥 요소들의 접두어를 반복하기 때문에, 그러지 않으면 출력이 제곱으로 커지기 때문입니다. 그리고 행·열 병합을 펼친 격자가 100,000칸을 넘는 표는 셀 내용만 씁니다.
html2markdown#
0.3.0에서 html2markdown이 추가됐고, 패키지 작업의 대부분이 여기에 들어갔습니다. rust-html2text의 렌더러는 Markdown처럼 보이는 텍스트를 만들지만, 터미널 폭에 맞춰 줄바꿈되고 파싱할 용도로 만든 게 아닙니다. 그래서 html2markdown은 rust-html2text와 패치한 html5ever를 페이지 파싱에만 쓰고, DOM은 직접 순회하면서 CommonMark에 GitHub의 파이프 표와 ~~취소선~~을 더한 형식으로 씁니다.
#제목,-글머리표,_기울임_과**굵게**.- 인라인 링크와 이미지.
base_url을 주면 절대 URL로 바꿉니다. 아이콘만 들어 있는 링크는aria-label이나title을 링크 텍스트로 씁니다. - 펜스 코드 블록. 페이지가 언어를 지정했으면(
class="language-python") 언어 태그를 붙입니다. - 파이프 표. 여러 행이나 열에 걸친 셀은 빈 셀이 되고, 헤더 행이 없는 표에는 빈 헤더를 붙이며, 레이아웃용 표는 문단으로 씁니다.
- 줄바꿈은 하지 않습니다. 숨겨진 콘텐츠(
hidden,aria-hidden,display: none), 스크립트, 스타일, 폼 컨트롤은 뺍니다.
필요한 것만 이스케이프하기#
어떤 HTML→Markdown 변환기는 Markdown 문법이 될 수 있는 문자를 전부 이스케이프합니다. 예를 들어 markdownify는 기본적으로 모든 _와 *를 이스케이프합니다. 결과는 정확하지만 snake\_case, 5 \* 3 같은 표기로 가득해서, 사람이 읽기 어렵고 모델에게는 토큰 낭비입니다. pyhtml2text는 CommonMark 파서가 문법으로 읽을 자리에서만 문자를 이스케이프합니다.
html2markdown('<p>Use <code>snake_case</code>, 5 * 3, $5 and '
'<a href="../setup.html">setup</a></p>',
base_url="https://example.com/docs/intro.html")Use `snake_case`, 5 * 3, $5 and [setup](https://example.com/setup.html)한국어 조사 앞의 굵은 글씨#
이건 maru 페이지에서 직접 겪은 문제입니다. 한국어에서는 조사가 띄어쓰기 없이 명사에 바로 붙으니, 괄호가 들어간 단어를 굵게 하면 이런 HTML이 나옵니다.
<p><b>타이타닉(Titanic)</b>의 감독</p>가장 자연스러운 Markdown은 **타이타닉(Titanic)**의이고, markdownify도 이렇게 씁니다. 그런데 이건 작동하지 않습니다. CommonMark의 flanking 규칙에 따르면 **가 문장 부호 뒤에서 닫히려면 그 뒤에 공백이나 또 다른 문장 부호가 와야 합니다. 의는 글자이므로 별표가 그대로 글자로 남습니다. 순수 Python html2text는 공백을 넣어서 이 문제를 피합니다.
**타이타닉(Titanic)** 의 감독굵게는 표시되지만 한국어가 달라집니다. 조사를 띄어 쓴 셈이니 맞춤법이 틀린 문장이 됩니다. pyhtml2text는 대신 문장 부호를 마커 바깥으로 옮깁니다.
**타이타닉(Titanic**)의 감독닫는 괄호는 더 이상 굵지 않지만, 강조는 제대로 파싱되고 원문의 글자는 하나도 빠지지 않습니다. 일본어와 중국어, 그리고 뒤에 오는 말 앞에 공백을 두지 않는 모든 언어에 똑같이 적용됩니다.
다시 읽어서 출력 검증하기#
단위 테스트를 통과했다고 변환기를 믿지는 않습니다. Markdown 출력의 버그는 아무도 테스트를 써 볼 생각을 못 한 조합에서 나오니까요. 그래서 변환기는 두 가지 방식으로 자기 결과를 확인합니다.
변환하는 동안 문단마다 강조 마커를 flanking 규칙에 비추어 확인합니다. 그런 다음 pulldown-cmark로 문단을 다시 파싱해서 의도한 대로 읽히는지 확인합니다. 그렇지 않으면 다른 강조 스타일(_ 대신 *, 또는 그 반대)로 다시 씁니다. 두 버전 모두 통과하지 못하면 텍스트, 링크, 코드는 남기고 서식만 버려서 단어가 하나도 사라지지 않게 합니다.
테스트 스위트에서는 tests/markdown_check.py가 다른 파서로 왕복 검사를 합니다. HTML을 Markdown으로 바꾸고, 그걸 markdown-it으로 다시 HTML로 렌더링한 뒤, 보이는 모든 단어, 링크 대상, 이미지 소스를 원본과 비교합니다. 이스케이프되지 않은 문법에 먹힌 텍스트, 파싱되지 않은 링크, 엉뚱한 *가 모두 차이로 드러납니다. 실제 페이지와 수만 개의 무작위 문서에 대해 돌리고, 실패한 문서를 최소 사례로 줄여 주는 기능도 있습니다.
maru 프로덕션 적용#
maru의 Markdown 협상(negotiation)은 0.3.0이 나온 날 순수 Python html2text에서 pyhtml2text로 바뀌었습니다. maru 가이드 페이지 하나 기준으로 변환 시간이 4.2 ms에서 0.36 ms로, 약 12배 빨라졌습니다. 링크는 절대 URL이 됐고, 표는 진짜 파이프 표가 됐으며, 한국어 굵은 글씨 뒤의 조사도 붙은 그대로입니다. abi3 휠이 이미 x86_64와 arm64의 Python 3.14를 지원하기 때문에, Docker 이미지에는 설치를 위해 새로 추가할 게 없었습니다.
크레딧, 그리고 버그 제보처#
일반 텍스트 렌더링은 Chris Emerson의 rust-html2text이고, MIT 라이선스입니다. pyhtml2text는 여기에 그 포크와 패치한 html5ever 사본(MIT/Apache-2.0), 그리고 Markdown 변환기를 더한 것입니다. 이 패키지는 두 프로젝트 어디와도 제휴하거나 보증받은 관계가 아니니, 문제는 업스트림이 아니라 pyhtml2text 이슈 트래커에 제보해 주세요. 이 코드의 버그는 제가 고칠 몫이고, 업스트림 메인테이너가 받지 않기로 한 코드에 대한 제보를 받게 해서는 안 되니까요.
Python에서 HTML을 변환하고 있다면, 특히 모델이 읽을 용도라면 한번 써 보세요.
pip install pyhtml2text소스는 GitHub에 있고, 0.1.0부터 0.3.0까지의 변경 사항은 changelog에 정리돼 있습니다.
