92 lines
3.0 KiB
Python
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"
|