maipulse: 脱敏存档

- Python 后端 + 单页前端 + Android(Chaquopy) 壳
- 曲库 1343 首 / 称号 2800 个
- 机台身份与店铺信息已抹除,字段留空
- 打包脚本含 16KB 页对齐与自动脱敏
This commit is contained in:
shouko committed 2026-10-28 22:27:46 +08:00
commit 8421898626
161 files changed
+50297

No files matched your search

+258
View File
@@ -0,0 +1,258 @@
# -*- coding: utf-8 -*-
"""把 SDGB 官方更新包(A0xx)里的曲目 / 称号合并进 maipulse 的 master 数据。
用法:
python tools/apply_update.py "C:\\Users\\20558\\Downloads\\SDGB_A041_20261020134418_0"
python tools/apply_update.py <包目录> --dry 只看差异,不写盘
包里有什么就用什么:
music/music*/Music.xml -> data/master/music.json(定数、bpm、版本、谱师全在这)
title/title*/Title.xml -> data/master/title.json
其它目录(event / map / information / MovieData / SoundData / AssetBundleImages)是机台资源,
maipulse 用不到,直接跳过。
★ 只增量合并:已存在的 id 用包里更完整的字段覆盖空值,不删任何旧条目。
★ 写盘前自动备份到 data/_backup/<时间戳>/。
"""
from __future__ import annotations
import io
import json
import os
import shutil
import sys
import time
import xml.etree.ElementTree as ET
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join(BASE_DIR, "data")
MASTER_DIR = os.path.join(DATA_DIR, "master")
MASTER_INDEX = os.path.join(DATA_DIR, "master.json")
BACKUP_ROOT = os.path.join(DATA_DIR, "_backup")
def txt(node, path, default=""):
e = node.find(path)
if e is None:
return default
return (e.text or default) or default
def num(node, path, default=0):
e = node.find(path)
if e is None:
return default
try:
return int((e.text or "").strip())
except (ValueError, AttributeError):
return default
def flag(node, path, default=False):
return (txt(node, path, "true" if default else "false") or "").strip().lower() == "true"
# ---------------------------------------------------------------- 解析
def parse_music(path: str) -> dict:
root = ET.parse(path).getroot()
notes = []
for n in root.findall("./notesData/Notes"):
if not flag(n, "./isEnable", True):
notes.append({"level": 0, "levelDecimal": 0, "designer": ""})
continue
notes.append({
"level": num(n, "./level"),
"levelDecimal": num(n, "./levelDecimal"),
"designer": txt(n, "./notesDesigner/str"),
})
# 谱面固定补到 6 档(BAS/ADV/EXP/MAS/Re:MAS + 宴),和现有 master 对齐
while len(notes) < 6:
notes.append({"level": 0, "levelDecimal": 0, "designer": ""})
return {
"id": num(root, "./name/id"),
"name": txt(root, "./name/str"),
"sortName": txt(root, "./sortName"),
"artist": txt(root, "./artistName/str"),
"genre": txt(root, "./genreName/str"),
"bpm": num(root, "./bpm"),
"version": num(root, "./version"),
"addVersion": txt(root, "./AddVersion/str"),
"lockType": num(root, "./lockType"),
"disable": flag(root, "./disable"),
"notes": notes,
}
def parse_title(path: str) -> dict:
root = ET.parse(path).getroot()
return {
"id": num(root, "./name/id"),
"name": txt(root, "./name/str"),
# Title.xml 里叫 <genre>,Music.xml 里叫 <genreName>,两个都兼容
"genre": txt(root, "./genre/str") or txt(root, "./genreName/str"),
"release": txt(root, "./releaseTagName/str"),
"lock": txt(root, "./dispCond"),
"norm": txt(root, "./normText"),
"isDefault": flag(root, "./isDefault"),
"disable": flag(root, "./disable"),
}
def collect(pkg: str, sub: str, filename: str, parser) -> list:
out = []
base = os.path.join(pkg, sub)
if not os.path.isdir(base):
return out
for name in sorted(os.listdir(base)):
p = os.path.join(base, name, filename)
if os.path.isfile(p):
try:
item = parser(p)
except ET.ParseError as exc:
print(" 跳过 %s:%s" % (p, exc))
continue
if item.get("id"):
out.append(item)
return out
# ---------------------------------------------------------------- 合并
def merge(old: list, new: list):
"""按 id 增量合并。返回 (合并后列表, 新增, 更新)。"""
index = {x["id"]: i for i, x in enumerate(old)}
added, updated = [], []
for item in new:
i = index.get(item["id"])
if i is None:
old.append(item)
index[item["id"]] = len(old) - 1
added.append(item)
continue
cur = old[i]
dirty = False
for k, v in item.items():
if k == "notes":
# 谱面:包里启用的档位比旧的更权威
if any(n["level"] or n["levelDecimal"] for n in v):
if v != cur.get("notes"):
cur["notes"] = v
dirty = True
continue
if v in ("", 0, False, None) and cur.get(k) not in ("", 0, False, None):
continue # 包里是空值就不覆盖已有值
if cur.get(k) != v:
cur[k] = v
dirty = True
if dirty:
updated.append(item["id"])
old.sort(key=lambda x: x["id"])
return old, added, updated
def backup(paths: list) -> str:
stamp = time.strftime("%Y%m%d-%H%M%S")
dst = os.path.join(BACKUP_ROOT, stamp)
os.makedirs(dst, exist_ok=True)
for p in paths:
if os.path.isfile(p):
shutil.copy2(p, os.path.join(dst, os.path.basename(p)))
return dst
def write_json(path: str, obj):
with io.open(path, "w", encoding="utf-8", newline="\n") as fp:
json.dump(obj, fp, ensure_ascii=False, separators=(",", ":"))
def rebuild_index():
"""master.json 是总索引:{kind: {kind, count, items}}。"""
index = {}
if os.path.isfile(MASTER_INDEX):
try:
index = json.load(io.open(MASTER_INDEX, encoding="utf-8"))
except (OSError, ValueError):
index = {}
for name in os.listdir(MASTER_DIR):
if not name.endswith(".json"):
continue
kind = name[:-5]
p = os.path.join(MASTER_DIR, name)
try:
items = json.load(io.open(p, encoding="utf-8"))
except (OSError, ValueError):
continue
index[kind] = {"kind": kind, "count": len(items), "items": items}
write_json(MASTER_INDEX, index)
return {k: v["count"] for k, v in index.items()}
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
if not args:
print(__doc__)
return 1
pkg = os.path.abspath(args[0])
dry = "--dry" in sys.argv
if not os.path.isdir(pkg):
print("包目录不存在:%s" % pkg)
return 1
print("包:%s" % pkg)
ver = os.path.join(pkg, "DataConfig.xml")
if os.path.isfile(ver):
try:
r = ET.parse(ver).getroot()
print("版本:%s.%s.%s" % (num(r, "./version/major"), num(r, "./version/minor"),
num(r, "./version/release")))
except ET.ParseError:
pass
music_path = os.path.join(MASTER_DIR, "music.json")
title_path = os.path.join(MASTER_DIR, "title.json")
music_old = json.load(io.open(music_path, encoding="utf-8"))
title_old = json.load(io.open(title_path, encoding="utf-8"))
pkg_music = collect(pkg, "music", "Music.xml", parse_music)
pkg_title = collect(pkg, "title", "Title.xml", parse_title)
print("包里:曲目 %d 首 / 称号 %d 个" % (len(pkg_music), len(pkg_title)))
music_new, m_add, m_upd = merge(music_old, pkg_music)
title_new, t_add, t_upd = merge(title_old, pkg_title)
print("\n曲目新增 %d 首:" % len(m_add))
for it in m_add:
lv = "/".join("%d%s" % (n["level"], ("." + str(n["levelDecimal"])) if n["levelDecimal"] else "")
for n in it["notes"][:4])
print(" %-7d %-30s v%-6d %s" % (it["id"], it["name"][:28], it["version"], lv))
if m_upd:
print("曲目更新 %d 首:%s" % (len(m_upd), ", ".join(map(str, m_upd))))
print("\n称号新增 %d 个:" % len(t_add))
for it in t_add:
print(" %-7d %s" % (it["id"], it["name"]))
if t_upd:
print("称号更新 %d 个:%s" % (len(t_upd), ", ".join(map(str, t_upd))))
print("\n总计:曲目 %d -> %d,称号 %d -> %d"
% (len(music_old) - len(m_add), len(music_new),
len(title_old) - len(t_add), len(title_new)))
if dry:
print("\n--dry:没有写盘")
return 0
dst = backup([music_path, title_path, MASTER_INDEX])
print("\n已备份到 %s" % dst)
write_json(music_path, music_new)
write_json(title_path, title_new)
counts = rebuild_index()
print("已写入。master.json 计数:")
for k, v in sorted(counts.items()):
print(" %-8s %d" % (k, v))
return 0
if __name__ == "__main__":
sys.exit(main())