2026-09-14 17:06:32 +03:00

92 lines
3.0 KiB
Python

"""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"