<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>Rust · 인재덕</title>
    <link>https://jared.lynskey.co.nz/ko/tags/rust/</link>
    <description>소프트웨어 엔지니어링과 개발 조직 리더십에 대한 이야기. 성과 리뷰, 채용과 팀 확장, CI/CD, 데브옵스 모니터링, 에이전틱 AI. 서울에서 씁니다.</description>
    <generator>Hugo</generator>
    <language>ko</language>
    <copyright>© 2026 인재덕</copyright>
    <lastBuildDate>Thu, 22 Oct 2026 14:42:37 +0900</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ko/tags/rust/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>닫힌 풀 리퀘스트, 포크, 그리고 PyPI 패키지: Python을 위한 빠른 HTML→Markdown 변환</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-10-18-pyhtml2text/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-10-18-pyhtml2text/</guid>
      <pubDate>Sun, 18 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Python</category>
      <category>Rust</category>
      <category>PyPI</category>
      <category>오픈소스</category>
      <category>Markdown</category>
      <category>LLM</category>
      <category>PyO3</category>
      <category>성능</category>
      <description>AI 에이전트용으로 HTML을 Markdown으로 변환하면서 상대 링크를 절대 URL로 풀어야 했습니다. 원본 Rust 크레이트는 제 풀 리퀘스트가 LLM으로 작성됐다는 이유로 받아 줄 수 없었고, 그래서 포크를 만들어 pyhtml2text를 공개했습니다. base_url을 지원하는 Python 바인딩, 새로 만든 html2markdown, 프로파일 기반 최적화(PGO) 휠, 깊이 중첩된 HTML을 선형 시간에 처리하도록 패치한 파서까지. 왜 만들었는지, 무엇이 들어 있는지, 출력을 어떻게 검증했는지 정리했습니다.</description>
      <content:encoded><![CDATA[<p>10월 15일, PyPI에 <a href="https://pypi.org/project/pyhtml2text/"  target="_blank" rel="noreferrer">pyhtml2text</a>를 공개했습니다. HTML을 일반 텍스트나 Markdown으로 바꿔 주는 패키지이고, Chris Emerson의 Rust 크레이트인 <a href="https://github.com/jugglerchris/rust-html2text"  target="_blank" rel="noreferrer">rust-html2text</a>를 기반으로 합니다. Markdown 변환은 제가 측정한 페이지에서 흔히 쓰는 Python 변환기들보다 3배에서 21배 빨랐습니다. 상대 링크를 페이지 URL 기준으로 풀어 주는 기능도 있어서, 출력물이 원래 페이지를 떠나도 링크가 그대로 작동합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">pip install pyhtml2text</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">pyhtml2text</span> <span class="kn">import</span> <span class="n">html2markdown</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html</span> <span class="o">=</span> <span class="s2">&#34;&#34;&#34;&lt;h2&gt;Before you start&lt;/h2&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;p&gt;Read the &lt;a href=&#34;setup.html&#34;&gt;setup guide&lt;/a&gt; and &lt;b&gt;back up&lt;/b&gt; your data.&lt;/p&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;img src=&#34;https://jared.lynskey.co.nz/img/diagram.png&#34; alt=&#34;How it fits together&#34;&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;table&gt;&lt;tr&gt;&lt;th&gt;Plan&lt;/th&gt;&lt;th&gt;Price&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Basic&lt;/td&gt;&lt;td&gt;Free&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">html2markdown</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/intro.html&#34;</span><span class="p">))</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gu">## Before you start
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Read the [<span class="nt">setup guide</span>](<span class="na">https://example.com/docs/setup.html</span>) and <span class="gs">**back up**</span> your data.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">![<span class="nt">How it fits together</span>](<span class="na">https://example.com/img/diagram.png</span>)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">| Plan | Price |
</span></span><span class="line"><span class="cl">| --- | --- |
</span></span><span class="line"><span class="cl">| Basic | Free |</span></span></code></pre></div></div>
<p>시작은 rust-html2text에 보낸 풀 리퀘스트였습니다. 메인테이너는 LLM으로 작성된 코드라는 이유로 이를 닫았고, 그래서 pyhtml2text는 포크 위에서 돌아갑니다. 이 글은 그 과정과 포크·패키지에 들어 있는 것, 그리고 Markdown 출력이 맞는지 어떻게 확인했는지에 대한 이야기입니다.</p>

<h2 class="relative group">왜 필요했나
    <div id="왜-필요했나" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%99%9c-%ed%95%84%ec%9a%94%ed%96%88%eb%82%98" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>제가 만드는 마켓플레이스 겸 커뮤니티 앱은 <code>Accept: text/markdown</code> 요청에 HTML 대신 Markdown으로 답합니다. 내비게이션 바와 푸터를 헤치고 다니고 싶지 않은 AI 에이전트와 LLM 클라이언트를 위해서입니다. 엣지 쪽 이야기는 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-cloudflare-crawlers-cache/" >크롤러와 캐시에 관한 글</a>에서 다뤘습니다. 앱 데이터에서 나오는 페이지는 HTML이 텅 빈 JavaScript 셸이라 Markdown을 바로 만듭니다. 나머지는 정적 HTML(마케팅, 약관, 블로그 페이지)이고, 이건 변환을 거칩니다.</p>
<p>변환할 때 문제는 링크입니다. 정적 사이트의 페이지에는 <code>/en/posts/...</code>나 <code>../setup/</code> 같은 링크가 가득합니다. 브라우저에서는 문제없습니다. 어느 페이지에서 온 링크인지 브라우저가 알고 있으니까요. 하지만 Markdown을 읽는 에이전트에게는 그런 맥락이 없어서 <code>/login</code>을 따라갈 수 없습니다. 필요한 건 <code>https://example.com/login</code>입니다.</p>
<p>순수 Python으로 작성된 <a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a>에는 이를 위한 <code>baseurl</code> 옵션이 있어서 처음에는 그걸 썼습니다. 하지만 느리고, 아래에서 다룰 Markdown 문제도 있었습니다. rust-html2text는 빠르고 테스트도 잘 돼 있었지만, 링크를 풀어 주는 기능이 없었습니다.</p>

<h2 class="relative group">먼저 업스트림에 요청하기
    <div id="먼저-업스트림에-요청하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%a8%bc%ec%a0%80-%ec%97%85%ec%8a%a4%ed%8a%b8%eb%a6%bc%ec%97%90-%ec%9a%94%ec%b2%ad%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>8월 29일, 사용 사례와 원하는 동작을 적어 <a href="https://github.com/jugglerchris/rust-html2text/issues/287"  target="_blank" rel="noreferrer">이슈 #287</a>을 열었습니다. RFC 3986 방식으로 풀되, 절대 URL과 <code>mailto:</code>, <code>data:</code>, <code>#fragment</code> 링크는 건드리지 않는 동작이었습니다. 몇 분 뒤 <a href="https://github.com/jugglerchris/rust-html2text/pull/288"  target="_blank" rel="noreferrer">풀 리퀘스트 #288</a>을 열었습니다. <code>Config::base_url(...)</code> 옵션을 추가하는 PR이었고, 이슈에는 이게 출발점일 뿐이며 메인테이너가 다른 방식을 원한다면 충분히 이해한다고 적었습니다.</p>
<p>10월 11일, 메인테이너가 PR을 닫았습니다.</p>
<blockquote><p>Thanks for the contribution, but I&rsquo;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&rsquo;t previously stated)</p>
</blockquote><p>(기여해 줘서 고맙지만 LLM으로 생성된 것으로 보여 받을 수 없다, 이전에는 명시돼 있지 않던 간단한 정책을 이제 README.md에 추가했다는 내용입니다.)</p>
<p>그리고 이슈에는 이렇게 답했습니다.</p>
<blockquote><p>Hi - this is a reasonable feature request, thanks! I have closed the PR as it appears to be LLM-written which I can&rsquo;t accept, but thanks for the attempt.</p>
<p>A short HTML sample and expected output would definitely help get the feature implemented!</p>
</blockquote><p>(합리적인 기능 요청이라 고맙다, PR은 LLM으로 작성된 것으로 보여 받을 수 없어 닫았지만 시도해 줘서 고맙다, 짧은 HTML 예시와 기대하는 출력이 있으면 기능 구현에 분명 도움이 될 거라는 내용입니다.)</p>
<p>README에는 이제 이런 정책이 있습니다.</p>
<blockquote><p>At this time, this project is not able to accept code contributions produced with generative AI/LLMs due to the uncertain copyright status.</p>
</blockquote><p>(현재 이 프로젝트는 저작권 지위가 불확실하기 때문에 생성형 AI나 LLM으로 만든 코드 기여를 받을 수 없다는 내용입니다.)</p>
<p>판단은 정확했습니다. 그 코드는 제가 AI 코딩 도구로 작성했습니다. 저는 이 정책이 무리하다고 생각하지도 않습니다. LLM 출력물의 저작권 문제는 아직 정리되지 않았고, 다른 프로젝트들이 의존하는 MIT 크레이트에 그런 코드를 병합했을 때의 위험은 메인테이너가 떠안게 됩니다. 그분의 프로젝트이고, 답변도 정중했습니다. 기능 요청 이슈는 지금도 열려 있습니다.</p>
<p>그래도 저는 그 기능이 필요했고, 메인테이너와 그 프로젝트의 기여 정책을 두고 논쟁하는 건 기능을 얻는 좋은 방법이 아닙니다. 그래서 변경 사항은 포크에 두고 그 위에 Python 패키지를 만들었으며, 둘 다 업스트림이 아니라는 점을 분명히 밝혀 두었습니다.</p>

<h2 class="relative group">포크에 들어 있는 것
    <div id="포크에-들어-있는-것" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%8f%ac%ed%81%ac%ec%97%90-%eb%93%a4%ec%96%b4-%ec%9e%88%eb%8a%94-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>포크는 제 rust-html2text 포크의 <a href="https://github.com/jaredlynskey/rust-html2text/tree/pyhtml2text-perf"  target="_blank" rel="noreferrer"><code>pyhtml2text-perf</code> 브랜치</a>입니다. 업스트림 위로 깔끔하게 리베이스할 수 있도록 작은 커밋 세 개로만 유지하고 있습니다. 핵심은 PR에 있던 <code>Config::base_url</code> 옵션입니다. 상대 링크를 <a href="https://www.rfc-editor.org/rfc/rfc3986#section-5.3"  target="_blank" rel="noreferrer">RFC 3986 §5.3</a>에 따라 풀고, base URL을 주지 않으면 출력은 그대로입니다. 또 하나는 크레이트가 호출될 때마다 스타일 규칙을 새로 만들지 않게 한 변경인데, 짧은 스니펫 기준 7.3 µs가 5.6 µs가 됐습니다. 큰 페이지에서는 아무것도 아니지만, 몇천 번 호출하면 쌓입니다. 마지막 하나는 아래의 Markdown 변환기가 크레이트에서 필요로 하는 부분을 공개할 뿐입니다.</p>

<h2 class="relative group">Python 패키지
    <div id="python-패키지" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#python-%ed%8c%a8%ed%82%a4%ec%a7%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">pyhtml2text</a>는 PyO3와 maturin으로 만들었고, 함수는 두 개입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">pyhtml2text</span> <span class="kn">import</span> <span class="n">html2text</span><span class="p">,</span> <span class="n">html2markdown</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html2text</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">width</span><span class="o">=</span><span class="mi">80</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">decorate</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">link_footnotes</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="n">table_borders</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">wrap_links</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">max_wrap_width</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">raw</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html2markdown</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span></span></span></code></pre></div></div>
<p><code>html2text</code>는 rust-html2text의 일반 텍스트 렌더러로, 터미널과 이메일의 <code>text/plain</code> 파트를 위해 만들어진 것입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">html2text</span><span class="p">(</span><span class="s1">&#39;&lt;p&gt;Please &lt;a href=&#34;https://jared.lynskey.co.nz/login&#34;&gt;sign in&lt;/a&gt; first.&lt;/p&gt;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/page.html&#34;</span><span class="p">))</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Please [sign in][1] first.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">[1]: https://example.com/login</span></span></code></pre></div></div>
<p>바인딩에서 잘 드러나지 않는 점 몇 가지:</p>
<ul>
<li><strong>두 함수 모두 <code>str</code>이나 UTF-8 <code>bytes</code>를 받습니다.</strong> 그래서 <code>response.content</code>를 디코딩하지 않고 바로 넘길 수 있습니다. 입력은 복사하지 않고 빌려 씁니다.</li>
<li><strong>변환하는 동안 GIL을 풉니다.</strong> 처음부터 끝까지 순수 Rust라서 스레드가 여러 코어로 확장됩니다. 모듈이 GIL이 필요 없다고 선언하기 때문에, free-threaded Python 3.14t에서 모듈을 임포트해도 GIL이 다시 켜지지 않습니다.</li>
<li><strong>패닉은 <code>ValueError</code>로 돌아옵니다.</strong> PyO3는 기본적으로 <code>PanicException</code>을 던지는데, 이건 <code>except Exception</code>으로 잡히지 않습니다. 변환 오류를 처리했다고 생각하는 웹 워커가 여기서 죽어 버리니, 바인딩에서 잡아서 <code>ValueError</code>로 다시 던집니다.</li>
<li><strong>휠은 30개입니다.</strong> CPython 3.10 이상용 abi3 휠과 free-threaded 3.14t 휠을, Linux(glibc와 musl), macOS, Windows에서 maturin CI가 빌드할 수 있는 모든 아키텍처에 대해 제공합니다. 그래서 설치할 때 Rust 툴체인이 필요 없습니다.</li>
</ul>

<h2 class="relative group">빠르게 만들기
    <div id="빠르게-만들기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b9%a0%eb%a5%b4%ea%b2%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>0.2.0은 실제 페이지에서 0.1.0보다 1.5~1.7배, 짧은 스니펫에서는 두 배 빨랐고, 출력은 바이트 단위까지 똑같았습니다. 대부분은 프로파일 기반 최적화(PGO) 덕분입니다. CI가 계측용 휠을 빌드해서 문서 페이지와 RFC로 이뤄진 학습 세트를 돌리고, 그 프로파일로 실제 휠을 빌드합니다. 학습 세트에 없는 페이지에서 약 25% 빨라지며, CI가 계측 빌드를 네이티브로 돌릴 수 있는 플랫폼에만 적용됩니다. 나머지는 작은 할당이 아주 많을 때 훨씬 잘 버티는 mimalloc 할당자로 바꾼 것, 그리고 포크의 스타일 규칙 변경에서 나왔습니다.</p>
<p>속도를 올리면서 아무것도 바뀌지 않았는지 확인하려고, 실제 페이지 코퍼스를 모든 옵션 조합으로 변환한 출력의 해시를 기록했습니다. 빈 입력이나 아주 깊은 중첩 같은 엣지 케이스도 넣었고요. 모든 변경은 해시를 하나도 바꾸지 않아야 했습니다.</p>
<p>Apple Silicon Mac에서 Markdown으로 변환한 중앙값 시간입니다.</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>백과사전 문서 (1 MB)</th>
					<th>뉴스 사이트 첫 페이지 (370 KB)</th>
					<th>Python 문서 페이지 (245 KB)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><a href="https://pypi.org/project/markdownify/"  target="_blank" rel="noreferrer">markdownify</a></td>
					<td>184 ms</td>
					<td>28 ms</td>
					<td>97 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a> (순수 Python)</td>
					<td>73 ms</td>
					<td>13 ms</td>
					<td>37 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html-to-markdown/"  target="_blank" rel="noreferrer">html-to-markdown</a></td>
					<td>44 ms</td>
					<td>5.6 ms</td>
					<td>15 ms</td>
			</tr>
			<tr>
					<td><strong>pyhtml2text</strong></td>
					<td><strong>14 ms</strong></td>
					<td><strong>1.7 ms</strong></td>
					<td><strong>4.5 ms</strong></td>
			</tr>
	</tbody>
</table>
<p>일반 텍스트로 변환한 경우입니다.</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>백과사전 문서 (780 KB)</th>
					<th>뉴스 사이트 첫 페이지 (370 KB)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a> (순수 Python)</td>
					<td>71.5 ms</td>
					<td>14.9 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html2text-rs/"  target="_blank" rel="noreferrer">html2text-rs</a></td>
					<td>15.2 ms</td>
					<td>2.5 ms</td>
			</tr>
			<tr>
					<td><strong>pyhtml2text</strong></td>
					<td><strong>10.9 ms</strong></td>
					<td><strong>1.6 ms</strong></td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">깊이 중첩된 HTML, 그리고 html5ever 패치
    <div id="깊이-중첩된-html-그리고-html5ever-패치" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b9%8a%ec%9d%b4-%ec%a4%91%ec%b2%a9%eb%90%9c-html-%ea%b7%b8%eb%a6%ac%ea%b3%a0-html5ever-%ed%8c%a8%ec%b9%98" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>40,000단계로 중첩된 목록을 손으로 쓰는 사람은 없습니다. 하지만 남이 쓴 페이지를 변환하다 보면 언젠가는 악의적이거나 망가진 무언가가 그런 걸 보내옵니다. 기본 파서로는 40,000단계 중첩된 <code>&lt;ul&gt;&lt;li&gt;</code>를 파싱하는 데 <strong>6초</strong>가 걸렸습니다. 요청 하나에 워커 하나가 그만큼 묶이는 겁니다.</p>
<p>원인은 rust-html2text가 쓰는 HTML5 파서인 <a href="https://github.com/servo/html5ever"  target="_blank" rel="noreferrer">html5ever</a>에 있었습니다. 대부분의 블록 레벨 시작 태그는 열린 <code>&lt;p&gt;</code> 요소가 &ldquo;button scope 안에&rdquo; 있는지 확인해야 하는데, html5ever는 이 질문에 답하려고 열린 요소 스택 전체를 훑었습니다. 중첩이 깊어질수록 태그 하나당 시간이 늘었고, 그래서 전체 시간은 깊이의 제곱에 비례해 커졌습니다.</p>
<p>패키지에는 패치 하나를 적용한 html5ever 0.39.0이 들어 있습니다. 요소가 열리고 닫힐 때마다 그 답을 갱신해 두기 때문에, 확인하는 데 드는 시간이 더 이상 깊이에 좌우되지 않습니다. 같은 문서가 이제 <strong>0.14초</strong>에 파싱됩니다. 패치가 다른 것은 아무것도 바꾸지 않았는지 확인하려고, html5lib의 트리 구성 테스트 전체, 실제 페이지 약 20개, 무작위 태그 수프 문서 300,000개에서 기본 파서와 패치한 파서의 DOM을 비교했고, 전부 동일했습니다.</p>
<p>Markdown 변환기에도 같은 이유로 한계를 두었습니다. 어떤 입력도 스레드 스택을 넘치게 할 수 없고, 인용문과 목록은 32단계를 넘으면 더 들여쓰지 않아서 출력이 제곱으로 불어나지 않습니다. 그리고 행·열 병합을 펼친 격자가 100,000칸을 넘는 표는 셀 내용만 씁니다.</p>

<h2 class="relative group">html2markdown
    <div id="html2markdown" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#html2markdown" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>0.3.0에서 <code>html2markdown</code>이 추가됐고, 패키지 작업의 대부분이 여기에 들어갔습니다. rust-html2text의 렌더러는 Markdown처럼 보이는 텍스트를 만들지만, 터미널 폭에 맞춰 줄바꿈되고 파싱할 용도로 만든 게 아닙니다. 그래서 <code>html2markdown</code>은 파서만 빌려 쓰고, 출력은 직접 <a href="https://commonmark.org/"  target="_blank" rel="noreferrer">CommonMark</a>에 GitHub 스타일 파이프 표와 <code>~~취소선~~</code>을 더한 형식으로 씁니다.</p>
<ul>
<li><code>#</code> 제목, <code>-</code> 글머리표, <code>_기울임_</code>과 <code>**굵게**</code>.</li>
<li>인라인 링크와 이미지. <code>base_url</code>을 주면 절대 URL로 바꿉니다. 아이콘만 들어 있는 링크는 <code>aria-label</code>이나 <code>title</code>을 링크 텍스트로 씁니다.</li>
<li>펜스 코드 블록. 페이지가 언어를 지정했으면(<code>class=&quot;language-python&quot;</code>) 언어 태그를 붙입니다.</li>
<li>파이프 표. 여러 행이나 열에 걸친 셀은 빈 셀이 되고, 헤더 행이 없는 표에는 빈 헤더를 붙이며, 레이아웃용 표는 문단으로 씁니다.</li>
<li>줄바꿈은 하지 않습니다. 숨겨진 콘텐츠(<code>hidden</code>, <code>aria-hidden</code>, <code>display: none</code>), 스크립트, 스타일, 폼 컨트롤은 뺍니다.</li>
</ul>

<h3 class="relative group">필요한 것만 이스케이프하기
    <div id="필요한-것만-이스케이프하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%95%84%ec%9a%94%ed%95%9c-%ea%b2%83%eb%a7%8c-%ec%9d%b4%ec%8a%a4%ec%bc%80%ec%9d%b4%ed%94%84%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>어떤 HTML→Markdown 변환기는 Markdown 문법이 될 수 있는 문자를 전부 이스케이프합니다. 예를 들어 markdownify는 기본적으로 모든 <code>_</code>와 <code>*</code>를 이스케이프합니다. 결과는 정확하지만 <code>snake\_case</code>, <code>5 \* 3</code> 같은 표기로 가득해서, 사람이 읽기 어렵고 모델에게는 토큰 낭비입니다. pyhtml2text는 CommonMark 파서가 문법으로 읽을 자리에서만 문자를 이스케이프합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">html2markdown</span><span class="p">(</span><span class="s1">&#39;&lt;p&gt;Use &lt;code&gt;snake_case&lt;/code&gt;, 5 * 3, $5 and &#39;</span>
</span></span><span class="line"><span class="cl">              <span class="s1">&#39;&lt;a href=&#34;../setup.html&#34;&gt;setup&lt;/a&gt;&lt;/p&gt;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">              <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/intro.html&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">Use <span class="sb">`snake_case`</span>, 5 * 3, $5 and [<span class="nt">setup</span>](<span class="na">https://example.com/setup.html</span>)</span></span></code></pre></div></div>

<h3 class="relative group">한국어 조사 앞의 굵은 글씨
    <div id="한국어-조사-앞의-굵은-글씨" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%95%9c%ea%b5%ad%ec%96%b4-%ec%a1%b0%ec%82%ac-%ec%95%9e%ec%9d%98-%ea%b5%b5%ec%9d%80-%ea%b8%80%ec%94%a8" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>이건 제 앱의 페이지에서 직접 겪은 문제입니다. 한국어에서는 조사가 띄어쓰기 없이 명사에 바로 붙으니, 괄호가 들어간 단어를 굵게 하면 이런 HTML이 나옵니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;&lt;</span><span class="nt">b</span><span class="p">&gt;</span>타이타닉(Titanic)<span class="p">&lt;/</span><span class="nt">b</span><span class="p">&gt;</span>의 감독<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span></span></span></code></pre></div></div>
<p>가장 자연스러운 Markdown은 <code>**타이타닉(Titanic)**의</code>이고, markdownify도 이렇게 씁니다. 그런데 이건 작동하지 않습니다. CommonMark의 flanking 규칙에 따르면 <code>**</code>가 문장 부호 뒤에서 닫히려면 그 뒤에 공백이나 또 다른 문장 부호가 와야 합니다. <code>의</code>는 글자이므로 별표가 그대로 글자로 남습니다. 순수 Python html2text는 공백을 넣어서 이 문제를 피합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**타이타닉(Titanic)** 의 감독</span></span></code></pre></div></div>
<p>굵게는 표시되지만 한국어가 달라집니다. 조사를 띄어 쓴 셈이니 맞춤법이 틀린 문장이 됩니다. pyhtml2text는 대신 문장 부호를 마커 바깥으로 옮깁니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**타이타닉(Titanic**)의 감독</span></span></code></pre></div></div>
<p>닫는 괄호는 더 이상 굵지 않지만, 강조는 제대로 파싱되고 원문의 글자는 하나도 빠지지 않습니다. 일본어와 중국어, 그리고 뒤에 오는 말 앞에 공백을 두지 않는 모든 언어에 똑같이 적용됩니다.</p>

<h3 class="relative group">다시 읽어서 출력 검증하기
    <div id="다시-읽어서-출력-검증하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%8b%a4%ec%8b%9c-%ec%9d%bd%ec%96%b4%ec%84%9c-%ec%b6%9c%eb%a0%a5-%ea%b2%80%ec%a6%9d%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>단위 테스트를 통과했다고 변환기를 믿지는 않습니다. Markdown 출력의 버그는 아무도 테스트를 써 볼 생각을 못 한 조합에서 나오니까요. 그래서 출력을 다시 읽어 보는 방식으로 확인합니다.</p>
<p>변환하는 동안 문단마다 CommonMark로 다시 파싱해서 강조가 의도한 대로 읽히는지 확인합니다. 그렇지 않으면 다른 강조 스타일(<code>_</code> 대신 <code>*</code>, 또는 그 반대)로 다시 쓰고, 둘 다 안 되면 텍스트, 링크, 코드는 남기고 서식만 버려서 단어가 하나도 사라지지 않게 합니다. 테스트 스위트는 다른 Markdown 파서로 왕복 검사를 해서, 보이는 모든 단어, 링크 대상, 이미지 소스를 원본 HTML과 비교합니다. 이스케이프되지 않은 문법에 먹힌 텍스트, 파싱되지 않은 링크, 엉뚱한 <code>*</code>가 모두 차이로 드러납니다. 실제 페이지와 수만 개의 무작위 문서에 대해 돌리고, 실패하면 최소 사례로 줄여 줍니다.</p>

<h2 class="relative group">프로덕션 적용
    <div id="프로덕션-적용" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%94%84%eb%a1%9c%eb%8d%95%ec%85%98-%ec%a0%81%ec%9a%a9" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>제 앱의 Markdown 협상(negotiation)은 0.3.0이 나온 날 순수 Python html2text에서 pyhtml2text로 바뀌었습니다. 가이드 페이지 하나 기준으로 변환 시간이 4.2 ms에서 0.36 ms로, 약 12배 빨라졌습니다. 링크는 절대 URL이 됐고, 표는 진짜 파이프 표가 됐으며, 한국어 굵은 글씨 뒤의 조사도 붙은 그대로입니다. abi3 휠이 이미 x86_64와 arm64의 Python 3.14를 지원하기 때문에, 컨테이너 이미지에는 설치를 위해 새로 추가할 게 없었습니다.</p>

<h2 class="relative group">크레딧, 그리고 버그 제보처
    <div id="크레딧-그리고-버그-제보처" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%81%ac%eb%a0%88%eb%94%a7-%ea%b7%b8%eb%a6%ac%ea%b3%a0-%eb%b2%84%ea%b7%b8-%ec%a0%9c%eb%b3%b4%ec%b2%98" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>일반 텍스트 렌더링은 Chris Emerson의 rust-html2text이고, MIT 라이선스입니다. pyhtml2text는 여기에 그 포크와 패치한 html5ever 사본(MIT/Apache-2.0), 그리고 Markdown 변환기를 더한 것입니다. 이 패키지는 두 프로젝트 어디와도 제휴하거나 보증받은 관계가 아니니, <strong>문제는 업스트림이 아니라 <a href="https://github.com/jaredlynskey/pyhtml2text/issues"  target="_blank" rel="noreferrer">pyhtml2text 이슈 트래커</a>에 제보해 주세요.</strong> 이 코드의 버그는 제가 고칠 몫이고, 업스트림 메인테이너가 받지 않기로 한 코드에 대한 제보를 받게 해서는 안 되니까요.</p>
<p>Python에서 HTML을 변환하고 있다면, 특히 모델이 읽을 용도라면 한번 써 보세요.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">pip install pyhtml2text</span></span></code></pre></div></div>
<p>소스는 <a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">GitHub</a>에 있고, 0.1.0부터 0.3.0까지의 변경 사항은 changelog에 정리돼 있습니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-18-pyhtml2text/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
