"""Импорт карт 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'
\s*'
r'.*?\s*(?P[^<]*?)'
r'.*?\s*(?P.*?)
'
r'(?:\s*(?P.*?)
)?\s*\s*
\s*',
re.S,
)
def clean_text(raw: str | None) -> str | None:
if not raw:
return None
s = re.sub(r"
", "\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".*?", 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"))
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()