VanguardReMaster/tools/import_cards.py

271 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Импорт карт BT01 / TD01 / TD02 в YAML.
Источники:
* en.cf-vanguard.com — статы и тексты (HTML, view=text)
* content/cards-mvp.md — триггеры и редкости (выгрузка из вики)
Запуск: python tools/import_cards.py
"""
from __future__ import annotations
import html
import re
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "content" / "sets"
CARDS_MD = ROOT / "content" / "cards-mvp.md"
BASE = "https://en.cf-vanguard.com/cardlist/cardsearch/?expansion={exp}&view=text&sort=no&page={page}"
SOURCES = {
"BT01": [(5, p) for p in range(1, 5)],
"TD01": [(3, 1)],
"TD02": [(4, 1)],
}
SP_BASE = ["001", "002", "003", "004", "006", "007", "008", "010", "013", "017", "027", "032"]
CLAN_MAP = {
"Royal Paladin": "RoyalPaladin",
"Oracle Think Tank": "OracleThinkTank",
"Kagero": "Kagero",
"Nubatama": "Nubatama",
"Tachikaze": "Tachikaze",
"Nova Grappler": "NovaGrappler",
"Spike Brothers": "SpikeBrothers",
"Dark Irregulars": "DarkIrregulars",
"Granblue": "Granblue",
"Megacolony": "Megacolony",
}
def fetch(exp: int, page: int) -> str:
url = BASE.format(exp=exp, page=page)
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=60) as r:
return r.read().decode("utf-8", errors="replace")
CARD_RE = re.compile(
r'<li>\s*<a href="/cardlist/\?cardno=(?P<code>[^"&]+)[^"]*">'
r'.*?<h5>\s*(?P<name>[^<]*?)<span>'
r'.*?<div class="status">\s*(?P<status>.*?)</div>'
r'(?:\s*<p>(?P<text>.*?)</p>)?\s*</div>\s*</a>\s*</li>',
re.S,
)
def clean_text(raw: str | None) -> str | None:
if not raw:
return None
s = re.sub(r"<br\s*/?>", "\n", raw)
s = re.sub(r"<[^>]+>", "", s)
s = html.unescape(s)
s = s.replace("\\<", "<").replace("\\>", ">")
s = re.sub(r"[ \t]+", " ", s).strip()
if s in ("-", "—", ""):
return None
return s
def parse_status(status: str) -> dict:
parts = [p.strip() for p in re.split(r"<span>.*?</span>", status, flags=re.S) if p.strip()]
out = {"type": parts[0] if parts else "", "clan": "", "grade": 0, "power": 0, "shield": 0}
for p in parts[1:]:
if p.startswith("Grade "):
out["grade"] = int(p.split()[1])
elif p.startswith("Power "):
out["power"] = int(p.split()[1].replace(",", ""))
elif p.startswith("Shield "):
v = p.split()[1]
out["shield"] = 0 if v in ("-", "") else int(v.replace(",", ""))
elif p in CLAN_MAP:
out["clan"] = CLAN_MAP[p]
return out
def slug(name: str) -> str:
s = name.lower()
s = s.replace("&", " and ")
s = re.sub(r"[^a-z0-9]+", "-", s)
return s.strip("-")
def parse_cards_md() -> dict[str, dict]:
"""Код -> {trigger, rarity} из таблиц карт-каталога."""
meta: dict[str, dict] = {}
for line in CARDS_MD.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line.startswith("|"):
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if not cells or not re.match(r"^BT01/\d{3}$", cells[0]):
continue
# | № | Карта | G | Клан | Триггер | Редкость |
trig = cells[4].replace("*", "") if len(cells) > 4 else "—"
rar = cells[5].replace("*", "") if len(cells) > 5 else "C"
meta[cells[0]] = {
"trigger": "None" if trig in ("—", "", "-") else trig,
"rarity": rar.replace(" + SP", "").strip(),
"sp": "SP" in rar,
}
for line in CARDS_MD.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line.startswith("|"):
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if not cells or not re.match(r"^TD0[12]/\d{3}$", cells[0]):
continue
# | Код | Кол-во | Карта | G | Триггер |
trig = cells[4].replace("*", "") if len(cells) > 4 else "—"
code = cells[0]
meta[code] = {
"trigger": "None" if trig.startswith("—") else trig.split()[0],
"rarity": "TD",
"sp": False,
}
return meta
def parse_trigger(raw: str) -> str:
raw = raw.replace("*", "").strip()
if raw.startswith("—"):
return "None"
for t in ("Critical", "Draw", "Stand", "Heal"):
if raw.startswith(t):
return t
return "None"
def main() -> None:
meta = parse_cards_md()
print(f"meta: {len(meta)} codes")
all_cards: dict[str, dict] = {}
by_set: dict[str, list[dict]] = {}
for set_id, pages in SOURCES.items():
rows: list[dict] = []
for exp, page in pages:
raw = fetch(exp, page)
found = 0
for m in CARD_RE.finditer(raw):
code = m.group("code").replace("/", "/").strip()
if not code.startswith(set_id):
continue
name = html.unescape(m.group("name")).strip()
st = parse_status(m.group("status"))
m_code = code.split("EN")[0]
info = meta.get(m_code, {})
text = clean_text(m.group("text"))
if text:
# опечатка в официальной БД: у Dragonic Overlord потерян маркер способности
text = text.replace("Own(VC/RC)", "[AUTO](VC/RC)")
card = {
"printingId": code,
"nameId": slug(name),
"nameEn": name,
"type": "TriggerUnit" if st["type"] == "Trigger Unit" else "NormalUnit",
"grade": st["grade"],
"power": st["power"],
"shield": st["shield"],
"critical": 1,
"trigger": parse_trigger(info.get("trigger", "None")),
"clan": st["clan"],
"rarity": info.get("rarity", "C"),
"sentinel": bool(text and "Sentinel" in text),
"restraint": bool(text and "Restraint" in text),
"textEn": text,
}
rows.append(card)
if card["nameId"] not in all_cards:
all_cards[card["nameId"]] = card
found += 1
print(f" {set_id} exp={exp} page={page}: {found}")
by_set[set_id] = rows
# SP-принты уже приходят с сайта как BT01/S01EN..S12EN — отдельно генерировать не нужно.
bt01_regular = {r["nameId"]: r["printingId"] for r in by_set["BT01"] if "/S" not in r["printingId"]}
sp_count = 0
for r in by_set["BT01"]:
if "/S" in r["printingId"]:
r["isParallel"] = True
r["rarity"] = "SP"
r["basePrintingId"] = bt01_regular.get(r["nameId"])
sp_count += 1
print(f" SP: {sp_count}")
OUT.mkdir(parents=True, exist_ok=True)
written: set[str] = set()
for set_id, rows in by_set.items():
# определения карт — только те, что ещё не встречались в предыдущих сетах
defs: dict[str, dict] = {}
printings: list[dict] = []
for r in rows:
defs.setdefault(r["nameId"], r)
defs = {k: v for k, v in defs.items() if k not in written}
written.update(defs)
for r in rows:
entry = {
"printingId": r["printingId"],
"nameId": r["nameId"],
"rarity": r["rarity"],
"imageKey": r["printingId"].replace("/", "_").replace("EN", "").lower(),
}
if r.get("isParallel"):
entry["isParallel"] = True
entry["basePrintingId"] = r.get("basePrintingId")
printings.append(entry)
path = OUT / f"{set_id}.yaml"
with path.open("w", encoding="utf-8", newline="\n") as f:
f.write(f"set: {set_id}\n\ncards:\n")
for d in defs.values():
f.write(f" - nameId: {d['nameId']}\n")
f.write(f" nameEn: {yaml_str(d['nameEn'])}\n")
f.write(f" type: {d['type']}\n")
f.write(f" grade: {d['grade']}\n")
f.write(f" power: {d['power']}\n")
f.write(f" shield: {d['shield']}\n")
f.write(f" critical: {d['critical']}\n")
f.write(f" trigger: {d['trigger']}\n")
f.write(f" clan: {d['clan']}\n")
if d["textEn"]:
f.write(f" textEn: {yaml_str(d['textEn'])}\n")
if d.get("sentinel"):
f.write(" sentinel: true\n")
if d.get("restraint"):
f.write(" restraint: true\n")
f.write("\nprintings:\n")
for p in printings:
f.write(f" - printingId: {p['printingId']}\n")
f.write(f" nameId: {p['nameId']}\n")
f.write(f" rarity: {p['rarity']}\n")
if p.get("imageKey"):
f.write(f" imageKey: {p['imageKey']}\n")
if p.get("isParallel"):
f.write(" isParallel: true\n")
f.write(f" basePrintingId: {p['basePrintingId']}\n")
print(f"wrote {path}: {len(defs)} defs, {len(printings)} printings")
total_printings = sum(len(r) for r in by_set.values())
print(f"TOTAL: {len(all_cards)} cards, {total_printings} printings")
def yaml_str(value: str) -> str:
escaped = (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\t", "\\t")
)
return f'"{escaped}"'
if __name__ == "__main__":
main()