#!/usr/bin/env python3 """文章迁移(Phase 2 T2):Halo 渲染页 → content/posts/*.md 正文源用线上渲染页(reader 看到的即所得)——实测 DB snapshots 对 shu-zui/ni-tan 只有 2 字符(patch 残迹),而渲染页完整。 处理链:抓 /archives/{slug} → 剥主题噪音(button/nav/header) → 图片 /upload/* 下载到 content/posts/assets/{slug}/ 并改写引用 → pandoc html→gfm → frontmatter(分类/标签沿用 Halo,书评分配五档情绪色)。 """ import html as htmllib import json import re import subprocess import urllib.request from pathlib import Path BASE = "http://127.0.0.1:18090" ROOT = Path(".") ASSETS = ROOT / "content" / "posts" / "assets" # 书评五档情绪色(tokens --mood-1..5);技术文不分配 MOODS = ["#b45309", "#0f766e", "#7c3aed", "#9f1239", "#166534"] # Halo 分类名 → 集合枚举 CAT_MAP = {"Reading": "reading", "Linux": "tech", "默认分类": "tech"} def fetch(url: str) -> bytes: with urllib.request.urlopen(url, timeout=30) as r: return r.read() def article_html(slug: str) -> str: page = fetch(f"{BASE}/archives/{slug}").decode("utf-8") m = re.search(r"]*>(.*?)", page, re.S) if not m: raise RuntimeError(f"no article for {slug}") body = m.group(1) # 主题噪音:higan 主题把主题切换钮等塞进 article;标题在 frontmatter 重建,正文 h1 去重 body = re.sub(r"", "", body, flags=re.S) body = re.sub(r"]*>.*?.*?", "", body, count=1, flags=re.S) return body def localize_images(slug: str, body: str) -> str: def repl(m: re.Match) -> str: src = m.group(1) if not src.startswith("/upload/"): return m.group(0) dest_dir = ASSETS / slug dest_dir.mkdir(parents=True, exist_ok=True) fname = src.rsplit("/", 1)[1] data = fetch(BASE + src) (dest_dir / fname).write_bytes(data) return m.group(0).replace(src, f"./assets/{slug}/{fname}") return re.sub(r'src="([^"]+)"', repl, body) def to_markdown(body: str) -> str: p = subprocess.run( ["/home/wait4x/.local/bin/pandoc", "-f", "html", "-t", "gfm-raw_html", "--wrap=none"], input=body.encode(), capture_output=True, check=True, ) return p.stdout.decode("utf-8") def load_tag_display() -> dict[str, str]: """内部标签名(tag-xxx) → 显示名(read/linux/...)。""" with urllib.request.urlopen(BASE + "/apis/api.content.halo.run/v1alpha1/tags") as r: data = json.loads(r.read()) return {t["metadata"]["name"]: t["spec"]["displayName"] for t in data.get("items", [])} def main() -> int: posts = json.load(open(".migration/posts.json", encoding="utf-8")) tag_disp = load_tag_display() mood_i = 0 report = [] for p in posts: spec = p["spec"] slug = spec["slug"] tags = [tag_disp.get(t, t) if isinstance(t, str) else t.get("name", "") for t in (spec.get("tags") or [])] cat_raw = spec.get("category") or "" # 分类判定:Halo 分类名优先,回退看标签(read→reading / linux→tech) if CAT_MAP.get(cat_raw): category = CAT_MAP[cat_raw] elif any("read" in t.lower() for t in tags): category = "reading" else: category = "tech" mood = None if category == "reading": mood = MOODS[mood_i % len(MOODS)] mood_i += 1 body = localize_images(slug, article_html(slug)) md = to_markdown(body).strip() md = re.sub(r"^(-{4,}|_{4,})\s*", "", md) # 原 hr 开头去掉(frontmatter 后首元素应是正文) fm = [ "---", f'title: "{htmllib.escape(spec["title"], quote=False)}"', f"pubDate: {spec['publishTime'][:10]}", f"category: {category}", f"tags: [{', '.join(tags)}]", ] if mood: fm.append(f"mood: '{mood}'") fm.append("---") out = ROOT / "content" / "posts" / f"{slug}.md" out.write_text("\n".join(fm) + "\n\n" + md + "\n", encoding="utf-8") n_img = len(list((ASSETS / slug).glob("*"))) if (ASSETS / slug).exists() else 0 report.append((slug, category, len(md), n_img)) print(f" {slug} [{category}] {len(md)} md-chars, {n_img} imgs") # 清理被真实内容取代的样例文件(真实技术文 slug=csge-lei-pei-zhi,样例为 cs-ge-lei-pei-zhi) sample = ROOT / "content" / "posts" / "cs-ge-lei-pei-zhi.md" if sample.exists(): sample.unlink() print(" removed sample: cs-ge-lei-pei-zhi.md") print(f"migrated {len(report)} posts") return 0 if __name__ == "__main__": raise SystemExit(main())