DEV Community

appierun
appierun

Posted on Edited on

Convert a Webpage or HTML to Clean Markdown With Python (Standard Library Only) for LLM Prompts and Chatbot Knowledge

Raw HTML is a poor thing to feed a model. A typical docs page is mostly navigation, scripts, styles and wrapper divs, and the actual text is a fraction of the bytes. Markdown keeps what matters (headings, lists, links, tables, code) and drops the rest, which makes it a good format for prompts, RAG chunks and chatbot knowledge files.

This post builds a small HTML-to-Markdown converter on Python's built-in html.parser, with no pip install. It reads a local file or a URL, skips page chrome, keeps tables and code blocks, and turns relative links into absolute ones.

Disclosure: Appie.run publishes this post. Appie has free HTML and webpage to Markdown converters, mentioned near the end. The script below does not depend on them.

Earlier posts in this series cover converting PDFs to Markdown for RAG and converting Notion to Markdown.

What a useful converter has to do

  1. Drop the chrome. <head>, <script>, <style>, <nav>, <header>, <footer>, <aside>, forms and inline SVGs add noise and no meaning.
  2. Catch navigation that is not a <nav>. Many sites build sidebars from plain <div>s marked role="navigation".
  3. Keep structure: headings, nested lists, bold and italic, inline code and <pre> blocks.
  4. Make links absolute. /docs/config means nothing once the text is pasted into a prompt.
  5. Keep tables as tables, with pipes inside cells escaped.
  6. Collapse whitespace without breaking code blocks.

The script

Save this as html2md.py. It was tested with Python 3.13.5 on the cases below.

#!/usr/bin/env python3
"""Convert an HTML file or URL to Markdown with the standard library only."""
import sys
import urllib.request
from html.parser import HTMLParser
from urllib.parse import urljoin

SKIP = {"head", "script", "style", "nav", "footer", "header", "aside", "form", "noscript", "svg"}
SKIP_ROLES = {"navigation", "banner", "contentinfo", "search"}
BLOCK = {"p", "div", "section", "article", "main", "br", "table", "ul", "ol"}


class MarkdownParser(HTMLParser):
    def __init__(self, base_url=""):
        super().__init__(convert_charrefs=True)
        self.base = base_url
        self.out = []
        self.skip_tag = None     # tag that opened the skipped block
        self.skip_count = 0      # nesting of that tag inside the block
        self.lists = []          # stack of "ul" / "ol" with counters
        self.href = None
        self.link_text = []
        self.in_pre = False
        self.row = None
        self.rows = []

    def emit(self, text):
        if self.href is not None:
            self.link_text.append(text)
        elif self.row is not None:
            self.row[-1] += text
        else:
            self.out.append(text)

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if self.skip_tag:
            if tag == self.skip_tag:
                self.skip_count += 1
            return
        if tag in SKIP or a.get("role") in SKIP_ROLES:
            if tag not in {"img", "br", "input", "meta", "link"}:
                self.skip_tag, self.skip_count = tag, 1
            return
        if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
            self.out.append("\n\n" + "#" * int(tag[1]) + " ")
        elif tag in BLOCK:
            if tag == "br":
                self.out.append("  \n")
            elif tag in {"ul", "ol"} and self.lists:
                self.out.append("")  # nested list: no blank line
            else:
                self.out.append("\n\n")
            if tag in {"ul", "ol"}:
                self.lists.append([tag, 0])
        elif tag == "li":
            kind = self.lists[-1] if self.lists else ["ul", 0]
            kind[1] += 1
            indent = "  " * (len(self.lists) - 1)
            marker = f"{kind[1]}." if kind[0] == "ol" else "-"
            self.out.append(f"\n{indent}{marker} ")
        elif tag in {"strong", "b"}:
            self.emit("**")
        elif tag in {"em", "i"}:
            self.emit("*")
        elif tag == "code" and not self.in_pre:
            self.emit("`")
        elif tag == "pre":
            self.in_pre = True
            self.out.append("\n\n" + "`" * 3 + "\n")
        elif tag == "a" and a.get("href"):
            self.href = urljoin(self.base, a["href"])
            self.link_text = []
        elif tag == "img" and a.get("src"):
            self.emit(f"![{a.get('alt', '')}]({urljoin(self.base, a['src'])})")
        elif tag == "tr":
            self.row = []
        elif tag in {"td", "th"} and self.row is not None:
            self.row.append("")

    def handle_endtag(self, tag):
        if self.skip_tag:
            if tag == self.skip_tag:
                self.skip_count -= 1
                if self.skip_count == 0:
                    self.skip_tag = None
            return
        if tag in {"ul", "ol"} and self.lists:
            self.lists.pop()
            self.out.append("\n")
        elif tag in {"strong", "b"}:
            self.emit("**")
        elif tag in {"em", "i"}:
            self.emit("*")
        elif tag == "code" and not self.in_pre:
            self.emit("`")
        elif tag == "pre":
            self.in_pre = False
            self.out.append("\n" + "`" * 3 + "\n\n")
        elif tag == "a" and self.href is not None:
            text = "".join(self.link_text).strip() or self.href
            href, self.href = self.href, None
            self.emit(f"[{text}]({href})")
        elif tag == "tr" and self.row is not None:
            self.rows.append([c.strip().replace("|", "\\|") for c in self.row])
            self.row = None
        elif tag == "table" and self.rows:
            width = max(len(r) for r in self.rows)
            rows = [r + [""] * (width - len(r)) for r in self.rows]
            lines = ["| " + " | ".join(rows[0]) + " |",
                     "|" + " --- |" * width]
            lines += ["| " + " | ".join(r) + " |" for r in rows[1:]]
            self.out.append("\n\n" + "\n".join(lines) + "\n\n")
            self.rows = []
        elif tag in {"h1", "h2", "h3", "h4", "h5", "h6", "p"}:
            self.out.append("\n\n")

    def handle_data(self, data):
        if self.skip_tag:
            return
        if self.in_pre:
            self.out.append(data)
            return
        text = " ".join(data.split())
        if text:
            # keep a space between inline pieces
            if data[:1].isspace():
                text = " " + text
            if data[-1:].isspace():
                text += " "
            self.emit(text)

    def markdown(self):
        md = "".join(self.out)
        lines = [line.rstrip() if not line.endswith("  ") else line for line in md.splitlines()]
        cleaned, blank = [], 0
        for line in lines:
            if line.strip() == "":
                blank += 1
                if blank > 1:
                    continue
            else:
                blank = 0
                line = line if line.startswith(("  ", "|")) else line.lstrip()
            cleaned.append(line)
        return "\n".join(cleaned).strip() + "\n"


def load(source):
    if source.startswith(("http://", "https://")):
        req = urllib.request.Request(source, headers={"User-Agent": "html2md/1.0"})
        with urllib.request.urlopen(req, timeout=20) as resp:
            charset = resp.headers.get_content_charset() or "utf-8"
            return resp.read().decode(charset, errors="replace"), source
    with open(source, encoding="utf-8", errors="replace") as f:
        return f.read(), ""


def main():
    if len(sys.argv) < 2:
        sys.exit("usage: html2md.py <file.html | URL> [base_url]")
    html, base = load(sys.argv[1])
    if len(sys.argv) > 2:
        base = sys.argv[2]
    parser = MarkdownParser(base)
    parser.feed(html)
    parser.close()
    sys.stdout.write(parser.markdown())


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Usage:

python3 html2md.py page.html https://example.com/docs/start   # local file, with a base URL for links
python3 html2md.py https://docs.python.org/3/library/html.parser.html > html-parser.md
Enter fullscreen mode Exit fullscreen mode

How it works

  • HTMLParser is event based. It calls handle_starttag, handle_data and handle_endtag as it walks the document, and tolerates broken markup. convert_charrefs=True turns &amp; and friends into plain characters.
  • Skipping by element. When a tag in SKIP opens, or any element has a role in SKIP_ROLES, the parser records that tag name and counts nested tags of the same name. Everything is ignored until the matching close tag brings the count back to zero. Counting matters: a sidebar <div> usually contains more <div>s, and closing on the first </div> would leak the rest of the sidebar into your output.
  • Lists use a stack, so nested lists get two spaces of indentation per level and ordered lists keep their own counters.
  • Links are buffered: the text inside <a> is collected and written as [text](absolute-url) on </a>. urljoin resolves relative paths against the base URL, which is the page URL when you pass a URL.
  • Tables are collected row by row and written at </table>. Short rows are padded so every row has the same number of cells, and the first row becomes the header.
  • <pre> switches off whitespace collapsing and wraps the block in a fence.
  • markdown() trims trailing spaces (keeping the two-space hard line break), removes repeated blank lines and returns the result.

Test it

A small page with every awkward part:

<html><head><title>Docs</title><style>body{}</style><script>var x=1;</script></head>
<body><nav><a href="/">Home</a> <a href="/pricing">Pricing</a></nav>
<div class="sidebar" role="navigation"><div><a href="/docs/a">Page A</a></div><a href="/docs/b">Page B</a></div>
<main>
<h1>Getting started</h1>
<p>Install the <strong>CLI</strong>, then read the <a href="/docs/config">config guide</a>.</p>
<h2>Plans</h2>
<table><tr><th>Plan</th><th>Seats</th></tr><tr><td>Free</td><td>1</td></tr><tr><td>Team | Pro</td><td>10</td></tr></table>
<ul><li>Fast setup</li><li>Works with <em>any</em> site<ol><li>Step one</li><li>Step two</li></ol></li></ul>
<pre><code>pip install example
example init</code></pre>
<img src="/img/flow.png" alt="Flow diagram">
</main>
<footer>Copyright 2026</footer></body></html>
Enter fullscreen mode Exit fullscreen mode
python3 html2md.py sample.html https://example.com/docs/start
Enter fullscreen mode Exit fullscreen mode

Output:

# Getting started

Install the **CLI**, then read the [config guide](https://example.com/docs/config).

## Plans

| Plan | Seats |
| --- | --- |
| Free | 1 |
| Team \| Pro | 10 |

- Fast setup
- Works with *any* site
  1. Step one
  2. Step two

```
pip install example
example init
```

![Flow diagram](https://example.com/img/flow.png)
Enter fullscreen mode Exit fullscreen mode

The title, styles, script, both navigation blocks and the footer are gone, the nested sidebar <div> did not leak, the pipe in "Team | Pro" stayed inside its cell and every link is absolute.

On a real page, the Python docs page for html.parser was about 55 KB of HTML when we fetched it and came out at about 14.5 KB of Markdown. Your ratio will depend on the site.

Known limits (and how to extend it)

  • JavaScript-rendered pages come back nearly empty, because urllib only sees the initial HTML. Render them with a headless browser first and pass the saved HTML to the script.
  • Header anchor links such as the ¶ symbols on Sphinx docs survive as links. Skip <a> tags with class="headerlink" if they bother you.
  • Inline spacing around some tags can be off by one space (for example **Source code:**[link]). It rarely matters to a model, but check output that people will read.
  • Complex tables with colspan, rowspan or nested tables are flattened. Markdown tables cannot represent them, so consider keeping those as HTML.
  • Definition lists, footnotes and <details> are treated as plain text.
  • Pages without semantic markup may still include menus. If a site puts its content in <main> or <article>, an easy extension is to only emit text while inside those elements.
  • Respect the site. Fetch pages you are allowed to use, keep request rates low, and check the site's terms before converting content you do not own.

Is Markdown the right format?

For prompts and chatbot knowledge, usually yes: headings give chunkers natural split points, links stay readable and tables survive. Plain text is smaller but loses structure; raw HTML keeps structure but spends most of its size on markup. If you are building a RAG index, split the Markdown on ## headings and keep the page URL with each chunk so answers can cite it.

When you do not want to run a script

Appie has two free browser tools for one-off conversions:

  • Convert HTML to Markdown: upload an HTML file or paste markup. Headings, lists, links, images and tables are converted, and the page says input is not saved to an account.
  • Convert a Webpage to Markdown: enter a public URL. Links come out absolute and navigation and scripts are left out. For private pages, the page suggests pasting the HTML into the HTML converter instead.

Each run asks for a Cloudflare Turnstile check. For repeatable jobs, many pages or anything behind a login, the script is the better fit.

If the Markdown is going into a chatbot, Appie's AI Chatbots app can also take URLs and sitemaps as knowledge directly, and you can add content from Claude Code or Cursor through Appie's MCP server, as shown in this MCP walkthrough.

Honest limits of Appie's converters

  • One page or file per run, with a fresh captcha each time. There is no batch mode on the tool pages.
  • Public URLs only for the webpage tool.
  • No options for what to skip are described on the pages; the script lets you edit SKIP and SKIP_ROLES yourself.

FAQ

Why not BeautifulSoup plus markdownify?
They are good libraries and handle more edge cases. The standard-library version suits small containers, CI jobs and serverless functions where adding dependencies is a hassle, and it is short enough to read and change.

How do I convert a whole site?
Get the URL list from the sitemap, loop over it with a delay between requests, and write one .md file per URL. Our earlier sitemap posts on this account show how to pull URLs from a sitemap.

Does it handle non-UTF-8 pages?
For URLs it uses the charset from the Content-Type header and falls back to UTF-8, replacing undecodable bytes.

Can I keep the page title?
Read <title> separately (it is skipped with <head>) and write it as a # heading at the top if the page has no <h1>.

If you want a website chatbot trained on your pages, you can try Appie's AI Chatbots on the Free plan (500 credits, no credit card) at appie.run.

RG, Founder, Appie.run

Top comments (0)