diff --git a/README.md b/README.md index db7b9a8..8aec0c1 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ RF4 Spotter — неофициальный сервис свежих точек - для полного пользовательского сценария добавлен E2E-тест `отправка → pending → модерация → публичная статистика`; - начат этап 4: формула индекса зафиксирована, детерминированные агрегаты и правила включения данных покрыты тестами; далее — сквозная проверка фильтров; - RF4DB/RF4-STAT загружаются в изолированный staging; добавлены канонические алиасы и ручная очередь публикации. +- для RF4MAP и RF4 Posts готовы только read-only исследовательские detail-парсеры; продуктивное подключение ожидает согласования условий. Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). @@ -23,9 +24,11 @@ Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull 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, не влияющий на публичную статистику: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4eae948..b1eab8e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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`, сквозной контрактный тест). diff --git a/docs/community-source-pilot.md b/docs/community-source-pilot.md index 5e854f6..33832a5 100644 --- a/docs/community-source-pilot.md +++ b/docs/community-source-pilot.md @@ -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. Сохранить подтверждение разрешения и согласованные лимиты запросов. diff --git a/docs/data-source-audit.md b/docs/data-source-audit.md index 85300e9..05a2892 100644 --- a/docs/data-source-audit.md +++ b/docs/data-source-audit.md @@ -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 - официальный недельный рейтинг: - RF4DB: и - RF4-STAT: +- RF4MAP: +- RF4 Posts: +- отложенные кандидаты: и - исследовательский проект: diff --git a/rf4_research/community_cli.py b/rf4_research/community_cli.py index 356ddd7..8c2890a 100644 --- a/rf4_research/community_cli.py +++ b/rf4_research/community_cli.py @@ -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 diff --git a/rf4_research/community_sources.py b/rf4_research/community_sources.py index 54f8c75..e79bcfa 100644 --- a/rf4_research/community_sources.py +++ b/rf4_research/community_sources.py @@ -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 diff --git a/tests/fixtures/rf4map_point_sample.html b/tests/fixtures/rf4map_point_sample.html new file mode 100644 index 0000000..6b6750a --- /dev/null +++ b/tests/fixtures/rf4map_point_sample.html @@ -0,0 +1,4 @@ + +оз. Комариное + + diff --git a/tests/fixtures/rf4posts_spot_sample.html b/tests/fixtures/rf4posts_spot_sample.html new file mode 100644 index 0000000..4737b91 --- /dev/null +++ b/tests/fixtures/rf4posts_spot_sample.html @@ -0,0 +1,5 @@ + +

Озеро Янтарное

+
Карп чешуйчатыйЛинь
+ + diff --git a/tests/test_community_sources.py b/tests/test_community_sources.py index 805f00e..5506c1f 100644 --- a/tests/test_community_sources.py +++ b/tests/test_community_sources.py @@ -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("not a source page") + + +@pytest.mark.parametrize("parser", [parse_rf4map_point, parse_rf4posts_spot]) +def test_detail_parsers_reject_unrelated_html(parser) -> None: + with pytest.raises(CommunityParseError): + parser("not a source page", source_url="https://example.test/item/1")