Complete stage 0 source research

This commit is contained in:
ik
2026-09-02 19:52:32 +07:00
commit a6f91a1329
9 changed files with 1096 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.py[cod]
.pytest_cache/
.venv/
+25
View File
@@ -0,0 +1,25 @@
# RF4 Spotter
RF4 Spotter — неофициальный сервис свежих точек и статистики клёва для Russian Fishing 4. Сейчас в репозитории выполнен только этап 0: исследование публичного источника официальных рекордов. Сайт и продуктовый backend ещё не создавались.
## Исследовательский парсер
Требуется Python 3.11+ и `beautifulsoup4`:
```bash
python -m pip install -e .
python -m rf4_research.records \
--url https://rf4game.de/records/region/RU/ \
--region RU \
--category records
```
Команда делает один HTTP-запрос и печатает типизированные записи в JSON. Это исследовательский адаптер, а не готовый импортёр: в нём пока нет повторов, кэша, транзакций и дедупликации.
## Проверка
```bash
python -m unittest discover -s tests -v
```
Подтверждённая структура источника, ограничения и риски описаны в [docs/data-sources.md](docs/data-sources.md).
+703
View File
@@ -0,0 +1,703 @@
# RF4 Spotter — идеи и техническое задание для MVP
> Рабочий документ для передачи в Codex. Его задача — дать Codex достаточно контекста, чтобы начать проектирование и разработку без пересказа всей переписки.
## 1. Идея продукта
**RF4 Spotter** — неофициальный информационный сайт для игроков «Русской Рыбалки 4» (Russian Fishing 4), который отвечает на практический вопрос:
> **Куда мне пойти ловить прямо сейчас, какую снасть или приманку взять и насколько свежа эта информация?**
Сайт должен объединять:
- официальные рекорды RF4;
- пользовательские сообщения об уловах;
- координаты точек;
- приманки, наживки, оснастки и способы проводки;
- время поимки;
- историю активности;
- простой и понятный индекс клёва.
Главная ценность — не вечный справочник старых точек, а **оценка текущей активности с указанием свежести и надёжности данных**.
## 2. Что известно об источниках данных
### Официальные данные
У RF4, по предварительным данным, нет документированного публичного API со всеми уловами и координатами. Однако официальный сайт публикует таблицы рекордов и рейтингов, которые можно разбирать автоматически.
Предположительно из таблиц рекордов можно получать:
- регион;
- категорию рекорда;
- игрока;
- вид рыбы;
- вес;
- водоём;
- приманку или наживку;
- дату.
Исходные точки для исследования:
- официальный раздел рекордов: <https://rf4game.de/records/region/RU/>;
- пример открытого парсера: <https://github.com/hurfy/rf4-api>;
- существующий статистический сервис: <https://rf4-stat.ru/help/>;
- пример пользовательской базы: <https://download.rf4db.com/ru/catches>.
Перед реализацией парсера нужно проверить актуальную HTML-структуру, сетевые запросы страницы и условия использования сайта. Нельзя считать приведённые URL и структуру полей неизменными.
### Пользовательские данные
Официальные таблицы, вероятно, не содержат точных координат, проводки и всех обычных уловов. Их нужно собирать отдельно:
- через собственную форму на сайте;
- позднее — из разрешённых Telegram-, Discord- или VK-источников;
- позднее — из скриншотов с подтверждением распознанных полей пользователем.
### Важное ограничение
В MVP нельзя перехватывать трафик игрового клиента, внедряться в процесс игры, обходить античит или заниматься reverse engineering закрытого протокола. Проект должен работать только с публичными веб-данными и добровольно переданной пользователями информацией.
## 3. Для кого делаем
Основной пользователь — игрок RF4, который не хочет просматривать десятки сообщений в сообществах перед каждой игровой сессией.
Типичный запрос:
```text
Уровень: 19
Способ ловли: спиннинг
Доступные водоёмы: до Куори включительно
Цель: серебро
```
Ожидаемый ответ:
```text
Сейчас активна щука на Вьюнке
Точка: 110:103
Приманка: Spiker #2 01-015
Последнее подтверждение: 34 минуты назад
Уловов за 12 часов: 27 от 11 игроков
Активность: высокая
Уверенность: 87 из 100
```
## 4. Главная гипотеза MVP
Игроку полезнее несколько свежих и объяснимых рекомендаций, чем большая база точек без даты и источника.
MVP считается полезным, если пользователь может:
1. открыть главную страницу;
2. выбрать водоём, рыбу или способ ловли;
3. увидеть активные комбинации «водоём + рыба + точка»;
4. понять, на чём основана оценка;
5. открыть карточку точки с приманками и динамикой;
6. отправить свой улов через простую форму.
## 5. Состав MVP
### 5.1. Главная страница «Что клюёт сейчас»
На странице должны быть:
- фильтр по водоёму;
- фильтр по рыбе;
- фильтр по способу ловли;
- фильтр по периоду: 6, 12, 24, 72 часа;
- сортировка по активности, уверенности и свежести;
- карточки активных точек;
- явное время последнего обновления данных.
Карточка содержит:
- водоём;
- рыбу;
- координаты;
- лучшую приманку или наживку;
- количество уловов за выбранный период;
- количество разных авторов;
- средний и максимальный вес;
- время последнего подтверждения;
- `activity_score` от 0 до 100;
- `confidence_score` от 0 до 100;
- короткое текстовое объяснение оценки.
### 5.2. Страница точки
URL вида:
```text
/spots/vyunok/110-103/pike
```
Содержимое:
- координаты и описание места;
- статистика уловов за 24 часа, 3 дня и 7 дней;
- список наиболее результативных приманок или наживок;
- способы проводки и скорость, если указаны;
- распределение уловов по игровому времени;
- последние подтверждённые уловы;
- источник и свежесть каждой записи;
- предупреждение, если данных мало или они устарели.
### 5.3. Страница официальных рекордов
- таблица импортированных рекордов;
- фильтры по рыбе, водоёму, категории и периоду;
- дата последнего успешного импорта;
- отметка, что это данные официального сайта RF4;
- ссылка на исходную страницу.
### 5.4. Форма добавления улова
Обязательные поля:
- рыба;
- вес;
- водоём;
- координаты `x:y`;
- дата и время отправки.
Необязательные поля:
- приманка или наживка;
- тип оснастки;
- проводка;
- скорость проводки;
- игровое время;
- комментарий;
- имя или ник игрока;
- ссылка на исходную публикацию;
- скриншот.
Для первой версии скриншот хранится как подтверждение, но **не распознаётся автоматически**.
### 5.5. Простая модерация
Минимальная закрытая страница администратора:
- список новых записей;
- просмотр всех полей и скриншота;
- действия «одобрить», «отклонить», «исправить»;
- причина отклонения;
- журнал изменения статуса.
До одобрения пользовательский улов не влияет на публичный индекс клёва.
## 6. Что не входит в MVP
- перехват данных из запущенной игры;
- reverse engineering протокола RF4;
- OCR и компьютерное зрение для скриншотов;
- автоматический сбор всех публикаций Telegram, Discord и VK;
- мобильное приложение;
- сложная персонализация по снастям и бюджету;
- прогнозирование клёва с помощью ML;
- комментарии и социальная сеть;
- полноценная интерактивная карта каждого водоёма;
- автоматическое определение игровых обновлений и миграций рыбы.
Эти возможности допустимы после проверки основного сценария.
## 7. Предлагаемый стек
### Вариант для первого релиза
- **Frontend:** Astro + TypeScript;
- **интерактивные компоненты:** React или Svelte islands только там, где они нужны;
- **стили:** Tailwind CSS либо обычный CSS с дизайн-токенами;
- **Backend API:** Python 3.12 + FastAPI;
- **ORM и миграции:** SQLAlchemy 2 + Alembic;
- **БД:** PostgreSQL;
- **фоновые задания:** APScheduler или отдельный CLI, запускаемый cron;
- **парсер:** сначала `httpx` + BeautifulSoup; Playwright только если обычного HTTP недостаточно;
- **хранение скриншотов:** S3-совместимое объектное хранилище;
- **локальный запуск:** Docker Compose;
- **тесты:** pytest для backend, Vitest для frontend, Playwright для ключевого end-to-end сценария.
На старте Celery и Redis не нужны: периодический импорт можно сделать отдельной идемпотентной CLI-командой.
## 8. Архитектура
```text
Официальный сайт RF4 ──> импортёр рекордов ──┐
Форма пользователя ──> модерация ───────────┼──> PostgreSQL
│ │
Ручной импорт CSV/JSON ──────────────────────┘ │
v
расчёт активности
v
FastAPI JSON API
v
Astro-сайт
```
Предлагаемая структура репозитория:
```text
rf4-spotter/
├── README.md
├── .env.example
├── compose.yaml
├── apps/
│ ├── web/ # Astro
│ └── api/ # FastAPI
├── packages/
│ └── contracts/ # OpenAPI/types, если понадобится
├── data/
│ └── fixtures/ # обезличенные тестовые HTML/JSON
├── docs/
│ ├── RF4_MVP_SPEC.md
│ └── data-sources.md
└── scripts/
└── seed_demo_data.*
```
## 9. Модель данных
Названия таблиц и полей можно скорректировать после прототипа, но модель должна отделять справочники, исходные наблюдения и рассчитанные агрегаты.
### `fish`
| Поле | Тип | Назначение |
|---|---|---|
| `id` | UUID | идентификатор |
| `slug` | text unique | значение для URL |
| `name_ru` | text unique | название рыбы |
| `trophy_weight_g` | integer nullable | порог трофея, если известен |
### `waterbody`
| Поле | Тип | Назначение |
|---|---|---|
| `id` | UUID | идентификатор |
| `slug` | text unique | значение для URL |
| `name_ru` | text unique | название водоёма |
| `unlock_level` | integer nullable | уровень открытия |
### `bait`
Объединённый справочник наживок и искусственных приманок для MVP.
| Поле | Тип | Назначение |
|---|---|---|
| `id` | UUID | идентификатор |
| `name` | text | отображаемое название |
| `normalized_name` | text unique | ключ для сопоставления |
| `kind` | enum | `bait`, `lure`, `unknown` |
### `spot`
| Поле | Тип | Назначение |
|---|---|---|
| `id` | UUID | идентификатор |
| `waterbody_id` | UUID FK | водоём |
| `x` | integer | координата X |
| `y` | integer | координата Y |
| `description` | text nullable | заметка о месте |
Уникальный ключ MVP: `(waterbody_id, x, y)`.
### `catch_report`
| Поле | Тип | Назначение |
|---|---|---|
| `id` | UUID | идентификатор |
| `fish_id` | UUID FK | рыба |
| `spot_id` | UUID FK nullable | точка; у официального рекорда её может не быть |
| `waterbody_id` | UUID FK | водоём хранится явно |
| `bait_id` | UUID FK nullable | приманка или наживка |
| `weight_g` | integer | вес в граммах |
| `rig_type` | text nullable | тип оснастки |
| `retrieve_method` | text nullable | способ проводки |
| `retrieve_speed` | integer nullable | скорость проводки |
| `game_time` | time nullable | игровое время |
| `caught_at` | timestamptz nullable | время поимки, если известно |
| `reported_at` | timestamptz | время попадания в систему |
| `player_name` | text nullable | ник игрока |
| `source_type` | enum | `official_record`, `user`, `manual_import` |
| `source_url` | text nullable | ссылка на источник |
| `source_external_id` | text nullable | ключ для дедупликации |
| `source_confidence` | smallint | 0100 |
| `moderation_status` | enum | `pending`, `approved`, `rejected` |
| `screenshot_key` | text nullable | ключ объекта в хранилище |
| `raw_payload` | jsonb nullable | исходные данные импортёра |
### `official_record_import`
| Поле | Тип | Назначение |
|---|---|---|
| `id` | UUID | запуск импортёра |
| `started_at` | timestamptz | начало |
| `finished_at` | timestamptz nullable | завершение |
| `status` | enum | `running`, `success`, `partial`, `failed` |
| `source_url` | text | источник |
| `rows_seen` | integer | найдено строк |
| `rows_created` | integer | добавлено записей |
| `rows_updated` | integer | обновлено записей |
| `error_summary` | text nullable | краткая ошибка |
### `moderation_event`
Хранит историю решений по пользовательскому сообщению: кто, когда, какой статус установил и почему.
## 10. Нормализация и дедупликация
Это критическая часть проекта.
Нужно:
- хранить исходное значение из источника в `raw_payload`;
- нормализовать пробелы, регистр, дефисы и десятичные разделители;
- вести таблицу алиасов для рыб, водоёмов и приманок;
- вес всегда приводить к граммам;
- время хранить с явным указанием, реальное оно или игровое;
- официальный импорт делать идемпотентным;
- не считать две одинаковые строки двумя независимыми подтверждениями.
Для официальных рекордов ключ дедупликации можно сначала строить из нормализованной комбинации:
```text
region + category + player + fish + weight_g + waterbody + bait + record_date
```
Этот ключ следует хешировать и хранить в `source_external_id`.
## 11. Индекс активности
### Требование
Алгоритм MVP должен быть простым, детерминированным и объяснимым. Нельзя показывать псевдоточную оценку без расшифровки.
Расчёт производится для группы:
```text
водоём + точка + рыба + выбранный временной интервал
```
Предлагаемый первый вариант:
```text
freshness_i = exp(-age_hours_i / 18)
weighted_reports = sum(freshness_i * source_confidence_i / 100)
unique_players = количество уникальных непустых player_name
trophy_bonus = min(1, trophy_count / 3)
activity_raw =
55 * min(1, weighted_reports / 12)
+ 25 * min(1, unique_players / 6)
+ 20 * trophy_bonus
activity_score = round(activity_raw)
```
Начальная оценка уверенности:
```text
confidence_score = round(
45 * min(1, approved_reports / 10)
+ 35 * min(1, unique_players / 5)
+ 20 * average_source_confidence / 100
)
```
Правила отображения:
- меньше трёх одобренных пользовательских наблюдений — пометка «данных мало»;
- нет уловов за 72 часа — пометка «данные устарели»;
- один игрок не может создать высокую уверенность большим количеством сообщений;
- официальный рекорд без координат нельзя автоматически приписывать конкретной точке;
- значения 0–100 — сравнительные индексы сервиса, а не вероятность поймать рыбу.
Карточка должна объяснять результат, например:
```text
Высокая активность: 18 свежих уловов от 7 игроков.
Последнее подтверждение 42 минуты назад.
Уверенность средняя: часть сообщений без скриншотов.
```
Формулу после накопления реальных данных нужно откалибровать.
## 12. API MVP
Публичные методы:
```http
GET /api/v1/activity
GET /api/v1/spots/{spot_id}
GET /api/v1/spots/{spot_id}/catches
GET /api/v1/records
GET /api/v1/fishes
GET /api/v1/waterbodies
GET /api/v1/baits
POST /api/v1/catch-reports
```
Пример фильтров активности:
```http
GET /api/v1/activity?waterbody=vyunok&fish=pike&hours=24&method=spinning
```
Административные методы:
```http
GET /api/v1/admin/catch-reports?status=pending
PATCH /api/v1/admin/catch-reports/{id}
POST /api/v1/admin/imports/official-records
GET /api/v1/admin/imports
```
Все списочные методы должны иметь пагинацию, предсказуемую сортировку и валидацию фильтров.
## 13. Импорт официальных рекордов
Импортёр должен быть отдельным адаптером, чтобы изменение вёрстки RF4 не затронуло остальное приложение.
Интерфейс адаптера:
```python
class OfficialRecordsSource(Protocol):
async def fetch(self, region: str, category: str) -> list[RawRecord]: ...
```
Этапы:
1. загрузить страницу или JSON/XHR, если он существует;
2. сохранить диагностические метаданные ответа;
3. разобрать строки в `RawRecord`;
4. нормализовать значения;
5. вычислить ключ дедупликации;
6. добавить или обновить записи транзакционно;
7. записать результат запуска в `official_record_import`.
Требования:
- таймауты и повторы с ограничением;
- понятный User-Agent с названием проекта и контактным URL, когда он появится;
- умеренная частота запросов;
- кэширование;
- фикстуры реальных HTML-ответов для unit-тестов;
- отказ с понятной ошибкой, если ожидаемые колонки исчезли;
- никакого молчаливого импорта перепутанных полей;
- возможность запустить командой вроде `python -m app.cli import-records`;
- расписание не чаще необходимого; начать с одного раза в 30–60 минут.
Если официальный сайт отдаёт данные через внутренний JSON/XHR endpoint, нужно предпочесть его HTML-парсингу, но только если он доступен без обхода защиты.
## 14. Интерфейс и визуальный принцип
Сайт должен выглядеть как современный полезный инструмент, а не как форум или перегруженная игровая база.
Принципы:
- тёмная и светлая темы допустимы, но первая версия может иметь одну хорошо сделанную тему;
- важнее всего: рыба, водоём, точка, свежесть и рабочая приманка;
- цветовая шкала активности не должна быть единственным носителем смысла;
- на мобильном карточки должны читаться без горизонтальной прокрутки;
- таблица рекордов на мобильном превращается в карточки;
- возле любой оценки показывать, на каких данных она основана;
- не смешивать реальное время и игровое время;
- не выдавать старую точку за текущий клёв.
Минимальные состояния каждой страницы:
- загрузка;
- данные есть;
- данных нет;
- данных мало;
- источник временно недоступен;
- ошибка валидации.
## 15. Безопасность и приватность
- не принимать исполняемые файлы;
- проверять MIME-тип, расширение и размер скриншотов;
- удалять EXIF из загруженных изображений;
- генерировать серверные имена объектов;
- ограничить частоту отправки формы;
- добавить honeypot или CAPTCHA после появления спама;
- экранировать пользовательский текст;
- не публиковать IP и технические идентификаторы;
- ник игрока сделать необязательным;
- предусмотреть удаление пользовательского сообщения;
- административную часть защитить аутентификацией;
- секреты хранить только в переменных окружения;
- не коммитить `.env` и реальные скриншоты пользователей.
## 16. Критерии готовности MVP
MVP готов, когда:
- проект поднимается одной документированной командой через Docker Compose;
- миграции создают чистую БД;
- seed-команда добавляет демонстрационные водоёмы, рыб, приманки и уловы;
- импортёр получает или разбирает официальные рекорды из актуального источника;
- повторный импорт не создаёт дубликаты;
- сбой источника не удаляет ранее загруженные данные;
- главная страница показывает активные точки и фильтруется;
- карточка точки объясняет активность и уверенность;
- пользователь может отправить улов;
- администратор может его одобрить или отклонить;
- одобренный улов появляется в публичной статистике;
- есть автоматические тесты главного сценария;
- README описывает запуск, настройку, импорт, тесты и ограничения проекта.
## 17. Этапы разработки
### Этап 0. Исследование источника
- проверить официальный сайт RF4 и его сетевые запросы;
- зафиксировать доступные регионы и категории;
- проверить актуальность проекта `hurfy/rf4-api`;
- сохранить небольшие HTML/JSON-фикстуры;
- описать риски и ограничения в `docs/data-sources.md`;
- не писать весь продукт, пока не доказано, что хотя бы один источник стабильно разбирается.
Результат: маленький исследовательский скрипт и документ с подтверждённой структурой данных.
### Этап 1. Каркас и демонстрационные данные
- создать монорепозиторий;
- настроить Astro, FastAPI, PostgreSQL и миграции;
- создать модель данных;
- добавить seed;
- реализовать read-only API;
- сверстать главную и страницу точки на демоданных.
### Этап 2. Официальные рекорды
- реализовать адаптер источника;
- добавить нормализацию и дедупликацию;
- добавить журнал запусков;
- сделать страницу рекордов;
- добавить периодический запуск.
### Этап 3. Пользовательские уловы
- форма;
- загрузка скриншота;
- модерация;
- rate limit;
- включение одобренных записей в статистику.
### Этап 4. Индекс клёва
- агрегаты;
- активность и уверенность;
- человекочитаемое объяснение;
- тесты на свежесть, дубликаты и вклад разных игроков.
### Этап 5. Пилот
- наполнить базу небольшим набором реальных данных с разрешёнными источниками;
- дать нескольким игрокам протестировать сценарий;
- собрать обратную связь;
- только после этого решать, нужен ли OCR и импорт сообществ.
## 18. Идеи после MVP
Приоритет определять по реальному использованию:
- распознавание скриншота с обязательным подтверждением пользователем;
- Telegram-бот для отправки улова;
- импорт из разрешённых каналов и сообществ;
- персональный профиль: уровень, открытые водоёмы, снасти, бюджет;
- режим «куда пойти прямо сейчас»;
- сравнение приманок;
- динамика клёва по игровому времени;
- обнаружение смены рабочих точек после обновлений;
- уведомление, когда активизировалась выбранная рыба;
- карта водоёма;
- публичный API для сообщества;
- репутация источников и авторов;
- мультиязычность и поддержка разных регионов RF4.
## 19. Открытые вопросы
Codex не должен молча принимать решения по этим пунктам, если они становятся блокирующими:
1. Как будет называться проект и какой домен использовать?
2. Нужна ли авторизация обычных пользователей в первом релизе?
3. Где размещать приложение и объектное хранилище?
4. Можно ли использовать реальные ники игроков в публичной выдаче?
5. Какие именно официальные категории рекордов импортировать первыми?
6. Какие водоёмы и рыбы нужны в пилотной базе?
7. Какая лицензия будет у кода и данных?
8. Разрешают ли правила и `robots.txt` выбранную частоту автоматического сбора?
Для начала разработки допустимы безопасные значения по умолчанию:
- рабочее название `RF4 Spotter`;
- без регистрации обычных пользователей;
- один регион RU;
- одна или две основные категории рекордов;
- локальное S3-совместимое хранилище MinIO;
- демонстрационные ники и данные в seed;
- импорт раз в 60 минут.
## 20. Как работать над проектом в Codex
1. Создать пустой репозиторий и положить этот файл в `docs/RF4_MVP_SPEC.md`.
2. Открыть корень репозитория в Codex.
3. Дать Codex сначала исследовательскую задачу из блока ниже.
4. Попросить фиксировать решения в `docs/` и обновлять README.
5. Делить работу на небольшие проверяемые этапы, а не просить сразу «сделать весь сайт».
6. После каждого этапа просить запускать тесты и показывать, что именно готово.
7. Не передавать Codex пароли, токены и игровые учётные данные; использовать `.env.example`.
### Первый промпт для Codex
```text
Прочитай docs/RF4_MVP_SPEC.md целиком. Пока не создавай весь сайт.
Выполни только этап 0 — исследование источников официальных рекордов RF4.
Задачи:
1. Изучи текущую структуру официальной страницы рекордов и её сетевые запросы.
2. Проверь, существует ли доступный JSON/XHR endpoint, который можно использовать без обхода защиты.
3. Изучи архитектурные идеи проекта https://github.com/hurfy/rf4-api, но не копируй код вслепую и проверь его актуальность.
4. Создай минимальный исследовательский скрипт, который получает одну страницу/категорию и преобразует записи в типизированную внутреннюю структуру.
5. Добавь обезличенную небольшую фикстуру и unit-тест парсера.
6. Создай docs/data-sources.md: подтверждённые поля, URL, параметры, ограничения, рекомендуемую частоту опроса и риски поломки.
7. Не исследуй сетевой протокол игры, не запускай игровой клиент и не обходи защиту сайтов.
Перед изменениями изучи репозиторий и предложи краткий план. После работы запусти тесты и сообщи, что подтверждено фактически, а что осталось предположением.
```
### Второй промпт после успешного исследования
```text
Прочитай docs/RF4_MVP_SPEC.md и docs/data-sources.md. Реализуй этап 1: каркас приложения на Astro + TypeScript, FastAPI, PostgreSQL, SQLAlchemy 2 и Alembic.
Требования:
- локальный запуск через Docker Compose;
- модели и миграции для основных сущностей MVP;
- seed с демонстрационными данными;
- read-only API для активности, точек и справочников;
- главная страница и страница точки;
- адаптивная вёрстка;
- тесты backend и одного главного пользовательского сценария;
- README с точными командами запуска и проверки.
Используй данные только из seed: реальный импортёр будет отдельным этапом. Сначала предложи план и список файлов, затем реализуй и проверь результат.
```
## 21. Короткое описание проекта для README
```text
RF4 Spotter — неофициальный сервис свежих точек и статистики клёва для Russian Fishing 4. Он объединяет публичные рекорды и подтверждённые пользовательские уловы, показывает свежесть источников и рассчитывает объяснимые индексы активности и уверенности. Проект не взаимодействует с игровым клиентом и не является официальным продуктом RF4.
```
## 22. Главное продуктовое правило
> Если данных недостаточно или они устарели, сайт должен честно сказать об этом. «Не знаем» полезнее, чем красивая, но выдуманная рекомендация.
+95
View File
@@ -0,0 +1,95 @@
# Источники данных RF4: исследование этапа 0
Дата проверки: **2 сентября 2026 года**. Исследовалась только публичная веб-страница; игровой клиент, его трафик и закрытые протоколы не исследовались.
## Краткий вывод
Для первого адаптера следует получать серверный HTML страницы `https://rf4game.de/records/region/RU/`. На момент проверки таблица уже находится в исходном HTML и не требует JavaScript, авторизации или обхода защиты. Доступного JSON/XHR endpoint именно для рекордов не обнаружено.
Страница содержит общий WordPress AJAX-клиент с адресом `/wp-admin/admin-ajax.php`, но опубликованные на странице действия `gajax_Ratings` относятся к рейтингу игроков (`rating`, `rating_list`, `best`). Действия для таблицы рекордов не объявлены. Поэтому использовать или подбирать недокументированные AJAX-параметры не рекомендуется.
## Подтверждённые URL и параметры
- Абсолютные рекорды региона RU: `https://rf4game.de/records/region/RU/`.
- Недельная форма URL: `https://rf4game.de/records/weekly/region/RU/`.
- В навигации страницы перечислены регионы: `GL`, `RU`, `DE`, `US`, `FR`, `CN`, `PL`, `KR`, `JP`, `EN`.
- В навигации категорий видны: `records`, `ultralight`, `recordslight`, `bottomlight`, `sea`, `telestick`.
Категория и регион кодируются сегментами URL, а не query-параметрами. Фактический успешный ответ абсолютной страницы был `200`, `text/html; charset=UTF-8`, с `Cache-Control: no-cache`. Запрос `GET /robots.txt` вернул `404`, то есть явных инструкций для роботов на этом пути при проверке не было. Это не является разрешением на интенсивный сбор.
Серия быстрых проверок категорий позже получила одинаковые небольшие HTML-ответы вместо полных таблиц. Это может быть временной защитой или ограничением частоты; вывод требует повторной осторожной проверки. Именно поэтому исследовательский скрипт проверяет наличие таблицы и завершает работу с ошибкой, а не принимает произвольный HTML за пустой результат.
## Подтверждённый DOM-контракт
Корень таблицы: `div.records.flex_table`. Верхняя строка имеет шесть семантических классов в таком порядке:
1. `fish` — рыба;
2. `weight` — вес;
3. `location` — водоём;
4. `bait` — приманка/наживка (отображаемое значение находится в атрибуте `title` у `.bait_icon`);
5. `gamername` — игрок;
6. `data` — дата (именно `data`, не `date`).
Каждый `div.records_subtable` объединяет до нескольких результатов одной рыбы. Название рыбы находится только в заголовочной строке группы (`.fish .text`); вложенные строки имеют пустую ячейку рыбы и наследуют название группы.
Подтверждённые внутренние поля `OfficialRecord`:
| Поле | Источник/нормализация |
|---|---|
| `region` | сегмент URL, верхний регистр |
| `category` | сегмент URL/аргумент запуска |
| `fish` | `.fish .text` заголовка группы |
| `weight_g` | `.weight`; `kg`/`g` приводятся к целым граммам |
| `waterbody` | `.location` |
| `bait` | `.bait_icon[title]`, nullable |
| `player` | `.gamername`, nullable |
| `record_date` | `.data`, формат `D.MM.YY`/`DD.MM.YY` |
| `source_url` | URL полученной страницы |
Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU.
## Рекомендуемый режим получения
- Один понятный `User-Agent`, таймаут 20 секунд.
- Начать не чаще одного раза в 60 минут на выбранную комбинацию категории и региона.
- Не запускать параллельный обход всех регионов/категорий.
- Добавить кэш, ограниченные повторы с экспоненциальной задержкой и общий лимит запросов перед продуктивным импортом.
- При исчезновении таблицы, изменении порядка классов или ошибке HTTP считать запуск неуспешным и сохранять прежние данные.
- Повторно проверить условия использования и связаться с владельцем сайта до регулярного производственного сбора; отсутствие `robots.txt` не заменяет разрешения.
## Сравнение с `hurfy/rf4-api`
Репозиторий `hurfy/rf4-api` создан в августе 2024 года; последний push, видимый через GitHub API на дату исследования, был 14 января 2025 года. README прямо называет проект находящимся в разработке. Он использует Django, Celery и браузерный WebDriver, перебирает регионы и категории, затем разбирает те же классы `records_wrapper`, `records_subtable`, `gamername`, `weight`, `location`, `bait_icon`, `data`.
Полезные архитектурные идеи:
- отделить построение URL, получение HTML, разбор и сохранение;
- передавать регион и категорию вместе с сырой страницей;
- нормализовать вес и текст до записи в БД.
Что нельзя переносить без проверки:
- список категорий проекта ограничен `records`, `ultralight`, `telestick` и уже не отражает всю текущую навигацию;
- WebDriver избыточен для подтверждённой серверной HTML-страницы;
- парсер опирается на позиционный поиск `.rows` и не проверяет контракт колонок;
- заявленные в README охват и расписание сами по себе не доказывают текущую работоспособность.
## Фикстура и исследовательский код
`tests/fixtures/records_ru_sample.html` — сокращённая обезличенная фикстура, сохраняющая подтверждённую вложенность и классы. Реальные ники и реальные сочетания уловов в неё не переносились. `rf4_research/records.py` получает ровно одну переданную страницу, проверяет контракт колонок и выдаёт список типизированных `OfficialRecord`.
Unit-тест покрывает заголовочную и вложенную записи, килограммы/граммы, большой вес с разделителем тысяч и отказ при изменении колонок.
## Что остаётся предположением
- Все перечисленные категории и регионы стабильно отдают таблицу при умеренной частоте запросов.
- Формат двухзначного года сохранится; сейчас он интерпретируется стандартным правилом Python как 2000-е для наблюдаемых значений.
- Сайт разрешит регулярный производственный опрос раз в час.
- DOM-классы останутся стабильнее локализованных заголовков.
- Отсутствие найденного JSON endpoint не доказывает, что внутреннего endpoint вообще нет; подтверждено лишь, что для публичного сценария он не нужен и на странице не объявлен.
## Источники
- Официальная страница: <https://rf4game.de/records/region/RU/>
- Исследованный сторонний проект: <https://github.com/hurfy/rf4-api>
- Метаданные репозитория GitHub API: <https://api.github.com/repos/hurfy/rf4-api>
+8
View File
@@ -0,0 +1,8 @@
[project]
name = "rf4-spotter-research"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["beautifulsoup4>=4.12,<5"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+1
View File
@@ -0,0 +1 @@
"""Small, disposable research adapter for the official RF4 records page."""
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import asdict, dataclass
from datetime import date, datetime
from typing import Iterable
from urllib.request import Request, urlopen
from bs4 import BeautifulSoup, Tag
DEFAULT_URL = "https://rf4game.de/records/region/RU/"
USER_AGENT = "RF4-Spotter-Research/0.1 (+https://github.com/)"
class RecordsParseError(ValueError):
"""Raised when the page no longer matches the verified records contract."""
@dataclass(frozen=True, slots=True)
class OfficialRecord:
region: str
category: str
fish: str
weight_g: int
waterbody: str
bait: str | None
player: str | None
record_date: date
source_url: str
def _text(node: Tag | None) -> str:
return node.get_text(" ", strip=True) if node else ""
def _direct_child(parent: Tag, classes: Iterable[str]) -> Tag | None:
wanted = set(classes)
for child in parent.find_all("div", recursive=False):
if wanted.issubset(set(child.get("class", []))):
return child
return None
def parse_weight_g(raw: str) -> int:
normalized = " ".join(raw.replace("\xa0", " ").split()).lower()
match = re.fullmatch(r"([\d .,']+)\s*(kg|g)", normalized)
if not match:
raise RecordsParseError(f"unsupported weight: {raw!r}")
number, unit = match.groups()
number = number.replace(" ", "").replace("'", "").replace("", "")
if unit == "g":
return int(number.replace(".", "").replace(",", ""))
# The verified pages use a dot as the kg decimal separator and spaces as
# thousands separators (for example, "2 519.264 kg").
if "," in number and "." not in number:
number = number.replace(",", ".")
return round(float(number) * 1000)
def parse_record_date(raw: str, *, today: date | None = None) -> date:
today = today or date.today()
try:
parsed = datetime.strptime(raw.strip(), "%d.%m.%y").date()
except ValueError as exc:
raise RecordsParseError(f"unsupported record date: {raw!r}") from exc
if parsed > today.replace(year=today.year + 1):
raise RecordsParseError(f"record date is implausibly far in the future: {raw!r}")
return parsed
def parse_records_html(
html: str,
*,
region: str,
category: str,
source_url: str,
today: date | None = None,
) -> list[OfficialRecord]:
soup = BeautifulSoup(html, "html.parser")
table = soup.select_one("div.records.flex_table")
if table is None:
raise RecordsParseError("records table not found")
top_rows = _direct_child(table, ["rows"])
header = _direct_child(table, ["row", "header"])
expected_classes = ["fish", "weight", "location", "bait", "gamername", "data"]
actual_classes = [
next((name for name in expected_classes if name in cell.get("class", [])), "")
for cell in (header.find_all("div", recursive=False) if header else [])
]
if actual_classes != expected_classes:
raise RecordsParseError(
f"records columns changed: expected {expected_classes}, got {actual_classes}"
)
if top_rows is None:
raise RecordsParseError("records rows container not found")
result: list[OfficialRecord] = []
for group_wrapper in top_rows.find_all("div", class_="row", recursive=False):
group = _direct_child(group_wrapper, ["records_subtable", "flex_table"])
if group is None:
continue
group_header = _direct_child(group, ["row", "header"])
more_rows = _direct_child(group, ["rows"])
if group_header is None:
continue
fish = _text(group_header.select_one(".fish .text"))
rows = [group_header]
if more_rows is not None:
rows.extend(more_rows.find_all("div", class_="row", recursive=False))
for row in rows:
bait_node = row.select_one(".bait .bait_icon")
bait = bait_node.get("title", "").strip() if bait_node else ""
player = _text(row.select_one(".gamername"))
result.append(
OfficialRecord(
region=region.upper(),
category=category,
fish=fish,
weight_g=parse_weight_g(_text(row.select_one(".weight"))),
waterbody=_text(row.select_one(".location")),
bait=bait or None,
player=player or None,
record_date=parse_record_date(_text(row.select_one(".data")), today=today),
source_url=source_url,
)
)
if not result:
raise RecordsParseError("records table is present but contains no records")
return result
def fetch_html(url: str, *, timeout: float = 20.0) -> str:
request = Request(url, headers={"User-Agent": USER_AGENT, "Accept": "text/html"})
with urlopen(request, timeout=timeout) as response:
content_type = response.headers.get_content_type()
if content_type != "text/html":
raise RecordsParseError(f"expected text/html, got {content_type}")
charset = response.headers.get_content_charset() or "utf-8"
return response.read().decode(charset)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Fetch and parse one RF4 records page")
parser.add_argument("--url", default=DEFAULT_URL)
parser.add_argument("--region", default="RU")
parser.add_argument("--category", default="records")
args = parser.parse_args(argv)
try:
records = parse_records_html(
fetch_html(args.url),
region=args.region,
category=args.category,
source_url=args.url,
)
except Exception as exc:
print(f"research import failed: {exc}", file=sys.stderr)
return 1
print(json.dumps([asdict(item) for item in records], ensure_ascii=False, default=str))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="de">
<body>
<!-- Reduced and anonymized from the public RF4 records DOM contract. -->
<div class="records flex_table">
<div class="row header">
<div class="col fish">Fisch</div>
<div class="col weight">Gewicht</div>
<div class="col location">Gewässer</div>
<div class="col bait">Kunstköder</div>
<div class="col gamername">Spieler</div>
<div class="col data">Datum</div>
</div>
<div class="rows">
<div class="row">
<div class="records_subtable flex_table">
<div class="row header">
<div class="col fish"><div class="text">Hecht</div></div>
<div class="col weight">27.902&nbsp;kg</div>
<div class="col location">Testsee</div>
<div class="col bait"><div class="bait_icon" title="Testköder 01"></div></div>
<div class="col gamername">Spieler A</div>
<div class="col data">11.02.26</div>
</div>
<div class="rows">
<div class="row">
<div class="col fish"></div>
<div class="col weight">2 519.264&nbsp;kg</div>
<div class="col location">Testmeer</div>
<div class="col bait"><div class="bait_icon" title="Testköder 02"></div></div>
<div class="col gamername">Spieler B</div>
<div class="col data">03.05.26</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
from datetime import date
from pathlib import Path
import unittest
from rf4_research.records import RecordsParseError, parse_records_html, parse_weight_g
FIXTURE = Path(__file__).parent / "fixtures" / "records_ru_sample.html"
class RecordsParserTests(unittest.TestCase):
def test_parses_group_header_and_nested_records(self) -> None:
records = parse_records_html(
FIXTURE.read_text(encoding="utf-8"),
region="ru",
category="records",
source_url="https://rf4game.de/records/region/RU/",
today=date(2026, 9, 2),
)
self.assertEqual(len(records), 2)
self.assertEqual(records[0].fish, "Hecht")
self.assertEqual(records[0].weight_g, 27_902)
self.assertEqual(records[0].bait, "Testköder 01")
self.assertEqual(records[1].fish, "Hecht")
self.assertEqual(records[1].weight_g, 2_519_264)
self.assertEqual(records[1].record_date, date(2026, 5, 3))
def test_rejects_changed_column_contract(self) -> None:
html = FIXTURE.read_text(encoding="utf-8").replace(
'class="col data"', 'class="col changed"', 1
)
with self.assertRaisesRegex(RecordsParseError, "records columns changed"):
parse_records_html(
html,
region="RU",
category="records",
source_url="fixture://changed",
)
def test_weight_units_are_normalized_to_grams(self) -> None:
self.assertEqual(parse_weight_g("423 g"), 423)
self.assertEqual(parse_weight_g("8.023 kg"), 8_023)
if __name__ == "__main__":
unittest.main()