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]*?)%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())