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
This commit is contained in:
1 parent
7b71acf223
commit
aea21c8694
74 files changed
+1381
-97
No files matched your search
@@ -0,0 +1,128 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user