Compare commits
2
Commits
b574d94f4a
...
1a09bd59f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a09bd59f3 | ||
|
|
9cc3d44a49 |
@@ -0,0 +1,99 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
backend-and-migrations:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_DB: rf4_ci
|
||||
POSTGRES_USER: rf4
|
||||
POSTGRES_PASSWORD: rf4_ci
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U rf4 -d rf4_ci"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 10
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://rf4:rf4_ci@localhost:5432/rf4_ci
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r apps/api/requirements.txt
|
||||
pip install -e .
|
||||
- name: Apply migrations to clean PostgreSQL
|
||||
working-directory: apps/api
|
||||
run: |
|
||||
alembic upgrade head
|
||||
alembic current
|
||||
- name: Run Python tests
|
||||
run: pytest -q
|
||||
|
||||
astro-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: apps/web/package-lock.json
|
||||
- name: Install web dependencies
|
||||
working-directory: apps/web
|
||||
run: npm ci
|
||||
- name: Check and build Astro
|
||||
working-directory: apps/web
|
||||
run: npm run build
|
||||
|
||||
compose-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: apps/web/package-lock.json
|
||||
- name: Install web and browser dependencies
|
||||
working-directory: apps/web
|
||||
run: |
|
||||
npm ci
|
||||
npx playwright install --with-deps chromium
|
||||
- name: Build and start clean Compose stack
|
||||
run: docker compose up --build --wait
|
||||
- name: Verify readiness and run browser tests
|
||||
env:
|
||||
WEB_URL: http://127.0.0.1:4321
|
||||
run: |
|
||||
curl --fail --silent --show-error http://127.0.0.1:8000/ready
|
||||
npm --prefix apps/web run test:e2e
|
||||
- name: Collect diagnostics
|
||||
if: failure()
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
docker compose logs --no-color > artifacts/compose.log
|
||||
cp -R apps/web/test-results artifacts/test-results 2>/dev/null || true
|
||||
cp -R apps/web/playwright-report artifacts/playwright-report 2>/dev/null || true
|
||||
- name: Upload failure diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: compose-e2e-diagnostics
|
||||
path: artifacts
|
||||
if-no-files-found: ignore
|
||||
- name: Stop Compose stack
|
||||
if: always()
|
||||
run: docker compose down --volumes
|
||||
@@ -10,9 +10,12 @@ RF4 Spotter — неофициальный сервис свежих точек
|
||||
- для полного пользовательского сценария добавлен E2E-тест `отправка → pending → модерация → публичная статистика`;
|
||||
- начат этап 4: формула индекса зафиксирована, детерминированные агрегаты и правила включения данных покрыты тестами; далее — сквозная проверка фильтров;
|
||||
- RF4DB/RF4-STAT загружаются в изолированный staging; добавлены канонические алиасы и ручная очередь публикации.
|
||||
- для RF4MAP и RF4 Posts готовы только read-only исследовательские detail-парсеры; продуктивное подключение ожидает согласования условий.
|
||||
|
||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md).
|
||||
|
||||
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
|
||||
|
||||
Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md), а статус разрешений и лимитов — в [`docs/data-permissions.md`](docs/data-permissions.md). Данные сохраняются только в промежуточный staging и не влияют на индекс без явной проверки и публикации администратором.
|
||||
|
||||
Один ограниченный снимок публичных карточек можно получить исследовательским CLI:
|
||||
@@ -21,9 +24,11 @@ RF4 Spotter — неофициальный сервис свежих точек
|
||||
python -m rf4_research.community_cli rf4db --limit 25
|
||||
python -m rf4_research.community_cli rf4stat-fishing --limit 100
|
||||
python -m rf4_research.community_cli rf4stat-posts --limit 25
|
||||
python -m rf4_research.community_cli rf4map-point --url https://rf4map.ru/points/275 --limit 25
|
||||
python -m rf4_research.community_cli rf4posts-spot --url https://rf4-posts.com/ru/spots/UUID --limit 25
|
||||
```
|
||||
|
||||
Команды печатают нормализованный JSON в stdout и ничего не записывают в базу. Для регулярного получения необходимо соблюдать согласованные лимиты; для RF4-STAT — не менее пяти секунд между запросами разных страниц.
|
||||
Команды печатают нормализованный JSON в stdout и ничего не записывают в базу. Detail-команды требуют явный публичный URL и не обходят запрещённые `/api/`. Для регулярного получения необходимо соблюдать согласованные лимиты; для RF4-STAT — не менее пяти секунд между запросами разных страниц. RF4MAP/RF4 Posts пока предназначены только для разового исследования.
|
||||
|
||||
Проверенный JSON можно идемпотентно загрузить в изолированный staging, не влияющий на публичную статистику:
|
||||
|
||||
|
||||
+10
-9
@@ -58,7 +58,7 @@
|
||||
|
||||
- [x] Добавить health/readiness-проверки PostgreSQL, MinIO, API и импорта; отразить их в Compose (`/health` без зависимостей, `/ready` с компонентами и режимом обязательного импорта).
|
||||
- [x] Добавить структурированные JSON-логи без пользовательских секретов и персональных технических данных (whitelist полей, redaction, request ID; Uvicorn access-log отключён).
|
||||
- [ ] Добавить CI: backend tests, Astro check/build, E2E и применение всех миграций на чистой PostgreSQL; сохранять диагностические артефакты при падении.
|
||||
- [x] Добавить Gitea Actions CI: backend tests, Astro check/build, E2E и применение всех миграций на чистой PostgreSQL; сохранять логи Compose и Playwright-артефакты при падении (`.gitea/workflows/ci.yml`).
|
||||
- [ ] Добавить отдельный тест полного bootstrap: пустые volumes → миграции → seed → readiness → основной E2E.
|
||||
- [ ] Сделать seed устойчивым к частично заполненной БД и покрыть повторный/частичный запуск тестом; текущая реализация прекращает работу при наличии любой рыбы.
|
||||
- [ ] Проверить списочные API по требованию раздела 12: пагинация, предсказуемая сортировка и валидация фильтров для справочников, импортов, модерации и внешнего staging.
|
||||
@@ -84,6 +84,8 @@
|
||||
- [x] Добавить отдельную фикстуру и безопасный ручной импорт недельных официальных рекордов одной категории.
|
||||
- [x] Получено подтверждение владельца проекта о разрешениях RF4DB и RF4-STAT; добавлены пилотные HTML-парсеры и отчёт `docs/community-source-pilot.md`.
|
||||
- [x] Добавлены общий nullable-контракт, парсер detail-страницы RF4DB и ограниченный read-only CLI для RF4DB/RF4-STAT.
|
||||
- [x] Исследовать дополнительные публичные источники: добавлены read-only detail-парсеры RF4MAP и RF4 Posts, живые контрольные прогоны и тест разделения пространств ID; rf4pro/Farm.Trof отклонены для текущего пилота.
|
||||
- [ ] До подключения RF4MAP/RF4 Posts получить разрешение и зафиксировать лимиты, атрибуцию, правила изображений и семантику агрегированной точки RF4 Posts.
|
||||
- [x] Зафиксировать проектное подтверждение разрешений, текущую атрибуцию и консервативные пилотные лимиты в `docs/data-permissions.md`.
|
||||
- [ ] Приложить или сослаться на первичный документ разрешения и зафиксировать точные продуктивные лимиты, срок хранения, удаление и обязательную атрибуцию до включения scheduler RF4DB/RF4-STAT.
|
||||
- [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест).
|
||||
@@ -108,14 +110,13 @@
|
||||
|
||||
Технические health/readiness и безопасные логи готовы. Следующие пункты выполняются строго по одному:
|
||||
|
||||
1. CI: backend, Astro, E2E и миграции на чистой PostgreSQL;
|
||||
2. защита официального импорта от конкурентных запусков;
|
||||
3. полный bootstrap-тест и исправление seed для частично заполненной БД;
|
||||
4. security-аудит admin/CORS/headers/secrets и повторной загрузки скриншота;
|
||||
5. backup/restore PostgreSQL и MinIO с реальной проверкой восстановления;
|
||||
6. пагинация/сортировка списочных API и индексы PostgreSQL;
|
||||
7. accessibility и Lighthouse;
|
||||
8. production-профиль и финальное обновление README.
|
||||
1. защита официального импорта от конкурентных запусков;
|
||||
2. полный bootstrap-тест и исправление seed для частично заполненной БД;
|
||||
3. security-аудит admin/CORS/headers/secrets и повторной загрузки скриншота;
|
||||
4. backup/restore PostgreSQL и MinIO с реальной проверкой восстановления;
|
||||
5. пагинация/сортировка списочных API и индексы PostgreSQL;
|
||||
6. accessibility и Lighthouse;
|
||||
7. production-профиль и финальное обновление README.
|
||||
|
||||
После каждого пункта необходимо:
|
||||
|
||||
|
||||
@@ -50,18 +50,22 @@ Detail-страница дополнительно содержит ветер,
|
||||
- `rf4db` + UUID;
|
||||
- `rf4stat-fishing` + числовой ID;
|
||||
- `rf4stat-post` + `post_id:fish_index`.
|
||||
- `rf4map` + числовой ID отдельного наблюдения;
|
||||
- `rf4posts-spot` + `spot_uuid:fish_slug`.
|
||||
|
||||
Следующий слой импорта должен хранить `source_system` отдельно от внешнего ID. Автоматическое объединение RF4DB и RF4-STAT пока запрещено: одна и та же публикация может присутствовать в обоих агрегаторах, но надёжного общего первичного ключа нет.
|
||||
|
||||
## Реализовано
|
||||
|
||||
- `rf4_research/community_sources.py` — три fail-closed HTML-парсера;
|
||||
- `rf4_research/community_sources.py` — пять fail-closed HTML-парсеров;
|
||||
- `parse_rf4db_detail` — отдельное обогащение одного RF4DB-улова;
|
||||
- `python -m rf4_research.community_cli` — read-only CLI одного ограниченного снимка;
|
||||
- обезличенные минимальные фикстуры для каждого контракта;
|
||||
- тесты всех извлекаемых полей, locked-координат и отказа на постороннем HTML;
|
||||
- живой контрольный прогон без сохранения персональных данных и изображений в репозиторий.
|
||||
|
||||
RF4MAP и RF4 Posts остаются исследовательскими: на живых HTML-снимках получены соответственно 30 индивидуальных наблюдений и 6 видов рыб одной точки. Оба результата укладываются в nullable-контракт, имеют отдельные пространства ID и не смешиваются с RF4DB/RF4-STAT. Они не заведены в staging до согласования условий использования; RF4 Posts дополнительно требует отдельного решения, поскольку пост описывает точку, а не индивидуальный улов.
|
||||
|
||||
## Перед продуктивным импортом
|
||||
|
||||
1. Сохранить подтверждение разрешения и согласованные лимиты запросов.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Аудит источников и парсеров
|
||||
|
||||
Дата проверки: **3 сентября 2026 года**. Аудит охватывает код репозитория, один контрольный запрос к официальной таблице и публично описанные возможности потенциальных источников. Владелец RF4 Spotter позднее подтвердил наличие разрешений на получение данных RF4DB и RF4-STAT; технический результат пилота описан в `docs/community-source-pilot.md`.
|
||||
Дата последней проверки: **5 сентября 2026 года**. Аудит охватывает код репозитория, контрольные запросы к публичным HTML-страницам и публично описанные возможности потенциальных источников. Владелец RF4 Spotter подтвердил наличие разрешений на получение данных RF4DB и RF4-STAT; для новых кандидатов такого подтверждения пока нет.
|
||||
|
||||
## Итог
|
||||
|
||||
@@ -59,6 +59,19 @@ Telegram, Discord и VK могут давать свежие координат
|
||||
|
||||
Пилотные парсеры публичных `/fishing/` и `/posts/` добавлены с соблюдением `Crawl-delay`. Заблокированные координаты, погода, комментарии и интерактивная статистика не извлекаются. Данные, пришедшие туда с официального сайта, нельзя считать вторым независимым подтверждением.
|
||||
|
||||
### Исследовательские адаптеры — RF4MAP и RF4 Posts
|
||||
|
||||
Публичная detail-страница RF4MAP содержит в серверном HTML устойчивый ID наблюдения, координаты, ID и название рыбы, ID водоёма, дату, клипсу, необязательные приманку, автора и изображения. Контрольная точка содержала **30 отдельных наблюдений**. Веса нет. `robots.txt` разрешает публичные страницы и запрещает `/api/`; исследование использует только HTML одной страницы.
|
||||
|
||||
Публичная detail-страница RF4 Posts содержит устойчивый UUID точки, координаты, slug водоёма, список slug рыб, способ ловли, оснастку, клипсу, дату и ссылки на доказательства. Русская локализация позволяет связать slug с отображаемым названием. Контрольный пост дал **6 записей видов рыб из одной точки**. Это инструкция по точке, а не шесть доказанных индивидуальных уловов, поэтому вес остаётся `null`, а происхождение сохраняет общий UUID поста.
|
||||
|
||||
Оба fail-closed парсера добавлены только в read-only исследовательский CLI. Они совместимы с `ExternalCatch`, но не включены в `data_source`, staging или расписание: сначала нужны разрешение, лимиты, правила атрибуции/изображений и решение о том, допустимо ли считать RF4 Posts наблюдением улова.
|
||||
|
||||
Также проверены два менее пригодных кандидата:
|
||||
|
||||
- `rf4pro.com` отдаёт минимальную SPA-оболочку, а `robots.txt` запрещает `/api/` и JSON; защищённые маршруты не исследовались, адаптер не добавлен;
|
||||
- `rr4farmtrof.com/RF4Records/` загружает таблицу клиентским запросом и по смыслу агрегирует официальные рекорды; это не независимое подтверждение, endpoint не запрашивался и адаптер не добавлен.
|
||||
|
||||
### Приоритет C — справочники
|
||||
|
||||
Списки рыб, водоёмов, снастей, трофейных весов и переводов полезны для канонизации, но не для оценки текущего клёва. Источниками-кандидатами являются официальные страницы/патчноуты и разрешённый справочный экспорт партнёра. Нужны версия игры, язык и устойчивый внутренний ключ; сопоставление только по отображаемому имени недостаточно.
|
||||
@@ -110,4 +123,7 @@ quality: moderation_status, source_confidence
|
||||
- официальный недельный рейтинг: <https://rf4game.de/records/weekly/region/RU/>
|
||||
- RF4DB: <https://rf4db.com/ru> и <https://rf4db.com/ru/about/data>
|
||||
- RF4-STAT: <https://rf4-stat.ru/help/>
|
||||
- RF4MAP: <https://rf4map.ru/>
|
||||
- RF4 Posts: <https://rf4-posts.com/ru/about>
|
||||
- отложенные кандидаты: <https://rf4pro.com/> и <https://rr4farmtrof.com/RF4Records/>
|
||||
- исследовательский проект: <https://github.com/hurfy/rf4-api>
|
||||
|
||||
@@ -6,7 +6,13 @@ import sys
|
||||
from dataclasses import asdict
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from .community_sources import parse_rf4db_catches, parse_rf4stat_fishing, parse_rf4stat_posts
|
||||
from .community_sources import (
|
||||
parse_rf4db_catches,
|
||||
parse_rf4map_point,
|
||||
parse_rf4posts_spot,
|
||||
parse_rf4stat_fishing,
|
||||
parse_rf4stat_posts,
|
||||
)
|
||||
|
||||
|
||||
SOURCES = {
|
||||
@@ -14,6 +20,10 @@ SOURCES = {
|
||||
"rf4stat-fishing": ("https://rf4-stat.ru/fishing/", parse_rf4stat_fishing),
|
||||
"rf4stat-posts": ("https://rf4-stat.ru/posts/", parse_rf4stat_posts),
|
||||
}
|
||||
DETAIL_SOURCES = {
|
||||
"rf4map-point": parse_rf4map_point,
|
||||
"rf4posts-spot": parse_rf4posts_spot,
|
||||
}
|
||||
USER_AGENT = "RF4-Spotter/0.1 (authorized data integration)"
|
||||
|
||||
|
||||
@@ -27,14 +37,17 @@ def fetch_html(url: str, *, timeout: float = 30) -> str:
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Fetch one authorized RF4 community source page")
|
||||
parser.add_argument("source", choices=SOURCES)
|
||||
parser.add_argument("source", choices=(*SOURCES, *DETAIL_SOURCES))
|
||||
parser.add_argument("--url", help="Override the configured public page URL")
|
||||
parser.add_argument("--limit", type=int, default=100, choices=range(1, 501), metavar="1..500")
|
||||
args = parser.parse_args(argv)
|
||||
default_url, parse = SOURCES[args.source]
|
||||
if args.source in DETAIL_SOURCES and not args.url:
|
||||
parser.error(f"--url is required for {args.source}")
|
||||
default_url, parse = SOURCES.get(args.source, (None, DETAIL_SOURCES.get(args.source)))
|
||||
url = args.url or default_url
|
||||
try:
|
||||
records = parse(fetch_html(url))[:args.limit]
|
||||
html = fetch_html(url)
|
||||
records = (parse(html, source_url=url) if args.source in DETAIL_SOURCES else parse(html))[:args.limit]
|
||||
except Exception as exc:
|
||||
print(f"community source failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, time, timezone
|
||||
@@ -83,6 +84,49 @@ def _weight(raw: str) -> int | None:
|
||||
return round(float(compact) * 1000) if unit in {"кг", "kg"} else int(compact.replace(".", ""))
|
||||
|
||||
|
||||
def _next_payloads(html: str) -> list[str]:
|
||||
"""Decode the string payloads emitted by Next.js' server components."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
result: list[str] = []
|
||||
pattern = re.compile(r'^self\.__next_f\.push\(\[1,("(?:\\.|[^"\\])*")\]\)$', re.DOTALL)
|
||||
for script in soup.find_all("script"):
|
||||
raw = script.string or ""
|
||||
match = pattern.fullmatch(raw.strip())
|
||||
if not match:
|
||||
continue
|
||||
try:
|
||||
result.append(json.loads(match.group(1)))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
def _json_after_marker(
|
||||
payloads: list[str], marker: str, *, required_key: str | None = None,
|
||||
) -> dict[str, object] | None:
|
||||
decoder = json.JSONDecoder()
|
||||
for payload in payloads:
|
||||
offset = 0
|
||||
while (start := payload.find(marker, offset)) >= 0:
|
||||
offset = start + len(marker)
|
||||
try:
|
||||
value, _ = decoder.raw_decode(payload[offset:])
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(value, dict) and (required_key is None or required_key in value):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _datetime(raw: object) -> datetime | None:
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_rf4db_catches(html: str, *, base_url: str = "https://rf4db.com") -> list[ExternalCatch]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
result: list[ExternalCatch] = []
|
||||
@@ -243,3 +287,87 @@ def parse_rf4stat_posts(
|
||||
if not result:
|
||||
raise CommunityParseError("RF4-STAT posts not found")
|
||||
return result
|
||||
|
||||
|
||||
def parse_rf4map_point(html: str, *, source_url: str) -> list[ExternalCatch]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
lake_link = soup.select_one('a[href^="/lakes/"]')
|
||||
waterbody = _text(lake_link)
|
||||
waterbody_id = _key(lake_link.get("href")) if lake_link else None
|
||||
points = _json_after_marker(_next_payloads(html), '"points":')
|
||||
items = points.get("items") if points else None
|
||||
if not waterbody or not waterbody_id or not isinstance(items, list):
|
||||
raise CommunityParseError("RF4MAP point payload not found")
|
||||
|
||||
result: list[ExternalCatch] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict) or not isinstance(item.get("id"), int):
|
||||
continue
|
||||
fish = item.get("fish")
|
||||
bait = item.get("bait")
|
||||
evidence = item.get("imageUrls")
|
||||
if not isinstance(fish, dict) or not isinstance(fish.get("name"), str):
|
||||
continue
|
||||
if not isinstance(evidence, list):
|
||||
evidence = []
|
||||
result.append(ExternalCatch(
|
||||
source_system="rf4map", source_external_id=str(item["id"]), source_url=source_url,
|
||||
fish=str(fish["name"]), fish_external_id=str(fish["id"]) if isinstance(fish.get("id"), int) else None,
|
||||
waterbody=waterbody, waterbody_external_id=waterbody_id,
|
||||
x=item.get("positionX") if isinstance(item.get("positionX"), int) else None,
|
||||
y=item.get("positionY") if isinstance(item.get("positionY"), int) else None,
|
||||
weight_g=None,
|
||||
bait=str(bait["name"]) if isinstance(bait, dict) and isinstance(bait.get("name"), str) else None,
|
||||
rig_type=None, game_time=None, published_at=_datetime(item.get("createdAt")),
|
||||
player_name=item.get("authorName") if isinstance(item.get("authorName"), str) else None,
|
||||
weather=None, water_temperature_c=None,
|
||||
clip=str(item["clip"]) if isinstance(item.get("clip"), (int, float)) else None,
|
||||
fishing_style=None,
|
||||
evidence_urls=tuple(url for url in evidence or [] if isinstance(url, str)),
|
||||
))
|
||||
if not result:
|
||||
raise CommunityParseError("RF4MAP point observations not found")
|
||||
return result
|
||||
|
||||
|
||||
def parse_rf4posts_spot(html: str, *, source_url: str) -> list[ExternalCatch]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
spot = _json_after_marker(_next_payloads(html), '"spot":', required_key="id")
|
||||
species = spot.get("fishSpecies") if spot else None
|
||||
spot_id = spot.get("id") if spot else None
|
||||
if not isinstance(spot_id, str) or not isinstance(species, list) or not species:
|
||||
raise CommunityParseError("RF4 Posts spot payload not found")
|
||||
|
||||
waterbody = _text(soup.select_one("h1"))
|
||||
fish_list = soup.select_one('[role="list"][aria-label]')
|
||||
fish_names = [_text(node) for node in fish_list.select('[role="listitem"]')] if fish_list else []
|
||||
if not waterbody or len(fish_names) != len(species):
|
||||
raise CommunityParseError("RF4 Posts localized spot labels not found")
|
||||
x, y = _coordinates(str(spot.get("coordinates", "")))
|
||||
screenshots = spot.get("screenshots")
|
||||
if not isinstance(screenshots, list):
|
||||
screenshots = []
|
||||
evidence = tuple(
|
||||
str(item["url"]) for item in screenshots or []
|
||||
if isinstance(item, dict) and isinstance(item.get("url"), str)
|
||||
)
|
||||
result: list[ExternalCatch] = []
|
||||
for fish_id, fish_name in zip(species, fish_names, strict=True):
|
||||
if not isinstance(fish_id, str):
|
||||
continue
|
||||
result.append(ExternalCatch(
|
||||
source_system="rf4posts-spot", source_external_id=f"{spot_id}:{fish_id}", source_url=source_url,
|
||||
fish=fish_name, fish_external_id=fish_id,
|
||||
waterbody=waterbody,
|
||||
waterbody_external_id=str(spot["waterBody"]) if isinstance(spot.get("waterBody"), str) else None,
|
||||
x=x, y=y, weight_g=None, bait=None,
|
||||
rig_type=str(spot["bottomRigType"]) if isinstance(spot.get("bottomRigType"), str) else None,
|
||||
game_time=None, published_at=_datetime(spot.get("createdAt")), player_name=None,
|
||||
weather=None, water_temperature_c=None,
|
||||
clip=str(spot["clip"]) if isinstance(spot.get("clip"), (int, float)) else None,
|
||||
fishing_style=str(spot["tackleType"]) if isinstance(spot.get("tackleType"), str) else None,
|
||||
evidence_urls=evidence,
|
||||
))
|
||||
if not result:
|
||||
raise CommunityParseError("RF4 Posts fish species not found")
|
||||
return result
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
<html><body>
|
||||
<a href="/lakes/16">оз. Комариное</a>
|
||||
<script>self.__next_f.push([1,"30:[\"$\",\"component\",null,{\"points\":{\"items\":[{\"id\":37100,\"positionX\":53,\"positionY\":87,\"clip\":8,\"createdAt\":\"2026-09-05T00:10:04.927Z\",\"authorName\":null,\"fish\":{\"id\":98,\"name\":\"Линь\"},\"bait\":null,\"imageUrls\":[]},{\"id\":36867,\"positionX\":53,\"positionY\":87,\"clip\":8,\"createdAt\":\"2026-08-29T12:23:39.766Z\",\"authorName\":\"Gamer\",\"fish\":{\"id\":50,\"name\":\"Карась золотой\"},\"bait\":{\"id\":3130,\"name\":\"Тесто медовое\"},\"imageUrls\":[\"https://img.example.test/proof.jpg\"]}]}}]\n"])</script>
|
||||
</body></html>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<html><body>
|
||||
<h1>Озеро Янтарное</h1>
|
||||
<div role="list" aria-label="Виды рыб"><span role="listitem">Карп чешуйчатый</span><span role="listitem">Линь</span></div>
|
||||
<script>self.__next_f.push([1,"5:[\"$\",\"component\",null,{\"spot\":{\"id\":\"d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee\",\"waterBody\":\"lake_amber\",\"tackleType\":\"feeder\",\"clip\":8,\"bottomRigType\":\"method_popup\",\"fishSpecies\":[\"carp\",\"tench\"],\"coordinates\":\"132:147\",\"createdAt\":\"2026-09-04T00:46:28.477Z\",\"screenshots\":[{\"url\":\"https://img.example.test/spot.png\"}]}}]\n"])</script>
|
||||
</body></html>
|
||||
@@ -7,6 +7,8 @@ from rf4_research.community_sources import (
|
||||
CommunityParseError,
|
||||
parse_rf4db_catches,
|
||||
parse_rf4db_detail,
|
||||
parse_rf4map_point,
|
||||
parse_rf4posts_spot,
|
||||
parse_rf4stat_fishing,
|
||||
parse_rf4stat_posts,
|
||||
)
|
||||
@@ -69,7 +71,53 @@ def test_parses_rf4stat_posts_without_using_locked_coordinates() -> None:
|
||||
assert row.evidence_urls == ("https://img.example.test/proof.jpg",)
|
||||
|
||||
|
||||
def test_parses_rf4map_individual_observations_into_common_contract() -> None:
|
||||
rows = parse_rf4map_point(
|
||||
fixture("rf4map_point_sample.html"), source_url="https://rf4map.ru/points/275",
|
||||
)
|
||||
|
||||
assert len(rows) == 2
|
||||
assert (rows[0].source_system, rows[0].source_external_id) == ("rf4map", "37100")
|
||||
assert (rows[0].fish, rows[0].fish_external_id) == ("Линь", "98")
|
||||
assert (rows[0].waterbody, rows[0].waterbody_external_id) == ("оз. Комариное", "16")
|
||||
assert (rows[0].x, rows[0].y, rows[0].weight_g, rows[0].clip) == (53, 87, None, "8")
|
||||
assert rows[1].bait == "Тесто медовое"
|
||||
assert rows[1].evidence_urls == ("https://img.example.test/proof.jpg",)
|
||||
|
||||
|
||||
def test_parses_rf4posts_aggregate_spot_without_inventing_weights() -> None:
|
||||
rows = parse_rf4posts_spot(
|
||||
fixture("rf4posts_spot_sample.html"),
|
||||
source_url="https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee",
|
||||
)
|
||||
|
||||
assert [row.fish for row in rows] == ["Карп чешуйчатый", "Линь"]
|
||||
assert rows[0].source_external_id.endswith(":carp")
|
||||
assert (rows[0].x, rows[0].y, rows[0].weight_g) == (132, 147, None)
|
||||
assert (rows[0].rig_type, rows[0].fishing_style, rows[0].clip) == ("method_popup", "feeder", "8")
|
||||
assert rows[0].evidence_urls == ("https://img.example.test/spot.png",)
|
||||
|
||||
|
||||
def test_new_sources_keep_source_namespaces_for_compatible_records() -> None:
|
||||
rf4map = parse_rf4map_point(
|
||||
fixture("rf4map_point_sample.html"), source_url="https://rf4map.ru/points/275",
|
||||
)[0]
|
||||
rf4posts = parse_rf4posts_spot(
|
||||
fixture("rf4posts_spot_sample.html"), source_url="https://rf4-posts.com/ru/spots/example",
|
||||
)[1]
|
||||
|
||||
assert rf4map.fish == rf4posts.fish == "Линь"
|
||||
assert rf4map.source_system != rf4posts.source_system
|
||||
assert rf4map.source_external_id != rf4posts.source_external_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("parser", [parse_rf4db_catches, parse_rf4stat_fishing, parse_rf4stat_posts])
|
||||
def test_parsers_reject_unrelated_html(parser) -> None:
|
||||
with pytest.raises(CommunityParseError):
|
||||
parser("<html><body>not a source page</body></html>")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("parser", [parse_rf4map_point, parse_rf4posts_spot])
|
||||
def test_detail_parsers_reject_unrelated_html(parser) -> None:
|
||||
with pytest.raises(CommunityParseError):
|
||||
parser("<html><body>not a source page</body></html>", source_url="https://example.test/item/1")
|
||||
|
||||
Reference in New Issue
Block a user