The mapping is mechanical
Nothing is inferred, because the source says what it means:
| In the HTML | In the Markdown | Note |
|---|---|---|
h1 … h6 | # … ###### | Level kept — no heuristic, unlike PDF |
p | a paragraph | Source line breaks kept as hard breaks |
ul / ol | * / 1. | Nesting indented three spaces |
strong / em | ** / * | Nested emphasis collapses where Markdown cannot express it |
a | [text](href) | Href copied verbatim, not resolved |
img |  | Figure caption becomes a paragraph |
pre + code | a fenced block | The language class is dropped |
table | a pipe table | The first row becomes the header |
del / kbd | ~~x~~ / `x` | sup, sub, mark flatten into the text |
Measured on a 3 KB documentation page with a header, a sidebar, a table, a code block and a figure: 1,756 characters out, 274 words, four headings, one pipe table with a header row, one fenced block, alt text on the image, every entity decoded. The structure is all there — so is everything else.
The chrome comes with it
There is no readability pass: the whole document is converted, header to cookie banner. Numbers from the three pages I measured:
| Page | Markdown out | Links | Words before the h1 |
|---|---|---|---|
| Docs page, 3 KB | 1,756 chars, 274 words | 14 | 21 — nav and sidebar |
| Python docs, pathlib, 271 KB | 89,649 chars, 9,369 words | 490 | 198 — theme switcher and table of contents |
| Wikipedia, “Markdown”, 322 KB | 65,402 chars, 4,890 words | 661 | 283 — main menu, appearance, search |
On the small page that is 14% of the output: 21 words of navigation before the article, 17 words of footer and cookie banner after it. The sidebar's h3 arrived as a real ### heading, level with the article's own sections, and the ad link became a top-level link. The pathlib page returns 166 code fences and 490 links, 94 of them from menus.
wc -w page.md.Scripts are dropped, buttons are not
script and style are removed before parsing, so an inline var x = 1 < 2 never reaches the Markdown. Text inside controls does reach it: a cookie banner's button label arrives as the line “Accept all”, while its search input prints nothing, because an input carries no text — the placeholder is dropped. A <noscript> block stays, because that text really is in the file.
Where pipe tables break
A normal table with a thead converts cleanly, header cells and inline code included. Three things do not survive:
| In the HTML | In the Markdown | What to do |
|---|---|---|
colspan="2" | | Merged header | | Right | | The value sits in the first cell, the rest are empty |
rowspan="2" | | b2 | c2 | — a short row | The cell is not repeated, so the row lands under the wrong columns |
| a pipe inside a cell | | a|b | c | | One more column than the header |
Markdown has no merged cells, so this is a limit of the target format, not of the parser, and no renderer pads the short row: values after the gap shift left. Unmerge in the source, rebuild from a CSV or Excel original if one exists, escape stray pipes as \|. Tables built out of divs are not tables at all — a two-cell div grid came back as two paragraphs of one word each.
Links keep their href, headings collect noise
Every href is copied as written, which means relative ones stay relative: [index](../genindex.html) and [Contents](/wiki/Wikipedia%3AContents) point at files that will never exist next to your Markdown. Make the hrefs absolute before converting if the links matter. mailto: and javascript: links degrade to their anchor text — the right call, since neither is useful in a corpus. And a title attribute becomes a Markdown link title: [Contents](/wiki/Wikipedia%3AContents "Guides to browsing Wikipedia") — valid, but it can double the length of every link.
Documentation themes decorate each heading with a permalink, and the decoration lands inside the heading text: ## Basic use[¶](#basic-use "Link to this heading"). The pathlib page carries 103 of them. If you build an index keyed by heading text, strip \[¶\]\([^)]*\) first, or the key of every section ends in an anchor.
Line breaks follow the file, not the browser
A paragraph broken across two lines in the source stays broken in the Markdown; the browser would have joined it. It renders as one paragraph either way, but a chunker that splits on lines cuts mid-sentence and every regenerated diff carries churn no reader caused. Reflow paragraphs if your pipeline chunks by line:
source: <p>The worker pulls documents from object storage, converts them to
text and writes chunks into the index.</p>
markdown: The worker pulls documents from object storage, converts them to
text and writes chunks into the index.
Strip the chrome before converting
The converter takes a file, so cleaning is your job — and it is cheaper before the conversion than after, because the parser is what preserves the structure. Drop the usual containers, keep the article, and drop wrappers whose class or id looks like navigation. Measured on the same three pages with the same script: Wikipedia 4,890 to 3,644 words (−25%) and 661 to 437 links; pathlib 9,369 to 8,799 (−6%) and 490 to 396; the small docs page 274 to 236 words and 14 to 3 links — which is where its 14% of chrome lived.
import re, sys
from bs4 import BeautifulSoup
DROP_TAGS = ["script", "style", "noscript", "form", "nav", "header", "footer",
"aside", "svg", "iframe", "button", "select", "input", "template"]
JUNK = re.compile(r"(?:^|[-_])(nav\w*|menu|sidebar|breadcrumb|related|share|"
r"social|cookie|banner|toc|advert|ads?|skip)(?:[-_]|$)", re.I)
SKIP = {"html", "body", "main", "article", "table"}
soup = BeautifulSoup(open(sys.argv[1], encoding="utf-8").read(), "html.parser")
for tag in soup(DROP_TAGS):
tag.decompose()
main = (soup.find(attrs={"role": "main"}) or soup.find("article")
or soup.find("main") or soup.body or soup)
for tag in main.find_all(True):
if tag.name in SKIP or tag.attrs is None:
continue
marks = " ".join(filter(None, [" ".join(tag.get("class") or []),
tag.get("id") or "", tag.get("role") or ""]))
if JUNK.search(marks) and len(str(tag)) < len(str(main)) // 2:
tag.decompose()
open(sys.argv[2], "w", encoding="utf-8").write(str(main))
Two cautions. The script only works where the page uses semantic tags or predictable class names — a theme that wraps navigation in an anonymous div keeps it. And the shortening is not free: Wikipedia's infobox rows are not headed, so it arrives as a pipe table with an empty header row.
Pages that are drawn by JavaScript
The saved HTML of a React or Vue app is usually a shell — <div id="root"></div> and a bundle — so converting it returns almost nothing, because almost nothing was in the file. Save the rendered DOM instead: Save page as → Webpage, complete, or copy outerHTML from the elements panel once the page settles. Printing to PDF first is the wrong move: it throws away real structure and reintroduces the text-layer problem.
And plainly: the converter takes a file, never a URL. It does not fetch pages, so no login wall, paywall or bot check comes into it.
Privacy, limits and the API
The HTML is parsed on our server; one temporary copy exists for the length of the conversion and is deleted right after. Contents never reach a third-party conversion service or analytics, and for documents that can't leave your network there is a documented on-prem install.
Free tier: 5 files a day from one IP, up to 25 MB each, no account. Pro is not on sale yet — we are not taking money for it; leave a request on the pricing page and you will hear when it opens. For volume: POST /api/convert for one file, /api/convert/batch for up to 20, /api/convert/zip for an archive — examples in the API documentation. The converter needs no sign-up.