271 lines
9.8 KiB
Python
271 lines
9.8 KiB
Python
"""Импорт карт 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()
|