LlmFiles
Converter to Markdown
LlmFiles/Guides/HTML to Markdown

HTML to Markdown

HTML is the one input where nothing has to be guessed: a heading is a tag, a list is a tag, a table is a tag. The conversion is nearly lossless, and the real problem sits elsewhere — a saved web page is mostly navigation. Measured on a small docs page, the Python documentation and a Wikipedia article: what arrives intact, what leaks in, and the twenty lines that remove it.

Updated 26 Oct 2026 · LlmFiles

The mapping is mechanical

Nothing is inferred, because the source says what it means:

In the HTMLIn the MarkdownNote
h1 … h6# … ######Level kept — no heuristic, unlike PDF
pa paragraphSource 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![alt](src)Figure caption becomes a paragraph
pre + codea fenced blockThe language class is dropped
tablea pipe tableThe 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:

PageMarkdown outLinksWords before the h1
Docs page, 3 KB1,756 chars, 274 words1421 — nav and sidebar
Python docs, pathlib, 271 KB89,649 chars, 9,369 words490198 — theme switcher and table of contents
Wikipedia, “Markdown”, 322 KB65,402 chars, 4,890 words661283 — 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.

A menu is indexable text, and retrieval does not know it is a menu: search “previous chapter” and the best match in the corpus is the sidebar of every page. Count before you trust the output — 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 HTMLIn the MarkdownWhat to do
colspan="2"| Merged header | | Right |The value sits in the first cell, the rest are empty
rowspan="2"| b2 | c2 | — a short rowThe 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.

Frequently asked questions

Can I paste a URL instead of uploading a file?

No. The converter takes a file, so save the page first (Save page as → Webpage, complete) and upload that. Nothing is fetched from the network on your behalf, which also means login walls and bot checks never come into it.

Why did the site menu end up in my Markdown?

Because the whole document is converted, not just the article — there is no readability pass. Strip nav, header, footer and aside before converting; on the pages I measured that removed 6% to 25% of the words and 94 to 224 links.

Are tables preserved?

Yes, as pipe tables with the first row as the header. Merged cells are not: colspan leaves empty cells and rowspan leaves a short row, so values shift left. Tables built from divs become plain lines of text.

What happens to links, anchors and images?

Links keep the exact href — relative paths stay relative, so they no longer resolve. Images become ![alt](src) with the alt text intact. mailto: and javascript: links degrade to their anchor text, and permalink anchors inside headings stay in the heading text.

Is the code block language kept?

No. <pre><code class="language-python"> becomes a bare fenced block, so syntax highlighting and any post-processing keyed by language are lost.

What are the limits, and can this run in a pipeline?

Free: 5 files a day from one IP, up to 25 MB each, no sign-up. Pro is not being sold yet. Automation works via POST /api/convert, /api/convert/batch and /api/convert/zip.

Convert a file

Drag a document into the window and get Markdown you can read, diff and feed to a model. Free: 5 files a day, up to 25 MB.