"""Helpers to convert Confluence storage-format HTML to Markdown.""" import re import sys from contextlib import contextmanager from bs4 import BeautifulSoup from markdownify import markdownify as html_to_md _ILLEGAL = re.compile(r'[<>:"/\\|?*\x00-\x1f]') @contextmanager def _raised_recursion_limit(limit: int): """Temporarily raise the recursion limit (markdownify recurses per tag).""" old = sys.getrecursionlimit() if old < limit: sys.setrecursionlimit(limit) try: yield finally: sys.setrecursionlimit(old) def slugify(title: str, max_len: int = 80) -> str: """Turn a page title into a safe filename slug.""" slug = _ILLEGAL.sub("-", title).strip().strip(".") slug = re.sub(r"\s+", " ", slug).replace(" ", "-") slug = re.sub(r"-{2,}", "-", slug) return slug[:max_len].rstrip("-") or "untitled" def storage_to_markdown(html: str) -> str: """Convert Confluence storage-format HTML to Markdown. Falls back to plain-text extraction for pathologically nested HTML (markdownify's recursive walker raises RecursionError on e.g. deeply nested tables). """ if not html or not html.strip(): return "" try: with _raised_recursion_limit(20_000): md = html_to_md( html, heading_style="ATX", bullets="-", strip=["ac:structured-macro"], ) except RecursionError: # get_text() iterates (no deep recursion) — content is preserved, # just without markdown formatting. md = BeautifulSoup(html, "html.parser").get_text("\n", strip=True) # collapse excessive blank lines md = re.sub(r"\n{3,}", "\n\n", md).strip() return md def page_metadata(page: dict, base_url: str) -> dict: """Extract metadata from a Confluence content object.""" version = page.get("version", {}) return { "title": page.get("title", ""), "page_id": page.get("id", ""), "space": (page.get("space") or {}).get("key", ""), "url": f"{base_url}/pages/viewpage.action?pageId={page.get('id', '')}", "version": version.get("number", ""), "last_modified": version.get("when", ""), } def render_markdown(page: dict, base_url: str) -> str: """Render a full Markdown document for a page (front matter + body).""" meta = page_metadata(page, base_url) body = (page.get("body") or {}).get("storage", {}).get("value", "") content = storage_to_markdown(body) header = ( "---\n" f"title: {meta['title']}\n" f"page_id: {meta['page_id']}\n" f"space: {meta['space']}\n" f"url: {meta['url']}\n" f"version: {meta['version']}\n" f"last_modified: {meta['last_modified']}\n" "---\n\n" ) return header + f"# {meta['title']}\n\n" + content + "\n" def page_filename(page: dict) -> str: """Unique, safe filename for a page (id prefix avoids title collisions).""" return f"{page.get('id', '0')}_{slugify(page.get('title', 'untitled'))}.md"