Files
mikkeli bf96cd10e7 fix(tech): default Blog year category to execution year
Dateless posts silently lost their Blog:<year> category. build_wikitext
now falls back to datetime.now().year when no date is declared, so every
published post lands in a year category. Docs corrected accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:05:29 +09:00

162 lines
5.2 KiB
Python

#!/usr/bin/env python3
"""Tech blog pipeline — publishes .md files to MediaWiki."""
import argparse
import os
import sys
from datetime import datetime
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
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 "date" in fm and fm["date"]:
try:
datetime.strptime(str(fm["date"]), "%Y-%m-%d")
except ValueError:
errors.append("'date' must be YYYY-MM-DD")
if "tags" in fm and not isinstance(fm["tags"], list):
errors.append("'tags' must be a list")
if errors:
raise ValueError(f"{path}: " + "; ".join(errors))
def build_wikitext(fm: dict, body_wikitext: str, source_url: str, source_ref: str) -> str:
banner = f"{{{{Auto-generated|source={source_url}|commit={source_ref}}}}}"
cat_parts = ["[[Category:Tech blog]]"]
date_val = fm.get("date")
# Fall back to the action's execution year when no date is declared, so
# every post still lands in a Blog:<year> category.
year = str(date_val)[:4] if date_val else str(datetime.now().year)
cat_parts.append(f"[[Category:Blog:{year}]]")
for tag in (fm.get("tags") or []):
cat_parts.append(f"[[Category:{tag}]]")
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 = str(fm["title"])
body_wikitext = markdown_to_wikitext(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 = 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} (tech 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 = str(post.metadata["title"])
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 tech blog .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)