feat: add RF4MAP and RF4 Posts research parsers
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-05 07:36:35 +07:00
parent 9cc3d44a49
commit 1a09bd59f3
9 changed files with 230 additions and 7 deletions
+4 -1
View File
@@ -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, не влияющий на публичную статистику:
+2
View File
@@ -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`, сквозной контрактный тест).
+5 -1
View File
@@ -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. Сохранить подтверждение разрешения и согласованные лимиты запросов.
+17 -1
View File
@@ -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>
+17 -4
View File
@@ -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
+128
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+48
View File
@@ -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")