HTML β Clean, Readable Markdown
Markfly is a lightweight Python library that converts HTML into clean, readable CommonMark / GitHub-Flavored Markdown (GFM).
It walks an lxml.html element tree and intelligently preserves the structure and content that matter, including:
- π Links
- πΌοΈ Images
- π Lists
- π Tables
- π Reference-style link definitions
- β¨ Formatting and text structure
Converting HTML to Markdown sounds simple β until you need to correctly handle nested elements, links, images, lists, tables, and references.
Markfly takes care of that for you.
π HTML
β
π¦ Markfly
β
π Clean Markdown
The result is Markdown that is easy to read, store, process, and pass to downstream tools such as documentation generators, search pipelines, and LLM applications.
Markfly is a Python port of jina-ai/reader's MarkifyService, originally written in TypeScript.
It has been rebuilt from the ground up for Python using lxml, providing a familiar and efficient way to convert HTML element trees into Markdown.
π‘ TypeScript β Python
MarkifyServiceβ MarkflyHTMLβ Markdown
- π Built for Python
- π³ Works directly with
lxml.htmlelement trees - π Produces CommonMark / GFM-compatible Markdown
- π Correctly tracks links and references
- πΌοΈ Handles images
- π Supports tables
- π Preserves ordered and unordered lists
- π§Ή Focuses on producing clean, readable Markdown
π¦ Markfly β Turn HTML into Markdown, cleanly.
- CommonMark + GFM output β headings, paragraphs, emphasis, lists, links, images, code blocks, blockquotes, and (optionally) GFM tables, strikethrough, and checkboxes.
- Smart image resolution β falls back through
srcset,data-src,data-lazy-src,data-original, and sibling<picture>/<source>elements whensrcis missing, empty, or a placeholder. - Reference-style links β supports
inlined,referenced(full / collapsed / shortcut), anddiscardedlink styles. - Configurable formatting β heading style (ATX or Setext), bullet markers, code fence style, emphasis/strong delimiters, and more.
- Custom rules β register your own per-tag replacement rules, or mark specific tags to be kept as raw HTML.
- MathML support β converts MathML to LaTeX via an optional pluggable
math_convertercallable, with sensible fallback when one isn't provided. - Base URL resolution β automatically resolves relative links and image sources against a
baseUrl.
pip install markflyfrom markfly import html_to_markdown
html = """
<h1>Hello <em>World</em></h1>
<p>This is <strong>bold</strong> and a <a href="https://example.com">link</a>.</p>
<ul>
<li>one</li>
<li>two</li>
</ul>
"""
print(html_to_markdown(html))Output:
# Hello _World_
This is **bold** and a [link](https://example.com).
* one
* twoThe fastest path is the html_to_markdown() convenience function, which parses the HTML string and returns Markdown in one call:
from markfly import html_to_markdown
markdown = html_to_markdown("<p>Hello <b>world</b></p>")Pass options as keyword arguments:
markdown = html_to_markdown(
html,
gfm=True,
headingStyle="atx",
baseUrl="https://example.com",
)For more control β reusing an instance across many conversions, registering custom rules, or converting an already-parsed lxml element β use MarkflyService directly:
from lxml.html import fromstring
from markfly import MarkflyService, MarkflyOptions
options = MarkflyOptions(gfm=True, codeBlockStyle="fenced")
service = MarkflyService(options)
root = fromstring("<h2>Title</h2><p>Body text.</p>")
markdown = service.markfly(root)Note: each call to
service.markfly(root)resets internal state (link/image tracking, list and table stacks), so a singleMarkflyServiceinstance can safely be reused for multiple, independent conversions.
All options are set via MarkflyOptions, passed either as an options= object or as keyword arguments to html_to_markdown().
| Option | Type | Default | Description |
|---|---|---|---|
headingStyle |
"atx" | "setext" |
"atx" |
atx uses # headings; setext uses underlines for h1/h2. |
hr |
str |
"* * *" |
Markdown emitted for <hr>. |
bulletListMarker |
"-" | "+" | "*" |
"*" |
Marker used for unordered list items. |
codeBlockStyle |
"indented" | "fenced" |
"indented" |
How multi-line code blocks are rendered. |
fence |
"```" | "~~~" | None |
"```" |
Fence characters when codeBlockStyle="fenced". |
emDelimiter |
"_" | "*" |
"_" |
Delimiter for emphasis (<em>/<i>). |
strongDelimiter |
"__" | "**" |
"**" |
Delimiter for strong text (<strong>/<b>). |
linkStyle |
"inlined" | "referenced" | "discarded" |
"inlined" |
How <a> tags are rendered. |
linkReferenceStyle |
"full" | "collapsed" | "shortcut" |
"full" |
Reference format when linkStyle="referenced". |
preformattedCode |
bool |
False |
Reserved for preformatted code handling. |
footnoteStyle |
"inline" | "document" |
"inline" |
Reserved for footnote handling. |
baseUrl |
str | None |
None |
Base URL used to resolve relative links/images. blob:/data: URLs are ignored automatically. |
gfm |
bool |
False |
Enables GFM extensions: tables, strikethrough, checkboxes, and MathML. |
Pass gfm=True to enable GitHub-Flavored Markdown extensions:
html = """
<table>
<tr><th>Name</th><th>Role</th></tr>
<tr><td>Ada</td><td>Engineer</td></tr>
</table>
<p>Status: <s>Pending</s> Done</p>
<input type="checkbox" checked> Ship it
"""
print(html_to_markdown(html, gfm=True))Output:
| Name | Role |
| --- | --- |
| Ada | Engineer |
Status: ~~Pending~~ Done
- [x] Ship itGFM mode also enables MathML β LaTeX conversion for <math> elements (see Math support below).
html = '<a href="https://example.com">Example</a>'
# Inlined (default)
html_to_markdown(html)
# -> [Example](https://example.com)
# Referenced, full style
html_to_markdown(html, linkStyle="referenced", linkReferenceStyle="full")
# -> [Example][1]
# ->
# -> [1]: https://example.com
# Discarded β keeps the text, drops the link
html_to_markdown(html, linkStyle="discarded")
# -> ExampleSet baseUrl to resolve relative href and src values against a real origin:
html = '<a href="/docs">Docs</a> <img src="/logo.png" alt="logo">'
html_to_markdown(html, baseUrl="https://example.com")[Docs](https://example.com/docs) Markfly doesn't just read src. When src is missing, empty, or a known placeholder (e.g. a base64 GIF/PNG spacer), it tries, in order:
srcset/data-srcsetβ picks the highest-resolution candidatedata-src,data-lazy-src,data-original- Sibling
<source>elements inside a<picture>wrapper
This makes it resilient against lazy-loaded images from real-world scraped pages.
Register your own conversion logic for specific tags with addRule:
from lxml.html import fromstring
from markfly import MarkflyService, MarkflyRule
service = MarkflyService()
def render_mark(text, element, options, service):
return f"=={text}=="
service.addRule("highlight", MarkflyRule(filter="mark", replacement=render_mark))
root = fromstring("<p>This is <mark>important</mark>.</p>")
print(service.markfly(root))Or preserve specific tags as raw HTML instead of converting them:
service.keep("iframe")MathML β LaTeX conversion requires a converter callable, since there's no drop-in PyPI equivalent to @nomagick/mathml-to-latex. Without one, Markfly falls back to the element's alttext attribute or its plain text content.
from markfly import MarkflyService, MarkflyOptions
def my_math_converter(mathml_string: str) -> str:
# plug in your own MathML -> LaTeX conversion here
...
service = MarkflyService(MarkflyOptions(gfm=True), math_converter=my_math_converter)Convenience entry point. Parses an HTML string and returns Markdown.
The main converter class.
.markfly(element) -> strβ convert anlxml.htmlelement tree to Markdown..addRule(name, rule)β register a custom per-tag replacement rule..keep(tag)β preserve a tag as raw HTML instead of converting it..use(rule_fns)β apply a list of rule-registration functions.
Dataclass holding all converter options (see Options above).
Dataclass describing a custom rule: filter is a tag name or list of tag names; replacement is a callable (text, element, options, service) -> str.
- MathML conversion has no built-in LaTeX backend β supply your own
math_converter. blob:anddata:URLs are never used as abaseUrl, since resolving relative links against them doesn't make sense.preformattedCodeandfootnoteStyleare present inMarkflyOptionsfor API parity with the original TypeScript implementation but aren't fully wired up yet.
Markfly is a Python port of the Markdown conversion logic from jina-ai/reader.
Add your license of choice here before publishing to PyPI.