feat: add RF4MAP and RF4 Posts research parsers
This commit is contained in:
@@ -10,6 +10,7 @@ RF4 Spotter — неофициальный сервис свежих точек
|
|||||||
- для полного пользовательского сценария добавлен E2E-тест `отправка → pending → модерация → публичная статистика`;
|
- для полного пользовательского сценария добавлен E2E-тест `отправка → pending → модерация → публичная статистика`;
|
||||||
- начат этап 4: формула индекса зафиксирована, детерминированные агрегаты и правила включения данных покрыты тестами; далее — сквозная проверка фильтров;
|
- начат этап 4: формула индекса зафиксирована, детерминированные агрегаты и правила включения данных покрыты тестами; далее — сквозная проверка фильтров;
|
||||||
- RF4DB/RF4-STAT загружаются в изолированный staging; добавлены канонические алиасы и ручная очередь публикации.
|
- RF4DB/RF4-STAT загружаются в изолированный staging; добавлены канонические алиасы и ручная очередь публикации.
|
||||||
|
- для RF4MAP и RF4 Posts готовы только read-only исследовательские detail-парсеры; продуктивное подключение ожидает согласования условий.
|
||||||
|
|
||||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md).
|
Подробный план и актуальные чекбоксы находятся в [`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 rf4db --limit 25
|
||||||
python -m rf4_research.community_cli rf4stat-fishing --limit 100
|
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 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, не влияющий на публичную статистику:
|
Проверенный JSON можно идемпотентно загрузить в изолированный staging, не влияющий на публичную статистику:
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,8 @@
|
|||||||
- [x] Добавить отдельную фикстуру и безопасный ручной импорт недельных официальных рекордов одной категории.
|
- [x] Добавить отдельную фикстуру и безопасный ручной импорт недельных официальных рекордов одной категории.
|
||||||
- [x] Получено подтверждение владельца проекта о разрешениях RF4DB и RF4-STAT; добавлены пилотные HTML-парсеры и отчёт `docs/community-source-pilot.md`.
|
- [x] Получено подтверждение владельца проекта о разрешениях RF4DB и RF4-STAT; добавлены пилотные HTML-парсеры и отчёт `docs/community-source-pilot.md`.
|
||||||
- [x] Добавлены общий nullable-контракт, парсер detail-страницы RF4DB и ограниченный read-only CLI для RF4DB/RF4-STAT.
|
- [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`.
|
- [x] Зафиксировать проектное подтверждение разрешений, текущую атрибуцию и консервативные пилотные лимиты в `docs/data-permissions.md`.
|
||||||
- [ ] Приложить или сослаться на первичный документ разрешения и зафиксировать точные продуктивные лимиты, срок хранения, удаление и обязательную атрибуцию до включения scheduler RF4DB/RF4-STAT.
|
- [ ] Приложить или сослаться на первичный документ разрешения и зафиксировать точные продуктивные лимиты, срок хранения, удаление и обязательную атрибуцию до включения scheduler RF4DB/RF4-STAT.
|
||||||
- [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест).
|
- [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест).
|
||||||
|
|||||||
@@ -50,18 +50,22 @@ Detail-страница дополнительно содержит ветер,
|
|||||||
- `rf4db` + UUID;
|
- `rf4db` + UUID;
|
||||||
- `rf4stat-fishing` + числовой ID;
|
- `rf4stat-fishing` + числовой ID;
|
||||||
- `rf4stat-post` + `post_id:fish_index`.
|
- `rf4stat-post` + `post_id:fish_index`.
|
||||||
|
- `rf4map` + числовой ID отдельного наблюдения;
|
||||||
|
- `rf4posts-spot` + `spot_uuid:fish_slug`.
|
||||||
|
|
||||||
Следующий слой импорта должен хранить `source_system` отдельно от внешнего ID. Автоматическое объединение RF4DB и RF4-STAT пока запрещено: одна и та же публикация может присутствовать в обоих агрегаторах, но надёжного общего первичного ключа нет.
|
Следующий слой импорта должен хранить `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-улова;
|
- `parse_rf4db_detail` — отдельное обогащение одного RF4DB-улова;
|
||||||
- `python -m rf4_research.community_cli` — read-only CLI одного ограниченного снимка;
|
- `python -m rf4_research.community_cli` — read-only CLI одного ограниченного снимка;
|
||||||
- обезличенные минимальные фикстуры для каждого контракта;
|
- обезличенные минимальные фикстуры для каждого контракта;
|
||||||
- тесты всех извлекаемых полей, locked-координат и отказа на постороннем HTML;
|
- тесты всех извлекаемых полей, locked-координат и отказа на постороннем HTML;
|
||||||
- живой контрольный прогон без сохранения персональных данных и изображений в репозиторий.
|
- живой контрольный прогон без сохранения персональных данных и изображений в репозиторий.
|
||||||
|
|
||||||
|
RF4MAP и RF4 Posts остаются исследовательскими: на живых HTML-снимках получены соответственно 30 индивидуальных наблюдений и 6 видов рыб одной точки. Оба результата укладываются в nullable-контракт, имеют отдельные пространства ID и не смешиваются с RF4DB/RF4-STAT. Они не заведены в staging до согласования условий использования; RF4 Posts дополнительно требует отдельного решения, поскольку пост описывает точку, а не индивидуальный улов.
|
||||||
|
|
||||||
## Перед продуктивным импортом
|
## Перед продуктивным импортом
|
||||||
|
|
||||||
1. Сохранить подтверждение разрешения и согласованные лимиты запросов.
|
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`. Заблокированные координаты, погода, комментарии и интерактивная статистика не извлекаются. Данные, пришедшие туда с официального сайта, нельзя считать вторым независимым подтверждением.
|
Пилотные парсеры публичных `/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 — справочники
|
### Приоритет C — справочники
|
||||||
|
|
||||||
Списки рыб, водоёмов, снастей, трофейных весов и переводов полезны для канонизации, но не для оценки текущего клёва. Источниками-кандидатами являются официальные страницы/патчноуты и разрешённый справочный экспорт партнёра. Нужны версия игры, язык и устойчивый внутренний ключ; сопоставление только по отображаемому имени недостаточно.
|
Списки рыб, водоёмов, снастей, трофейных весов и переводов полезны для канонизации, но не для оценки текущего клёва. Источниками-кандидатами являются официальные страницы/патчноуты и разрешённый справочный экспорт партнёра. Нужны версия игры, язык и устойчивый внутренний ключ; сопоставление только по отображаемому имени недостаточно.
|
||||||
@@ -110,4 +123,7 @@ quality: moderation_status, source_confidence
|
|||||||
- официальный недельный рейтинг: <https://rf4game.de/records/weekly/region/RU/>
|
- официальный недельный рейтинг: <https://rf4game.de/records/weekly/region/RU/>
|
||||||
- RF4DB: <https://rf4db.com/ru> и <https://rf4db.com/ru/about/data>
|
- RF4DB: <https://rf4db.com/ru> и <https://rf4db.com/ru/about/data>
|
||||||
- RF4-STAT: <https://rf4-stat.ru/help/>
|
- 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>
|
- исследовательский проект: <https://github.com/hurfy/rf4-api>
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import sys
|
|||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from urllib.request import Request, urlopen
|
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 = {
|
SOURCES = {
|
||||||
@@ -14,6 +20,10 @@ SOURCES = {
|
|||||||
"rf4stat-fishing": ("https://rf4-stat.ru/fishing/", parse_rf4stat_fishing),
|
"rf4stat-fishing": ("https://rf4-stat.ru/fishing/", parse_rf4stat_fishing),
|
||||||
"rf4stat-posts": ("https://rf4-stat.ru/posts/", parse_rf4stat_posts),
|
"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)"
|
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:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(description="Fetch one authorized RF4 community source page")
|
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("--url", help="Override the configured public page URL")
|
||||||
parser.add_argument("--limit", type=int, default=100, choices=range(1, 501), metavar="1..500")
|
parser.add_argument("--limit", type=int, default=100, choices=range(1, 501), metavar="1..500")
|
||||||
args = parser.parse_args(argv)
|
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
|
url = args.url or default_url
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
print(f"community source failed: {exc}", file=sys.stderr)
|
print(f"community source failed: {exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, time, timezone
|
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(".", ""))
|
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]:
|
def parse_rf4db_catches(html: str, *, base_url: str = "https://rf4db.com") -> list[ExternalCatch]:
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
result: list[ExternalCatch] = []
|
result: list[ExternalCatch] = []
|
||||||
@@ -243,3 +287,87 @@ def parse_rf4stat_posts(
|
|||||||
if not result:
|
if not result:
|
||||||
raise CommunityParseError("RF4-STAT posts not found")
|
raise CommunityParseError("RF4-STAT posts not found")
|
||||||
return result
|
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,
|
CommunityParseError,
|
||||||
parse_rf4db_catches,
|
parse_rf4db_catches,
|
||||||
parse_rf4db_detail,
|
parse_rf4db_detail,
|
||||||
|
parse_rf4map_point,
|
||||||
|
parse_rf4posts_spot,
|
||||||
parse_rf4stat_fishing,
|
parse_rf4stat_fishing,
|
||||||
parse_rf4stat_posts,
|
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",)
|
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])
|
@pytest.mark.parametrize("parser", [parse_rf4db_catches, parse_rf4stat_fishing, parse_rf4stat_posts])
|
||||||
def test_parsers_reject_unrelated_html(parser) -> None:
|
def test_parsers_reject_unrelated_html(parser) -> None:
|
||||||
with pytest.raises(CommunityParseError):
|
with pytest.raises(CommunityParseError):
|
||||||
parser("<html><body>not a source page</body></html>")
|
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