"""Minimal HTML -> Markdown converter for the imported WordPress content. Deliberately narrow: it only handles the tags that actually occur in this export (paragraphs, headings, lists, emphasis, links, images, breaks, rules). Anything unrecognised is passed through as raw HTML, which Goldmark renders because ``markup.goldmark.renderer.unsafe`` is on. """ from __future__ import annotations import re from html.parser import HTMLParser INLINE = {"strong", "b", "em", "i", "a", "br", "span", "code", "u", "s"} BLOCK = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "hr", "blockquote", "figure", "figcaption", "div"} ESCAPE = re.compile(r"([\\`*_\[\]])") class Converter(HTMLParser): def __init__(self, resolve_image=None): super().__init__(convert_charrefs=True) self.out: list[tuple[str, str]] = [] self.resolve_image = resolve_image or (lambda src, cls: src) self._stack: list[str] = [] self._list: list[str] = [] # 'ul' / 'ol' nesting self._li_index: list[int] = [] self._link: str | None = None self._buf: list[str] = [] # -- helpers --------------------------------------------------------- def _emit(self, text: str) -> None: self._buf.append(text) def _flush_block(self, prefix: str = "", kind: str = "block") -> None: text = "".join(self._buf).strip() self._buf = [] if text: self.out.append((kind, prefix + text)) def _open_emphasis(self, marker: str) -> None: # Emitted lazily: WordPress often writes " 57258 ...", # and "** 57258**" is not valid emphasis in CommonMark. The marker is # placed after any leading whitespace instead (see handle_data). self._pending_emphasis = marker def _close_emphasis(self, marker: str) -> None: if getattr(self, "_pending_emphasis", None) == marker: self._pending_emphasis = None # empty return # Likewise move trailing whitespace out: "**Samstag **" -> "**Samstag** ". trailing = "" while self._buf and self._buf[-1] and self._buf[-1][-1] in " \t\n": trailing = self._buf[-1][-1] + trailing self._buf[-1] = self._buf[-1][:-1] if not self._buf[-1]: self._buf.pop() if not self._buf: return self._emit(marker) if trailing: self._emit(trailing) # -- tags ------------------------------------------------------------ def handle_starttag(self, tag, attrs): a = dict(attrs) if tag == "p": self._flush_block() elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"): self._flush_block() self._pending_prefix = "#" * int(tag[1]) + " " elif tag in ("ul", "ol"): self._flush_block() self._list.append(tag) self._li_index.append(0) elif tag == "li": self._flush_block() if self._list: self._li_index[-1] += 1 indent = " " * (len(self._list) - 1) marker = ("- " if self._list[-1] == "ul" else f"{self._li_index[-1]}. ") self._pending_prefix = indent + marker elif tag == "hr": self._flush_block() self.out.append(("block", "---")) elif tag == "br": self._emit(" \n") elif tag in ("strong", "b"): self._open_emphasis("**") elif tag in ("em", "i"): self._open_emphasis("*") elif tag == "a": self._link = a.get("href", "") self._emit("[") elif tag == "img": src = self.resolve_image(a.get("src", ""), a.get("class", "")) alt = (a.get("alt") or "").replace("]", "") self._emit(f"![{alt}]({src})") self._stack.append(tag) def handle_endtag(self, tag): while self._stack and self._stack.pop() != tag: pass if tag == "p": self._flush_block() elif tag in ("h1", "h2", "h3", "h4", "h5", "h6", "li"): prefix = getattr(self, "_pending_prefix", "") self._pending_prefix = "" self._flush_block(prefix, kind="li" if tag == "li" else "block") elif tag in ("ul", "ol"): self._flush_block() if self._list: self._list.pop() self._li_index.pop() elif tag in ("strong", "b"): self._close_emphasis("**") elif tag in ("em", "i"): self._close_emphasis("*") elif tag == "a": self._emit(f"]({self._link or ''})") self._link = None def handle_data(self, data): if not data: return # Collapse WordPress' aggressive whitespace, but keep explicit breaks. text = data.replace(" ", " ") text = re.sub(r"[ \t]*\n[ \t]*", "\n", text) pending = getattr(self, "_pending_emphasis", None) if pending: if not text.strip(): return # whitespace only; keep waiting for real content lead = text[:len(text) - len(text.lstrip())] if lead: self._emit(lead) text = text.lstrip() self._emit(pending) self._pending_emphasis = None elif not self._buf and not text.strip(): return self._emit(ESCAPE.sub(r"\\\1", text) if self._link is None else text.replace("[", r"\[").replace("]", r"\]")) def result(self) -> str: self._flush_block(getattr(self, "_pending_prefix", "")) blocks = [(kind, text.strip()) for kind, text in self.out if text.strip()] out = "" for index, (kind, text) in enumerate(blocks): if index == 0: out = text continue # Keep consecutive list items tight; a blank line between them makes # Goldmark render a "loose" list, wrapping every item in

. sep = "\n" if kind == "li" and blocks[index - 1][0] == "li" else "\n\n" out += sep + text return out def html_to_markdown(html_text: str, resolve_image=None) -> str: conv = Converter(resolve_image=resolve_image) conv.feed(html_text) conv.close() text = conv.result() # An escaped em-dash entity or stray double-escape looks worse than the source. text = text.replace("\\_\\_", "__") return re.sub(r"\n{3,}", "\n\n", text).strip()