🔄 Синхронизация — обзор и архитектура

Внутреннее

Эта страница описывает систему, которая работает с обратно разработанными (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. Зачем это всё

Аналитике нужны партии: позиция до броска, бросок, позиция после хода, результат, игроки, рейтинги, тайм-контроль. Источников несколько (два живых сайта плюс наши собственные боты), а формат у каждого — свой, низкоуровневый и «грязный». Задача синхронизации:

  1. обнаружить партии (кто играл, какие партии существуют);
  2. скачать их сырьё (canonical move-history / game-state);
  3. сохранить сырьё навечно (bronze-слой) — чтобы можно было пересчитать без повторного скрейпа;
  4. нормализовать в канонический контракт и залить в аналитику, где движок проверяет легальность.

Всё это происходит против сайтов, защищённых 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:&lt;algorithm&gt;"]
        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_stagefetch | 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 — Node fetch/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 после логина.