tools: парсер карт и артов с официального сайта

- tools/import_official.py: единый источник параметров и картинок

- карточка даёт rarity и trigger (поле gift), вики больше не нужна

- идемпотентность: 0 различий с content/sets, 94 определения / 124 принта

- ручные поля (snipeBackRow, boost, nameRu, textRu) сохраняются

- README: команда импорта и режимы; .gitignore: __pycache__

- план: TASK-2026-020
This commit is contained in:
MpakobecEx 2026-10-31 00:10:06 +05:00
parent f2ae6b9e4f
commit 0620ec5477
4 changed files with 619 additions and 3 deletions

4
.gitignore vendored
View File

@ -5,6 +5,10 @@
appsettings.Production.json
secrets.json
# --- Python ---
__pycache__/
*.pyc
# --- .NET ---
bin/
obj/

View File

@ -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;

View File

@ -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`,
регэксп по `<li>`, триггеры/редкости из `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 <tmp> --art-dir <tmp>`.
- Полный прогон в копию `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:** не коммитилось.

485
tools/import_official.py Normal file
View File

@ -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"<br\s*/?>", "\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]*?)</%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())