Complete stage 0 source research
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
@@ -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).
|
||||||
@@ -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 | 0–100 |
|
||||||
|
| `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. Главное продуктовое правило
|
||||||
|
|
||||||
|
> Если данных недостаточно или они устарели, сайт должен честно сказать об этом. «Не знаем» полезнее, чем красивая, но выдуманная рекомендация.
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Small, disposable research adapter for the official RF4 records page."""
|
||||||
@@ -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
@@ -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 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 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>
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user