"""Импорт карт 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()