diff --git a/.gitignore b/.gitignore index b5df926..0fed6f7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ appsettings.Production.json secrets.json +# --- Python --- +__pycache__/ +*.pyc + # --- .NET --- bin/ obj/ diff --git a/README.md b/README.md index 6927bbc..c9faa9d 100644 --- a/README.md +++ b/README.md @@ -98,13 +98,22 @@ OK: ошибок 0, предупреждений 0 ## Контент -Первоисточник — официальный карт-лист `en.cf-vanguard.com`; триггеры и редкости -выгружены из вики. Пересобрать YAML: +Первоисточник — официальный карт-лист `en.cf-vanguard.com`: и параметры карт, +и арты берутся с одного сайта (`tools/import_official.py`). Пересобрать YAML +и докачать недостающие арты: ```powershell -python tools/import_cards.py +py tools/import_official.py --sets BT01,TD01,TD02 ``` +Полезные режимы: `--list-sets` (какие сеты есть и их id), `--no-art`, +`--dry-run`, `--limit N` для пробного прогона. На Windows, где `python` — +заглушка Microsoft Store, используйте `py`. + +`tools/import_cards.py` — прежний вариант (триггеры и редкости брались из вики, +арты — с Fandom); оставлен как справка. Ручные поля из существующих YAML +(`snipeBackRow`, `boost`, `nameRu`, `textRu`) новый импорт сохраняет. + Валидатор проверяет инварианты, которые легко нарушить вручную: - у grade 0 щит 5000 **только** у Draw-триггера, иначе 10000; diff --git a/docs/Development/IMPLEMENTATION_PLAN.md b/docs/Development/IMPLEMENTATION_PLAN.md index 289e763..0e6a6a0 100644 --- a/docs/Development/IMPLEMENTATION_PLAN.md +++ b/docs/Development/IMPLEMENTATION_PLAN.md @@ -40,6 +40,7 @@ |---|---|---|---|---| | TASK-2026-001 | `[!]` | Плавность карточных перемещений и корректные источники | DeepSeek: анализ; реализация выполнена, ждёт ручной проверки C10 | EditMode 46/46; ручная — пользователь | | TASK-2026-019 | `[ ]` | Загрузочная сцена и кадр без покадровых аллокаций | DeepSeek: план готов; реализация ожидает | не выполнена | +| TASK-2026-020 | `[x]` | Парсер карт и артов с официального сайта | DeepSeek: анализ и реализация | идемпотентность 0 diff; `validate` не запускался | Новые задачи добавляются в конец файла отдельными разделами. Не используй один раздел для нескольких независимых задач. @@ -1704,6 +1705,123 @@ HUD с номером хода и фазовой шкалой, компактн - **Skill обновлён:** — - **Git:** — +--- + +### TASK-2026-020 — парсер карт и артов с официального сайта + +- **Статус:** `[x]` +- **Создано:** 2026-10-30 +- **Анализ:** DeepSeek +- **Реализация:** DeepSeek +- **Проверка:** побайтовое совпадение с текущими YAML (0 diff), 94/124, скачивание арта +- **Затронутые системы:** `tools/import_official.py` (новый), `README.md`; + `content/sets/*.yaml` и `CardArt` не изменялись. + +#### A. Цель и требования + +Один инструмент, который берёт с `en.cf-vanguard.com` и параметры карт, и +картинки, и пишет тот же YAML-формат, что и текущий импорт. Причины: + +- сейчас параметры идут с сайта (`view=text`), а триггеры и редкости — из + `content/cards-mvp.md` (выгрузка из вики), арты — с Fandom; +- официальная карточка `/cardlist/?cardno=…` содержит и `rarity`, и `gift` + (тип триггера), поэтому вики больше не нужна; +- картинки официального сайта лежат рядом с данными: + `/wordpress/wp-content/images/cardlist/{set}/{CODE}.png`. + +Ограничения: только стандартная библиотека Python; не менять схему YAML и +`CardEntry`/`PrintingEntry`; не терять ручные поля; не создавать в `Resources` +два ассета с одним именем. + +#### B. Текущее состояние и что выяснено + +- `tools/import_cards.py`: список `/cardlist/cardsearch/?expansion=N&view=text`, + регэксп по `
  • `, триггеры/редкости из `cards-mvp.md`, запись + `set/cards/printings`, SP-принты `BT01/S01EN…S12EN` помечаются + `isParallel` + `basePrintingId`. +- `tools/import_card_art.py`: Fandom-галереи, коды только `TD\d{2}`. +- Сайт: `expansion=5` → BT01 (92 принта, 4 страницы), `3` → TD01 (16), + `4` → TD02 (16); всего 94 определения и 124 принта — совпадает с README. +- Карточка содержит `type`, `group` (клан), `race`, `nation`, `grade`, + `power`, `critical`, `shield`, `skill`, `gift` (например + `Critical Trigger +5000`), `effect`, `flavor`, `rarity`, `number`. +- Список сетов с id — `/cardlist/?change_ex` (47 сетов, `BT01=5`, `TD01=3`, + `TD02=4`), можно не хардкодить id. +- Сайт отдаёт 404 на запросы без браузерного `User-Agent` — нужен UA. + +#### C. Чек-лист изменений + +- [x] **C1. `CREATE` `tools/import_official.py`** — единый парсер. + - `discover_sets()` по `/cardlist/?change_ex`; `--list-sets`. + - `list_codes()` со всех страниц (`max_page` из HTML) и сверка с «N Results». + - `parse_card()` по карточке: тип, клан, grade/power/critical/shield, + триггер из `gift`, редкость, текст, флаги `sentinel`/`restraint`. + - `write_set()` — тот же формат и порядок полей, что у старого импорта. + - Реализация: выполнена. Проверка: см. C5. +- [x] **C2. `CREATE` скачивание артов** — `{imageKey}.png` в `CardArt`. + - Пропуск, если файл с любым расширением уже есть (иначе Unity увидит два + ассета с одним именем); `--force-art` удаляет старый файл и его `.meta`. + - Реализация: выполнена. Проверка: TD01, 16/16 файлов, ~350–430 КиБ. +- [x] **C3. `MODIFY` дедупликация определений между сетами.** + - При прогоне одного сета читаются `nameId` из остальных YAML папки, чтобы + не дублировать карты, определённые в BT01. + - Реализация: выполнена. Проверка: BT01 80 + TD01 7 + TD02 7 = 94. +- [x] **C4. `MODIFY` сохранение ручных полей.** + - `nameRu`, `textRu`, `snipeBackRow`, `boost` переносятся из существующего + файла по `nameId` (на сайте их нет; иначе терялся бы Tejas). + - Реализация: выполнена. Проверка: `snipeBackRow` у Tejas сохраняется. +- [x] **C5. `VERIFY` идемпотентность.** + - Прогон `--sets BT01,TD01,TD02` в копию `content/sets`: `Compare-Object` + по каждому файлу — **0 строк различий**, размеры совпали + (37116 / 3771 / 3989 байт). + - Реализация: не применима. Проверка: выполнена. +- [x] **C6. `MODIFY` `README.md`** — команда нового импорта, режимы, + предупреждение про заглушку `python` в Microsoft Store (`py`). + - Реализация: выполнена. Проверка: прочитано. + +#### D. Последовательность выполнения + +1. C1: разбор списка и карточки, запись YAML. +2. C2: арты и защита от дублей в `Resources`. +3. C3–C4: дедупликация и ручные поля. +4. C5: прогон в копию и сверка. +5. C6: README. + +#### E. Проверки + +- `py tools/import_official.py --list-sets`. +- `py tools/import_official.py --sets TD01 --limit 4 --out --art-dir `. +- Полный прогон в копию `content/sets` + `Compare-Object` с репозиторием. +- `dotnet run --project server/src/Vanguard.Cli -- validate` — **не запускался** + (нужен `ssh home`/Docker); косвенно покрыт побайтовым совпадением YAML. +- Скачивание арта проверено на TD01 (16 файлов) и на `--force-art`. + +#### F. Риски и открытые вопросы + +- Разметка сайта нестабильна: любой редизайн ломает регэкспы. Симптом — + «не найдено имя карты» или расхождение с «N Results». +- Сайт отвечает 404 без браузерного `User-Agent` — UA обязателен, `curl` + без заголовка вводит в заблуждение. +- Полный прогон — ~2–6 минут на 124 карточки (по одному запросу на карту, + пауза `--delay`). Для нескольких сетов сразу это нормально, для правки + одной карты — избыточно. +- Официальные арты — PNG ~350–430 КиБ; текущие в репозитории — JPG. Массовая + замена не делалась: `--force-art` меняет расширение и удаляет `.meta`. +- `python` в Microsoft Store — заглушка, нужен `py` (отражено в README). +- Открытый вопрос: переносить ли `skill` (`Boost`/`Twin Drive!!`) в поле + `boost` — сейчас не переносится, чтобы не менять игровые данные. + +#### G. Результат + +- **Реализовано:** C1–C6. +- **Проверки:** полный прогон в копию — 0 различий с `content/sets`; + 94 определения / 124 принта; арт TD01 16/16; `--force-art` заменяет + `.jpg` на `.png` без дублей. +- **Не завершено:** `validate` на `ssh home`; массовая замена артов на + официальные PNG (решение пользователя). +- **Skill обновлён:** не требуется. +- **Git:** не коммитилось. + diff --git a/tools/import_official.py b/tools/import_official.py new file mode 100644 index 0000000..522c2ae --- /dev/null +++ b/tools/import_official.py @@ -0,0 +1,485 @@ +#!/usr/bin/env python3 +"""Импорт карт и артов с официального сайта en.cf-vanguard.com. + +Один источник и для параметров, и для картинок: + +* список сетов: /cardlist/?change_ex -> код сета -> id раскрытия; +* список карт: /cardlist/cardsearch/?expansion=N&view=text&sort=no&page=P; +* карточка: /cardlist/?cardno=BT01/001EN&expansion=5; +* картинка: /wordpress/wp-content/images/cardlist/bt01/BT01_001EN.png. + +Пишет тот же формат, что `tools/import_cards.py`: ``content/sets/{SET}.yaml`` +(``set`` / ``cards`` / ``printings``), и складывает арты в ``CardArt`` как +``{imageKey}.png``, не трогая уже существующие файлы. + +Примеры: + + python tools/import_official.py --list-sets + python tools/import_official.py --sets BT01,TD01,TD02 + python tools/import_official.py --sets BT01 --no-art --dry-run + python tools/import_official.py --sets TD01 --out .tmp/sets --art-dir .tmp/art + +Зависимостей нет — только стандартная библиотека. +""" + +from __future__ import annotations + +import argparse +import html as html_mod +import re +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BASE = "https://en.cf-vanguard.com" +UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126 Safari/537.36" + +DEFAULT_SETS = "BT01,TD01,TD02" + +# Триггеры базовой эры; порядок важен для разбора поля gift. +TRIGGERS = ("Critical", "Draw", "Stand", "Heal") + +# Выгрузка Fandom и старая версия скрипта использовали эти же имена. +# Официальный сайт иногда теряет маркер AUTO у Dragonic Overlord. +TEXT_FIXES = {"Own(VC/RC)": "[AUTO](VC/RC)"} + + +# ---------------------------------------------------------------- сеть + + +def fetch(url: str, retries: int = 3, timeout: int = 60) -> str: + last: Exception | None = None + for attempt in range(retries): + try: + request = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read().decode("utf-8", errors="replace") + except (urllib.error.URLError, TimeoutError) as exc: # noqa: PERF203 + last = exc + time.sleep(1.5 * (attempt + 1)) + raise RuntimeError(f"не удалось загрузить {url}: {last}") + + +def fetch_bytes(url: str, retries: int = 3, timeout: int = 90) -> bytes: + last: Exception | None = None + for attempt in range(retries): + try: + request = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read() + except (urllib.error.URLError, TimeoutError) as exc: # noqa: PERF203 + last = exc + time.sleep(1.5 * (attempt + 1)) + raise RuntimeError(f"не удалось скачать {url}: {last}") + + +def discover_sets() -> dict[str, int]: + """Код сета -> id раскрытия, по странице выбора продуктов.""" + page = fetch(BASE + "/cardlist/?change_ex") + pattern = re.compile(r'expansion=(\d+)"[\s\S]{0,300}?alt="\[([A-Z0-9]+)\]') + result: dict[str, int] = {} + for match in pattern.finditer(page): + code = match.group(2) + if code not in result: + result[code] = int(match.group(1)) + return result + + +def max_page(page: str) -> int: + match = re.search(r"max_page = (\d+)", page) + return int(match.group(1)) if match else 1 + + +def results_count(page: str) -> int | None: + match = re.search(r"([\d,]+)\s+Results", page) + return int(match.group(1).replace(",", "")) if match else None + + +def list_codes(expansion: int) -> list[str]: + """Все коды принтов сета в порядке выдачи сайта.""" + first = fetch(f"{BASE}/cardlist/cardsearch/?expansion={expansion}&view=text&sort=no&page=1") + pages = max_page(first) + codes: list[str] = [] + seen: set[str] = set() + + def collect(page: str) -> None: + for code in re.findall(r'cardno=([A-Z0-9]+/[A-Z0-9]+)(?:&|")', page): + if code not in seen: + seen.add(code) + codes.append(code) + + collect(first) + for page_no in range(2, pages + 1): + collect(fetch( + f"{BASE}/cardlist/cardsearch/?expansion={expansion}" + f"&view=text&sort=no&page={page_no}" + )) + + expected = results_count(first) + if expected is not None and expected != len(codes): + print( + f" ! ожидалось {expected} карт, собрано {len(codes)}", + file=sys.stderr, + ) + return codes + + +# ---------------------------------------------------------------- разбор + + +def strip_tags(raw: str) -> str: + value = re.sub(r"", "\n", raw) + value = re.sub(r"<[^>]+>", "", value) + value = html_mod.unescape(value) + value = value.replace("\\<", "<").replace("\\>", ">") + value = re.sub(r"[ \t]+", " ", value) + return value.strip() + + +def field(page: str, css_class: str, tag: str = "div") -> str | None: + match = re.search( + r'<%s class="%s">([\s\S]*?)' % (tag, re.escape(css_class), tag), page + ) + if not match: + return None + value = strip_tags(match.group(1)) + return None if value in ("-", "—", "") else value + + +def number_or(value: str | None, default: int = 0) -> int: + if not value: + return default + digits = re.sub(r"[^\d]", "", value) + return int(digits) if digits else default + + +def parse_trigger(gift: str | None) -> str: + if not gift: + return "None" + for trigger in TRIGGERS: + if gift.startswith(trigger): + return trigger + return "None" + + +def slug(name: str) -> str: + value = name.lower().replace("&", " and ") + value = re.sub(r"[^a-z0-9]+", "-", value) + return value.strip("-") + + +def parse_card(page: str) -> dict: + """Параметры карты из страницы /cardlist/?cardno=...""" + name = field(page, "face", tag="span") + if not name: + raise ValueError("не найдено имя карты") + + kind = field(page, "type") or "" + clan = (field(page, "group") or "").replace(" ", "") + text = field(page, "effect") + if text: + for source, target in TEXT_FIXES.items(): + text = text.replace(source, target) + + return { + "name": name, + "type": "TriggerUnit" if kind.lower().startswith("trigger") else "NormalUnit", + "clan": clan, + "grade": number_or(field(page, "grade")), + "power": number_or(field(page, "power")), + "critical": number_or(field(page, "critical"), 1), + "shield": number_or(field(page, "shield")), + "trigger": parse_trigger(field(page, "gift")), + "rarity": field(page, "rarity") or "", + "race": field(page, "race") or "", + "nation": field(page, "nation") or "", + "skill": field(page, "skill") or "", + "flavor": field(page, "flavor"), + "text": text, + } + + +def parse_detail(expansion: int, code: str) -> dict: + page = fetch(f"{BASE}/cardlist/?cardno={code}&expansion={expansion}") + card = parse_card(page) + number = field(page, "number") or code + if number != code: + print(f" ! {code}: сайт вернул номер {number}", file=sys.stderr) + card["code"] = number + return card + + +# ---------------------------------------------------------------- арт + + +def art_url(code: str) -> str: + set_code, rest = code.split("/", 1) + return ( + f"{BASE}/wordpress/wp-content/images/cardlist/" + f"{set_code.lower()}/{set_code}_{rest}.png" + ) + + +def image_key(printing_id: str) -> str: + return printing_id.replace("/", "_").replace("EN", "").lower() + + +def existing_art(art_dir: Path, key: str) -> Path | None: + for extension in (".png", ".jpg", ".jpeg"): + candidate = art_dir / (key + extension) + if candidate.exists(): + return candidate + return None + + +def download_art(code: str, art_dir: Path, force: bool = False) -> str: + key = image_key(code) + existing = existing_art(art_dir, key) + if existing is not None and not force: + return "skip" + + # Сначала качаем: при сетевой ошибке старый арт остаётся на месте. + data = fetch_bytes(art_url(code)) + art_dir.mkdir(parents=True, exist_ok=True) + target = art_dir / f"{key}.png" + + # --force-art: убираем старый файл другого расширения и его .meta, иначе + # в Resources окажется два ассета с одним именем (Unity считает это ошибкой). + if existing is not None and existing != target and existing.exists(): + Path(str(existing) + ".meta").unlink(missing_ok=True) + existing.unlink() + + target.write_bytes(data) + return f"{len(data) // 1024} KiB" + + +# ---------------------------------------------------------------- YAML + + +def yaml_str(value: str) -> str: + escaped = ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\t", "\\t") + ) + return f'"{escaped}"' + + +# Поля, которых нет на сайте: их нельзя терять при повторном импорте. +MANUAL_FIELDS = ("nameRu", "textRu", "snipeBackRow", "boost") + + +def read_manual(path: Path) -> dict[str, dict[str, str]]: + """Ручные поля из уже существующего YAML сета, по nameId.""" + result: dict[str, dict[str, str]] = {} + if not path.exists(): + return result + current: str | None = None + for raw in path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = raw.strip() + if line.startswith("- nameId:"): + current = line.split(":", 1)[1].strip() + result[current] = {} + continue + if line == "printings:": + current = None + continue + if current and ":" in line: + key, value = line.split(":", 1) + key = key.strip() + if key in MANUAL_FIELDS: + result[current][key] = value.strip() + return result + + +def existing_name_ids(out_dir: Path, exclude: set[str]) -> set[str]: + """nameId, уже определённые в других YAML-сетах этой папки.""" + names: set[str] = set() + if not out_dir.exists(): + return names + for path in out_dir.glob("*.yaml"): + if path.stem in exclude: + continue + text = path.read_text(encoding="utf-8", errors="replace") + names.update(re.findall(r"^\s*-\s*nameId:\s*(\S+)", text, re.M)) + return names + + +def write_set( + path: Path, + set_id: str, + cards: list[dict], + printings: list[dict], + manual: dict[str, dict[str, str]] | None = None, +) -> None: + manual = manual or {} + with path.open("w", encoding="utf-8", newline="\n") as handle: + handle.write(f"set: {set_id}\n\ncards:\n") + for card in cards: + handle.write(f" - nameId: {card['nameId']}\n") + handle.write(f" nameEn: {yaml_str(card['nameEn'])}\n") + handle.write(f" type: {card['type']}\n") + handle.write(f" grade: {card['grade']}\n") + handle.write(f" power: {card['power']}\n") + handle.write(f" shield: {card['shield']}\n") + handle.write(f" critical: {card['critical']}\n") + handle.write(f" trigger: {card['trigger']}\n") + handle.write(f" clan: {card['clan']}\n") + extra = manual.get(card["nameId"], {}) + for key in MANUAL_FIELDS: + if key in extra: + handle.write(f" {key}: {extra[key]}\n") + if card["textEn"]: + handle.write(f" textEn: {yaml_str(card['textEn'])}\n") + if card["sentinel"]: + handle.write(" sentinel: true\n") + if card["restraint"]: + handle.write(" restraint: true\n") + handle.write("\nprintings:\n") + for printing in printings: + handle.write(f" - printingId: {printing['printingId']}\n") + handle.write(f" nameId: {printing['nameId']}\n") + handle.write(f" rarity: {printing['rarity']}\n") + if printing.get("imageKey"): + handle.write(f" imageKey: {printing['imageKey']}\n") + if printing.get("isParallel"): + handle.write(" isParallel: true\n") + handle.write(f" basePrintingId: {printing['basePrintingId']}\n") + + +# ---------------------------------------------------------------- сборка + + +def build_set(set_id: str, expansion: int, skip_defs: set[str], args) -> tuple[list[dict], list[dict]]: + codes = list_codes(expansion) + if args.limit: + codes = codes[: args.limit] + print(f"{set_id} (expansion={expansion}): {len(codes)} принтов") + + rows: list[dict] = [] + for index, code in enumerate(codes, 1): + card = parse_detail(expansion, code) + card["code"] = code + card["nameId"] = slug(card["name"]) + card["nameEn"] = card["name"] + card["textEn"] = card["text"] + card["sentinel"] = bool(card["text"] and "Sentinel" in card["text"]) + card["restraint"] = bool(card["text"] and "Restraint" in card["text"]) + card["imageKey"] = image_key(code) + rows.append(card) + print(f" [{index}/{len(codes)}] {code} {card['name']} " + f"G{card['grade']} {card['trigger']} {card['rarity']}") + if args.delay: + time.sleep(args.delay) + + # арт + if not args.no_art: + downloaded = 0 + for card in rows: + try: + status = download_art(card["code"], args.art_dir, args.force_art) + except Exception as exc: # noqa: BLE001 + print(f" ! арт {card['code']}: {exc}", file=sys.stderr) + continue + if status != "skip": + downloaded += 1 + print(f" art {card['code']} -> {card['imageKey']}.png ({status})") + print(f" арт: скачано {downloaded}, уже было {len(rows) - downloaded}") + + # определения без повторов между сетами + definitions: dict[str, dict] = {} + for card in rows: + definitions.setdefault(card["nameId"], card) + definitions = {k: v for k, v in definitions.items() if k not in skip_defs} + skip_defs.update(definitions) + + # параллельные принты (BT01/S01EN и т.п.) + base_by_name = { + row["nameId"]: row["code"] for row in rows if "/S" not in row["code"] + } + printings: list[dict] = [] + for row in rows: + entry = { + "printingId": row["code"], + "nameId": row["nameId"], + "rarity": row["rarity"] or "C", + "imageKey": row["imageKey"], + } + if "/S" in row["code"]: + entry["isParallel"] = True + entry["basePrintingId"] = base_by_name.get(row["nameId"]) + entry["rarity"] = "SP" + printings.append(entry) + + for card in definitions.values(): + if card["clan"] and not re.fullmatch(r"[A-Za-z]+", card["clan"]): + print(f" ! клан без маппинга: {card['clan']}", file=sys.stderr) + + return list(definitions.values()), printings + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sets", default=DEFAULT_SETS, help="коды сетов через запятую") + parser.add_argument("--out", default=str(ROOT / "content" / "sets"), help="папка YAML") + parser.add_argument( + "--art-dir", + default=str(ROOT / "client" / "Assets" / "Vanguard" / "Resources" / "CardArt"), + help="папка артов", + ) + parser.add_argument("--no-art", action="store_true", help="не скачивать арты") + parser.add_argument("--force-art", action="store_true", help="перезаписать арты") + parser.add_argument("--dry-run", action="store_true", help="ничего не писать") + parser.add_argument("--list-sets", action="store_true", help="показать сеты и выйти") + parser.add_argument("--delay", type=float, default=0.25, help="пауза между запросами") + parser.add_argument("--limit", type=int, default=0, help="только первые N карт сета") + args = parser.parse_args() + + args.out = Path(args.out) + args.art_dir = Path(args.art_dir) + + mapping = discover_sets() + if args.list_sets: + for code, expansion in sorted(mapping.items(), key=lambda item: item[1], reverse=True): + print(f"{code} = {expansion}") + return 0 + + sets = [value.strip().upper() for value in args.sets.split(",") if value.strip()] + unknown = [value for value in sets if value not in mapping] + if unknown: + print(f"неизвестные сеты: {', '.join(unknown)}", file=sys.stderr) + print("доступные: " + ", ".join(sorted(mapping)), file=sys.stderr) + return 2 + + if not args.dry_run: + args.out.mkdir(parents=True, exist_ok=True) + + skip_defs: set[str] = set() + if not args.dry_run: + # при запуске только одного сета не дублировать определения из других файлов + skip_defs.update(existing_name_ids(args.out, set(sets))) + total_cards = 0 + total_printings = 0 + + for set_id in sets: + path = args.out / f"{set_id}.yaml" + manual = read_manual(path) + cards, printings = build_set(set_id, mapping[set_id], skip_defs, args) + total_cards += len(cards) + total_printings += len(printings) + if args.dry_run: + print(f" dry-run: {path} ({len(cards)} определений, {len(printings)} принтов)") + continue + write_set(path, set_id, cards, printings, manual) + print(f" записано {path}: {len(cards)} определений, {len(printings)} принтов") + + print(f"ИТОГО: {total_cards} определений, {total_printings} принтов") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())