Task 14: add CMS refined-data sync script (sync_cms_data.py)

Pull hesupipe published products/news/cases from the CMS control-api
public endpoints (the same refined data src/content/{products,news,cases}.ts
fetch at build time; those TS files are fetchers, not literal arrays) and
sync them into SiteForge entries via login + PATCH/POST:

- match by route slug (product /product/<slug>/, post keeps legacy WP
  route on update, slug-col truncation fallback), create when unmatched,
  never delete SiteForge-only entries
- port wp-text.ts cleaning (Gutenberg comments, [caption] -> figure,
  trailing form blocks, H1 -> H2, wrapInlineInP, excerpt) so bodies land
  sanitized (bleach on the API side strips aligncenter classes/styles)
- upload CMS media (/v1/media/... and local /images/ design fallbacks)
  through the SiteForge media API, rewriting body refs to media/<file>;
  per-target temp-dir cache dedupes uploads across runs
- product fields: color/standard/category + specs dict flattened into
  scalar keys (trade-pro Specifications table renders scalars only) +
  gallery as semicolon list; post fields: publish_date/category/tags;
  case fields: results/location/industry/gallery
- sync site name/tagline from site.ts SITE literal and set locale=en
  via PUT /api/i18n/sites/{id}/locale

Verified against a local uvicorn on a temp snapshot of the container DB:
52 products (47 updated + 5 created), 129 posts updated, 0 failures,
181 media covers, idempotent re-run, theme registry tests unchanged
(30 pass, 1 pre-existing failure).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-29 12:05:57 +08:00
parent c12f0e28b2
commit 3ce4292794
+755
View File
@@ -0,0 +1,755 @@
# -*- coding: utf-8 -*-
"""Task 14 — CMS 精整理数据 → SiteForge 条目同步。
数据源:CMS control-api 公开端点(hesupipe 站 published 修订)——即
runtimes/astro-site-runtime/src/content/{products,news,cases}.ts 在构建期拉取并
精整理的同一份事实数据(TS 文件本身是 fetcher + 字段映射,不内含条目字面量;
本脚本按同一映射规则在 Python 侧复刻)。站点名称/tagline 取自 site.ts 的
SITE 常量(字面量,直接文本解析)。
同步策略:
- 产品/新闻/案例按 slug 匹配现有条目(产品按 route 尾段 /product/<slug>/,
文章额外容忍 slug 列 70 字符截断),命中→PATCH 更新,未命中→POST 创建;
- SiteForge 有而 CMS 没有的条目一律保留(可能手动添加);
- 图片:CMS 媒体库 URL(/v1/media/public/...)与本地设计图通过 SiteForge
媒体 API 上传后改写为 media/<file> 相对 URL(跨次运行经临时目录缓存去重);
- 正文复刻 wp-text.ts 的 cleanWpHtml/wrapInlineInP/htmlToExcerpt 清洗
WordPress 残留(Gutenberg 注释、caption 短码、表单区块、H1 降级等),
SiteForge 侧 bleach 白名单再剥离 aligncenter 等类名/内联样式;
- 站点配置:site.ts 的 name/tagline + locale=en(PUT /api/i18n/.../locale)。
用法:
PYTHONUTF8=1 .venv/Scripts/python scripts/sync_cms_data.py --site 483295c29ea0 \
--api http://127.0.0.1:8787 --cms-api http://127.0.0.1:3210/v1 \
[--password $SITEFORGE_ADMIN_PASSWORD] [--dry-run] [--types product,post,case]
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import tempfile
from pathlib import Path
from urllib.parse import urlparse
import httpx
# --------------------------------------------------------------------------
# WordPress HTML 清洗(wp-text.ts 逐条移植;SiteForge 端 bleach 再做属性剥离)
# --------------------------------------------------------------------------
def strip_trailing_form_block(html: str) -> str:
blank = r"(?:\s|&nbsp;|&#160;)*"
tail = (
blank + r"(?:<div>\s*)?<h[1-4][^>]*>[^<]*</h[1-4]>\s*"
r"(?:<div[^>]*>(?:\s|&nbsp;|&#160;|\[[^\]]*\]|<br\s*/?>)*</div>\s*)+"
r"(?:</div>)?"
r"(?:\s*<div[^>]*>" + blank + r"</div>\s*)*"
+ blank + r"$"
)
return re.sub(tail, "", html, flags=re.I)
def _caption_repl(m: "re.Match[str]") -> str:
attrs, inner = m.group(1), m.group(2)
img_m = re.search(r"<img[^>]*>", inner)
img = img_m.group(0) if img_m else ""
caption = inner.replace(img, "", 1).strip()
align_m = re.search(r'align="([^"]*)"', attrs)
align = align_m.group(1) if align_m else "aligncenter"
width_m = re.search(r'width="(\d+)"', attrs)
style = f' style="max-width:{int(width_m.group(1)) + 12}px"' if width_m else ""
figcap = f"<figcaption>{caption}</figcaption>" if caption else ""
return f'<figure class="wp-caption {align}"{style}>{img}{figcap}</figure>'
def clean_wp_html(html: str) -> str:
html = re.sub(r"<!--\s*/?wp:[^>]*-->", "", html) # Gutenberg 块注释
html = re.sub(r"\[caption([^\]]*)\](.*?)\[/caption\]", _caption_repl, html, flags=re.S)
html = re.sub(
r"\[/?((?:contact-form-7|wpforms|formidable|gravityform|caldera_form)[^\]]*)\]",
"",
html,
flags=re.I,
)
html = strip_trailing_form_block(html)
html = re.sub(r'<div class="richTextDocs-code-line"[^>]*>(?:\s|&nbsp;)*</div>', "", html)
html = re.sub(r"<h1(\s[^>]*)?>", "<h2>", html, flags=re.I)
html = re.sub(r"</h1>", "</h2>", html, flags=re.I)
html = re.sub(r"\n{3,}", "\n\n", html)
return html.strip()
_BLOCK_RE = re.compile(
r"<(p|h[1-6]|ul|ol|table|figure|blockquote|pre|hr)\b[\s\S]*?(?:</\1>|/>)", re.I
)
def wrap_inline_in_p(html: str) -> str:
parts: list[str] = []
last = 0
for m in _BLOCK_RE.finditer(html):
before = html[last : m.start()]
if before.strip():
parts.append(f"<p>{before.strip()}</p>")
parts.append(m.group(0))
last = m.end()
tail = html[last:]
if tail.strip():
parts.append(f"<p>{tail.strip()}</p>")
return "\n".join(parts)
def html_to_excerpt(html: str, max_len: int = 140) -> str:
text = clean_wp_html(html)
text = re.sub(r"<[^>]+>", " ", text)
text = text.replace("&nbsp;", " ").replace("&amp;", "&")
text = re.sub(r"\s+", " ", text).strip()
if len(text) > max_len:
return re.sub(r"\s+\S*$", "", text[:max_len]) + "…"
return text
# --------------------------------------------------------------------------
# site.ts 字面量解析(站点 name/tagline)
# --------------------------------------------------------------------------
def parse_site_ts(path: Path) -> dict:
out = {}
try:
src = path.read_text(encoding="utf-8")
except OSError as exc:
print(f"[warn] 无法读取 site.ts({exc}),站点配置跳过")
return out
m = re.search(r"export const SITE = \{([\s\S]*?)\} as const;", src)
if not m:
return out
body = m.group(1)
for key in ("name", "fullName", "tagline"):
km = re.search(rf'{key}:\s*"([^"]*)"', body)
if km:
out[key] = km.group(1)
return out
# --------------------------------------------------------------------------
# 图片处理:CMS 媒体 URL / 本地图 → SiteForge 媒体库
# --------------------------------------------------------------------------
MEDIA_URL_RE = re.compile(
r"/v1/media/public/([0-9a-f-]+)/([0-9a-f-]+)/(thumb|medium|original)"
)
def sniff_image(data: bytes) -> tuple[str, str] | None:
"""返回 (扩展名, mime);不识别返回 None。"""
if data.startswith(b"\x89PNG"):
return ".png", "image/png"
if data.startswith(b"\xff\xd8"):
return ".jpg", "image/jpeg"
if data.startswith(b"GIF8"):
return ".gif", "image/gif"
if len(data) > 12 and data[8:12] == b"WEBP":
return ".webp", "image/webp"
return None
class MediaSync:
"""上传图片到 SiteForge 媒体库;同源 URL 进程内与跨次运行(临时缓存)去重。"""
def __init__(self, client: httpx.Client, api: str, site_id: str, csrf: str,
cms_origin: str, cms_public_dir: Path, cache_path: Path,
max_uploads: int = 800, dry_run: bool = False):
self.client = client
self.api = api
self.site_id = site_id
self.csrf = csrf
self.cms_origin = cms_origin
self.cms_public_dir = cms_public_dir
self.cache_path = cache_path
self.max_uploads = max_uploads
self.dry_run = dry_run
self.hits = 0
self.cache: dict[str, str] = {}
self.uploads = 0
self.failures: list[str] = []
if cache_path.exists():
try:
self.cache = json.loads(cache_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
self.cache = {}
def _save_cache(self) -> None:
try:
self.cache_path.write_text(
json.dumps(self.cache, ensure_ascii=False, indent=0), encoding="utf-8"
)
except OSError as exc:
print(f"[warn] 媒体缓存写入失败:{exc}")
def _fetch_bytes(self, src: str) -> bytes | None:
if src.startswith("/v1/media/"):
try:
r = self.client.get(self.cms_origin + src, timeout=30)
r.raise_for_status()
return r.content
except httpx.HTTPError as exc:
self.failures.append(f"{src}: 拉取失败 {exc}")
return None
if src.startswith("/images/"):
p = self.cms_public_dir / src.lstrip("/")
try:
return p.read_bytes()
except OSError:
self.failures.append(f"{src}: 本地文件缺失 {p}")
return None
return None
def url_for(self, src: str) -> str:
"""CMS 图片引用 → SiteForge media/<file> URL;失败返回空串。"""
src = (src or "").strip()
if not src:
return ""
if not src.startswith(("/v1/media/", "/images/")):
return src # http(s) 或已同步过的引用原样返回
if self.dry_run:
return src # 演练模式不落任何写入,保留原引用
if src in self.cache:
self.hits += 1
return self.cache[src]
if self.uploads >= self.max_uploads:
self.failures.append(f"{src}: 超过单次上传上限 {self.max_uploads}")
return ""
data = self._fetch_bytes(src)
if not data:
return ""
sniff = sniff_image(data)
if not sniff:
self.failures.append(f"{src}: 无法识别图片格式")
return ""
ext, mime = sniff
try:
r = self.client.post(
f"{self.api}/api/sites/{self.site_id}/media",
files={"file": (f"cms{ext}", data, mime)},
headers={"x-siteforge-csrf": self.csrf},
timeout=60,
)
r.raise_for_status()
sf_url = r.json().get("url", "")
except (httpx.HTTPError, ValueError) as exc:
self.failures.append(f"{src}: 上传失败 {exc}")
return ""
if not sf_url:
self.failures.append(f"{src}: 上传响应无 url")
return ""
self.uploads += 1
self.cache[src] = sf_url
return sf_url
def rewrite_body(self, html: str) -> str:
"""正文中出现的全部 CMS 媒体 URL 原位改写为 media/<file>。"""
def repl(m: "re.Match[str]") -> str:
return self.url_for(m.group(0)) or m.group(0)
return MEDIA_URL_RE.sub(repl, html)
# --------------------------------------------------------------------------
# CMS 数据 → SiteForge payload
# --------------------------------------------------------------------------
PRODUCT_DEFAULT_IMAGES = {
"hdpe-water-supply-pipe": "/images/pipe-gas-2.jpg",
"hdpe-gas-pipe": "/images/pipe-gas-1.jpg",
"hdpe-drainage-pipe": "/images/pipe-gas-3.jpg",
"hdpe-industrial-pipe": "/images/pipe-gas-4.jpg",
"hdpe-agricultural-irrigation-pipe": "/images/design/agriculture.webp",
"hdpe-pipe-coils": "/images/design/fittings.webp",
}
PRODUCT_DEFAULT_IMAGE = "/images/design/hdpe.webp"
CASE_DEFAULT_IMAGE = "/images/design/agriculture.webp"
NEWS_DEFAULT_IMAGES = (
("/images/design/factory.webp", ("exhib",)),
("/images/design/agriculture.webp", ("industr",)),
)
NEWS_DEFAULT_IMAGE = "/images/design/hero-pipes.webp"
# 主题 Specifications 表只渲染标量字段,且排除这些键(theme_pro.make_context)。
SPEC_EXCLUDED_KEYS = {
"brand", "category", "model", "description", "image", "cover", "url", "gallery",
}
def _s(value) -> str:
return str(value) if value is not None else ""
def _clean_sections(items) -> list[dict]:
out = []
for it in items or []:
if not isinstance(it, dict):
continue
title = _s(it.get("title")).strip()
content = clean_wp_html(_s(it.get("content")))
if title and content:
out.append({"title": title, "content": content})
return out
def _map_gallery_urls(items) -> list[str]:
urls = []
for g in items or []:
if isinstance(g, dict):
u = _s(g.get("url")).strip()
if u:
urls.append(u)
return urls
def build_product(slug: str, entry: dict, media: MediaSync) -> dict:
data = entry.get("data") or {}
name = _s(data.get("name")) or slug
raw_desc = clean_wp_html(_s(data.get("description")))
summary_raw = _s(data.get("summary"))
gallery_src = _map_gallery_urls(data.get("images"))
cover_raw = _s(data.get("coverImage")).strip()
body_first = None
m = re.search(r"<img[^>]+src=[\"']([^\"']+)[\"']", raw_desc, re.I)
if m and m.group(1).startswith(("/images/", "/v1/media/")):
body_first = m.group(1)
img_src = (
gallery_src[0] if gallery_src
else cover_raw if cover_raw.startswith(("/images/", "/v1/media/"))
else body_first or PRODUCT_DEFAULT_IMAGES.get(slug, PRODUCT_DEFAULT_IMAGE)
)
sections = _clean_sections(data.get("sections"))
features = [
{"title": _s(f.get("title")), "desc": _s(f.get("desc"))}
for f in (data.get("features") or []) if isinstance(f, dict) and _s(f.get("title"))
]
specs = {
_s(k): _s(v)
for k, v in (data.get("specifications") or {}).items()
if _s(v)
}
categories = [
{"slug": _s(c.get("slug")), "name": _s(c.get("name"))}
for c in (data.get("productCategories") or [])
if isinstance(c, dict) and _s(c.get("slug"))
]
body_html = raw_desc
for sec in sections:
body_html += f"\n\n<h2>{sec['title']}</h2>\n{sec['content']}"
if features:
body_html += "\n\n<h2>Features</h2>\n"
for f in features:
body_html += f"\n<h3>{f['title']}</h3>\n<p>{f['desc']}</p>\n"
body_html = media.rewrite_body(body_html)
fields: dict = {}
if _s(data.get("brand")):
fields["brand"] = _s(data["brand"])
if _s(data.get("color")):
fields["color"] = _s(data["color"])
if _s(data.get("standard")):
fields["standard"] = _s(data["standard"])
if categories:
fields["category"] = categories[0]["name"]
fields["categories"] = json.dumps(categories, ensure_ascii=False)
elif _s(data.get("category")):
fields["category"] = _s(data["category"])
if specs:
fields["specs"] = json.dumps(specs, ensure_ascii=False)
for k, v in specs.items(): # 展平为标量 → 主题 Specifications 表逐键成行
if k not in SPEC_EXCLUDED_KEYS and k not in fields:
fields[k] = v
if data.get("applications"):
fields["applications"] = ";".join(_s(a) for a in data["applications"])
if features:
fields["features"] = json.dumps(features, ensure_ascii=False)
gallery_urls = []
for u in gallery_src + ([cover_raw] if cover_raw else []):
sf = media.url_for(u)
if sf and sf not in gallery_urls:
gallery_urls.append(sf)
if gallery_urls:
fields["gallery"] = ";".join(gallery_urls)
desc_text = (
html_to_excerpt(raw_desc)
or html_to_excerpt(summary_raw)
or f"{name} — manufactured by HESU under ISO 9001 certified quality management."
)
payload = {
"type": "product",
"title": name,
"route": f"/product/{slug}/",
"summary": desc_text,
"body": body_html,
"cover": media.url_for(img_src),
"meta": {"fields": fields},
}
if _s(data.get("seoTitle")):
payload["seo_title"] = _s(data["seoTitle"])
if _s(data.get("seoDescription")):
payload["seo_description"] = _s(data["seoDescription"])
return payload
def build_post(slug: str, entry: dict, media: MediaSync) -> dict:
data = entry.get("data") or {}
title = _s(data.get("title")) or slug
body = wrap_inline_in_p(clean_wp_html(_s(data.get("body"))))
cats = [
{"slug": _s(c.get("slug")), "name": _s(c.get("name"))}
for c in (data.get("categories") or [])
if isinstance(c, dict) and _s(c.get("slug"))
]
tag = _s(data.get("tag")) or (cats[0]["name"] if cats else "News")
cover_raw = _s(data.get("coverImage")).strip()
if not cover_raw.startswith(("/images/", "/v1/media/")):
low = tag.lower()
cover_raw = next(
(u for u, keys in NEWS_DEFAULT_IMAGES if any(k in low for k in keys)),
NEWS_DEFAULT_IMAGE,
)
date = ""
for raw in (_s(data.get("publishedAt")), _s(entry.get("publishedAt"))):
dm = re.match(r"(\d{4}-\d{2}-\d{2})", raw)
if dm:
date = dm.group(1)
break
fields: dict = {}
if date:
fields["publish_date"] = date
if tag:
fields["category"] = tag
if len(cats) > 1:
fields["tags"] = ";".join(c["name"] for c in cats)
payload = {
"type": "post",
"title": title,
"route": f"/post/{slug}/",
"summary": html_to_excerpt(_s(data.get("summary"))) or html_to_excerpt(body) or title,
"body": media.rewrite_body(body),
"cover": media.url_for(cover_raw),
"meta": {"fields": fields},
}
if _s(data.get("seoTitle")):
payload["seo_title"] = _s(data["seoTitle"])
if _s(data.get("seoDescription")):
payload["seo_description"] = _s(data["seoDescription"])
return payload
def build_case(slug: str, entry: dict, media: MediaSync) -> dict:
data = entry.get("data") or {}
title = _s(data.get("title")) or slug
background = clean_wp_html(_s(data.get("customerBackground")))
challenge = clean_wp_html(_s(data.get("challenge")))
solution = clean_wp_html(_s(data.get("solution")))
results = [
_s((r.get("value") if isinstance(r, dict) else r)).strip()
for r in (data.get("results") or [])
]
results = [r for r in results if r]
cover_raw = _s(data.get("coverImage")).strip()
if not cover_raw.startswith(("/images/", "/v1/media/")):
cover_raw = CASE_DEFAULT_IMAGE
body = ""
for h, html in (("Customer Background", background), ("The Challenge", challenge),
("Our Solution", solution)):
if html:
body += f"<h2>{h}</h2>\n{html}\n\n"
body = media.rewrite_body(body.strip())
fields: dict = {}
if results:
fields["results"] = ";".join(results)
if _s(data.get("location")):
fields["location"] = _s(data["location"])
if _s(data.get("industry")):
fields["industry"] = _s(data["industry"])
gallery_src = _map_gallery_urls(data.get("images"))
gallery_urls = []
for u in gallery_src + ([cover_raw] if cover_raw else []):
sf = media.url_for(u)
if sf and sf not in gallery_urls:
gallery_urls.append(sf)
if gallery_urls:
fields["gallery"] = ";".join(gallery_urls)
payload = {
"type": "case",
"title": title,
"route": f"/case/{slug}/",
"summary": (challenge.splitlines()[0] if challenge else "") or \
(solution.splitlines()[0] if solution else "") or title,
"body": body,
"cover": media.url_for(cover_raw),
"meta": {"fields": fields},
}
if _s(data.get("seoTitle")):
payload["seo_title"] = _s(data["seoTitle"])
if _s(data.get("seoDescription")):
payload["seo_description"] = _s(data["seoDescription"])
return payload
# --------------------------------------------------------------------------
# SiteForge 客户端
# --------------------------------------------------------------------------
class SiteForge:
def __init__(self, api: str, username: str, password: str):
self.api = api.rstrip("/")
self.client = httpx.Client(base_url=self.api, timeout=60)
r = self.client.post(
"/api/auth/login", json={"username": username, "password": password}
)
if r.status_code != 200:
raise SystemExit(f"[fatal] 登录失败 {r.status_code}: {r.text[:200]}")
me = self.client.get("/api/auth/me").json()
self.csrf = me.get("csrf") or ""
def entries(self, site_id: str) -> list[dict]:
r = self.client.get(f"/api/sites/{site_id}/entries")
r.raise_for_status()
return r.json()
def save(self, site_id: str, payload: dict, entry_id: str | None) -> tuple[str, dict]:
headers = {"x-siteforge-csrf": self.csrf}
if entry_id:
r = self.client.patch(
f"/api/sites/{site_id}/entries/{entry_id}",
json=payload, headers=headers,
)
action = "updated"
else:
r = self.client.post(
f"/api/sites/{site_id}/entries", json=payload, headers=headers
)
action = "created"
if r.status_code not in (200, 201):
raise RuntimeError(f"HTTP {r.status_code}: {r.text[:300]}")
return action, r.json()
def patch_site(self, site_id: str, patch: dict) -> None:
r = self.client.patch(
f"/api/sites/{site_id}", json=patch, headers={"x-siteforge-csrf": self.csrf}
)
r.raise_for_status()
def set_locale(self, site_id: str, locale: str) -> None:
r = self.client.put(
f"/api/i18n/sites/{site_id}/locale",
json={"locale": locale}, headers={"x-siteforge-csrf": self.csrf},
)
r.raise_for_status()
# --------------------------------------------------------------------------
# 主流程
# --------------------------------------------------------------------------
def fetch_cms(cms_api: str, cms_site: str) -> dict[str, list[dict]]:
endpoints = {
"product": f"/content/public/entries?type=product&locale=en&site={cms_site}",
"post": f"/content/public/news?locale=en&site={cms_site}",
"case": f"/content/public/cases?locale=en&site={cms_site}",
}
out: dict[str, list[dict]] = {}
with httpx.Client(base_url=cms_api.rstrip("/"), timeout=60) as c:
for typ, path in endpoints.items():
r = c.get(path)
if r.status_code == 404:
out[typ] = []
continue
r.raise_for_status()
out[typ] = r.json()
return out
def route_slug(route: str) -> str:
return (route or "").strip("/").split("/")[-1] if (route or "").strip("/") else ""
def build_match_index(existing: list[dict]) -> dict[str, dict[str, dict]]:
idx: dict[str, dict[str, dict]] = {"route": {}, "slug": {}}
seen_slug: dict[str, int] = {}
for e in existing:
rs = route_slug(e.get("route", ""))
if rs and e["type"] != "page":
key = (e["type"], rs)
idx["route"].setdefault(key, e)
sl = e.get("slug") or ""
if sl:
key = (e["type"], sl)
seen_slug[key] = seen_slug.get(key, 0) + 1
idx["slug"].setdefault(key, e)
for key, n in seen_slug.items():
if n > 1:
idx["slug"].pop(key, None) # 撞名则不可靠,弃用
return idx
def match_entry(typ: str, cms_slug: str, idx: dict) -> dict | None:
hit = idx["route"].get((typ, cms_slug))
if hit:
return hit
hit = idx["slug"].get((typ, cms_slug))
if hit:
return hit
if len(cms_slug) > 70: # core.slug_of 截断为 70 字符
hit = idx["slug"].get((typ, cms_slug[:70]))
if hit:
return hit
return None
def main() -> int:
ap = argparse.ArgumentParser(description="CMS 精整理数据 → SiteForge 同步")
ap.add_argument("--site", required=True, help="SiteForge site id")
ap.add_argument("--api", default="http://127.0.0.1:8787", help="SiteForge API origin")
ap.add_argument("--username", default="admin")
ap.add_argument("--password", default="", help="缺省读 SITEFORGE_ADMIN_PASSWORD")
ap.add_argument("--cms-api", default="http://localhost:3000/v1",
help="CMS control-api(公开端点)")
ap.add_argument("--cms-site", default="hesupipe")
ap.add_argument("--cms-public-dir",
default="D:/project/cms/runtimes/astro-site-runtime/public",
help="CMS 静态资源目录(/images/ 设计图兜底)")
ap.add_argument("--site-ts",
default="D:/project/cms/runtimes/astro-site-runtime/src/content/site.ts")
ap.add_argument("--locale", default="en")
ap.add_argument("--types", default="product,post,case")
ap.add_argument("--max-media", type=int, default=800)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--skip-site-config", action="store_true")
args = ap.parse_args()
import os
password = args.password or os.environ.get("SITEFORGE_ADMIN_PASSWORD", "")
if not password:
print("[fatal] 缺少管理员密码:--password 或 SITEFORGE_ADMIN_PASSWORD")
return 2
types = [t.strip() for t in args.types.split(",") if t.strip()]
cms = fetch_cms(args.cms_api, args.cms_site)
print(f"[cms] 拉取完成:"
+ " ".join(f"{t}={len(cms.get(t, []))}" for t in types))
sf = SiteForge(args.api, args.username, password)
existing = sf.entries(args.site)
idx = build_match_index(existing)
site_ts = parse_site_ts(Path(args.site_ts))
api_key = re.sub(r"[^a-z0-9]+", "-", urlparse(args.api).netloc.lower()).strip("-")
cache_path = (
Path(tempfile.gettempdir())
/ f"siteforge-cms-sync-media-{api_key}-{args.site}.json"
)
media = MediaSync(
sf.client, args.api.rstrip("/"), args.site, sf.csrf,
cms_origin=args.cms_api.rstrip("/").removesuffix("/v1"),
cms_public_dir=Path(args.cms_public_dir),
cache_path=cache_path, max_uploads=args.max_media, dry_run=args.dry_run,
)
builders = {"product": build_product, "post": build_post, "case": build_case}
stats = {t: {"created": 0, "updated": 0, "failed": 0} for t in types}
failures: list[str] = []
for typ in types:
for entry in cms.get(typ, []):
slug = entry.get("slug") or ""
if not slug:
continue
try:
payload = builders[typ](slug, entry, media)
except Exception as exc: # noqa: BLE001 —— 单条失败不阻断整体
stats[typ]["failed"] += 1
failures.append(f"{typ}/{slug}: 构建失败 {exc}")
continue
if args.dry_run:
hit = match_entry(typ, slug, idx)
stats[typ]["updated" if hit else "created"] += 1
continue
hit = match_entry(typ, slug, idx)
body = dict(payload)
if hit:
body.pop("route", None) # 保留既有 URL(WP 迁移 SEO 连续性)
merged_meta = dict(hit.get("meta") or {})
merged_meta.update(body.get("meta") or {})
body["meta"] = merged_meta
try:
action, _ = sf.save(args.site, body, hit["id"] if hit else None)
stats[typ][action] += 1
except Exception as exc: # noqa: BLE001
stats[typ]["failed"] += 1
failures.append(f"{typ}/{slug}: {exc}")
if not args.dry_run:
media._save_cache()
if not args.skip_site_config:
patch: dict = {}
if site_ts.get("name"):
patch["name"] = site_ts["name"]
if site_ts.get("tagline"):
patch["tagline"] = site_ts["tagline"]
if patch:
sf.patch_site(args.site, patch)
print(f"[site] 站点配置已同步:{patch}")
try:
sf.set_locale(args.site, args.locale)
print(f"[site] locale={args.locale}")
except Exception as exc: # noqa: BLE001
failures.append(f"locale: {exc}")
final = sf.entries(args.site)
per_type: dict[str, int] = {}
cover_media = gallery_fields = spec_fields = 0
for e in final:
per_type[e["type"]] = per_type.get(e["type"], 0) + 1
f = (e.get("meta") or {}).get("fields") or {}
if str(e.get("cover", "")).startswith("media/"):
cover_media += 1
if f.get("gallery"):
gallery_fields += 1
if f.get("specs"):
spec_fields += 1
print("\n===== 同步统计 =====")
for t in types:
s = stats[t]
print(f"{t}: cms={len(cms.get(t, []))} created={s['created']} "
f"updated={s['updated']} failed={s['failed']} site_total={per_type.get(t, 0)}")
print(f"媒体上传:{media.uploads} 张(缓存命中 {media.hits})")
print(f"全站 media 封面:{cover_media} | 含 gallery 字段:{gallery_fields} "
f"| 含 specs 字段:{spec_fields}")
if media.failures:
print(f"媒体失败 {len(media.failures)} 项(前 10):")
for f in media.failures[:10]:
print(f" - {f}")
if failures:
print(f"条目失败 {len(failures)} 项(前 10):")
for f in failures[:10]:
print(f" - {f}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())