265 lines
9.0 KiB
Python
265 lines
9.0 KiB
Python
#!/usr/bin/env python3
|
|
"""SFCraft 文档 · 物品贴图自动同步 (Python 3)
|
|
|
|
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: str, retries: int = 2) -> dict:
|
|
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: # noqa: BLE001
|
|
if i < retries:
|
|
time.sleep(0.8)
|
|
else:
|
|
raise
|
|
# 理论上不可达(retries 为负时 range 为空才可能走到), 兜底避免隐式返回 None
|
|
raise RuntimeError(f"请求失败(无可用异常信息): {url}")
|
|
|
|
|
|
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[str] = 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/<id>.png 后重新运行\n"
|
|
" - 修正组件中的物品 id\n"
|
|
" - 若是 wiki 命名特例, 可在 sync-items.py 的 ALIASES 表中补充",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
print("\n全部贴图就绪")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|