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")