feat(ses): add SES light novel pipeline; extract shared lib/wiki.py
New pipeline at pipelines/ses/ publishes mikkeli/ses-light-novel to the SES: MediaWiki namespace. All pages get Category:SES and Category:SES:<type> (type from frontmatter, fallback to parent dir name). Shared functions (connect_wiki, markdown_to_wikitext, strip_first_h1, AUTO_BANNER_PREFIX) extracted from songs/publish.py into lib/wiki.py; songs refactored to import from there. Also adds publish-ses.yml workflow stub and updates Dockerfile and root README. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SES light novel pipeline — publishes .md files to MediaWiki under the SES: namespace."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
import mwclient
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
from lib.wiki import AUTO_BANNER_PREFIX, connect_wiki, markdown_to_wikitext, strip_first_h1
|
||||
|
||||
|
||||
def validate(fm: dict, path: Path) -> None:
|
||||
errors = []
|
||||
|
||||
val = fm.get("title")
|
||||
if not val or not str(val).strip():
|
||||
errors.append("missing or empty 'title'")
|
||||
|
||||
if not isinstance(fm.get("wiki", {}).get("publish"), bool):
|
||||
errors.append("'wiki.publish' must be a boolean (true/false), not a string")
|
||||
|
||||
if "categories" in fm and not isinstance(fm["categories"], list):
|
||||
errors.append("'categories' must be a list")
|
||||
|
||||
if errors:
|
||||
raise ValueError(f"{path}: " + "; ".join(errors))
|
||||
|
||||
|
||||
def get_page_title(fm: dict) -> str:
|
||||
return f"SES:{fm['title']}"
|
||||
|
||||
|
||||
def get_page_type(fm: dict, path: Path, source_dir: Path) -> str:
|
||||
if fm.get("type"):
|
||||
return str(fm["type"])
|
||||
rel = path.relative_to(source_dir)
|
||||
parent = rel.parent.name
|
||||
return parent if parent else "uncategorized"
|
||||
|
||||
|
||||
def build_wikitext(fm: dict, body_wikitext: str, source_url: str, source_ref: str, page_type: str) -> str:
|
||||
banner = f"{{{{Auto-generated|source={source_url}|commit={source_ref}}}}}"
|
||||
cat_parts = [f"[[Category:SES]]", f"[[Category:SES:{page_type}]]"]
|
||||
for cat in (fm.get("categories") or []):
|
||||
cat_parts.append(f"[[Category:{cat}]]")
|
||||
categories = "\n".join(cat_parts) + "\n"
|
||||
return f"{banner}\n\n{body_wikitext}{categories}"
|
||||
|
||||
|
||||
def publish_one(abs_path: Path, source_dir: Path, post, site: mwclient.Site, source_ref: str, gitea_repo_url: str) -> str:
|
||||
"""Returns 'published', 'noop', or 'skipped-manual'."""
|
||||
rel = abs_path.relative_to(source_dir)
|
||||
fm = post.metadata
|
||||
title = get_page_title(fm)
|
||||
page_type = get_page_type(fm, abs_path, source_dir)
|
||||
|
||||
body_wikitext = markdown_to_wikitext(strip_first_h1(post.content))
|
||||
source_url = f"{gitea_repo_url}/src/commit/{source_ref}/{rel}"
|
||||
page_content = build_wikitext(fm, body_wikitext, source_url, source_ref, page_type)
|
||||
|
||||
page = site.pages[title]
|
||||
existing = page.text()
|
||||
|
||||
if existing and not existing.startswith(AUTO_BANNER_PREFIX):
|
||||
return "skipped-manual"
|
||||
|
||||
if existing == page_content:
|
||||
return "noop"
|
||||
|
||||
ref_short = source_ref[:8] if source_ref else "unknown"
|
||||
page.save(page_content, summary=f"Auto-published from {ref_short} (ses pipeline)")
|
||||
return "published"
|
||||
|
||||
|
||||
def load_and_validate(files: list[Path], source_dir: Path) -> dict[Path, object]:
|
||||
posts = {}
|
||||
titles: dict[str, Path] = {}
|
||||
|
||||
for path in files:
|
||||
if not path.exists():
|
||||
print(f" warn: {path} not found, skipping", file=sys.stderr)
|
||||
continue
|
||||
|
||||
post = frontmatter.load(str(path))
|
||||
|
||||
wiki = post.metadata.get("wiki")
|
||||
if not isinstance(wiki, dict) or not wiki.get("publish"):
|
||||
continue
|
||||
|
||||
validate(post.metadata, path)
|
||||
|
||||
title = get_page_title(post.metadata)
|
||||
if title in titles:
|
||||
raise ValueError(f"Duplicate wiki title '{title}': {path} and {titles[title]}")
|
||||
titles[title] = path
|
||||
posts[path] = post
|
||||
|
||||
return posts
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Publish SES light novel .md files to MediaWiki.")
|
||||
parser.add_argument("--source-dir", required=True, type=Path)
|
||||
parser.add_argument("--files", nargs="*", default=[], help="Relative paths within source-dir")
|
||||
parser.add_argument("--all", action="store_true", help="Publish all publishable files")
|
||||
args = parser.parse_args()
|
||||
|
||||
source_dir = args.source_dir.resolve()
|
||||
source_ref = os.environ.get("SOURCE_REF", "")
|
||||
gitea_repo_url = os.environ.get("GITEA_REPO_URL", "").rstrip("/")
|
||||
|
||||
if args.all:
|
||||
candidates = list(source_dir.rglob("*.md"))
|
||||
elif args.files:
|
||||
candidates = [source_dir / f for f in args.files]
|
||||
else:
|
||||
print("Nothing to do: pass --files or --all.")
|
||||
return
|
||||
|
||||
files = [
|
||||
p for p in candidates
|
||||
if p.exists()
|
||||
and p.suffix == ".md"
|
||||
and p.name != "README.md"
|
||||
]
|
||||
|
||||
if not files:
|
||||
print("No publishable candidates after filtering.")
|
||||
return
|
||||
|
||||
print(f"Validating {len(files)} file(s)...")
|
||||
posts = load_and_validate(files, source_dir)
|
||||
print("Validation passed.")
|
||||
|
||||
site = connect_wiki()
|
||||
|
||||
counts = {"published": 0, "noop": 0, "skipped-manual": 0}
|
||||
for abs_path, post in posts.items():
|
||||
rel = abs_path.relative_to(source_dir)
|
||||
outcome = publish_one(abs_path, source_dir, post, site, source_ref, gitea_repo_url)
|
||||
counts[outcome] += 1
|
||||
if outcome == "skipped-manual":
|
||||
print(f" [skipped-manual] {rel} ← page exists without auto-gen banner; delete or add banner to hand over to bot", file=sys.stderr)
|
||||
else:
|
||||
print(f" [{outcome}] {rel}")
|
||||
|
||||
print(
|
||||
f"\nDone: {counts['published']} published, {counts['noop']} unchanged, "
|
||||
f"{counts['skipped-manual']} skipped (existing manual pages)."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user