Files
blog/scripts/migrate/posts_to_md.py
wait4xandGLM-5.3 aea21c8694 feat(migrate): Halo 全量内容迁移(压缩版)——6文/21图/1瞬间 URL 1:1
- 正文源=线上渲染页(Halo 快照层 patch 残迹:泥潭/赎罪仅 2 字符),
  pandoc html→gfm + tags API 显示名换算后分类:5 书评(五档情绪色)+1 技术
- 图库 21 张:长边 2400/q86 压缩入仓(原图 196M→9.5M——公开仓历史膨胀
  与 nginx 100M 推送上限双杀;原图在 Phase 0 备份 tar 中永久留档)
- 瞬间 1 条;URL 1:1(6 slug 全等,零 301)
- 本提交由 7 个本地提交重写而成(清除已进历史的 196M blob,未推送过);
  因 reset --soft 暂存竞态,一并包含 spec 归档文件
  (.spec/specs/archive/2026-10-24_migrate-blog-content/)

验证: check_urls.py 6==6;astro build 11 页 exit 0(8.67s);
      EXIF 管线跑通;staging 更新;halo healthy+200 全程未动

Co-authored-by: GLM-5.3
2026-10-25 00:39:08 +08:00

129 lines
4.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"<article[^>]*>(.*?)</article>", 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"<button.*?</button>", "", body, flags=re.S)
body = re.sub(r"<header.*?<h1[^>]*>.*?</h1>.*?</header>", "", 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())