Initial commit: Confluence PAT crawler
This commit is contained in:
commit
51d6793ff6
6
.env.example
Normal file
6
.env.example
Normal file
@ -0,0 +1,6 @@
|
||||
# Confluence Data Center base URL (no trailing slash)
|
||||
CONFLUENCE_URL=https://wiki.example.com
|
||||
|
||||
# Personal access token: avatar (top right) -> Settings -> Personal access tokens
|
||||
# Only works on Confluence Data Center / Server 7.9+ (not Cloud).
|
||||
CONFLUENCE_PAT=your-token-here
|
||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
# secrets
|
||||
.env
|
||||
|
||||
# python
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
|
||||
# crawler output
|
||||
output/
|
||||
|
||||
# crawler logs
|
||||
*.log
|
||||
|
||||
# generated report
|
||||
report/
|
||||
48
README.md
Normal file
48
README.md
Normal file
@ -0,0 +1,48 @@
|
||||
# Confluence Crawler (PAT)
|
||||
|
||||
Crawls Confluence Data Center pages into Markdown files, authenticated with a
|
||||
**personal access token (PAT)** from `.env`.
|
||||
|
||||
> ⚠️ PATs only work on **Confluence Data Center / Server 7.9+**. Confluence
|
||||
> Cloud does not support PATs — use an API token (basic auth) there instead.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # then fill in your values
|
||||
```
|
||||
|
||||
`.env`:
|
||||
|
||||
```
|
||||
CONFLUENCE_URL=https://collab.lge.com
|
||||
CONFLUENCE_PAT=<your personal access token>
|
||||
```
|
||||
|
||||
Create the PAT in Confluence: avatar (top right) → Settings → **Personal access
|
||||
tokens** → Create token. The token inherits your permissions — you can only
|
||||
crawl pages you can see.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
python main.py --space KEY --out output/KEY # one space
|
||||
python main.py --all --out output # all visible spaces
|
||||
python main.py --all --max-spaces 3 --verbose # limit + debug logging
|
||||
```
|
||||
|
||||
Each page becomes `output/<space>/<page_id>_<slug>.md` with YAML front matter
|
||||
(title, page id, space, URL, version, last modified) and the body converted
|
||||
from Confluence storage format to Markdown.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest # unit tests + live tests (live uses the PAT from .env)
|
||||
pytest -m "not live" # unit tests only, no network
|
||||
pytest -m live # live tests only
|
||||
```
|
||||
|
||||
Live tests are skipped automatically when `.env` lacks credentials.
|
||||
1
confluence_crawler/__init__.py
Normal file
1
confluence_crawler/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Confluence crawler: dump spaces/pages to Markdown using a PAT."""
|
||||
35
confluence_crawler/config.py
Normal file
35
confluence_crawler/config.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""Configuration loading from .env (CONFLUENCE_PAT, CONFLUENCE_URL)."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
ENV_FILE = BASE_DIR / ".env"
|
||||
|
||||
load_dotenv(ENV_FILE)
|
||||
|
||||
|
||||
def get_pat() -> str:
|
||||
"""Return the Confluence personal access token from CONFLUENCE_PAT."""
|
||||
pat = os.getenv("CONFLUENCE_PAT", "").strip()
|
||||
if not pat:
|
||||
raise RuntimeError(
|
||||
"CONFLUENCE_PAT is not set. Add it to .env (see .env.example)."
|
||||
)
|
||||
return pat
|
||||
|
||||
|
||||
def get_url() -> str:
|
||||
"""Return the Confluence base URL from CONFLUENCE_URL (no trailing slash)."""
|
||||
url = os.getenv("CONFLUENCE_URL", "").strip().rstrip("/")
|
||||
if not url:
|
||||
raise RuntimeError(
|
||||
"CONFLUENCE_URL is not set. Add it to .env (see .env.example)."
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
def get_config() -> dict:
|
||||
"""Return both settings as a dict."""
|
||||
return {"url": get_url(), "pat": get_pat()}
|
||||
183
confluence_crawler/crawler.py
Normal file
183
confluence_crawler/crawler.py
Normal file
@ -0,0 +1,183 @@
|
||||
"""Crawl Confluence spaces and pages into Markdown files (PAT auth)."""
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from atlassian import Confluence
|
||||
from requests.exceptions import HTTPError, RequestException
|
||||
|
||||
from .markdown import page_filename, render_markdown
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfluenceCrawler:
|
||||
"""Crawls a Confluence Data Center instance using a personal access token.
|
||||
|
||||
Note: pagination is done manually with start/limit offsets because the
|
||||
library's automatic next-link following resolves ``_links.next`` against
|
||||
the site root, which breaks on instances deployed under a context path
|
||||
(e.g. https://host/main).
|
||||
"""
|
||||
|
||||
def __init__(self, url: str, pat: str, timeout: int = 30):
|
||||
self.url = url
|
||||
# token= sets "Authorization: Bearer <pat>" (Data Center PAT auth)
|
||||
self.confluence = Confluence(url=url, token=pat, timeout=timeout)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# low-level helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _get_with_retry(self, path: str, params: dict, retries: int = 3, backoff: float = 2.0) -> dict:
|
||||
"""GET with retry on transient errors (5xx, 429, connection issues).
|
||||
|
||||
Client errors (4xx except 429) are raised immediately.
|
||||
"""
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
return self.confluence.get(path, params=params)
|
||||
except HTTPError as exc:
|
||||
code = exc.response.status_code if exc.response is not None else None
|
||||
if code is not None and 400 <= code < 500 and code != 429:
|
||||
raise # non-retryable client error
|
||||
last_exc = exc
|
||||
except RequestException as exc:
|
||||
last_exc = exc
|
||||
if attempt < retries:
|
||||
wait = backoff * (2**attempt)
|
||||
logger.warning(
|
||||
"GET %s failed (%s); retry %d/%d in %.0fs",
|
||||
path, last_exc, attempt + 1, retries, wait,
|
||||
)
|
||||
time.sleep(wait)
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# listing
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def list_spaces(self, limit: int = 50, max_spaces: int | None = None) -> list[dict]:
|
||||
"""Return visible spaces (manual offset pagination).
|
||||
|
||||
max_spaces caps how many are fetched (recommended for tests —
|
||||
enumerating the full directory on large instances is slow).
|
||||
"""
|
||||
spaces: list[dict] = []
|
||||
start = 0
|
||||
while True:
|
||||
resp = self.confluence.get_all_spaces(start=start, limit=limit)
|
||||
results = resp.get("results", [])
|
||||
spaces.extend(results)
|
||||
if max_spaces is not None and len(spaces) >= max_spaces:
|
||||
return spaces[:max_spaces]
|
||||
total = resp.get("totalSize")
|
||||
if not results:
|
||||
break
|
||||
if total is not None and start + len(results) >= total:
|
||||
break
|
||||
if total is None and len(results) < limit:
|
||||
break
|
||||
start += len(results)
|
||||
return spaces
|
||||
|
||||
def get_pages(
|
||||
self,
|
||||
space_key: str,
|
||||
expand: str = "body.storage,version,space",
|
||||
limit: int = 50,
|
||||
max_pages: int | None = None,
|
||||
) -> list[dict]:
|
||||
"""Return pages in a space (manual offset pagination).
|
||||
|
||||
max_pages caps the number fetched (useful for tests / dry runs).
|
||||
"""
|
||||
pages: list[dict] = []
|
||||
start = 0
|
||||
while True:
|
||||
resp = self._get_with_retry(
|
||||
"rest/api/content",
|
||||
params={
|
||||
"spaceKey": space_key,
|
||||
"type": "page",
|
||||
"start": start,
|
||||
"limit": limit,
|
||||
"expand": expand,
|
||||
},
|
||||
)
|
||||
results = resp.get("results", [])
|
||||
pages.extend(results)
|
||||
if max_pages is not None and len(pages) >= max_pages:
|
||||
logger.info("space %s: reached max_pages=%d", space_key, max_pages)
|
||||
return pages[:max_pages]
|
||||
if len(results) < limit:
|
||||
return pages
|
||||
start += len(results)
|
||||
logger.info("space %s: fetched %d pages so far...", space_key, len(pages))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# crawling
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def crawl_space(self, space_key: str, out_dir: Path, max_pages: int | None = None) -> list[Path]:
|
||||
"""Crawl one space; write one .md file per page + manifest.json."""
|
||||
out_dir = Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
pages = self.get_pages(space_key, max_pages=max_pages)
|
||||
|
||||
written: list[Path] = []
|
||||
manifest: list[dict] = []
|
||||
failed: list[str] = []
|
||||
for i, page in enumerate(pages, 1):
|
||||
try:
|
||||
md = render_markdown(page, self.url)
|
||||
except Exception as exc: # never let one page kill the crawl
|
||||
logger.error("render failed for page %s: %s", page.get("id"), exc)
|
||||
failed.append(str(page.get("id")))
|
||||
md = (
|
||||
render_markdown(
|
||||
{**page, "body": {"storage": {"value": ""}}}, self.url
|
||||
)
|
||||
+ f"\n> [!] conversion failed: {exc}\n"
|
||||
)
|
||||
path = out_dir / page_filename(page)
|
||||
path.write_text(md, encoding="utf-8")
|
||||
written.append(path)
|
||||
manifest.append(
|
||||
{
|
||||
"id": page.get("id"),
|
||||
"title": page.get("title"),
|
||||
"file": path.name,
|
||||
"url": f"{self.url}/pages/viewpage.action?pageId={page.get('id')}",
|
||||
}
|
||||
)
|
||||
if i % 100 == 0:
|
||||
logger.info("space %s: wrote %d/%d pages", space_key, i, len(pages))
|
||||
|
||||
(out_dir / "manifest.json").write_text(
|
||||
json.dumps(manifest, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
logger.info("space %s: done, %d pages -> %s (failed: %d)",
|
||||
space_key, len(pages), out_dir, len(failed))
|
||||
return written
|
||||
|
||||
def crawl_all(self, out_dir: Path, max_spaces: int | None = None) -> dict[str, list[Path]]:
|
||||
"""Crawl all visible spaces into out_dir/<space_key>/."""
|
||||
out_dir = Path(out_dir)
|
||||
spaces = self.list_spaces()
|
||||
if max_spaces:
|
||||
spaces = spaces[:max_spaces]
|
||||
result: dict[str, list[Path]] = {}
|
||||
for space in spaces:
|
||||
key = space.get("key")
|
||||
if not key:
|
||||
continue
|
||||
try:
|
||||
result[key] = self.crawl_space(key, out_dir / key)
|
||||
except Exception as exc: # keep crawling other spaces
|
||||
logger.error("space %s failed: %s", key, exc)
|
||||
return result
|
||||
91
confluence_crawler/markdown.py
Normal file
91
confluence_crawler/markdown.py
Normal file
@ -0,0 +1,91 @@
|
||||
"""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"
|
||||
55
main.py
Normal file
55
main.py
Normal file
@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI: crawl Confluence into Markdown files.
|
||||
|
||||
Reads CONFLUENCE_PAT and CONFLUENCE_URL from .env.
|
||||
|
||||
Examples:
|
||||
python main.py --space KEY --out output/KEY
|
||||
python main.py --all --out output
|
||||
python main.py --all --max-spaces 3 --verbose
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from confluence_crawler.config import get_config
|
||||
from confluence_crawler.crawler import ConfluenceCrawler
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Crawl Confluence pages to Markdown.")
|
||||
parser.add_argument("--space", help="crawl only this space key")
|
||||
parser.add_argument("--all", action="store_true", help="crawl all visible spaces")
|
||||
parser.add_argument("--out", default="output", help="output directory (default: output)")
|
||||
parser.add_argument("--max-spaces", type=int, help="limit number of spaces with --all")
|
||||
parser.add_argument("--max-pages", type=int, help="limit pages per space (dry run)")
|
||||
parser.add_argument("--verbose", action="store_true", help="debug logging")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO)
|
||||
|
||||
if not args.space and not args.all:
|
||||
parser.error("specify --space KEY or --all")
|
||||
|
||||
try:
|
||||
cfg = get_config()
|
||||
except RuntimeError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
crawler = ConfluenceCrawler(cfg["url"], cfg["pat"])
|
||||
out = Path(args.out)
|
||||
if args.space:
|
||||
paths = crawler.crawl_space(args.space, out, max_pages=args.max_pages)
|
||||
print(f"wrote {len(paths)} pages to {out}")
|
||||
else:
|
||||
result = crawler.crawl_all(out, max_spaces=args.max_spaces)
|
||||
total = sum(len(p) for p in result.values())
|
||||
print(f"wrote {total} pages across {len(result)} spaces to {out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
6
pytest.ini
Normal file
6
pytest.ini
Normal file
@ -0,0 +1,6 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
pythonpath = .
|
||||
addopts = -q
|
||||
markers =
|
||||
live: tests that hit the real Confluence instance (uses CONFLUENCE_PAT from .env)
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
atlassian-python-api>=5.0.4
|
||||
python-dotenv>=1.0.0
|
||||
markdownify>=0.13.0
|
||||
pytest>=8.0.0
|
||||
33
tests/test_config.py
Normal file
33
tests/test_config.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""Unit tests for config loading from .env (CONFLUENCE_PAT / CONFLUENCE_URL)."""
|
||||
import pytest
|
||||
|
||||
from confluence_crawler.config import get_config, get_pat, get_url
|
||||
|
||||
|
||||
def test_pat_loaded_from_env():
|
||||
pat = get_pat()
|
||||
assert pat, "CONFLUENCE_PAT must be set in .env"
|
||||
assert len(pat) >= 20, "PAT looks too short"
|
||||
|
||||
|
||||
def test_url_loaded_from_env():
|
||||
url = get_url()
|
||||
assert url.startswith("https://"), f"CONFLUENCE_URL should be https, got {url!r}"
|
||||
assert not url.endswith("/"), "CONFLUENCE_URL should not end with /"
|
||||
|
||||
|
||||
def test_config_returns_both():
|
||||
cfg = get_config()
|
||||
assert cfg["pat"] and cfg["url"]
|
||||
|
||||
|
||||
def test_missing_pat_raises(monkeypatch):
|
||||
monkeypatch.delenv("CONFLUENCE_PAT", raising=False)
|
||||
with pytest.raises(RuntimeError, match="CONFLUENCE_PAT"):
|
||||
get_pat()
|
||||
|
||||
|
||||
def test_missing_url_raises(monkeypatch):
|
||||
monkeypatch.delenv("CONFLUENCE_URL", raising=False)
|
||||
with pytest.raises(RuntimeError, match="CONFLUENCE_URL"):
|
||||
get_url()
|
||||
57
tests/test_crawler_live.py
Normal file
57
tests/test_crawler_live.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""Live tests against the real Confluence instance using CONFLUENCE_PAT from .env.
|
||||
|
||||
These hit the network. They run when .env has valid credentials and are
|
||||
skipped otherwise.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from confluence_crawler.config import get_config
|
||||
from confluence_crawler.crawler import ConfluenceCrawler
|
||||
|
||||
try:
|
||||
CFG = get_config()
|
||||
except RuntimeError:
|
||||
CFG = None
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.live,
|
||||
pytest.mark.skipif(CFG is None, reason="CONFLUENCE_PAT/CONFLUENCE_URL not set in .env"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def crawler():
|
||||
return ConfluenceCrawler(CFG["url"], CFG["pat"])
|
||||
|
||||
|
||||
def test_authentication_and_list_spaces(crawler):
|
||||
"""PAT auth works and at least one space is visible (bounded fetch)."""
|
||||
spaces = crawler.list_spaces(limit=25, max_spaces=25)
|
||||
assert spaces, "no spaces visible with this PAT (check permissions)"
|
||||
assert all("key" in s and "name" in s for s in spaces)
|
||||
|
||||
|
||||
def test_get_pages_bounded(crawler):
|
||||
"""Manual pagination fetches pages with body + version (max 5)."""
|
||||
pages = crawler.get_pages("WEBOSAUTO", max_pages=5)
|
||||
assert pages, "no pages returned for WEBOSAUTO"
|
||||
for p in pages:
|
||||
assert p.get("type") == "page"
|
||||
assert p.get("body", {}).get("storage", {}).get("value"), f"no body for page {p.get('id')}"
|
||||
|
||||
|
||||
def test_crawl_space_bounded(crawler, tmp_path):
|
||||
"""Crawl a bounded number of pages and verify .md files + manifest."""
|
||||
paths = crawler.crawl_space("WEBOSAUTO", tmp_path, max_pages=5)
|
||||
assert len(paths) == 5
|
||||
for p in paths:
|
||||
assert p.suffix == ".md"
|
||||
text = p.read_text(encoding="utf-8")
|
||||
assert text.strip(), f"empty markdown file: {p}"
|
||||
assert text.startswith("---"), f"missing front matter: {p}"
|
||||
manifest = tmp_path / "manifest.json"
|
||||
assert manifest.exists()
|
||||
import json
|
||||
entries = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
assert len(entries) == 5
|
||||
assert all("url" in e and "file" in e for e in entries)
|
||||
64
tests/test_markdown.py
Normal file
64
tests/test_markdown.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""Unit tests for storage-format HTML -> Markdown conversion."""
|
||||
from confluence_crawler.markdown import (
|
||||
page_filename,
|
||||
render_markdown,
|
||||
slugify,
|
||||
storage_to_markdown,
|
||||
)
|
||||
|
||||
|
||||
def test_slugify_sanitizes_illegal_chars():
|
||||
assert slugify('a/b\\c:d*e?f"g<h>i|j') == "a-b-c-d-e-f-g-h-i-j"
|
||||
assert slugify(" Hello World ") == "Hello-World"
|
||||
assert slugify("///") == "untitled"
|
||||
|
||||
|
||||
def test_storage_to_markdown_basic():
|
||||
html = "<h1>Hello</h1><p>Some <strong>bold</strong> and <em>italic</em> text.</p>"
|
||||
md = storage_to_markdown(html)
|
||||
assert "Hello" in md
|
||||
assert "**bold**" in md
|
||||
assert "*italic*" in md
|
||||
|
||||
|
||||
def test_storage_to_markdown_table():
|
||||
html = "<table><tr><td>a</td><td>b</td></tr><tr><td>1</td><td>2</td></tr></table>"
|
||||
md = storage_to_markdown(html)
|
||||
assert "a" in md and "b" in md and "1" in md and "2" in md
|
||||
|
||||
|
||||
def test_storage_to_markdown_empty():
|
||||
assert storage_to_markdown("") == ""
|
||||
assert storage_to_markdown(" ") == ""
|
||||
|
||||
|
||||
def test_storage_to_markdown_deeply_nested_no_crash():
|
||||
"""Regression: deeply nested tables must not raise RecursionError."""
|
||||
nested = "<table><tr><td>" * 7000 + "core" + "</td></tr></table>" * 7000
|
||||
md = storage_to_markdown(nested)
|
||||
assert "core" in md # content preserved via fallback
|
||||
|
||||
|
||||
def test_render_markdown_has_front_matter():
|
||||
page = {
|
||||
"id": "123",
|
||||
"title": "My Page",
|
||||
"space": {"key": "DEV"},
|
||||
"version": {"number": 3, "when": "2025-01-01T00:00:00.000Z"},
|
||||
"body": {"storage": {"value": "<p>Hello</p>"}},
|
||||
}
|
||||
md = render_markdown(page, "https://wiki.example.com")
|
||||
assert "title: My Page" in md
|
||||
assert "page_id: 123" in md
|
||||
assert "space: DEV" in md
|
||||
assert "https://wiki.example.com/pages/viewpage.action?pageId=123" in md
|
||||
assert "# My Page" in md
|
||||
assert "Hello" in md
|
||||
|
||||
|
||||
def test_page_filename_unique_and_safe():
|
||||
p1 = page_filename({"id": "1", "title": "A/B: C"})
|
||||
p2 = page_filename({"id": "2", "title": "A/B: C"})
|
||||
assert p1 != p2
|
||||
assert p1 == "1_A-B-C.md"
|
||||
assert p2 == "2_A-B-C.md"
|
||||
Loading…
x
Reference in New Issue
Block a user