Ngày 15 tháng 10, tôi phát hành pyhtml2text 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 rust-html2text, 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.
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 |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.
Vì sao tôi cần nó#
maru trả về Markdown thay vì HTML khi nhận Accept: text/markdown, 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 bài về Cloudflare, crawler và bộ nhớ đệm. Các trang tin rao, việc làm và cộng đồng dựng Markdown trực tiếp từ cơ sở dữ liệu, vì HTML của chúng chỉ là một cái vỏ Expo rỗng. Mọi trang còn lại là HTML thật do Hugo tạo ra (trang giới thiệu, trang pháp lý và blog), và những trang đó được chuyển đổi.
Vấn đề khi chuyển đổi nằm ở các liên kết. Một trang Hugo đầy những liên kết kiểu /en/posts/... và ../setup/. 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 /login. Nó cần https://example.com/login.
Thư viện html2text viết bằng Python thuần có tuỳ chọn baseurl 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.
Hỏi dự án gốc trước#
Ngày 29 tháng 8, tôi mở issue #287, 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 mailto:, data: và #fragment. Vài phút sau, tôi mở pull request #288. Nó thêm tuỳ chọn Config::base_url(...), 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.
Ngày 11 tháng 10, người bảo trì đóng 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)
(Tạm dịch: “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ó.)”)
và trả lời trong issue:
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!
(Tạm dịch: “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!”)
README giờ có chính sách này:
At this time, this project is not able to accept code contributions produced with generative AI/LLMs due to the uncertain copyright status.
(Tạm dịch: “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.”)
Nhận định đó là đúng: tôi viết đoạn mã ấy bằng Claude Code. 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ở.
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).
Bản fork có gì#
Bản fork là nhánh pyhtml2text-perf trong bản fork rust-html2text của tôi, và tôi giữ nó thật gọn để có thể rebase sạch sẽ lên upstream. Nó thêm ba commit:
Config::base_url. Đây chính là thay đổi trong PR. Liên kết tương đối được phân giải theo RFC 3986 §5.3, bằng khoảng 200 dòng ngay trong crate thay vì dùng crateurl, vì thứ duy nhất cần là phân giải tham chiếu. Khi không đặt base URL, đầu ra không đổi.- Dùng chung các quy tắc style. Crate này sao chép dữ liệu stylesheet vào mỗi tài liệu nó render, và
config::plain()dựng lại mười quy tắc trang trí (decoration) ở mỗi lần gọi. Đặt các danh sách quy tắc sau mộtArc(copy-on-write) và chỉ dựng chúng một lần đã đưa thời gian xử lý một đoạn ngắn từ 7.3 µs xuống 5.6 µs. Với một trang lớn thì chẳng đáng kể gì, nhưng khi gọi vài nghìn lần thì nó cộng dồn lại. - Xuất (export) các kiểu node DOM và bộ phân giải URL. Bộ chuyển đổi Markdown mô tả ở phần dưới tự duyệt DOM đã được phân tích, nên nó cần các kiểu này, và phải phân giải liên kết đúng theo cách mà bộ render văn bản làm.
Gói Python#
pyhtml2text được xây bằng PyO3 và maturin, và có hai hàm:
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 là bộ render văn bản thuần của rust-html2text, vốn được làm cho terminal và phần text/plain của email:
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/loginVài điểm về phần binding có thể không hiển nhiên:
- Cả hai hàm đều nhận
strhoặcbytesUTF-8, nên bạn có thể truyền thẳngresponse.contentmà 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. - GIL được nhả ra trong lúc chuyển đổi. 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.
- Một panic sẽ trả về dưới dạng
ValueError. Mặc định PyO3 némPanicException, thứ màexcept Exceptionkhô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ạngValueError. - Có 30 wheel: 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.
Làm cho nó nhanh#
Phiên bản 0.2.0 nhanh hơn 0.1.0 từ 1.5 đến 1.7 lần trên các trang thật, và nhanh gấp đôi với các đoạn ngắn, trong khi đầu ra giống hệt từng byte. Phần lớn là nhờ ba thay đổi:
- Tối ưu theo profile (profile-guided optimisation). CI build một wheel có gắn công cụ đo (instrumented), chạy nó trên một tập huấn luyện gồm các trang tài liệu Python và các RFC, rồi dùng profile thu được để build wheel thật. Kết quả nhanh hơn khoảng 25% trên những trang nằm ngoài tập huấn luyện. Việc này chỉ làm ở những nơi GitHub chạy được bản build có đo một cách gốc (native): Linux x86_64 và aarch64, macOS, Windows x64. Các nền tảng khác nhận cùng mã nguồn nhưng không có bước này.
- mimalloc. html2text cấp phát rất nhiều vùng nhớ nhỏ, và mimalloc xử lý chúng tốt hơn nhiều so với bộ cấp phát của hệ thống. Nó không được dùng trên glibc armv7, ppc64le và s390x, vì các trình biên dịch chéo (cross compiler) của manylinux2014 ở đó quá cũ để build nó.
- Các quy tắc style dùng chung từ bản fork.
Để chắc rằng việc tăng tốc không làm thay đổi gì, một script benchmark băm (hash) đầu ra của mọi trang trong một kho dữ liệu cục bộ với mười một bộ tuỳ chọn, cộng thêm các trường hợp biên như đầu vào rỗng, 300 tầng lồng nhau và rowspan=0. Mỗi thay đổi đều phải giữ nguyên mọi giá trị băm.
Đây là thời gian trung vị trên một máy Mac Apple Silicon, khi chuyển sang Markdown:
| Bài Wikipedia (1 MB) | Trang chủ BBC News (370 KB) | Trang tài liệu Python (245 KB) | |
|---|---|---|---|
| markdownify | 184 ms | 28 ms | 97 ms |
| html2text (Python thuần) | 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 |
Và khi chuyển sang văn bản thuần:
| Bài Wikipedia (780 KB) | Trang chủ BBC News (370 KB) | |
|---|---|---|
| html2text (Python thuần) | 71.5 ms | 14.9 ms |
| html2text-rs | 15.2 ms | 2.5 ms |
| pyhtml2text | 10.9 ms | 1.6 ms |
HTML lồng nhau rất sâu, và một bản vá cho html5ever#
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 <ul><li> lồng nhau mất 6 giây để phân tích, tức là một worker bị kẹt cứng chỉ vì một yêu cầu.
Nguyên nhân nằm ở html5ever, parser HTML5 mà rust-html2text sử dụng. Phần lớn các thẻ mở cấp khối (block-level) phải kiểm tra xem có phần tử <p> nào đang mở “trong phạm vi button” (in button scope) hay không, để biết có cần đóng nó lại không. html5ever trả lời câu hỏi đó bằng cách duyệt toàn bộ ngăn xếp các phần tử đang mở, nên lồng càng sâu thì mỗi thẻ càng mất nhiều thời gian, và tổng thời gian tăng theo bình phương độ sâu.
Gói này đính kèm (vendor) html5ever 0.39.0 với một bản vá. Ngăn xếp các phần tử đang mở giờ ghi nhớ vị trí của <p> trên cùng và của ranh giới button-scope trên cùng, và cập nhật chúng ở mỗi lần push, pop và truncate, nên phép kiểm tra chỉ còn O(1). Cùng tài liệu đó giờ được phân tích trong 0.14 giây. Ở bản build debug, mọi câu trả lời còn được đối chiếu với cách duyệt ngăn xếp cũ. Để chắc rằng bản vá không thay đổi gì khác, tôi so sánh DOM do parser nguyên bản và parser đã vá tạo ra trên toàn bộ bài kiểm thử 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.
Bộ chuyển đổi Markdown cũng có các giới hạn riêng vì cùng lý do đó. Nó duyệt cây bằng một ngăn xếp tường minh thay vì đệ quy, nên không đầu vào nào có thể làm tràn ngăn xếp của luồng. Nó ngừng thụt lề blockquote và danh sách khi vượt quá 32 tầng, vì mỗi dòng đều lặp lại tiền tố của mọi thứ bao quanh nó, và nếu không giới hạn thì đầu ra sẽ phình theo bình phương. Còn một bảng mà lưới sau khi mở rộng vượt quá 100,000 ô thì chỉ được ghi ra dưới dạng các ô riêng lẻ.
html2markdown#
Phiên bản 0.3.0 thêm html2markdown, 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 html2markdown chỉ dùng rust-html2text và html5ever đã vá để phân tích trang, sau đó tự duyệt DOM và ghi ra CommonMark, cộng thêm bảng pipe và ~~strikethrough~~ theo kiểu GitHub:
- Tiêu đề
#, gạch đầu dòng-,_emphasis_và**strong**. - 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ó
base_url. Một liên kết chỉ chứa mỗi biểu tượng (icon) sẽ dùngaria-labelhoặctitlecủa nó làm văn bản. - Khối mã có rào (fenced code block), gắn tên ngôn ngữ khi trang có ghi (
class="language-python"). - 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.
- Không ngắt dòng. Nội dung ẩn (
hidden,aria-hidden,display: none), script, style và các điều khiển biểu mẫu (form control) bị bỏ qua.
Chỉ escape những gì cần thiết#
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 _ và *. Đầu ra vẫn đúng, nhưng đầy những snake\_case và 5 \* 3, 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:
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)In đậm trước một tiểu từ tiếng Hàn#
Vấn đề này đến từ chính các trang của maru. Trong tiếng Hàn, tiểu từ ngữ pháp (particle, chẳng hạn 의 có nghĩa “của”) đượ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><b>타이타닉(Titanic)</b>의 감독</p>Cách viết Markdown hiển nhiên nhất là **타이타닉(Titanic)**의, 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 ** đó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à 의 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:
**타이타닉(Titanic)** 의 감독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 “của” 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:
**타이타닉(Titanic**)의 감독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.
Kiểm tra đầu ra bằng cách đọc ngược lại#
Tôi không tin một bộ chuyển đổi chỉ vì các unit test của nó đều qua, vì lỗi trong đầu ra Markdown thường nằm ở những tổ hợp mà chẳng ai nghĩ tới để viết test. Vì vậy bộ chuyển đổi tự kiểm tra công việc của mình theo hai cách.
Trong lúc chuyển đổi, các dấu nhấn mạnh của mỗi đoạn văn được kiểm tra theo quy tắc flanking. Sau đó đoạn văn được phân tích lại bằng pulldown-cmark để xác nhận rằng nó đọc ra đúng như ý định. Nếu không đúng, bộ chuyển đổi viết lại đoạn đó với kiểu nhấn mạnh còn lại (* thay cho _, hoặc ngược lại). Nếu cả hai phiên bản đều không đạt, nó giữ lại văn bản, liên kết và mã, và bỏ phần định dạng, để không mất chữ nào.
Trong bộ test, tests/markdown_check.py làm một vòng khứ hồi (round trip) bằng một parser khác. Nó chuyển HTML sang Markdown, dùng markdown-it render ngược lại thành HTML, rồi so sánh từng từ hiển thị, từng đích liên kết và từng nguồn hình ảnh với bản gốc. Văn bản bị nuốt mất do cú pháp chưa được escape, một liên kết không phân tích được hay một dấu * 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à trên hàng chục nghìn tài liệu ngẫu nhiên, và có thể thu nhỏ một tài liệu gây lỗi xuống thành một trường hợp tối giản.
Chạy thật trên maru#
Phần thương lượng markdown (markdown negotiation) của maru 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 của maru, 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. Các liên kết là địa chỉ tuyệt đối, các bảng là bảng pipe thật, và 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 image Docker không cần thêm gì mới để cài nó.
Ghi công, và nơi báo lỗi#
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 vui lòng báo lỗi tại trang issue của pyhtml2text, đừ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.
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ó:
pip install pyhtml2textMã nguồn có trên GitHub, và changelog liệt kê mọi thay đổi từ 0.1.0 đến 0.3.0.
