🔄 Синхронизация — обзор и архитектура
Внутреннее
Эта страница описывает систему, которая работает с обратно разработанными (reverse-engineered) приватными API сторонних сайтов (
dicechess.com,beturanga.com). Репозиторииdicechess-sync,dicechess-observer,dicechess-raw-archive,beturanga-observer— приватные и таковыми остаются. Публичные репозитории (dicechess-analytics,dicechess-engine-scala) не должны содержать ни единой строки про специфику этих сайтов: их граница — нейтральный аутентифицированный контрактPOST /api/games. Не выноси отсюда токены, JWT и connection-string наружу.
Это точка входа во всю подсистему добычи партий и информации об игроках. Здесь — карта: какие бывают режимы добычи, как данные текут от игрового сайта до аналитической базы, кто и куда пишет. Глубокая механика вынесена в соседние страницы — отсюда на них ссылки.
1. Зачем это всё
Аналитике нужны партии: позиция до броска, бросок, позиция после хода, результат, игроки, рейтинги, тайм-контроль. Источников несколько (два живых сайта плюс наши собственные боты), а формат у каждого — свой, низкоуровневый и «грязный». Задача синхронизации:
- обнаружить партии (кто играл, какие партии существуют);
- скачать их сырьё (canonical move-history / game-state);
- сохранить сырьё навечно (bronze-слой) — чтобы можно было пересчитать без повторного скрейпа;
- нормализовать в канонический контракт и залить в аналитику, где движок проверяет легальность.
Всё это происходит против сайтов, защищённых Cloudflare и собственными rate-limit’ами, поэтому половина инженерной работы — не «как скачать», а «как скачать вежливо и устойчиво, не получив бан».
2. Два режима добычи
Партии добываются двумя принципиально разными способами. Они дополняют друг друга, делят транспорт (curl) и сходятся в одной точке (POST /api/games), но устроены противоположно.
🛰️ LIVE-наблюдатель (dicechess-observer) | 🕸️ BACKFILL-краулер (dicechess-sync) | |
|---|---|---|
| Природа | Реактивный: смотрит на идущие партии | Проактивный: обходит историю |
| Что обнаруживает | POST /api/games/active (poll & diff) | посев SEED_IDS + спайдер по оппонентам (player/history) |
| Триггер ingest | Партия исчезла из списка активных = завершилась | Партия найдена в истории игрока |
| Состояние | In-memory (+ локальный spool при недоступном архиве) | Долговечное в своей SQLite (./data/sync.db) |
| Резюмируемость | Нет (живёт «сейчас»; пропустил — потеряно) | Да: весь прогресс в SQLite, контейнер рестартится свободно |
| Покрытие | Только то, что играется прямо сейчас | Вся история, до которой дотянется граф игроков |
| Темп | Партий мало/мин — лимиты не жмут | Десятки тысяч фетчей — лимиты определяют дизайн |
| Где крутится | rpi4 (residential IP) | rpi4 / контейнер crawl-service |
| Панель | Web-панель (:8040): Start/Stop, dry-run, статистика | CLI + демон, логи; состояние читается из SQLite |
Подробности: Live-наблюдатель (dicechess-observer) и 09 Pipeline - dicechess-sync. Граф игроков и перечисление истории — 04 Граф игроков и перечисление; конвейер одной партии — 05 Конвейер партий и состояния.
Почему именно так
Live-наблюдатель ловит «свежак» дёшево (партия и так на виду), но ничего не знает о прошлом. Backfill вычерпывает прошлое, но это дорого и опасно по лимитам. Вместе они дают и полноту истории, и низкую задержку на новых партиях.
3. Медальонная архитектура: сайт → bronze → gold
Данные движутся слоями (medallion: bronze → gold). Между сырым сайтом и аналитикой стоит bronze-слой — неизменяемый архив сырья.
flowchart LR SITE["🌐 Игровой сайт<br/>(dicechess.com / beturanga.com)<br/>грязный низкоуровневый JSON"] BRONZE["🧱 BRONZE — raw-архив<br/>dexus :5433<br/>verbatim JSON, immutable<br/>1 строка на (source, external_id)"] GOLD["📊 GOLD — аналитика<br/>aurora :8020 → PostgreSQL<br/>games / turns / positions / game_events<br/>нормализовано, проверено движком"] SITE -->|"curl + JWT"| BRONZE SITE -->|"normalize → POST /api/games"| GOLD BRONZE -.->|"converter: replay через движок<br/>PUT /api/games (replace)"| GOLD
Bronze (dicechess-raw-archive, PostgreSQL на dexus 192.168.10.4:5433) хранит verbatim то, что писатель скачал с сайта, до нормализации. Payload — gzip JSON-конверта (history_meta, move_history, для observer ещё discovery_meta; для beturanga — единый game_state). Одна строка на (source, external_id), append-only, INSERT … ON CONFLICT (source, external_id) DO NOTHING (first-writer-wins). Промотированы в индексируемые колонки: source, external_id, white_user_id, black_user_id, started_at, fetched_at, meta_fetched_at. Масштаб крошечный: ~2.4 КБ/партия gzipped → 10M партий ≈ 24 ГБ.
Gold (dicechess-analytics, PostgreSQL на aurora 192.168.10.3:8020) — нормализованные, дедуплицированные, проверенные движком партии в таблицах games / turns / positions / game_events. Это публичная схема аналитики.
Зачем нужен bronze — пересчёт без перескрейпа
Правила нормализации, нормализация FEN и проверки движком дорабатываются. Если бы мы хранили только gold, любая правка парсера требовала бы заново обойти rate-limited, Cloudflare-защищённый сайт — десятки тысяч запросов и недели вежливого краулинга. С bronze мы переконвертируем всю историю локально из сырья и перезаливаем через
PUT /api/games/{id}(replace), ни разу не обратившись к сайту. Bronze — это формализация (durable promotion) кэшаraw_game_data, которыйdicechess-syncи так держит у себя локально. Подробнее: Raw-архив — bronze-слой.
4. Большая картина: кто куда пишет
graph TD subgraph SITES["🌐 Источники"] DC["dicechess.com<br/>REST + Cloudflare"] BT["beturanga.com<br/>Socket.IO"] end CURL["🔌 curl-транспорт<br/>OpenSSL ≥ 3.5, Bearer JWT<br/>(обходит TLS-фингерпринт CF)"] DC --> CURL subgraph WRITERS["✍️ Четыре писателя"] OBS["🛰️ dicechess-observer<br/>live, in-memory"] SYNC["🕸️ dicechess-sync<br/>backfill, SQLite"] EXT["🧩 dicechess-extension<br/>подмена игрока на bot:<algorithm>"] BTOBS["♟️ beturanga-observer<br/>Socket.IO, UUIDv5"] end CURL --> OBS CURL --> SYNC BT --> BTOBS EXT -.->|браузер игрока| DC subgraph BRONZE["🧱 BRONZE (dexus :5433)"] RAW["raw_games<br/>verbatim, immutable<br/>first-writer-wins"] end OBS --> RAW SYNC --> RAW BTOBS --> RAW subgraph GOLD["📊 GOLD (aurora :8020)"] ING["POST /api/games<br/>нейтральный контракт<br/>проверка движком, дедуп"] PG[("PostgreSQL<br/>games / turns /<br/>positions / game_events")] ING --> PG end OBS -->|normalize → POST| ING SYNC -->|normalize → POST| ING EXT -->|POST| ING BTOBS -->|normalize → POST| ING CONV["♻️ converter<br/>replay после фикса движка"] RAW -.-> CONV CONV -.->|PUT /api/games| ING
Четыре писателя в POST /api/games
Все четыре источника сходятся в одном нейтральном контракте аналитики. Сама аналитика ничего не знает про dicechess.com/beturanga.com — она принимает «завершённую, проверенную партию от доверенного писателя».
- 🛰️
dicechess-observer— живые партии dicechess.com; постит каждую, как только она исчезла из активных. - 🕸️
dicechess-sync— бэкфилл всей истории dicechess.com; главный герой этой подсистемы. - 🧩
dicechess-extension— браузерное расширение, играющее нашим ботом против сайт-движка и постящее партию с подменой игрока наbot:<algorithm>(чтобы потом мерить силу алгоритма). Это знание живёт только в расширении — из сырья его не восстановить. - ♟️
beturanga-observer— второй сайт (Socket.IO); нормализуетgame-stateи постит так же.
Тонкости идентичности (external_id / source / player_type, нативный отрицательный id ботов против нашего bot:<algorithm>, гонка first-writer-wins, дедуп V6) — отдельная страница 08 Идентичность, источники и дедупликация.
first-writer-wins
Один и тот же
gameId(UUID) могут постить observer, sync и extension. Кто первый — тот и определяет строку (включая идентичности игроков); повторныеPOSTполучают200и не перезаписывают. Это и есть гонка observer ↔ extension вокругbot:<algorithm>против хост-аккаунта. То же на стороне bronze:ON CONFLICT DO NOTHING.
5. Граф игроков — как backfill находит партии
У dicechess.com нет «дай все партии». Единственный способ обнаружить партию — спросить историю конкретного игрока (POST /api/player/history). А вход в множество игроков сейчас задаётся вручную — список сидов в SEED_IDS (помечаются discovered_via='manual'). Дальше — спайдер по графу: каждая партия из истории игрока называет его оппонента, оппонент кладётся во фронтир, его история вскрывает новых оппонентов, и так обход в ширину (BFS) расходится по всему живому ядру сайта.
Лидерборды — задуманный, но ещё не реализованный источник сидов
По дизайну сиды должны приходить из лидербордов (
/api/leaderboard,/api/x2Leaderboard, топ ~200 на рейтинг) — под них даже зарезервированы значенияdiscovered_via. Но автоматический фетч лидербордов пока не реализован: на сегодня сиды кладутся руками черезSEED_IDS. Спайдер по оппонентам — реальный и работает независимо от способа посева.
graph LR LB["🌱 Сиды<br/>(SEED_IDS, вручную)"] --> P1["Игрок A<br/>(pending)"] P1 -->|"player/history"| G["партии A"] G --> OPP["оппоненты A<br/>(во фронтир)"] OPP --> P2["Игрок B"] OPP --> P3["Игрок C"] P2 -->|"BFS, по priority"| MORE["…"]
Фронтир хранится в таблице players; игроки берутся по убыванию priority (priority = максимум известного рейтинга), так что сильнейшие обходятся первыми. Боты (отрицательный user_id) и Guest… во фронтир-источники не идут — паркуются в skipped. Полная механика — приоритеты, полный против инкрементального свипа, курсор synced_through_ms, резюм по enumerate_offset — на странице 04 Граф игроков и перечисление.
6. Две машины состояний (кратко)
Backfill ведёт два независимых состояния: по игроку (перечислять ли его историю) и по партии (на какой стадии конвейера она). Оба живут в SQLite dicechess-sync и потому переживают рестарт.
6.1. Игрок (players.sync_status)
stateDiagram-v2 [*] --> pending : seed / найден как оппонент pending --> in_progress : claimNext in_progress --> synced : enumerate завершён in_progress --> error : transport-сбой error --> in_progress : по next_retry_at pending --> skipped : бот / Guest synced --> pending : requeueStale (инкрем. рефреш) synced --> [*]
pending → in_progress → synced; плюс error (с backoff next_retry_at) и skipped (бот/гость). Синхронизированного можно вернуть в pending для инкрементального рефреша (requeueStale). Детали — 04 Граф игроков и перечисление.
6.2. Партия (games.status)
stateDiagram-v2 [*] --> discovered : найдена в истории игрока discovered --> fetched : GET game-move-history (raw закэширован) fetched --> posted : POST 201/200 fetched --> rejected : POST 422 (движок отверг) discovered --> error : сбой fetch (timer-retry) fetched --> error : сбой post (timer-retry) rejected --> fetched : replayRejected (после бампа движка) discovered --> skipped : isCancelled posted --> [*]
Счастливый путь — discovered → fetched → posted. Сырьё кэшируется на стадии fetched, поэтому повторный post бесплатен (без сайта). Особые состояния: rejected (движок вернул 422 — терминально до правки движка, потом массовый replayRejected), error с error_stage ∈ fetch | normalize | post, skipped (отменённая партия). Полная механика стадий fetch → normalize → post → archive и идемпотентность — 05 Конвейер партий и состояния.
7. Сквозной путь одной партии
От «партия идёт на сайте» до «строка в gold-аналитике» — end-to-end (на примере backfill через dicechess-sync).
sequenceDiagram autonumber participant S as 🌐 dicechess.com participant L as ⏱️ RateLimiter<br/>(single-flight) participant C as 🕸️ sync (curl + SQLite) participant R as 🧱 bronze (dexus) participant E as ♟️ движок (внутри analytics) participant G as 📊 gold (aurora PG) Note over C: 1. ПЕРЕЧИСЛЕНИЕ C->>L: schedule(player/history) L->>S: POST /api/player/history (page) S-->>C: gameHistoryList + totalCount Note over C: каждую партию → games(discovered)<br/>оппонента → players(pending) Note over C: 2. СКАЧИВАНИЕ C->>L: schedule(game-move-history) L->>S: GET /api/game-move-history?gameId=… S-->>C: gameMoveHistoryStateMap (raw) C->>C: raw_game_data ← verbatim JSON · games → fetched Note over C: 3. АРХИВ (bronze) C->>R: archive.put(bundle) (ON CONFLICT DO NOTHING) R-->>C: inserted | exists Note over C: 4. НОРМАЛИЗАЦИЯ + ЗАЛИВКА C->>C: normalizeStateMap + assembleGameIngest C->>G: POST /api/games (LAN fetch, без CF) G->>E: replay всех ходов (валидация легальности) alt легальна E-->>G: ok → dedup позиций → INSERT G-->>C: 201 created / 200 exists → games → posted else невалидна E-->>G: reject G-->>C: 422 → games → rejected (replay после бампа движка) end
Ключевые моменты этого пути:
- Транспорт раздвоен. Запросы к сайту идут через
curl-subprocess (Cloudflare фингерпринтит TLS — Nodefetch/undici ловит403). Запросы к аналитике — обычныйfetch(LAN, без Cloudflare). - Один RateLimiter на весь процесс: single-flight (не больше одного запроса к сайту одновременно), spacing + jitter, экспоненциальный backoff с retry на throttle. Подробнее — 06 Защита от блокировок и Cloudflare.
- Результат партии берётся из
player/history(POV игрока → переводим в POV белых), а не выводится из доски: нормализация может оставитьresult=null(например, сдача), и метаданные истории это заполняют. - Движок — арбитр легальности.
422означает «движок не смог повторить партию». Что это значит и правило частичного хода — 07 Контракт ingest и валидация движком.
8. Навигация по разделу
Всё, что здесь упомянуто кратко, разворачивается в соседних страницах. Снизу вверх «как эксплуатировать» (09) ссылается на «как устроено» (04–08).
- 04 Граф игроков и перечисление — синхронизация информации об игроках: seeds (SEED_IDS) + BFS по оппонентам,
POST /api/player/history, полный против инкрементального свипа (курсорsynced_through_ms), резюм поenumerate_offset, приоритет по рейтингу, таблицаplayers, машина состояний игрока. - 05 Конвейер партий и состояния — синхронизация партий: таблицы
games+raw_game_data(ER-диаграмма), машина состояний партии, стадииfetch → normalize → post → archive, сырой кэш и реплей, идемпотентность. - 06 Защита от блокировок и Cloudflare — rate-limiter (single-flight, spacing + jitter, экспон. backoff, retry-on-throttle, бюджет), curl/Cloudflare/OpenSSL-фингерпринт, рычаг
ENUMERATE_PAGE_SIZE, уроки429. - 07 Контракт ingest и валидация движком — стык с аналитикой:
POSTпротивPUT /api/games, схемаGameIngest, реплей-гейт движком (что значит422, правило частичного хода), first-writer-wins. - 08 Идентичность, источники и дедупликация —
external_id/source/player_typeпо всем четырём писателям, нативный отрицательный id ботов против нашегоbot:<algorithm>, гонка first-writer-wins, подмена игрока расширением, дедуп V6, beturanga ObjectId/UUIDv5. - 09 Pipeline - dicechess-sync — служебная справка по сервису: репозиторий/версия, CLI (
sync:player/sync:crawl/crawl:service/sync:archive), полная таблица env, демон-цикл, Docker-деплой, резюмируемость. - Live-наблюдатель (dicechess-observer) — отдельная страница про live-добычу dicechess.com.
- Beturanga — второй источник — второй сайт (Socket.IO), отличия протокола, гибридная стратегия Фишера.
- Raw-архив — bronze-слой — решение про bronze-слой: схема, клиент
@rabestro/dicechess-raw-archive, деплой на dexus.
Связанное: 01 Структура БД для записи партий Dice Chess (gold-схема), 🎓 Нормализованный FEN, 🎓 Что такое “дедупликация”, 07 Backend API Architecture, Где лежит JWT после логина.