<?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>PyPI · Nhân Tài Đức</title>
    <link>https://jared.lynskey.co.nz/vi/tags/pypi/</link>
    <description>Những câu chuyện ngắn về kỹ thuật phần mềm và lãnh đạo đội ngũ kỹ sư — performance review, tuyển dụng và mở rộng team, CI/CD, giám sát DevOps và AI agent. Viết từ Seoul.</description>
    <generator>Hugo</generator>
    <language>vi</language>
    <copyright>© 2026 Nhân Tài Đức</copyright>
    <lastBuildDate>Thu, 22 Oct 2026 14:42:37 +0900</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/vi/tags/pypi/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Một pull request bị đóng, một bản fork và một gói PyPI: chuyển HTML sang Markdown thật nhanh cho Python</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-10-18-pyhtml2text/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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>Open Source</category>
      <category>Markdown</category>
      <category>LLM</category>
      <category>PyO3</category>
      <category>Performance</category>
      <description>Tôi cần phân giải các liên kết tương đối khi chuyển HTML sang Markdown cho các AI agent. Crate Rust gốc không thể nhận pull request của tôi vì nó được viết bằng LLM, nên tôi fork nó và phát hành pyhtml2text: bản binding Python có base_url, một hàm html2markdown mới, các wheel được tối ưu theo profile, và một parser đã vá để xử lý HTML lồng nhau rất sâu trong thời gian tuyến tính. Bài này kể vì sao nó ra đời, bên trong có gì, và tôi đã kiểm tra đầu ra như thế nào.</description>
      <content:encoded><![CDATA[<p>Ngày 15 tháng 10, tôi phát hành <a href="https://pypi.org/project/pyhtml2text/"  target="_blank" rel="noreferrer">pyhtml2text</a> trên PyPI. Thư viện này chuyển HTML sang văn bản thuần (plain text) hoặc Markdown, và được xây dựng trên <a href="https://github.com/jugglerchris/rust-html2text"  target="_blank" rel="noreferrer">rust-html2text</a>, crate Rust của Chris Emerson. Khi chuyển sang Markdown, nó nhanh hơn các bộ chuyển đổi Python thông dụng từ 3 đến 21 lần trên những trang tôi đo. Nó còn phân giải được các liên kết tương đối dựa trên URL của trang, nên liên kết trong đầu ra vẫn dùng được khi đã tách khỏi trang gốc.</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>Mọi chuyện bắt đầu từ một pull request gửi cho rust-html2text. Người bảo trì (maintainer) đã đóng nó vì nó được viết bằng LLM, nên pyhtml2text chạy trên một bản fork. Bài này kể chuyện đó diễn ra thế nào, bản fork và gói Python có gì, và tôi đã kiểm tra thế nào để chắc rằng Markdown đầu ra là đúng.</p>

<h2 class="relative group">Vì sao tôi cần nó
    <div id="vì-sao-tôi-cần-nó" 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="#v%c3%ac-sao-t%c3%b4i-c%e1%ba%a7n-n%c3%b3" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Ứng dụng chợ kiêm cộng đồng mà tôi đang làm trả về Markdown thay vì HTML khi nhận <code>Accept: text/markdown</code>, dành cho các AI agent và client LLM không muốn phải lội qua thanh điều hướng và chân trang. Phần xử lý ở tầng edge tôi đã viết trong <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-cloudflare-crawlers-cache/" >bài về crawler và bộ nhớ đệm</a>. Những trang lấy từ dữ liệu của ứng dụng thì HTML chỉ là một vỏ JavaScript rỗng, nên Markdown được dựng thẳng từ dữ liệu. Phần còn lại là HTML tĩnh (trang giới thiệu, điều khoản pháp lý và blog), và phần đó thì phải chuyển đổi.</p>
<p>Vấn đề khi chuyển đổi nằm ở các liên kết. Một trang web tĩnh đầy những liên kết kiểu <code>/en/posts/...</code> và <code>../setup/</code>. Trong trình duyệt thì không sao, vì trình duyệt biết chúng đến từ trang nào. Một agent đọc Markdown thì không có ngữ cảnh đó, nên nó không thể đi theo <code>/login</code>. Nó cần <code>https://example.com/login</code>.</p>
<p>Thư viện <a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a> viết bằng Python thuần có tuỳ chọn <code>baseurl</code> cho việc này, và lúc đầu tôi dùng nó. Nhưng nó chậm, và Markdown của nó có vài vấn đề mà tôi sẽ nói ở phần dưới. rust-html2text thì nhanh và được kiểm thử kỹ, nhưng lại không có cách nào phân giải liên kết.</p>

<h2 class="relative group">Hỏi dự án gốc trước
    <div id="hỏi-dự-án-gốc-trước" 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="#h%e1%bb%8fi-d%e1%bb%b1-%c3%a1n-g%e1%bb%91c-tr%c6%b0%e1%bb%9bc" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Ngày 29 tháng 8, tôi mở <a href="https://github.com/jugglerchris/rust-html2text/issues/287"  target="_blank" rel="noreferrer">issue #287</a>, mô tả trường hợp sử dụng và hành vi tôi muốn: phân giải theo RFC 3986, giữ nguyên các URL tuyệt đối và các liên kết <code>mailto:</code>, <code>data:</code> và <code>#fragment</code>. Vài phút sau, tôi mở <a href="https://github.com/jugglerchris/rust-html2text/pull/288"  target="_blank" rel="noreferrer">pull request #288</a>. Nó thêm tuỳ chọn <code>Config::base_url(...)</code>, và trong issue tôi có nói rằng đây chỉ là điểm khởi đầu, và tôi sẽ hiểu nếu người bảo trì muốn làm theo cách khác.</p>
<p>Ngày 11 tháng 10, người bảo trì đóng 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>(Tạm dịch: &ldquo;Cảm ơn bạn đã đóng góp, nhưng tôi không thể nhận cái này vì có vẻ nó được tạo bằng LLM. (Tôi vừa thêm một chính sách ngắn vào README.md, trước đây chưa có.)&rdquo;)</p>
<p>và trả lời trong issue:</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>(Tạm dịch: &ldquo;Chào bạn, đây là một yêu cầu tính năng hợp lý, cảm ơn! Tôi đã đóng PR vì có vẻ nó được viết bằng LLM, điều mà tôi không thể chấp nhận, nhưng cảm ơn bạn đã thử. Một mẫu HTML ngắn kèm đầu ra mong muốn chắc chắn sẽ giúp tính năng này được hiện thực!&rdquo;)</p>
<p>README giờ có chính sách này:</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>(Tạm dịch: &ldquo;Hiện tại, dự án này không thể nhận các đóng góp mã được tạo bằng AI tạo sinh/LLM, vì tình trạng bản quyền còn chưa rõ ràng.&rdquo;)</p>
<p>Nhận định đó là đúng: tôi viết đoạn mã ấy bằng một trợ lý lập trình AI. Tôi cũng không nghĩ chính sách đó vô lý. Tình trạng bản quyền của đầu ra từ LLM chưa ngã ngũ, và người gánh rủi ro khi gộp nó vào một crate MIT mà nhiều dự án khác phụ thuộc là người bảo trì. Đó là dự án của họ, và câu trả lời rất lịch sự. Yêu cầu tính năng đó đến giờ vẫn còn mở.</p>
<p>Dù vậy, tôi vẫn cần tính năng này, và tranh cãi với người bảo trì về chính sách đóng góp của chính dự án họ không phải là cách hay để có nó. Vì thế tôi giữ thay đổi trên một bản fork rồi xây gói Python dựa trên đó, và cả hai đều ghi rõ rằng chúng không phải là bản gốc (upstream).</p>

<h2 class="relative group">Bản fork có gì
    <div id="bản-fork-có-gì" 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="#b%e1%ba%a3n-fork-c%c3%b3-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bản fork là <a href="https://github.com/jaredlynskey/rust-html2text/tree/pyhtml2text-perf"  target="_blank" rel="noreferrer">nhánh <code>pyhtml2text-perf</code></a> trong bản fork rust-html2text của tôi. Tôi chỉ giữ ba commit nhỏ để nó luôn rebase gọn gàng lên dự án gốc (upstream). Commit chính là tùy chọn <code>Config::base_url</code> lấy từ PR: liên kết tương đối được phân giải theo <a href="https://www.rfc-editor.org/rfc/rfc3986#section-5.3"  target="_blank" rel="noreferrer">RFC 3986 §5.3</a>, và nếu không truyền base URL thì đầu ra giữ nguyên. Commit thứ hai khiến crate không còn dựng lại các quy tắc style mỗi lần được gọi, nhờ vậy một đoạn HTML ngắn giảm từ 7.3 µs xuống 5.6 µs. Với trang lớn thì chẳng đáng là bao, nhưng gọi vài nghìn lần thì cộng dồn lại cũng đáng kể. Commit thứ ba chỉ mở ra (export) những phần mà bộ chuyển Markdown ở dưới cần dùng từ crate.</p>

<h2 class="relative group">Gói Python
    <div id="gói-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="#g%c3%b3i-python" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">pyhtml2text</a> được xây bằng PyO3 và maturin, và có hai hàm:</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> là bộ render văn bản thuần của rust-html2text, vốn được làm cho terminal và phần <code>text/plain</code> của email:</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>Vài điểm về phần binding có thể không hiển nhiên:</p>
<ul>
<li><strong>Cả hai hàm đều nhận <code>str</code> hoặc <code>bytes</code> UTF-8</strong>, nên bạn có thể truyền thẳng <code>response.content</code> mà không cần giải mã trước. Dữ liệu đầu vào được mượn (borrow), không bị sao chép.</li>
<li><strong>GIL được nhả ra trong lúc chuyển đổi.</strong> Từ đầu tới cuối đều là Rust thuần, nên các luồng (thread) chạy song song được trên nhiều nhân. Module cũng khai báo rằng nó không cần GIL, nên Python 3.14t bản free-threaded sẽ không bật GIL trở lại khi import module.</li>
<li><strong>Một panic sẽ trả về dưới dạng <code>ValueError</code>.</strong> Mặc định PyO3 ném <code>PanicException</code>, thứ mà <code>except Exception</code> không bắt được. Một web worker tưởng rằng mình đã xử lý hết lỗi chuyển đổi sẽ sập vì nó, nên phần binding bắt panic lại và ném ra dưới dạng <code>ValueError</code>.</li>
<li><strong>Có 30 wheel:</strong> wheel abi3 cho CPython 3.10 trở lên, cộng thêm wheel cho bản free-threaded 3.14t, trên Linux (glibc và musl), macOS và Windows, ở mọi kiến trúc mà CI của maturin build được. Vì vậy không ai cần cài bộ công cụ Rust để cài gói này.</li>
</ul>

<h2 class="relative group">Làm cho nó nhanh
    <div id="làm-cho-nó-nhanh" 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="#l%c3%a0m-cho-n%c3%b3-nhanh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bản 0.2.0 nhanh hơn 0.1.0 từ 1.5 đến 1.7 lần trên trang thật và nhanh gấp đôi với đoạn HTML ngắn, mà đầu ra vẫn giống hệt đến từng byte. Phần lớn là nhờ tối ưu hóa dựa trên profile (profile-guided optimisation, PGO): CI build một wheel có gắn đo đạc (instrumented), chạy nó qua một bộ dữ liệu huấn luyện gồm các trang tài liệu và RFC, rồi dùng profile thu được để build wheel thật. Kết quả là nhanh hơn khoảng 25% trên những trang không nằm trong bộ huấn luyện, và chỉ áp dụng trên các nền tảng mà CI chạy được bản build đo đạc một cách native. Phần còn lại đến từ việc chuyển sang bộ cấp phát bộ nhớ mimalloc, vốn xử lý hàng loạt lần cấp phát nhỏ tốt hơn hẳn, và từ thay đổi về quy tắc style trong bản fork.</p>
<p>Để chắc chắn việc tăng tốc không làm thay đổi gì, tôi băm (hash) đầu ra của một tập trang thật với mọi tổ hợp tùy chọn, kèm các trường hợp biên như đầu vào rỗng hay lồng nhau cực sâu. Mỗi thay đổi đều phải giữ nguyên toàn bộ giá trị băm.</p>
<p>Đây là thời gian trung vị trên một máy Mac Apple Silicon, khi chuyển sang Markdown:</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Bài bách khoa toàn thư (1 MB)</th>
					<th>Trang chủ một báo điện tử (370 KB)</th>
					<th>Trang tài liệu 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 thuần)</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>Và khi chuyển sang văn bản thuần:</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Bài bách khoa toàn thư (780 KB)</th>
					<th>Trang chủ một báo điện tử (370 KB)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a> (Python thuần)</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 lồng nhau rất sâu, và một bản vá cho html5ever
    <div id="html-lồng-nhau-rất-sâu-và-một-bản-vá-cho-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="#html-l%e1%bb%93ng-nhau-r%e1%ba%a5t-s%c3%a2u-v%c3%a0-m%e1%bb%99t-b%e1%ba%a3n-v%c3%a1-cho-html5ever" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Chẳng ai tự tay viết 40,000 danh sách lồng vào nhau. Nhưng nếu bạn chuyển đổi những trang mà mình không viết ra, sớm muộn gì cũng sẽ có thứ độc hại hoặc bị hỏng gửi đến bạn một trang như thế. Với parser nguyên bản, 40,000 cặp <code>&lt;ul&gt;&lt;li&gt;</code> lồng nhau mất <strong>6 giây</strong> để phân tích, tức là một worker bị kẹt cứng chỉ vì một yêu cầu.</p>
<p>Nguyên nhân nằm ở <a href="https://github.com/servo/html5ever"  target="_blank" rel="noreferrer">html5ever</a>, bộ phân tích HTML5 mà rust-html2text dùng. Phần lớn thẻ mở cấp khối (block-level) phải kiểm tra xem có phần tử <code>&lt;p&gt;</code> nào đang mở &ldquo;trong button scope&rdquo; hay không, và html5ever trả lời câu đó bằng cách duyệt toàn bộ ngăn xếp các phần tử đang mở. Lồng càng sâu thì mỗi thẻ càng tốn thời gian, nên tổng thời gian tăng theo bình phương độ sâu.</p>
<p>Gói này đi kèm html5ever 0.39.0 với đúng một bản vá: câu trả lời được cập nhật sẵn mỗi khi một phần tử được mở hoặc đóng, nên việc kiểm tra không còn phụ thuộc vào độ sâu nữa. Cùng tài liệu đó giờ chỉ mất <strong>0.14 giây</strong> để phân tích. Để chắc bản vá không làm thay đổi gì khác, tôi so sánh DOM do bộ phân tích gốc và bản đã vá tạo ra trên toàn bộ bài kiểm thử dựng cây (tree-construction) của html5lib, khoảng 20 trang thật và 300,000 tài liệu tag soup ngẫu nhiên, và tất cả đều giống hệt nhau.</p>
<p>Vì cùng lý do đó, bộ chuyển Markdown cũng có giới hạn riêng. Không đầu vào nào có thể làm tràn ngăn xếp của luồng; blockquote và danh sách sẽ ngừng thụt lề khi vượt quá 32 cấp, để đầu ra không phình theo bình phương; và một bảng mà lưới sau khi trải ra vượt quá 100,000 ô sẽ chỉ được ghi ra nội dung các ô.</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="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phiên bản 0.3.0 thêm <code>html2markdown</code>, và phần lớn công sức trong gói này dồn vào nó. Bộ render của rust-html2text tạo ra văn bản trông giống Markdown, nhưng nó được ngắt dòng cho terminal và không được làm ra để phân tích lại. Vì vậy <code>html2markdown</code> chỉ mượn phần phân tích cú pháp, còn đầu ra thì tự ghi theo <a href="https://commonmark.org/"  target="_blank" rel="noreferrer">CommonMark</a>, cộng thêm bảng pipe và <code>~~strikethrough~~</code> theo kiểu GitHub:</p>
<ul>
<li>Tiêu đề <code>#</code>, gạch đầu dòng <code>-</code>, <code>_emphasis_</code> và <code>**strong**</code>.</li>
<li>Liên kết và hình ảnh nội dòng (inline), được chuyển thành địa chỉ tuyệt đối khi có <code>base_url</code>. Một liên kết chỉ chứa mỗi biểu tượng (icon) sẽ dùng <code>aria-label</code> hoặc <code>title</code> của nó làm văn bản.</li>
<li>Khối mã có rào (fenced code block), gắn tên ngôn ngữ khi trang có ghi (<code>class=&quot;language-python&quot;</code>).</li>
<li>Bảng pipe. Ô trải nhiều hàng hoặc nhiều cột trở thành các ô trống, bảng không có hàng tiêu đề sẽ được thêm một hàng tiêu đề trống, và bảng dùng để dàn trang được ghi ra thành các đoạn văn.</li>
<li>Không ngắt dòng. Nội dung ẩn (<code>hidden</code>, <code>aria-hidden</code>, <code>display: none</code>), script, style và các điều khiển biểu mẫu (form control) bị bỏ qua.</li>
</ul>

<h3 class="relative group">Chỉ escape những gì cần thiết
    <div id="chỉ-escape-những-gì-cần-thiết" 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="#ch%e1%bb%89-escape-nh%e1%bb%afng-g%c3%ac-c%e1%ba%a7n-thi%e1%ba%bft" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Một số bộ chuyển HTML sang Markdown escape mọi ký tự có thể là cú pháp Markdown. Ví dụ, markdownify mặc định escape mọi <code>_</code> và <code>*</code>. Đầu ra vẫn đúng, nhưng đầy những <code>snake\_case</code> và <code>5 \* 3</code>, khó đọc hơn với con người và tốn token vô ích với một mô hình. pyhtml2text chỉ escape một ký tự ở những chỗ mà nếu không escape, một parser CommonMark sẽ đọc nó thành cú pháp:</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">In đậm trước một tiểu từ tiếng Hàn
    <div id="in-đậm-trước-một-tiểu-từ-tiếng-hàn" 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="#in-%c4%91%e1%ba%adm-tr%c6%b0%e1%bb%9bc-m%e1%bb%99t-ti%e1%bb%83u-t%e1%bb%ab-ti%e1%ba%bfng-h%c3%a0n" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Vấn đề này đến từ chính các trang trong ứng dụng của tôi. Trong tiếng Hàn, tiểu từ ngữ pháp (particle, chẳng hạn <code>의</code> có nghĩa &ldquo;của&rdquo;) được viết dính liền ngay sau danh từ, không có dấu cách. Vì vậy một thuật ngữ in đậm có chứa dấu ngoặc trông như thế này:</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>Cách viết Markdown hiển nhiên nhất là <code>**타이타닉(Titanic)**의</code>, cũng chính là thứ markdownify tạo ra, và nó không hoạt động. Quy tắc flanking của CommonMark không cho <code>**</code> đóng lại ngay sau một dấu câu, trừ khi theo sau nó là dấu cách hoặc thêm một dấu câu nữa. Mà <code>의</code> là một chữ cái, nên các dấu sao bị giữ nguyên dưới dạng ký tự thường. html2text bản Python thuần né chuyện này bằng cách chèn thêm một dấu cách:</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>Cách đó hiển thị được chữ in đậm, nhưng làm thay đổi tiếng Hàn. Tiểu từ giờ bị tách ra thành một từ riêng, mà trong tiếng Hàn thì đó là lỗi chính tả, giống như viết &ldquo;của&rdquo; tách khỏi một từ mà lẽ ra nó phải dính vào. pyhtml2text thay vào đó đưa dấu câu ra ngoài cặp dấu in đậm:</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>Dấu ngoặc đóng không còn được in đậm nữa, nhưng phần nhấn mạnh vẫn được phân tích đúng và mọi ký tự của bản gốc đều được giữ lại. Điều tương tự cũng áp dụng cho tiếng Nhật và tiếng Trung, và cho bất kỳ ngôn ngữ nào không đặt dấu cách trước phần đi theo sau.</p>

<h3 class="relative group">Kiểm tra đầu ra bằng cách đọc ngược lại
    <div id="kiểm-tra-đầu-ra-bằng-cách-đọc-ngược-lại" 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="#ki%e1%bb%83m-tra-%c4%91%e1%ba%a7u-ra-b%e1%ba%b1ng-c%c3%a1ch-%c4%91%e1%bb%8dc-ng%c6%b0%e1%bb%a3c-l%e1%ba%a1i" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Tôi không tin một bộ chuyển đổi chỉ vì nó qua hết bài kiểm thử đơn vị (unit test), bởi lỗi trong đầu ra Markdown thường nằm ở những tổ hợp mà chẳng ai nghĩ tới để viết kiểm thử. Vì vậy đầu ra được kiểm tra bằng cách đọc ngược lại.</p>
<p>Trong lúc chuyển đổi, mỗi đoạn văn được phân tích lại theo CommonMark để xác nhận phần nhấn mạnh được đọc đúng như ý. Nếu không đúng, đoạn đó được viết lại bằng kiểu nhấn mạnh còn lại (<code>*</code> thay cho <code>_</code>, hoặc ngược lại); nếu cả hai đều không được thì giữ lại chữ, liên kết và mã, chỉ bỏ định dạng, để không mất chữ nào. Bộ kiểm thử thì chạy một vòng khứ hồi (round trip) qua một bộ phân tích Markdown khác, rồi so từng chữ hiển thị, đích liên kết và nguồn ảnh với HTML gốc. Chữ bị cú pháp chưa escape &ldquo;nuốt&rdquo; mất, liên kết không phân tích được hay một dấu <code>*</code> lạc chỗ đều hiện ra thành chỗ khác biệt. Nó chạy trên các trang thật và hàng chục nghìn tài liệu ngẫu nhiên, và khi có lỗi thì thu nhỏ tài liệu đó về một trường hợp tối giản.</p>

<h2 class="relative group">Chạy thật
    <div id="chạy-thật" 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="#ch%e1%ba%a1y-th%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phần thương lượng markdown (markdown negotiation) trong ứng dụng của tôi chuyển từ html2text bản Python thuần sang pyhtml2text ngay trong ngày 0.3.0 ra mắt. Trên một trang hướng dẫn, thời gian chuyển đổi giảm từ 4.2 ms xuống 0.36 ms, nhanh hơn khoảng 12 lần. Liên kết đều là tuyệt đối, bảng là bảng pipe thật sự, còn chữ in đậm tiếng Hàn vẫn giữ tiểu từ dính liền. Các wheel abi3 vốn đã hỗ trợ Python 3.14 trên x86_64 và arm64, nên container image không cần cài thêm gì để dùng nó.</p>

<h2 class="relative group">Ghi công, và nơi báo lỗi
    <div id="ghi-công-và-nơi-báo-lỗi" 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="#ghi-c%c3%b4ng-v%c3%a0-n%c6%a1i-b%c3%a1o-l%e1%bb%97i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phần render văn bản thuần là rust-html2text của Chris Emerson, theo giấy phép MIT. pyhtml2text bổ sung một bản fork của nó, một bản html5ever đã vá (MIT/Apache-2.0) và bộ chuyển đổi Markdown. Gói này không liên kết với cũng như không được bảo trợ bởi dự án nào trong hai dự án đó, nên <strong>vui lòng báo lỗi tại <a href="https://github.com/jaredlynskey/pyhtml2text/issues"  target="_blank" rel="noreferrer">trang issue của pyhtml2text</a></strong>, đừng báo lên dự án gốc. Lỗi nào trong mã này thì tôi là người phải sửa, và những người bảo trì dự án gốc không nên phải nhận báo cáo về đoạn mã mà họ đã từ chối nhận.</p>
<p>Nếu bạn đang chuyển đổi HTML bằng Python, nhất là để cho một mô hình đọc, hãy thử nó:</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>Mã nguồn có trên <a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">GitHub</a>, và changelog liệt kê mọi thay đổi từ 0.1.0 đến 0.3.0.</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>
