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:
parent
f2ae6b9e4f
commit
0620ec5477
|
|
@ -5,6 +5,10 @@
|
||||||
appsettings.Production.json
|
appsettings.Production.json
|
||||||
secrets.json
|
secrets.json
|
||||||
|
|
||||||
|
# --- Python ---
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
# --- .NET ---
|
# --- .NET ---
|
||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -98,13 +98,22 @@ OK: ошибок 0, предупреждений 0
|
||||||
|
|
||||||
## Контент
|
## Контент
|
||||||
|
|
||||||
Первоисточник — официальный карт-лист `en.cf-vanguard.com`; триггеры и редкости
|
Первоисточник — официальный карт-лист `en.cf-vanguard.com`: и параметры карт,
|
||||||
выгружены из вики. Пересобрать YAML:
|
и арты берутся с одного сайта (`tools/import_official.py`). Пересобрать YAML
|
||||||
|
и докачать недостающие арты:
|
||||||
|
|
||||||
```powershell
|
```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;
|
- у grade 0 щит 5000 **только** у Draw-триггера, иначе 10000;
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| TASK-2026-001 | `[!]` | Плавность карточных перемещений и корректные источники | DeepSeek: анализ; реализация выполнена, ждёт ручной проверки C10 | EditMode 46/46; ручная — пользователь |
|
| TASK-2026-001 | `[!]` | Плавность карточных перемещений и корректные источники | DeepSeek: анализ; реализация выполнена, ждёт ручной проверки C10 | EditMode 46/46; ручная — пользователь |
|
||||||
| TASK-2026-019 | `[ ]` | Загрузочная сцена и кадр без покадровых аллокаций | DeepSeek: план готов; реализация ожидает | не выполнена |
|
| TASK-2026-019 | `[ ]` | Загрузочная сцена и кадр без покадровых аллокаций | DeepSeek: план готов; реализация ожидает | не выполнена |
|
||||||
|
| TASK-2026-020 | `[x]` | Парсер карт и артов с официального сайта | DeepSeek: анализ и реализация | идемпотентность 0 diff; `validate` не запускался |
|
||||||
|
|
||||||
Новые задачи добавляются в конец файла отдельными разделами. Не используй один
|
Новые задачи добавляются в конец файла отдельными разделами. Не используй один
|
||||||
раздел для нескольких независимых задач.
|
раздел для нескольких независимых задач.
|
||||||
|
|
@ -1704,6 +1705,123 @@ HUD с номером хода и фазовой шкалой, компактн
|
||||||
- **Skill обновлён:** —
|
- **Skill обновлён:** —
|
||||||
- **Git:** —
|
- **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:** не коммитилось.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
Loading…
Reference in New Issue