feat: add RF4DB and RF4-STAT research parsers
This commit is contained in:
@@ -12,7 +12,17 @@ RF4 Spotter — неофициальный сервис свежих точек
|
|||||||
|
|
||||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md).
|
Подробный план и актуальные чекбоксы находятся в [`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
|
## Запуск через Docker
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -71,7 +71,10 @@
|
|||||||
- [ ] Добавить `data_source` и языковые/версионные алиасы рыб, водоёмов и приманок до подключения второго автоматического источника.
|
- [ ] Добавить `data_source` и языковые/версионные алиасы рыб, водоёмов и приманок до подключения второго автоматического источника.
|
||||||
- [ ] Вынести общий официальный DOM-парсер, устранив дублирование исследовательской и продуктивной реализации.
|
- [ ] Вынести общий официальный 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 — пилот
|
## Этап 5 — пилот
|
||||||
|
|||||||
@@ -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. Не хранить и не проксировать изображения без отдельного условия разрешения; на первом этапе достаточно исходной ссылки.
|
||||||
@@ -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 вида рыб и тысячи точек. Страница об источниках поясняет, что игровые справочники взяты из игры, а точки и комментарии собраны игроками в открытом доступе.
|
`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.
|
`https://rf4-stat.ru/help/` сообщает, что рекорды берутся с официального сайта, а точки и посты — из VK, Discord, Telegram и собственной формы. Сервис обновляет записи регулярно, часть доступа является Premium.
|
||||||
|
|
||||||
Парсинг страниц означал бы повторный сбор уже агрегированных материалов и мог бы обходить продуктовые ограничения. Использовать источник можно только через согласованный API/выгрузку; данные, пришедшие туда с официального сайта, нельзя считать вторым независимым подтверждением.
|
Пилотные парсеры публичных `/fishing/` и `/posts/` добавлены с соблюдением `Crawl-delay`. Заблокированные координаты, погода, комментарии и интерактивная статистика не извлекаются. Данные, пришедшие туда с официального сайта, нельзя считать вторым независимым подтверждением.
|
||||||
|
|
||||||
### Приоритет C — справочники
|
### Приоритет C — справочники
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
<main>
|
||||||
|
<article class="catch-card"><div class="catch-card__body"><div class="catch-card__main">
|
||||||
|
<h2><a href="/ru/fishes/pike">Щука обыкновенная</a></h2>
|
||||||
|
<p class="catch-card__place"><span><a href="/ru/maps/test-lake">Тестовое озеро</a></span> · <b>71:92</b> · <small>Игровое время 06:14</small> · <small>Наживка <a href="/ru/wiki/baits/test-lure">Тестовая приманка</a></small></p>
|
||||||
|
<div class="catch-card__badges"><span class="catch-badge">Погода <b>Ясно</b></span><span class="catch-badge">Темп. воды <b>12.5°C</b></span><span class="catch-badge catch-badge--rig">Искусственная приманка</span></div>
|
||||||
|
</div><a class="catch-card__time" href="/ru/catches/11111111-1111-4111-8111-111111111111"><time><strong>Недавно</strong></time></a></div></div></article>
|
||||||
|
</main>
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<article class="catch-detail shell"><div class="catch-detail__body">
|
||||||
|
<dl class="catch-facts">
|
||||||
|
<div><dt>Ветер</dt><dd>SE 4 м/с</dd></div>
|
||||||
|
<div><dt>Выпуск лески</dt><dd>5.1 m</dd></div>
|
||||||
|
<div><dt>Клипса</dt><dd>5 m</dd></div>
|
||||||
|
<div><dt>Направление заброса</dt><dd>181.1°</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div class="catch-equipment">
|
||||||
|
<article><div><small>Приманка поклёвки</small><a href="/ru/wiki/baits/test-lure">Тестовая приманка</a></div></article>
|
||||||
|
<article><div><small>Катушка</small><a href="/ru/wiki/reels/test-reel">Тестовая катушка</a></div></article>
|
||||||
|
</div>
|
||||||
|
</div></article>
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
<table><tr class="load-row"><td class="time"><a class="share" href="fishing/123">#123</a><a class="share hide-print" href="fishing/123.png">image</a><div>11:27</div><small>03.09</small></td><td class="mobile-location">Тестовое озеро</td><td class="fish-icon"><a href="fish/pike">fish</a></td><td class="fish"><a data-filter="fish">Щука</a><small>11 584 г</small></td><td><a data-filter="bait">Тестовая приманка</a></td><td class="list-gamer"><a>Игрок</a></td><td class="list-position"><span class="position">71:92</span><span class="clip">35</span><img class="post-style-icon" title="Вид ловли: Донная"/></td></tr></table>
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
<div class="post-item load-row" data-post-id="456" data-published-at="1788434956"><div class="location">Тестовое озеро</div><div class="spot-col"><span class="position position-locked">XX:XX</span><span class="clip">25</span><img class="post-style-icon" title="Вид ловли: Спиннинг"/></div><div class="fish-icon"><a href="fish/pike">fish</a><small class="weight">4 321 г</small><div class="fish-name">Щука</div></div><div class="bait-text">Тестовая приманка</div><div class="player">Игрок</div><div class="screen-file-slot"><img src="https://img.example.test/proof.jpg"/></div></div>
|
||||||
@@ -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("<html></html>", 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("<html><body>not a source page</body></html>")
|
||||||
Reference in New Issue
Block a user