#!/usr/bin/env python3 """SFCraft 文档 · 物品贴图自动同步 (Linux / macOS / Unix 版) 与 scripts/sync-items.ps1 逻辑一致: 1. 下载 sfcraft 仓库 textures/item 全部贴图到 assets/items 2. 扫描 content 中 crafting 组件用到的物品 id (支持九格直填与 pattern 别名两种写法) 3. 本地缺失的 id 从 Minecraft wiki 自动获取(自动挑选最新版本渲染) 4. 仍找不到 -> 列出缺失物品并以退出码 1 结束 用法: python3 scripts/sync-items.py [-f] """ import argparse import json import re import sys import time import urllib.parse import urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parent.parent ITEMS_DIR = ROOT / "assets" / "items" CONTENT_DIR = ROOT / "content" UA = "sfcraft-docs-sync/1.0 (https://github.com/saltedfishclub/sfcraft-docs)" SF_OWNER = "saltedfishclub" SF_REPO = "sfcraft" SF_REF = "rev/26.2" SF_DIR = "src/main/resources/assets/sfcraft/textures/item" SF_LIST_URL = ( f"https://api.github.com/repos/{SF_OWNER}/{SF_REPO}/contents/{SF_DIR}?ref={SF_REF}" ) WIKI_API = "https://zh.minecraft.wiki/api.php" WIKI_IMG = "https://zh.minecraft.wiki/images/" SHORTCODE_RE = re.compile(r"\{\{<\s*crafting\b(.*?)\}\}", re.S) PAIR_RE = re.compile(r'(\w+)="([^"]*)"') SLOT_KEYS = ["a1", "a2", "a3", "b1", "b2", "b3", "c1", "c2", "c3", "out"] CONNECTORS = {"and", "of", "the", "for", "with", "to", "from", "by", "in", "on", "at"} ALIASES = { "tnt": "TNT", "tnt_minecart": "TNT_Minecart", "dragon_breath": "Dragon's_Breath", "slime_ball": "Slime", "snow_golem": "Snow_Golem", "ocelot": "Ocelot", } def http_get_json(url, retries=2): last = None for i in range(retries + 1): try: req = urllib.request.Request(url, headers={"User-Agent": UA}) with urllib.request.urlopen(req, timeout=30) as resp: return json.load(resp) except Exception as e: # noqa: BLE001 last = e if i < retries: time.sleep(0.8) raise last def http_download(url, target): req = urllib.request.Request(url, headers={"User-Agent": UA}) with urllib.request.urlopen(req, timeout=60) as resp, open(target, "wb") as f: f.write(resp.read()) with open(target, "rb") as f: if f.read(4) != b"\x89PNG": raise ValueError("下载内容不是有效 PNG") def add_item_id(ids, raw): v = raw.strip() if not v: return item = v.split(":", 1)[0].strip() if item: ids.add(item) def scan_item_ids(): """扫描 content 下所有 crafting 组件, 收集物品 id。""" ids = set() for path in sorted(CONTENT_DIR.rglob("*.md")): try: text = path.read_text(encoding="utf-8") except OSError: continue for m in SHORTCODE_RE.finditer(text): params = dict(PAIR_RE.findall(m.group(1))) for k in SLOT_KEYS: if k in params: add_item_id(ids, params[k]) if "pattern" in params: pat = re.sub(r"\\n|;|\r?\n", " ", params["pattern"]) for tok in pat.split(): if tok == ".": continue if tok in params: add_item_id(ids, params[tok]) elif re.fullmatch(r"[a-z0-9_]+", tok): ids.add(tok) return ids def name_variants(item_id): """由物品 id 生成可能的 wiki 英文名(含特殊命名变体与别名)。""" words = [w for w in item_id.split("_") if w] title = "_".join(w[0].upper() + w[1:] for w in words) mixed = "_".join(w if w in CONNECTORS else w[0].upper() + w[1:] for w in words) variants = [title] if mixed != title: variants.append(mixed) if len(item_id) <= 4 and not re.search(r"[aeiou]", item_id): variants.append(item_id.upper()) one_word = "".join(w[0].upper() + w[1:] for w in words) if one_word != title: variants.append(one_word) if item_id in ALIASES: variants.insert(0, ALIASES[item_id]) seen = set() out = [] for v in variants: if v not in seen: seen.add(v) out.append(v) return out def wiki_candidates(name): """查询 wiki 图片列表, 返回符合 <英文名>_JE..[_BE..].png 规则的文件名。""" regex = re.compile( r"^" + re.escape(name) + r"(_\(item\))?_JE\d+(\.\d+)*(_BE\d+)?\.png$" ) urls = [ WIKI_API + "?action=query&list=allimages&aiprefix=" + urllib.parse.quote(name + "_") + "&ailimit=500&format=json", WIKI_API + "?action=query&list=allimages&aiprefix=" + urllib.parse.quote(name + "_JE") + "&ailimit=200&format=json", ] seen = set() all_names = [] for url in urls: try: data = http_get_json(url) for item in data.get("query", {}).get("allimages", []) or []: nm = item["name"] if nm not in seen: seen.add(nm) all_names.append(nm) except Exception as e: # noqa: BLE001 print(f" [警告] wiki 查询失败 ({name}): {e}", file=sys.stderr) time.sleep(0.25) return [n for n in all_names if regex.match(n)] def select_best(name, candidates): """挑选最新版本: 优先 (item) 物品图标, 再比 JE / BE 版本号。""" regex = re.compile( r"^" + re.escape(name) + r"(_\(item\))?_JE(\d+)(\.\d+)*(_BE(\d+))?\.png$" ) best = None best_key = None for cand in candidates: m = regex.match(cand) if not m: continue key = ( 1 if m.group(1) else 0, int(m.group(2)), int(m.group(5)) if m.group(5) else -1, ) if best_key is None or key > best_key: best, best_key = cand, key return best def main(): ap = argparse.ArgumentParser(description="SFCraft 物品贴图同步") ap.add_argument("-f", "--force", action="store_true", help="强制重新下载 sfcraft 自定义贴图") args = ap.parse_args() ITEMS_DIR.mkdir(parents=True, exist_ok=True) # 1. sfcraft 自定义贴图 print("\n== 1/4 同步 sfcraft 自定义贴图") sf_count = sf_new = 0 try: listing = http_get_json(SF_LIST_URL) for f in listing: target = ITEMS_DIR / f["name"] need = ( args.force or not target.exists() or target.stat().st_size != int(f["size"]) ) if need: http_download(f["download_url"], target) sf_new += 1 sf_count += 1 print(f" {sf_count} 个文件, 本次更新 {sf_new} 个") except Exception as e: # noqa: BLE001 print(f" [警告] 无法访问 GitHub, 跳过 sfcraft 贴图同步: {e}", file=sys.stderr) # 2. 扫描组件物品 id print("\n== 2/4 扫描组件物品 id") ids = scan_item_ids() all_ids = sorted(ids) need = [i for i in all_ids if not (ITEMS_DIR / f"{i}.png").exists()] print(f" 使用 {len(all_ids)} 个物品 id, 本地已有 {len(all_ids) - len(need)} 个") # 3. 从 Minecraft wiki 获取缺失贴图 print("\n== 3/4 从 Minecraft wiki 获取缺失贴图") failed = [] for item_id in need: file = None for name in name_variants(item_id): cands = wiki_candidates(name) if cands: file = select_best(name, cands) break if file: try: http_download(WIKI_IMG + urllib.parse.quote(file), ITEMS_DIR / f"{item_id}.png") print(f" ok {item_id} <- {file}") continue except Exception as e: # noqa: BLE001 print(f" fail {item_id} 下载失败: {e}", file=sys.stderr) else: print(f" fail {item_id} 在 wiki 上未找到", file=sys.stderr) failed.append(item_id) # 4. 汇总错误 if failed: print("\n[错误] 以下物品贴图无法自动获取:", file=sys.stderr) for i in sorted(failed): print(f" - {i}", file=sys.stderr) print( "\n可能原因:\n" " 1. 物品 id 拼写错误(组件中填写的是 assets/items 下的文件名)\n" " 2. 该物品是 sfcraft 自定义物品, 但 textures/item 目录中没有同名贴图\n" " 3. 原版物品在 Minecraft wiki 上的命名特殊, 未被自动规则覆盖\n" "\n解决办法:\n" " - 将贴图手动放入 assets/items/.png 后重新运行\n" " - 修正组件中的物品 id\n" " - 若是 wiki 命名特例, 可在 sync-items.py 的 ALIASES 表中补充", file=sys.stderr, ) sys.exit(1) print("\n全部贴图就绪") if __name__ == "__main__": main()