From 16d64606f8618c59917547aac3da931a5ca6d566 Mon Sep 17 00:00:00 2001 From: IK Date: Thu, 3 Sep 2026 18:37:09 +0700 Subject: [PATCH] feat: add RF4DB and RF4-STAT research parsers --- README.md | 12 +- docs/ROADMAP.md | 5 +- docs/community-source-pilot.md | 70 ++++++ docs/data-source-audit.md | 10 +- rf4_research/community_cli.py | 46 ++++ rf4_research/community_sources.py | 245 +++++++++++++++++++++ tests/fixtures/rf4db_catches_sample.html | 7 + tests/fixtures/rf4db_detail_sample.html | 12 + tests/fixtures/rf4stat_fishing_sample.html | 1 + tests/fixtures/rf4stat_posts_sample.html | 1 + tests/test_community_sources.py | 75 +++++++ 11 files changed, 477 insertions(+), 7 deletions(-) create mode 100644 docs/community-source-pilot.md create mode 100644 rf4_research/community_cli.py create mode 100644 rf4_research/community_sources.py create mode 100644 tests/fixtures/rf4db_catches_sample.html create mode 100644 tests/fixtures/rf4db_detail_sample.html create mode 100644 tests/fixtures/rf4stat_fishing_sample.html create mode 100644 tests/fixtures/rf4stat_posts_sample.html create mode 100644 tests/test_community_sources.py diff --git a/README.md b/README.md index 5fb6c60..82527b5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,17 @@ RF4 Spotter — неофициальный сервис свежих точек Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). -Актуальная инвентаризация источников, проверка парсеров и правила подключения новых адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Сейчас проект использует официальный HTML рекордов и собственную модерируемую форму; сторонние базы не импортируются без согласования. +Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md); эти данные пока разбираются в общий промежуточный контракт, но не импортируются в рабочую БД и не влияют на индекс. + +Один ограниченный снимок публичных карточек можно получить исследовательским CLI: + +```bash +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 +``` + +Команды печатают нормализованный JSON в stdout и ничего не записывают в базу. Для регулярного получения необходимо соблюдать согласованные лимиты; для RF4-STAT — не менее пяти секунд между запросами разных страниц. ## Запуск через Docker diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0773dbc..2c2ee0f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -71,7 +71,10 @@ - [ ] Добавить `data_source` и языковые/версионные алиасы рыб, водоёмов и приманок до подключения второго автоматического источника. - [ ] Вынести общий официальный DOM-парсер, устранив дублирование исследовательской и продуктивной реализации. - [ ] Добавить отдельную фикстуру и безопасный ручной импорт недельных официальных рекордов одной категории. -- [ ] Получить явное разрешение или документированный API/экспорт RF4DB и RF4-STAT до реализации адаптеров. +- [x] Получено подтверждение владельца проекта о разрешениях RF4DB и RF4-STAT; добавлены пилотные HTML-парсеры и отчёт `docs/community-source-pilot.md`. +- [x] Добавлены общий nullable-контракт, парсер detail-страницы RF4DB и ограниченный read-only CLI для RF4DB/RF4-STAT. +- [ ] Зафиксировать сами подтверждения разрешений и согласованные лимиты/атрибуцию в репозитории или закрытой операционной документации. +- [ ] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс. - [ ] Согласовать один добровольный канал сообщества и правила происхождения, модерации и удаления сообщений. ## Этап 5 — пилот diff --git a/docs/community-source-pilot.md b/docs/community-source-pilot.md new file mode 100644 index 0000000..249b3fd --- /dev/null +++ b/docs/community-source-pilot.md @@ -0,0 +1,70 @@ +# Пилот парсинга RF4DB и RF4-STAT + +Дата контрольного запуска: **3 сентября 2026 года**. Владелец RF4 Spotter подтвердил наличие разрешений на получение данных из обоих сервисов. Пилот использует только публичный HTML, не обращается к закрытым API, не обходит авторизацию/Premium и не скачивает изображения. + +## RF4DB + +Проверенная страница: `https://download.rf4db.com/ru/catches`. + +HTML формируется Next.js, но готовые карточки присутствуют в серверном ответе. Из карточки извлекаются: + +- UUID улова и каноническая detail-ссылка; +- русское название и внешний slug рыбы; +- русское название и внешний ID водоёма; +- координаты; +- игровое время; +- приманка и её внешний ID; +- оснастка; +- погода и температура воды. + +На контрольной странице найдено 24 элемента `catch-card`: 23 являлись уловами с detail-ссылкой, один — неполным/служебным элементом и безопасно пропущен. У всех 23 уловов были координаты. Вес, игрок и абсолютное время публикации отсутствовали и не подменяются догадками. + +Detail-страница дополнительно содержит ветер, клипсу, выпуск лески, направление заброса и полный список снастей, но по-прежнему не содержит вес. Добавлен отдельный `RF4DBCatchDetail`: контрольная живая страница вернула ветер, три числовых параметра заброса и 11 элементов снасти. Detail-запросы не выполняются автоматически для всей выдачи, чтобы не умножать нагрузку на источник. + +`robots.txt` на download-домене запрещает автоматический обход для `User-Agent: *` и `/api/`. Наличие отдельного разрешения нужно сохранить в документации проекта. Публичный API не исследовался и не использовался; для пилота взят только серверный HTML одной страницы. + +## RF4-STAT + +Проверенные страницы: + +- `https://rf4-stat.ru/fishing/`; +- `https://rf4-stat.ru/posts/`; +- `https://rf4-stat.ru/active-spots/`. + +Запросы выполнялись последовательно с `Crawl-delay: 5`, указанным в `robots.txt`. + +Из таблицы `/fishing/` извлекаются ID, ссылка, рыба и slug, вес, водоём, приманка, игрок, время публикации, клипса, стиль ловли и ссылка на изображение. Контрольный результат: **100 из 100 строк**. Вес присутствовал во всех 100 строках; координаты в публичном представлении были замаскированы и не извлекались. + +Из `/posts/` один пост разворачивается в одно или несколько наблюдений по числу рыб. Извлекаются ID `post:fish_index`, время публикации Unix, рыба, вес, водоём, приманки, игрок, клипса, стиль и URL миниатюр-доказательств. Контрольный результат: **16 наблюдений из 10 постов**, у всех 16 были вес и доказательства. Координаты и погода, помеченные `position-locked`/`locked weather`, намеренно не извлекались. + +`/active-spots/` содержит десять агрегированных карточек, но публично видимые координаты и интерактивная статистика заблокированы, а стабильного ID наблюдения и рыбы нет. Эти карточки не преобразуются в уловы. Значения из скрытых `data-position` не используются. + +## Общий контракт + +Парсеры возвращают `ExternalCatch` и никогда не записывают данные непосредственно в БД. Поля `weight_g`, координаты, игрок и время nullable, потому что источники дополняют друг друга, но не должны склеиваться только из-за похожих значений. + +Текущие пространства идентификаторов разделены: + +- `rf4db` + UUID; +- `rf4stat-fishing` + числовой ID; +- `rf4stat-post` + `post_id:fish_index`. + +Следующий слой импорта должен хранить `source_system` отдельно от внешнего ID. Автоматическое объединение RF4DB и RF4-STAT пока запрещено: одна и та же публикация может присутствовать в обоих агрегаторах, но надёжного общего первичного ключа нет. + +## Реализовано + +- `rf4_research/community_sources.py` — три fail-closed HTML-парсера; +- `parse_rf4db_detail` — отдельное обогащение одного RF4DB-улова; +- `python -m rf4_research.community_cli` — read-only CLI одного ограниченного снимка; +- обезличенные минимальные фикстуры для каждого контракта; +- тесты всех извлекаемых полей, locked-координат и отказа на постороннем HTML; +- живой контрольный прогон без сохранения персональных данных и изображений в репозиторий. + +## Перед продуктивным импортом + +1. Сохранить подтверждение разрешения и согласованные лимиты запросов. +2. Добавить миграцию `data_source`/`source_system` и nullable-модель внешнего наблюдения либо обоснованно сделать вес nullable. +3. Ввести алиасы рыб и водоёмов по внешним slug, а не только по названию. +4. Назначить разные начальные уровни доверия для RF4DB и двух каналов RF4-STAT. +5. Добавить идемпотентную staging-загрузку и ручную модерацию перед влиянием на индекс. +6. Не хранить и не проксировать изображения без отдельного условия разрешения; на первом этапе достаточно исходной ссылки. diff --git a/docs/data-source-audit.md b/docs/data-source-audit.md index 6b8e3e2..85300e9 100644 --- a/docs/data-source-audit.md +++ b/docs/data-source-audit.md @@ -1,6 +1,6 @@ # Аудит источников и парсеров -Дата проверки: **3 сентября 2026 года**. Аудит охватывает код репозитория, один контрольный запрос к официальной таблице и публично описанные возможности потенциальных источников. Он не является разрешением на сбор чужих данных. +Дата проверки: **3 сентября 2026 года**. Аудит охватывает код репозитория, один контрольный запрос к официальной таблице и публично описанные возможности потенциальных источников. Владелец RF4 Spotter позднее подтвердил наличие разрешений на получение данных RF4DB и RF4-STAT; технический результат пилота описан в `docs/community-source-pilot.md`. ## Итог @@ -47,17 +47,17 @@ Telegram, Discord и VK могут давать свежие координат Наиболее безопасная реализация — бот или форма, куда автор сам пересылает сообщение и подтверждает распознанные поля. Это лучше скрытого чтения групп и позволяет использовать существующую очередь модерации. -### Приоритет B — RF4DB по договорённости +### Приоритет B — RF4DB (разрешение подтверждено владельцем проекта) `https://rf4db.com/ru` показывает актуальные пользовательские уловы, координаты, игровое время, погоду, снасть и изображения; публичная страница заявляет 19 водоёмов, 252 вида рыб и тысячи точек. Страница об источниках поясняет, что игровые справочники взяты из игры, а точки и комментарии собраны игроками в открытом доступе. -Технически HTML пригоден для адаптера, но это уже собранная база другого проекта. До письменного разрешения владельца нельзя делать парсер или копировать изображения. Лучший вариант — запросить документированный экспорт/API и условия атрибуции, удаления и частоты обновления. +Серверный HTML пригоден для адаптера; пилотный fail-closed парсер добавлен. Перед регулярным запуском нужно сохранить подтверждение разрешения и его условия: атрибуцию, частоту, удаление и допустимость ссылок на изображения. -### Приоритет B — RF4-STAT по договорённости +### Приоритет B — RF4-STAT (разрешение подтверждено владельцем проекта) `https://rf4-stat.ru/help/` сообщает, что рекорды берутся с официального сайта, а точки и посты — из VK, Discord, Telegram и собственной формы. Сервис обновляет записи регулярно, часть доступа является Premium. -Парсинг страниц означал бы повторный сбор уже агрегированных материалов и мог бы обходить продуктовые ограничения. Использовать источник можно только через согласованный API/выгрузку; данные, пришедшие туда с официального сайта, нельзя считать вторым независимым подтверждением. +Пилотные парсеры публичных `/fishing/` и `/posts/` добавлены с соблюдением `Crawl-delay`. Заблокированные координаты, погода, комментарии и интерактивная статистика не извлекаются. Данные, пришедшие туда с официального сайта, нельзя считать вторым независимым подтверждением. ### Приоритет C — справочники diff --git a/rf4_research/community_cli.py b/rf4_research/community_cli.py new file mode 100644 index 0000000..356ddd7 --- /dev/null +++ b/rf4_research/community_cli.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import argparse +import json +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 + + +SOURCES = { + "rf4db": ("https://download.rf4db.com/ru/catches", parse_rf4db_catches), + "rf4stat-fishing": ("https://rf4-stat.ru/fishing/", parse_rf4stat_fishing), + "rf4stat-posts": ("https://rf4-stat.ru/posts/", parse_rf4stat_posts), +} +USER_AGENT = "RF4-Spotter/0.1 (authorized data integration)" + + +def fetch_html(url: str, *, timeout: float = 30) -> str: + request = Request(url, headers={"User-Agent": USER_AGENT, "Accept": "text/html"}) + with urlopen(request, timeout=timeout) as response: + if response.headers.get_content_type() != "text/html": + raise ValueError(f"expected text/html, got {response.headers.get_content_type()}") + return response.read().decode(response.headers.get_content_charset() or "utf-8") + + +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("--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] + url = args.url or default_url + try: + records = parse(fetch_html(url))[:args.limit] + except Exception as exc: + print(f"community source 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/rf4_research/community_sources.py b/rf4_research/community_sources.py new file mode 100644 index 0000000..54f8c75 --- /dev/null +++ b/rf4_research/community_sources.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import datetime, time, timezone +from urllib.parse import urljoin + +from bs4 import BeautifulSoup, Tag + + +class CommunityParseError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class ExternalCatch: + source_system: str + source_external_id: str + source_url: str + fish: str + fish_external_id: str | None + waterbody: str + waterbody_external_id: str | None + x: int | None + y: int | None + weight_g: int | None + bait: str | None + rig_type: str | None + game_time: time | None + published_at: datetime | None + player_name: str | None + weather: str | None + water_temperature_c: float | None + clip: str | None + fishing_style: str | None + evidence_urls: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class EquipmentItem: + kind: str + name: str + external_id: str | None + + +@dataclass(frozen=True, slots=True) +class RF4DBCatchDetail: + source_external_id: str + source_url: str + wind: str | None + line_release_m: float | None + clip_m: float | None + cast_direction_deg: float | None + equipment: tuple[EquipmentItem, ...] + + +def _text(node: Tag | None) -> str: + return " ".join(node.get_text(" ", strip=True).split()) if node else "" + + +def _key(href: str | None) -> str | None: + if not href: + return None + return href.rstrip("/").rsplit("/", 1)[-1] + + +def _coordinates(raw: str) -> tuple[int | None, int | None]: + match = re.fullmatch(r"\s*(-?\d{1,5}):(-?\d{1,5})\s*", raw) + return (int(match.group(1)), int(match.group(2))) if match else (None, None) + + +def _game_time(raw: str) -> time | None: + match = re.search(r"Игровое время\s*(\d{1,2}):(\d{2})", raw) + return time(int(match.group(1)), int(match.group(2))) if match else None + + +def _weight(raw: str) -> int | None: + match = re.search(r"([\d\s.,]+)\s*(кг|kg|г|g)\b", raw.casefold()) + if not match: + return None + number, unit = match.groups() + compact = number.replace(" ", "").replace(",", ".") + return round(float(compact) * 1000) if unit in {"кг", "kg"} else int(compact.replace(".", "")) + + +def parse_rf4db_catches(html: str, *, base_url: str = "https://rf4db.com") -> list[ExternalCatch]: + soup = BeautifulSoup(html, "html.parser") + result: list[ExternalCatch] = [] + for card in soup.select("article.catch-card"): + detail = card.select_one('a.catch-card__time[href*="/catches/"]') + fish_link = card.select_one("h2 a") + map_link = card.select_one('.catch-card__place a[href*="/maps/"]') + if not detail or not fish_link or not map_link: + continue + external_id = _key(detail.get("href")) + if not external_id: + continue + x, y = _coordinates(_text(card.select_one(".catch-card__place b"))) + bait_link = card.select_one('.catch-card__place a[href*="/wiki/baits/"]') + badges = card.select(".catch-badge") + weather = next((_text(b.select_one("b")) for b in badges if _text(b).startswith("Погода")), None) + temperature_text = next((_text(b.select_one("b")) for b in badges if _text(b).startswith("Темп. воды")), "") + temperature_match = re.search(r"-?\d+(?:[.,]\d+)?", temperature_text) + rig = card.select_one(".catch-badge--rig") + result.append(ExternalCatch( + source_system="rf4db", source_external_id=external_id, + source_url=urljoin(base_url, str(detail.get("href"))), + fish=_text(fish_link), fish_external_id=_key(fish_link.get("href")), + waterbody=_text(map_link), waterbody_external_id=_key(map_link.get("href")), + x=x, y=y, weight_g=None, bait=_text(bait_link) or None, + rig_type=_text(rig) or None, + game_time=_game_time(_text(card.select_one(".catch-card__place"))), + published_at=None, player_name=None, weather=weather or None, + water_temperature_c=float(temperature_match.group().replace(",", ".")) if temperature_match else None, + clip=None, fishing_style=None, evidence_urls=(), + )) + if not result: + raise CommunityParseError("RF4DB catch cards not found") + return result + + +def parse_rf4db_detail(html: str, *, source_url: str) -> RF4DBCatchDetail: + soup = BeautifulSoup(html, "html.parser") + root = soup.select_one("article.catch-detail") + if root is None: + raise CommunityParseError("RF4DB catch detail not found") + external_id = _key(source_url) + if not external_id: + raise CommunityParseError("RF4DB catch detail URL has no ID") + facts = {_text(row.select_one("dt")): _text(row.select_one("dd")) for row in root.select(".catch-facts > div")} + + def number(label: str) -> float | None: + match = re.search(r"-?\d+(?:[.,]\d+)?", facts.get(label, "")) + return float(match.group().replace(",", ".")) if match else None + + equipment: list[EquipmentItem] = [] + for item in root.select(".catch-equipment article"): + link = item.select_one("a[href]") + name = _text(link) + if not name: + continue + equipment.append(EquipmentItem( + kind=_text(item.select_one("small")) or "Снаряжение", + name=name, + external_id=_key(link.get("href")) if link else None, + )) + return RF4DBCatchDetail( + source_external_id=external_id, + source_url=source_url, + wind=facts.get("Ветер") or None, + line_release_m=number("Выпуск лески"), + clip_m=number("Клипса"), + cast_direction_deg=number("Направление заброса"), + equipment=tuple(equipment), + ) + + +def _rf4stat_date(raw_date: str, raw_time: str, *, now: datetime) -> datetime | None: + try: + day, month = (int(part) for part in raw_date.split(".")) + hour, minute = (int(part) for part in raw_time.split(":")) + value = datetime(now.year, month, day, hour, minute, tzinfo=timezone.utc) + if value > now: + value = value.replace(year=value.year - 1) + return value + except (TypeError, ValueError): + return None + + +def parse_rf4stat_fishing( + html: str, *, base_url: str = "https://rf4-stat.ru/", now: datetime | None = None, +) -> list[ExternalCatch]: + soup = BeautifulSoup(html, "html.parser") + now = now or datetime.now(timezone.utc) + result: list[ExternalCatch] = [] + for row in soup.select("tr.load-row"): + id_link = row.select_one('a.share[href^="fishing/"]:not(.hide-print)') + fish_link = row.select_one('.fish-icon a[href^="fish/"]') + if not id_link or not fish_link: + continue + external_id = _key(id_link.get("href")) + if not external_id: + continue + position = row.select_one(".list-position .position:not(.position-locked)") + x, y = _coordinates(_text(position)) + style = row.select_one(".post-style-icon[title]") + style_text = str(style.get("title", "")) if style else "" + result.append(ExternalCatch( + source_system="rf4stat-fishing", source_external_id=external_id, + source_url=urljoin(base_url, str(id_link.get("href"))), + fish=_text(row.select_one("td.fish a")), fish_external_id=_key(fish_link.get("href")), + waterbody=_text(row.select_one("td.mobile-location")), waterbody_external_id=None, + x=x, y=y, weight_g=_weight(_text(row.select_one("td.fish small"))), + bait=_text(row.select_one('[data-filter="bait"]')) or None, + rig_type=None, game_time=None, + published_at=_rf4stat_date(_text(row.select_one("td.time small")), _text(row.select_one("td.time > div")), now=now), + player_name=_text(row.select_one("td.list-gamer a")) or None, + weather=None, water_temperature_c=None, + clip=_text(row.select_one(".clip")) or None, + fishing_style=style_text.removeprefix("Вид ловли:").strip() or None, + evidence_urls=(urljoin(base_url, str(row.select_one("a.share.hide-print").get("href"))),) if row.select_one("a.share.hide-print") else (), + )) + if not result: + raise CommunityParseError("RF4-STAT fishing rows not found") + return result + + +def parse_rf4stat_posts( + html: str, *, base_url: str = "https://rf4-stat.ru/", +) -> list[ExternalCatch]: + soup = BeautifulSoup(html, "html.parser") + result: list[ExternalCatch] = [] + for post in soup.select(".post-item.load-row"): + post_id = str(post.get("data-post-id", "")).strip() + if not post_id: + continue + published_raw = str(post.get("data-published-at", "")) + published_at = datetime.fromtimestamp(int(published_raw), tz=timezone.utc) if published_raw.isdigit() else None + position = post.select_one(".spot-col .position:not(.position-locked)") + x, y = _coordinates(_text(position)) + style = post.select_one(".post-style-icon[title]") + style_text = str(style.get("title", "")) if style else "" + evidence = tuple(urljoin(base_url, str(image.get("src"))) for image in post.select(".screen-file-slot img[src]")) + baits = _text(post.select_one(".bait-text")) or None + for index, fish in enumerate(post.select(".fish-icon")): + fish_link = fish.select_one('a[href^="fish/"]') + fish_name = _text(fish.select_one(".fish-name")) + if not fish_name: + continue + result.append(ExternalCatch( + source_system="rf4stat-post", source_external_id=f"{post_id}:{index}", + source_url=urljoin(base_url, f"posts/{post_id}"), + fish=fish_name, fish_external_id=_key(fish_link.get("href")) if fish_link else None, + waterbody=_text(post.select_one(".location")), waterbody_external_id=None, + x=x, y=y, weight_g=_weight(_text(fish.select_one(".weight"))), + bait=baits, rig_type=None, game_time=None, published_at=published_at, + player_name=_text(post.select_one(".player")) or None, + weather=None, water_temperature_c=None, + clip=_text(post.select_one(".clip")) or None, + fishing_style=style_text.removeprefix("Вид ловли:").strip() or None, + evidence_urls=evidence, + )) + if not result: + raise CommunityParseError("RF4-STAT posts not found") + return result diff --git a/tests/fixtures/rf4db_catches_sample.html b/tests/fixtures/rf4db_catches_sample.html new file mode 100644 index 0000000..f9b396a --- /dev/null +++ b/tests/fixtures/rf4db_catches_sample.html @@ -0,0 +1,7 @@ +
+ +
diff --git a/tests/fixtures/rf4db_detail_sample.html b/tests/fixtures/rf4db_detail_sample.html new file mode 100644 index 0000000..91989d2 --- /dev/null +++ b/tests/fixtures/rf4db_detail_sample.html @@ -0,0 +1,12 @@ +
+
+
Ветер
SE 4 м/с
+
Выпуск лески
5.1 m
+
Клипса
5 m
+
Направление заброса
181.1°
+
+
+ + +
+
diff --git a/tests/fixtures/rf4stat_fishing_sample.html b/tests/fixtures/rf4stat_fishing_sample.html new file mode 100644 index 0000000..d222236 --- /dev/null +++ b/tests/fixtures/rf4stat_fishing_sample.html @@ -0,0 +1 @@ +
11:27
03.09
Тестовое озероfishЩука11 584 гТестовая приманкаИгрок71:9235
diff --git a/tests/fixtures/rf4stat_posts_sample.html b/tests/fixtures/rf4stat_posts_sample.html new file mode 100644 index 0000000..773cc71 --- /dev/null +++ b/tests/fixtures/rf4stat_posts_sample.html @@ -0,0 +1 @@ +
Тестовое озеро
XX:XX25
fish4 321 г
Щука
Тестовая приманка
Игрок
diff --git a/tests/test_community_sources.py b/tests/test_community_sources.py new file mode 100644 index 0000000..805f00e --- /dev/null +++ b/tests/test_community_sources.py @@ -0,0 +1,75 @@ +from datetime import datetime, time, timezone +from pathlib import Path + +import pytest + +from rf4_research.community_sources import ( + CommunityParseError, + parse_rf4db_catches, + parse_rf4db_detail, + parse_rf4stat_fishing, + parse_rf4stat_posts, +) + + +FIXTURES = Path(__file__).parent / "fixtures" + + +def fixture(name: str) -> str: + return (FIXTURES / name).read_text(encoding="utf-8") + + +def test_parses_rf4db_public_catch_cards() -> None: + row = parse_rf4db_catches(fixture("rf4db_catches_sample.html"))[0] + + assert (row.source_system, row.source_external_id) == ("rf4db", "11111111-1111-4111-8111-111111111111") + assert (row.fish, row.fish_external_id) == ("Щука обыкновенная", "pike") + assert (row.waterbody, row.waterbody_external_id) == ("Тестовое озеро", "test-lake") + assert (row.x, row.y, row.weight_g) == (71, 92, None) + assert row.game_time == time(6, 14) + assert row.water_temperature_c == 12.5 + + +def test_parses_rf4db_detail_equipment_and_cast_facts() -> None: + detail = parse_rf4db_detail( + fixture("rf4db_detail_sample.html"), + source_url="https://rf4db.com/ru/catches/11111111-1111-4111-8111-111111111111", + ) + + assert detail.source_external_id == "11111111-1111-4111-8111-111111111111" + assert (detail.wind, detail.line_release_m, detail.clip_m, detail.cast_direction_deg) == ("SE 4 м/с", 5.1, 5.0, 181.1) + assert [(item.kind, item.name, item.external_id) for item in detail.equipment] == [ + ("Приманка поклёвки", "Тестовая приманка", "test-lure"), + ("Катушка", "Тестовая катушка", "test-reel"), + ] + + +def test_rf4db_detail_rejects_unrelated_html() -> None: + with pytest.raises(CommunityParseError, match="detail not found"): + parse_rf4db_detail("", source_url="https://rf4db.com/ru/catches/id") + + +def test_parses_rf4stat_fishing_rows() -> None: + row = parse_rf4stat_fishing( + fixture("rf4stat_fishing_sample.html"), + now=datetime(2026, 9, 3, 12, tzinfo=timezone.utc), + )[0] + + assert (row.source_system, row.source_external_id) == ("rf4stat-fishing", "123") + assert (row.x, row.y, row.weight_g) == (71, 92, 11_584) + assert row.published_at == datetime(2026, 9, 3, 11, 27, tzinfo=timezone.utc) + assert (row.player_name, row.clip, row.fishing_style) == ("Игрок", "35", "Донная") + + +def test_parses_rf4stat_posts_without_using_locked_coordinates() -> None: + row = parse_rf4stat_posts(fixture("rf4stat_posts_sample.html"))[0] + + assert (row.source_system, row.source_external_id) == ("rf4stat-post", "456:0") + assert (row.x, row.y, row.weight_g) == (None, None, 4_321) + assert row.evidence_urls == ("https://img.example.test/proof.jpg",) + + +@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("not a source page")