commit a6f91a13293b60aa585693f04001b014a0531176 Author: IK Date: Wed Sep 2 19:52:32 2026 +0700 Complete stage 0 source research diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93c42f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..35fed2b --- /dev/null +++ b/README.md @@ -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). diff --git a/docs/RF4_MVP_SPEC.md b/docs/RF4_MVP_SPEC.md new file mode 100644 index 0000000..a2ad496 --- /dev/null +++ b/docs/RF4_MVP_SPEC.md @@ -0,0 +1,703 @@ +# RF4 Spotter — идеи и техническое задание для MVP + +> Рабочий документ для передачи в Codex. Его задача — дать Codex достаточно контекста, чтобы начать проектирование и разработку без пересказа всей переписки. + +## 1. Идея продукта + +**RF4 Spotter** — неофициальный информационный сайт для игроков «Русской Рыбалки 4» (Russian Fishing 4), который отвечает на практический вопрос: + +> **Куда мне пойти ловить прямо сейчас, какую снасть или приманку взять и насколько свежа эта информация?** + +Сайт должен объединять: + +- официальные рекорды RF4; +- пользовательские сообщения об уловах; +- координаты точек; +- приманки, наживки, оснастки и способы проводки; +- время поимки; +- историю активности; +- простой и понятный индекс клёва. + +Главная ценность — не вечный справочник старых точек, а **оценка текущей активности с указанием свежести и надёжности данных**. + +## 2. Что известно об источниках данных + +### Официальные данные + +У RF4, по предварительным данным, нет документированного публичного API со всеми уловами и координатами. Однако официальный сайт публикует таблицы рекордов и рейтингов, которые можно разбирать автоматически. + +Предположительно из таблиц рекордов можно получать: + +- регион; +- категорию рекорда; +- игрока; +- вид рыбы; +- вес; +- водоём; +- приманку или наживку; +- дату. + +Исходные точки для исследования: + +- официальный раздел рекордов: ; +- пример открытого парсера: ; +- существующий статистический сервис: ; +- пример пользовательской базы: . + +Перед реализацией парсера нужно проверить актуальную 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. Главное продуктовое правило + +> Если данных недостаточно или они устарели, сайт должен честно сказать об этом. «Не знаем» полезнее, чем красивая, но выдуманная рекомендация. + diff --git a/docs/data-sources.md b/docs/data-sources.md new file mode 100644 index 0000000..35840c8 --- /dev/null +++ b/docs/data-sources.md @@ -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 вообще нет; подтверждено лишь, что для публичного сценария он не нужен и на странице не объявлен. + +## Источники + +- Официальная страница: +- Исследованный сторонний проект: +- Метаданные репозитория GitHub API: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..62abbcf --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/rf4_research/__init__.py b/rf4_research/__init__.py new file mode 100644 index 0000000..fec6d75 --- /dev/null +++ b/rf4_research/__init__.py @@ -0,0 +1 @@ +"""Small, disposable research adapter for the official RF4 records page.""" diff --git a/rf4_research/records.py b/rf4_research/records.py new file mode 100644 index 0000000..83c8c10 --- /dev/null +++ b/rf4_research/records.py @@ -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()) diff --git a/tests/fixtures/records_ru_sample.html b/tests/fixtures/records_ru_sample.html new file mode 100644 index 0000000..0435e3a --- /dev/null +++ b/tests/fixtures/records_ru_sample.html @@ -0,0 +1,40 @@ + + + + +
+
+
Fisch
+
Gewicht
+
Gewässer
+
Kunstköder
+
Spieler
+
Datum
+
+
+
+
+
+
Hecht
+
27.902 kg
+
Testsee
+
+
Spieler A
+
11.02.26
+
+
+
+
+
2 519.264 kg
+
Testmeer
+
+
Spieler B
+
03.05.26
+
+
+
+
+
+
+ + diff --git a/tests/test_records_parser.py b/tests/test_records_parser.py new file mode 100644 index 0000000..7b9c594 --- /dev/null +++ b/tests/test_records_parser.py @@ -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()