Compare commits
5
Commits
d407e8fcfd
...
16d64606f8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16d64606f8 | ||
|
|
17c9c40241 | ||
|
|
97afd2730e | ||
|
|
c3ddd71f59 | ||
|
|
121225971d |
@@ -6,12 +6,24 @@ RF4 Spotter — неофициальный сервис свежих точек
|
|||||||
|
|
||||||
- этапы 0 и 1 завершены;
|
- этапы 0 и 1 завершены;
|
||||||
- этап 2, официальный импорт, завершён технически; автоматический профиль остаётся выключенным до явного разрешения владельца источника;
|
- этап 2, официальный импорт, завершён технически; автоматический профиль остаётся выключенным до явного разрешения владельца источника;
|
||||||
- этап 3 выполнен частично: форма, MinIO, модерация, удаление с аудитом и постоянный rate limit готовы;
|
- этап 3 функционально завершён: форма, раздельные ошибки создания/скриншота с повторной загрузкой, MinIO, модерация, удаление с аудитом и постоянный rate limit готовы;
|
||||||
- ближайшие задачи — сквозной тест полного пользовательского сценария и раздельные состояния ошибок формы;
|
- для полного пользовательского сценария добавлен E2E-тест `отправка → pending → модерация → публичная статистика`;
|
||||||
- затем начинается этап 4: формализация и расширенное тестирование индекса клёва.
|
- начат этап 4: формула индекса зафиксирована, детерминированные агрегаты и правила включения данных покрыты тестами; далее — состояния карточек и сквозная проверка фильтров.
|
||||||
|
|
||||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md).
|
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md).
|
||||||
|
|
||||||
|
Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`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
|
||||||
|
|
||||||
Требуются Docker Engine и Docker Compose. Это основной и рекомендуемый сценарий:
|
Требуются Docker Engine и Docker Compose. Это основной и рекомендуемый сценарий:
|
||||||
@@ -54,10 +66,12 @@ docker compose up --build
|
|||||||
- справочники рыб, водоёмов и приманок;
|
- справочники рыб, водоёмов и приманок;
|
||||||
- Astro SSR-интерфейс с адаптивным дизайном из `design-reference` без переноса React/Vinext-стека;
|
- Astro SSR-интерфейс с адаптивным дизайном из `design-reference` без переноса React/Vinext-стека;
|
||||||
- объяснимые индексы активности и уверенности по формуле спецификации;
|
- объяснимые индексы активности и уверенности по формуле спецификации;
|
||||||
|
- документированная формула и детерминированные тесты окон 6/12/24/72 часа;
|
||||||
- состояния «нет данных» и «источник недоступен»;
|
- состояния «нет данных» и «источник недоступен»;
|
||||||
- идемпотентный импорт официальных записей с журналом запусков;
|
- идемпотентный импорт официальных записей с журналом запусков;
|
||||||
- публичная страница `/records` с источником и временем последнего импорта;
|
- публичная страница `/records` с источником и временем последнего импорта;
|
||||||
- форма `/report`, защищённые admin API и журнал модерации;
|
- форма `/report`, защищённые admin API и журнал модерации;
|
||||||
|
- отдельные состояния ошибки создания заявки и загрузки скриншота; неудачный скриншот можно добавить повторно по ID уже сохранённой заявки;
|
||||||
- honeypot и постоянный rate limit в PostgreSQL с HMAC-отпечатками вместо исходных IP;
|
- honeypot и постоянный rate limit в PostgreSQL с HMAC-отпечатками вместо исходных IP;
|
||||||
- скриншоты уловов в MinIO/S3 с проверкой MIME, расширения, размера и фактического содержимого, повторным кодированием и очисткой метаданных;
|
- скриншоты уловов в MinIO/S3 с проверкой MIME, расширения, размера и фактического содержимого, повторным кодированием и очисткой метаданных;
|
||||||
- административная очередь `/admin/moderation` с одобрением, отклонением и обезличенным удалением записи с аудитом.
|
- административная очередь `/admin/moderation` с одобрением, отклонением и обезличенным удалением записи с аудитом.
|
||||||
@@ -75,7 +89,7 @@ python3 -m venv .venv
|
|||||||
.venv/bin/pytest -q
|
.venv/bin/pytest -q
|
||||||
```
|
```
|
||||||
|
|
||||||
На текущем этапе набор содержит 21 backend/parser-тест.
|
Актуальное число тестов выводит команда `pytest`; набор включает backend, импорт, расчёт активности и исследовательский парсер.
|
||||||
|
|
||||||
Frontend:
|
Frontend:
|
||||||
|
|
||||||
@@ -125,6 +139,8 @@ curl -H "Authorization: Bearer change-me-in-production" \
|
|||||||
|
|
||||||
Перед внешним развёртыванием обязательно замените демонстрационные `ADMIN_TOKEN`, `RATE_LIMIT_SECRET`, `S3_ACCESS_KEY` и `S3_SECRET_KEY`. Форма принимает JPEG, PNG и WebP до 8 МБ; API сверяет MIME и расширение с фактическим форматом, повторно кодирует изображение и удаляет EXIF перед сохранением в MinIO. Модератор получает временную подписанную ссылку через admin API.
|
Перед внешним развёртыванием обязательно замените демонстрационные `ADMIN_TOKEN`, `RATE_LIMIT_SECRET`, `S3_ACCESS_KEY` и `S3_SECRET_KEY`. Форма принимает JPEG, PNG и WebP до 8 МБ; API сверяет MIME и расширение с фактическим форматом, повторно кодирует изображение и удаляет EXIF перед сохранением в MinIO. Модератор получает временную подписанную ссылку через admin API.
|
||||||
|
|
||||||
|
Если создание записи прошло успешно, а загрузка скриншота завершилась ошибкой, форма сохраняет ID заявки и предлагает повторить только загрузку изображения. Повторно отправлять сам улов не требуется.
|
||||||
|
|
||||||
Очередь модерации доступна по адресу <http://localhost:4321/admin/moderation>. Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище.
|
Очередь модерации доступна по адресу <http://localhost:4321/admin/moderation>. Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище.
|
||||||
|
|
||||||
Администратор может одобрить, отклонить или удалить сообщение. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита.
|
Администратор может одобрить, отклонить или удалить сообщение. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита.
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ def activity_rows(
|
|||||||
waterbody: str | None = None,
|
waterbody: str | None = None,
|
||||||
fish: str | None = None,
|
fish: str | None = None,
|
||||||
method: str | None = None,
|
method: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
) -> list[ActivityOut]:
|
) -> list[ActivityOut]:
|
||||||
now = datetime.now(timezone.utc)
|
now = _aware(now or datetime.now(timezone.utc))
|
||||||
query = (
|
query = (
|
||||||
select(CatchReport)
|
select(CatchReport)
|
||||||
.options(
|
.options(
|
||||||
@@ -28,6 +29,7 @@ def activity_rows(
|
|||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
CatchReport.moderation_status == ModerationStatus.approved,
|
CatchReport.moderation_status == ModerationStatus.approved,
|
||||||
|
CatchReport.deleted_at.is_(None),
|
||||||
CatchReport.spot_id.is_not(None),
|
CatchReport.spot_id.is_not(None),
|
||||||
CatchReport.reported_at >= now - timedelta(hours=hours),
|
CatchReport.reported_at >= now - timedelta(hours=hours),
|
||||||
)
|
)
|
||||||
@@ -66,7 +68,7 @@ def activity_rows(
|
|||||||
average_weight_g=round(sum(r.weight_g for r in items) / len(items)),
|
average_weight_g=round(sum(r.weight_g for r in items) / len(items)),
|
||||||
max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest,
|
max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest,
|
||||||
activity_score=activity, confidence_score=confidence,
|
activity_score=activity, confidence_score=confidence,
|
||||||
explanation=f"{len(items)} свежих уловов от {len(players)} игроков. Последнее подтверждение {freshness_text}." + (" Данных мало." if len(items) < 3 else ""),
|
explanation=_explanation(len(items), len(players), freshness_text, activity, confidence),
|
||||||
))
|
))
|
||||||
return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True)
|
return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True)
|
||||||
|
|
||||||
@@ -80,3 +82,27 @@ def _freshness_text(delta: timedelta) -> str:
|
|||||||
if minutes < 60:
|
if minutes < 60:
|
||||||
return f"{minutes} мин. назад"
|
return f"{minutes} мин. назад"
|
||||||
return f"{minutes // 60} ч. назад"
|
return f"{minutes // 60} ч. назад"
|
||||||
|
|
||||||
|
|
||||||
|
def _explanation(catches: int, players: int, freshness: str, activity: int, confidence: int) -> str:
|
||||||
|
activity_label = "Высокая" if activity >= 60 else "Средняя" if activity >= 40 else "Низкая"
|
||||||
|
confidence_label = "высокая" if confidence >= 70 else "средняя" if confidence >= 40 else "низкая"
|
||||||
|
summary = (
|
||||||
|
f"{activity_label} активность: {_count(catches, 'свежий улов', 'свежих улова', 'свежих уловов')} "
|
||||||
|
f"от {_count(players, 'игрока', 'игроков', 'игроков')}. "
|
||||||
|
f"Последнее подтверждение {freshness}. Уверенность {confidence_label}."
|
||||||
|
)
|
||||||
|
return summary + (" Данных мало: нужно хотя бы 3 наблюдения." if catches < 3 else "")
|
||||||
|
|
||||||
|
|
||||||
|
def _count(value: int, one: str, few: str, many: str) -> str:
|
||||||
|
remainder = value % 100
|
||||||
|
if 11 <= remainder <= 14:
|
||||||
|
word = many
|
||||||
|
elif value % 10 == 1:
|
||||||
|
word = one
|
||||||
|
elif 2 <= value % 10 <= 4:
|
||||||
|
word = few
|
||||||
|
else:
|
||||||
|
word = many
|
||||||
|
return f"{value} {word}"
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ def _spot_or_404(db: Session, spot_id: UUID) -> Spot:
|
|||||||
@app.get("/api/v1/spots/{spot_id}", response_model=SpotOut)
|
@app.get("/api/v1/spots/{spot_id}", response_model=SpotOut)
|
||||||
def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
|
def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
|
||||||
spot = _spot_or_404(db, spot_id)
|
spot = _spot_or_404(db, spot_id)
|
||||||
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot.id, CatchReport.moderation_status == ModerationStatus.approved)))
|
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot.id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None))))
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
def count_since(delta: timedelta) -> int:
|
def count_since(delta: timedelta) -> int:
|
||||||
return sum(_aware(r.reported_at) >= now - delta for r in reports)
|
return sum(_aware(r.reported_at) >= now - delta for r in reports)
|
||||||
@@ -89,7 +89,7 @@ def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
|
|||||||
@app.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut])
|
@app.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut])
|
||||||
def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]:
|
def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]:
|
||||||
_spot_or_404(db, spot_id)
|
_spot_or_404(db, spot_id)
|
||||||
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved).order_by(CatchReport.reported_at.desc()).offset(offset).limit(limit)))
|
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc()).offset(offset).limit(limit)))
|
||||||
return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed) for r in reports]
|
return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed) for r in reports]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.activity import activity_rows
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 9, 3, 6, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db() -> Session:
|
||||||
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as session:
|
||||||
|
yield session
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def add_report(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
age_hours: float,
|
||||||
|
player: str | None = "Player",
|
||||||
|
confidence: int = 100,
|
||||||
|
status: ModerationStatus = ModerationStatus.approved,
|
||||||
|
deleted: bool = False,
|
||||||
|
source: SourceType = SourceType.user,
|
||||||
|
weight_g: int = 5_000,
|
||||||
|
) -> CatchReport:
|
||||||
|
waterbody = db.query(Waterbody).first()
|
||||||
|
fish = db.query(Fish).first()
|
||||||
|
spot = db.query(Spot).first()
|
||||||
|
if waterbody is None:
|
||||||
|
waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро", unlock_level=1)
|
||||||
|
fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000)
|
||||||
|
spot = Spot(waterbody=waterbody, x=10, y=20, description=None)
|
||||||
|
db.add_all([waterbody, fish, spot])
|
||||||
|
db.flush()
|
||||||
|
report = CatchReport(
|
||||||
|
fish=fish,
|
||||||
|
spot=spot,
|
||||||
|
waterbody=waterbody,
|
||||||
|
bait=None,
|
||||||
|
weight_g=weight_g,
|
||||||
|
fishing_method="spinning",
|
||||||
|
caught_at=NOW - timedelta(hours=age_hours),
|
||||||
|
reported_at=NOW - timedelta(hours=age_hours),
|
||||||
|
player_name=player,
|
||||||
|
source_type=source,
|
||||||
|
source_confidence=confidence,
|
||||||
|
moderation_status=status,
|
||||||
|
deleted_at=NOW if deleted else None,
|
||||||
|
)
|
||||||
|
db.add(report)
|
||||||
|
db.commit()
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def test_freshness_and_source_confidence_follow_documented_formula(db: Session) -> None:
|
||||||
|
add_report(db, age_hours=18, confidence=80)
|
||||||
|
|
||||||
|
row = activity_rows(db, hours=24, now=NOW)[0]
|
||||||
|
weighted = math.exp(-1) * 0.8
|
||||||
|
expected_activity = round(55 * weighted / 12 + 25 / 6)
|
||||||
|
expected_confidence = round(45 / 10 + 35 / 5 + 20 * 0.8)
|
||||||
|
|
||||||
|
assert row.activity_score == expected_activity
|
||||||
|
assert row.confidence_score == expected_confidence
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_reports_from_one_player_do_not_add_unique_player_weight(db: Session) -> None:
|
||||||
|
add_report(db, age_hours=1, player=" Same Player ")
|
||||||
|
add_report(db, age_hours=1, player="same player")
|
||||||
|
add_report(db, age_hours=1, player=None)
|
||||||
|
|
||||||
|
row = activity_rows(db, hours=6, now=NOW)[0]
|
||||||
|
|
||||||
|
assert row.catches == 3
|
||||||
|
assert row.unique_players == 1
|
||||||
|
assert "3 свежих улова от 1 игрока" in row.explanation
|
||||||
|
|
||||||
|
|
||||||
|
def test_explanation_describes_low_data_and_confidence(db: Session) -> None:
|
||||||
|
add_report(db, age_hours=2, player="Player", confidence=50)
|
||||||
|
|
||||||
|
row = activity_rows(db, hours=6, now=NOW)[0]
|
||||||
|
|
||||||
|
assert row.explanation.startswith("Низкая активность: 1 свежий улов от 1 игрока.")
|
||||||
|
assert "Уверенность низкая." in row.explanation
|
||||||
|
assert "Данных мало: нужно хотя бы 3 наблюдения." in row.explanation
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_rejected_and_deleted_reports_are_excluded(db: Session) -> None:
|
||||||
|
add_report(db, age_hours=1)
|
||||||
|
add_report(db, age_hours=1, status=ModerationStatus.pending)
|
||||||
|
add_report(db, age_hours=1, status=ModerationStatus.rejected)
|
||||||
|
add_report(db, age_hours=1, deleted=True)
|
||||||
|
|
||||||
|
row = activity_rows(db, hours=6, now=NOW)[0]
|
||||||
|
|
||||||
|
assert row.catches == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("hours", "expected_catches"),
|
||||||
|
[(6, 1), (12, 2), (24, 3), (72, 4)],
|
||||||
|
)
|
||||||
|
def test_supported_windows_are_deterministic(db: Session, hours: int, expected_catches: int) -> None:
|
||||||
|
for age in (5, 11, 23, 71, 73):
|
||||||
|
add_report(db, age_hours=age, player=f"Player {age}")
|
||||||
|
|
||||||
|
row = activity_rows(db, hours=hours, now=NOW)[0]
|
||||||
|
|
||||||
|
assert row.catches == expected_catches
|
||||||
|
|
||||||
|
|
||||||
|
def test_reports_without_coordinates_do_not_create_activity_group(db: Session) -> None:
|
||||||
|
report = add_report(db, age_hours=1, source=SourceType.official_record)
|
||||||
|
report.spot = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert activity_rows(db, hours=72, now=NOW) == []
|
||||||
@@ -46,11 +46,12 @@ def test_activity_filters_and_explains_score() -> None:
|
|||||||
assert len(payload) == 1
|
assert len(payload) == 1
|
||||||
assert payload[0]["catches"] == 3
|
assert payload[0]["catches"] == 3
|
||||||
assert payload[0]["unique_players"] == 3
|
assert payload[0]["unique_players"] == 3
|
||||||
assert "3 свежих уловов" in payload[0]["explanation"]
|
assert "3 свежих улова" in payload[0]["explanation"]
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_period_is_rejected() -> None:
|
def test_invalid_period_is_rejected() -> None:
|
||||||
assert client.get("/api/v1/activity?hours=13").status_code == 422
|
assert client.get("/api/v1/activity?hours=13").status_code == 422
|
||||||
|
assert client.get("/api/v1/activity?sort=unknown").status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_spot_detail_and_catches() -> None:
|
def test_spot_detail_and_catches() -> None:
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import type { Activity } from "../lib/api";
|
|||||||
import { ago, kg } from "../lib/api";
|
import { ago, kg } from "../lib/api";
|
||||||
const { item } = Astro.props as { item: Activity };
|
const { item } = Astro.props as { item: Activity };
|
||||||
const level = item.activity_score >= 80 ? "Очень высокий" : item.activity_score >= 60 ? "Высокий" : item.activity_score >= 40 ? "Средний" : "Низкий";
|
const level = item.activity_score >= 80 ? "Очень высокий" : item.activity_score >= 60 ? "Высокий" : item.activity_score >= 40 ? "Средний" : "Низкий";
|
||||||
|
const limited = item.catches < 3;
|
||||||
---
|
---
|
||||||
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={`/spots/${item.spot_id}`}>
|
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={`/spots/${item.spot_id}`}>
|
||||||
<span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
|
<span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
|
||||||
<div class="spot-main"><div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill"><i></i>{level}</span></div><h3>{item.fish}</h3><div class="spot-meta"><span>⌖ {item.x}:{item.y}</span><span>◷ {ago(item.last_confirmed_at)}</span></div><div class="bait-line"><span class="lure-dot"></span><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div></div>
|
<div class="spot-main"><div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill"><i></i>{level}</span>{limited && <span class="data-quality">Данных мало</span>}</div><h3>{item.fish}</h3><div class="spot-meta"><span>⌖ {item.x}:{item.y}</span><span>◷ {ago(item.last_confirmed_at)}</span></div><p class="data-note">{item.explanation}</p><div class="bait-line"><span class="lure-dot"></span><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div></div>
|
||||||
<div class="spot-stats"><div><strong>{item.catches}</strong><span>уловов</span></div><div><strong>{item.unique_players}</strong><span>игроков</span></div><div><strong>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{item.confidence_score}%</strong><span>уверенность</span></div></div><span class="card-arrow">›</span>
|
<div class="spot-stats"><div><strong>{item.catches}</strong><span>уловов</span></div><div><strong>{item.unique_players}</strong><span>игроков</span></div><div><strong>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{item.confidence_score}%</strong><span>уверенность</span></div></div><span class="card-arrow">›</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request, redirect }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const reportId = String(form.get("report_id") || "");
|
||||||
|
const screenshot = form.get("screenshot");
|
||||||
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(reportId) || !(screenshot instanceof File) || screenshot.size === 0) return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303);
|
||||||
|
try {
|
||||||
|
const upload = new FormData(); upload.set("screenshot", screenshot);
|
||||||
|
const response = await fetch(`${base}/api/v1/catch-reports/${reportId}/screenshot`, { method: "POST", body: upload });
|
||||||
|
return redirect(response.ok ? "/report?state=screenshot_sent" : `/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303);
|
||||||
|
} catch { return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303); }
|
||||||
|
};
|
||||||
@@ -5,17 +5,19 @@ export const POST: APIRoute = async ({ request, redirect }) => {
|
|||||||
const text = (name: string) => String(form.get(name) || "").trim() || null;
|
const text = (name: string) => String(form.get(name) || "").trim() || null;
|
||||||
const number = (name: string) => text(name) ? Number(text(name)) : null;
|
const number = (name: string) => text(name) ? Number(text(name)) : null;
|
||||||
const payload = { fish_slug: String(form.get("fish_slug") || ""), waterbody_slug: String(form.get("waterbody_slug") || ""), x: Number(form.get("x")), y: Number(form.get("y")), weight_g: Number(form.get("weight_g")), bait_name: text("bait_name"), fishing_method: text("fishing_method"), retrieve_method: text("retrieve_method"), retrieve_speed: number("retrieve_speed"), player_name: text("player_name"), comment: text("comment"), website: String(form.get("website") || "") };
|
const payload = { fish_slug: String(form.get("fish_slug") || ""), waterbody_slug: String(form.get("waterbody_slug") || ""), x: Number(form.get("x")), y: Number(form.get("y")), weight_g: Number(form.get("weight_g")), bait_name: text("bait_name"), fishing_method: text("fishing_method"), retrieve_method: text("retrieve_method"), retrieve_speed: number("retrieve_speed"), player_name: text("player_name"), comment: text("comment"), website: String(form.get("website") || "") };
|
||||||
|
let createdId: string | null = null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${base}/api/v1/catch-reports`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
const response = await fetch(`${base}/api/v1/catch-reports`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
||||||
if (!response.ok) return redirect("/report?state=error", 303);
|
if (!response.ok) return redirect("/report?state=create_error", 303);
|
||||||
const created = await response.json();
|
const created = await response.json();
|
||||||
|
createdId = created.id;
|
||||||
const screenshot = form.get("screenshot");
|
const screenshot = form.get("screenshot");
|
||||||
if (screenshot instanceof File && screenshot.size > 0) {
|
if (screenshot instanceof File && screenshot.size > 0) {
|
||||||
const upload = new FormData(); upload.set("screenshot", screenshot);
|
const upload = new FormData(); upload.set("screenshot", screenshot);
|
||||||
const uploaded = await fetch(`${base}/api/v1/catch-reports/${created.id}/screenshot`, { method: "POST", body: upload });
|
const uploaded = await fetch(`${base}/api/v1/catch-reports/${created.id}/screenshot`, { method: "POST", body: upload });
|
||||||
if (!uploaded.ok) return redirect("/report?state=error", 303);
|
if (!uploaded.ok) return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(created.id)}`, 303);
|
||||||
}
|
}
|
||||||
return redirect("/report?state=sent", 303);
|
return redirect("/report?state=sent", 303);
|
||||||
}
|
}
|
||||||
catch { return redirect("/report?state=error", 303); }
|
catch { return redirect(createdId ? `/report?state=screenshot_error&report_id=${encodeURIComponent(createdId)}` : "/report?state=create_error", 303); }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ const waterbody = params.get("waterbody") ?? "";
|
|||||||
const fish = params.get("fish") ?? "";
|
const fish = params.get("fish") ?? "";
|
||||||
const sort = params.get("sort") ?? "activity";
|
const sort = params.get("sort") ?? "activity";
|
||||||
let items: Activity[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
let items: Activity[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
||||||
|
const filterError = !["6", "12", "24", "72"].includes(hours) || !["activity", "confidence", "freshness"].includes(sort);
|
||||||
let unavailable = false;
|
let unavailable = false;
|
||||||
try {
|
try {
|
||||||
[items, fishes, waterbodies] = await Promise.all([
|
[fishes, waterbodies] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]);
|
||||||
api<Activity[]>(`/api/v1/activity?hours=${hours}&waterbody=${waterbody}&fish=${fish}&sort=${sort}`),
|
if (!filterError) {
|
||||||
api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")
|
const query = new URLSearchParams({ hours, waterbody, fish, sort });
|
||||||
]);
|
items = await api<Activity[]>(`/api/v1/activity?${query}`);
|
||||||
|
}
|
||||||
} catch { unavailable = true; }
|
} catch { unavailable = true; }
|
||||||
---
|
---
|
||||||
<Layout title="Что клюёт сейчас — RF4 Spotter">
|
<Layout title="Что клюёт сейчас — RF4 Spotter">
|
||||||
@@ -26,7 +28,7 @@ try {
|
|||||||
<label>Сначала<select name="sort"><option value="activity" selected={sort === "activity"}>Активные</option><option value="confidence" selected={sort === "confidence"}>Надёжные</option><option value="freshness" selected={sort === "freshness"}>Свежие</option></select></label>
|
<label>Сначала<select name="sort"><option value="activity" selected={sort === "activity"}>Активные</option><option value="confidence" selected={sort === "confidence"}>Надёжные</option><option value="freshness" selected={sort === "freshness"}>Свежие</option></select></label>
|
||||||
<button>⌕ Найти клёв</button>
|
<button>⌕ Найти клёв</button>
|
||||||
</form></section>
|
</form></section>
|
||||||
<section class="dashboard content-grid"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} точки</span></div>{unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки.</p></div> : items.length ? <div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div> : <div class="state"><h2>Пока нет свежих данных</h2><p>Попробуйте другой водоём или период.</p></div>}</div>
|
<section class="dashboard content-grid"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} точки</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a href="/">Сбросить фильтры</a></div> : unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте позже.</p></div> : items.length ? <div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
|
||||||
{items[0] && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку">↗</a></div><div class="detail-score"><div class="score-ring" style={`--score:${items[0].activity_score * 3.6}deg`}><div><strong>{items[0].activity_score}</strong><span>из 100</span></div></div><div><span>Индекс активности</span><strong>{items[0].activity_score >= 70 ? "Клёв отличный" : "Клёв хороший"}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span>◎</span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span>♟</span><small>Игроков</small><strong>{items[0].unique_players}</strong></div><div><span>◷</span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span>⌁</span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><i></i><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} уловов</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
|
{items[0] && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку">↗</a></div><div class="detail-score"><div class="score-ring" style={`--score:${items[0].activity_score * 3.6}deg`}><div><strong>{items[0].activity_score}</strong><span>из 100</span></div></div><div><span>Индекс активности</span><strong>{items[0].activity_score >= 70 ? "Клёв отличный" : "Клёв хороший"}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span>◎</span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span>♟</span><small>Игроков</small><strong>{items[0].unique_players}</strong></div><div><span>◷</span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span>⌁</span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><i></i><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} уловов</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
|
||||||
</section>
|
</section>
|
||||||
<section class="how-it-works content-grid"><div><span class="overline">Как читать данные</span><h2>Не обещаем рыбу.<br/>Показываем факты.</h2></div><div class="principles"><article><span>01</span><h3>Свежесть</h3><p>Чем старше сообщение, тем меньше оно влияет на активность.</p></article><article><span>02</span><h3>Разные игроки</h3><p>Десять уловов одного человека не равны десяти подтверждениям.</p></article><article><span>03</span><h3>Уверенность</h3><p>Каждая оценка объясняет, сколько данных за ней стоит.</p></article></div></section>
|
<section class="how-it-works content-grid"><div><span class="overline">Как читать данные</span><h2>Не обещаем рыбу.<br/>Показываем факты.</h2></div><div class="principles"><article><span>01</span><h3>Свежесть</h3><p>Чем старше сообщение, тем меньше оно влияет на активность.</p></article><article><span>02</span><h3>Разные игроки</h3><p>Десять уловов одного человека не равны десяти подтверждениям.</p></article><article><span>03</span><h3>Уверенность</h3><p>Каждая оценка объясняет, сколько данных за ней стоит.</p></article></div></section>
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import { api, type DictionaryItem } from "../lib/api";
|
|||||||
let fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [], unavailable = false;
|
let fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [], unavailable = false;
|
||||||
try { [fishes, waterbodies] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); } catch { unavailable = true; }
|
try { [fishes, waterbodies] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); } catch { unavailable = true; }
|
||||||
const state = Astro.url.searchParams.get("state");
|
const state = Astro.url.searchParams.get("state");
|
||||||
|
const reportId = Astro.url.searchParams.get("report_id");
|
||||||
---
|
---
|
||||||
<Layout title="Добавить улов — RF4 Spotter">
|
<Layout title="Добавить улов — RF4 Spotter">
|
||||||
<section class="form-hero"><div><span class="eyebrow"><b>+1</b> Помочь сообществу</span><h1>Добавить<br/><em>свой улов</em></h1></div><p>Полминуты — и рабочая точка появится в общей статистике после проверки модератором.</p></section>
|
<section class="form-hero"><div><span class="eyebrow"><b>+1</b> Помочь сообществу</span><h1>Добавить<br/><em>свой улов</em></h1></div><p>Полминуты — и рабочая точка появится в общей статистике после проверки модератором.</p></section>
|
||||||
{state === "sent" && <div class="notice success">Улов отправлен на модерацию. Спасибо!</div>}
|
{state === "sent" && <div class="notice success">Улов отправлен на модерацию. Спасибо!</div>}
|
||||||
{state === "error" && <div class="notice error">Не удалось отправить улов. Проверьте поля и повторите.</div>}
|
{state === "screenshot_sent" && <div class="notice success">Скриншот добавлен к ранее созданной заявке.</div>}
|
||||||
|
{state === "create_error" && <div class="notice error">Заявка не создана. Проверьте поля и повторите отправку.</div>}
|
||||||
|
{state === "screenshot_error" && <div class="notice warning"><strong>Заявка сохранена без скриншота.</strong> Изображение не загрузилось; можно повторить отдельно, не отправляя улов заново.</div>}
|
||||||
|
{state === "screenshot_error" && reportId && <form class="screenshot-retry" method="post" action="/api/report-screenshot" enctype="multipart/form-data"><input type="hidden" name="report_id" value={reportId} /><label>Повторная загрузка скриншота<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" required /></label><button type="submit">Загрузить скриншот</button></form>}
|
||||||
{unavailable ? <div class="state"><h2>Форма временно недоступна</h2></div> : <form class="report-form" method="post" action="/api/report" enctype="multipart/form-data">
|
{unavailable ? <div class="state"><h2>Форма временно недоступна</h2></div> : <form class="report-form" method="post" action="/api/report" enctype="multipart/form-data">
|
||||||
<div class="form-grid"><label>Рыба *<select name="fish_slug" required>{fishes.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Водоём *<select name="waterbody_slug" required>{waterbodies.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Координата X *<input name="x" type="number" min="-10000" max="10000" required /></label><label>Координата Y *<input name="y" type="number" min="-10000" max="10000" required /></label><label>Вес, граммы *<input name="weight_g" type="number" min="1" max="3000000" required /></label><label>Приманка<input name="bait_name" maxlength="200" /></label><label>Способ ловли<select name="fishing_method"><option value="">Не указан</option><option value="spinning">Спиннинг</option><option value="bottom">Донная</option><option value="float">Поплавочная</option></select></label><label>Проводка<input name="retrieve_method" maxlength="100" /></label><label>Скорость проводки<input name="retrieve_speed" type="number" min="0" max="100" /></label><label>Ник игрока<input name="player_name" maxlength="100" /></label></div>
|
<div class="form-grid"><label>Рыба *<select name="fish_slug" required>{fishes.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Водоём *<select name="waterbody_slug" required>{waterbodies.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Координата X *<input name="x" type="number" min="-10000" max="10000" required /></label><label>Координата Y *<input name="y" type="number" min="-10000" max="10000" required /></label><label>Вес, граммы *<input name="weight_g" type="number" min="1" max="3000000" required /></label><label>Приманка<input name="bait_name" maxlength="200" /></label><label>Способ ловли<select name="fishing_method"><option value="">Не указан</option><option value="spinning">Спиннинг</option><option value="bottom">Донная</option><option value="float">Поплавочная</option></select></label><label>Проводка<input name="retrieve_method" maxlength="100" /></label><label>Скорость проводки<input name="retrieve_speed" type="number" min="0" max="100" /></label><label>Ник игрока<input name="player_name" maxlength="100" /></label></div>
|
||||||
<label class="wide">Комментарий<textarea name="comment" maxlength="1000" rows="4"></textarea></label><label class="wide">Скриншот, JPEG/PNG/WebP до 8 МБ<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" /></label><label class="honeypot" aria-hidden="true">Сайт<input name="website" tabindex="-1" autocomplete="off" /></label><p class="privacy">Ник и скриншот необязательны. Из изображения удаляются EXIF и прочие метаданные.</p><button type="submit">Отправить на проверку</button>
|
<label class="wide">Комментарий<textarea name="comment" maxlength="1000" rows="4"></textarea></label><label class="wide">Скриншот, JPEG/PNG/WebP до 8 МБ<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" /></label><label class="honeypot" aria-hidden="true">Сайт<input name="website" tabindex="-1" autocomplete="off" /></label><p class="privacy">Ник и скриншот необязательны. Из изображения удаляются EXIF и прочие метаданные.</p><button type="submit">Отправить на проверку</button>
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
import { api, kg, type Catch, type Spot } from "../../lib/api";
|
import { api, kg, type Activity, type Catch, type Spot } from "../../lib/api";
|
||||||
const { id } = Astro.params;
|
const { id } = Astro.params;
|
||||||
let spot: Spot | null = null, catches: Catch[] = [], unavailable = false;
|
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
|
||||||
try { [spot, catches] = await Promise.all([api<Spot>(`/api/v1/spots/${id}`), api<Catch[]>(`/api/v1/spots/${id}/catches`)]); } catch { unavailable = true; }
|
try {
|
||||||
|
const [spotResult, catchResult, activityRows] = await Promise.all([api<Spot>(`/api/v1/spots/${id}`), api<Catch[]>(`/api/v1/spots/${id}/catches`), api<Activity[]>("/api/v1/activity?hours=24&limit=100")]);
|
||||||
|
spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === id) ?? null;
|
||||||
|
} catch { unavailable = true; }
|
||||||
---
|
---
|
||||||
<Layout title={spot ? `${spot.waterbody} ${spot.x}:${spot.y} — RF4 Spotter` : "Точка — RF4 Spotter"}>
|
<Layout title={spot ? `${spot.waterbody} ${spot.x}:${spot.y} — RF4 Spotter` : "Точка — RF4 Spotter"}>
|
||||||
<a class="back" href="/">← Все активные точки</a>
|
<a class="back" href="/">← Все активные точки</a>
|
||||||
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
||||||
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><div class="pin">{spot.x}<span>:</span>{spot.y}</div></section>
|
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><div class="pin">{spot.x}<span>:</span>{spot.y}</div></section>
|
||||||
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
||||||
<section class="detail-grid"><div><div class="section-heading"><h2>Последние уловы</h2></div><div class="catch-list">{catches.map(item => <article><div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span></div><div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span></div></article>)}</div></div><aside><span class="eyebrow">Лучшие приманки</span><ol>{spot.top_baits.map(name => <li>{name}</li>)}</ol><p class="note">Статистика построена только по одобренным демо-наблюдениям.</p></aside></section>
|
<section class="detail-grid"><div><div class="section-heading"><h2>Последние уловы</h2></div>{catches.length ? <div class="catch-list">{catches.map(item => <article><div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span></div><div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span></div></article>)}</div> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}</div><aside><span class="eyebrow">Оценка за 24 часа</span>{activity ? <><h2>{activity.activity_score} / 100</h2><p>{activity.explanation}</p><p class="note">Основано на {activity.catches} наблюдениях от {activity.unique_players} игроков. Уверенность: {activity.confidence_score}%.</p></> : <p>За последние 24 часа данных для расчёта нет.</p>}<span class="eyebrow">Лучшие приманки</span>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li>{name}</li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside></section>
|
||||||
</>}
|
</>}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
.filters-wrap{background:var(--deep);padding:24px 0;position:sticky;top:0;z-index:20;box-shadow:0 10px 35px #08222620}.filters{display:grid;grid-template-columns:repeat(4,1fr) auto;gap:12px;align-items:end}.filters label,.report-form label,.record-filters label{color:#9fb0ad;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.filters select{display:block;width:100%;height:48px;margin-top:7px;padding:0 13px;border:1px solid #ffffff29;border-radius:10px;background:#ffffff0c;color:#f5f8f3}.filters option{color:var(--ink)}.filters button,.report-form button,.record-filters button{height:48px;padding:0 24px;border:0;border-radius:10px;background:var(--lime);color:var(--deep);font-weight:750}
|
.filters-wrap{background:var(--deep);padding:24px 0;position:sticky;top:0;z-index:20;box-shadow:0 10px 35px #08222620}.filters{display:grid;grid-template-columns:repeat(4,1fr) auto;gap:12px;align-items:end}.filters label,.report-form label,.record-filters label{color:#9fb0ad;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.filters select{display:block;width:100%;height:48px;margin-top:7px;padding:0 13px;border:1px solid #ffffff29;border-radius:10px;background:#ffffff0c;color:#f5f8f3}.filters option{color:var(--ink)}.filters button,.report-form button,.record-filters button{height:48px;padding:0 24px;border:0;border-radius:10px;background:var(--lime);color:var(--deep);font-weight:750}
|
||||||
.dashboard{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(360px,.75fr);gap:26px;padding:66px 0 104px;align-items:start}.section-heading{display:flex;justify-content:space-between;align-items:end;margin:0 0 23px}.section-heading h2,.detail-head h2,.how-it-works h2{font:400 38px Georgia,serif;margin:6px 0 0;letter-spacing:-.04em}.result-count{border:1px solid #b9c6bf;color:#667875;font-size:13px;padding:7px 11px;border-radius:20px}.spot-list{display:flex;flex-direction:column;gap:12px}.spot-card{position:relative;display:grid;grid-template-columns:45px 1fr 190px;gap:18px;padding:22px 48px 22px 20px;border:1px solid #d5ded7;border-radius:16px;background:#fbfcf9;text-decoration:none;transition:.22s ease}.spot-card:hover{transform:translateY(-2px);border-color:#7d9488;box-shadow:0 18px 40px #14333812;background:#fff}.spot-rank{width:36px;height:36px;border:1px solid #cad4cd;border-radius:50%;display:grid;place-items:center;font:italic 14px Georgia,serif;color:#788985}.spot-topline{display:flex;align-items:center;gap:12px;color:#687a77;font-size:13px}.activity-pill{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:30px;background:#e6f7c3;color:#426315;font-size:11px;font-weight:750}.activity-pill i{width:6px;height:6px;border-radius:50%;background:#6d9f32}.spot-main h3{font:400 25px Georgia,serif;margin:8px 0}.spot-meta{display:flex;gap:16px;color:#71817f;font-size:13px}.bait-line{display:flex;gap:11px;align-items:center;margin-top:18px;padding-top:15px;border-top:1px solid #e2e8e2}.lure-dot,.best-lure i{display:block;width:12px;height:28px;border-radius:50% 50% 43% 43%;background:var(--orange);transform:rotate(22deg);box-shadow:inset -4px 0 #091e2250}.bait-line div{display:flex;flex-direction:column;gap:2px}.bait-line div span{color:#82908e;font-size:11px;text-transform:uppercase;letter-spacing:.09em}.bait-line strong{font-size:14px}.spot-stats{border-left:1px solid #e2e8e2;padding-left:18px;display:grid;grid-template-columns:1fr 1fr;gap:18px 10px;align-content:center}.spot-stats div{display:flex;flex-direction:column}.spot-stats strong{font:400 20px Georgia,serif}.spot-stats span{font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:#7c8d89}.card-arrow{position:absolute;right:18px;top:50%;transform:translateY(-50%);font-size:28px;color:#91a09c}
|
.dashboard{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(360px,.75fr);gap:26px;padding:66px 0 104px;align-items:start}.section-heading{display:flex;justify-content:space-between;align-items:end;margin:0 0 23px}.section-heading h2,.detail-head h2,.how-it-works h2{font:400 38px Georgia,serif;margin:6px 0 0;letter-spacing:-.04em}.result-count{border:1px solid #b9c6bf;color:#667875;font-size:13px;padding:7px 11px;border-radius:20px}.spot-list{display:flex;flex-direction:column;gap:12px}.spot-card{position:relative;display:grid;grid-template-columns:45px 1fr 190px;gap:18px;padding:22px 48px 22px 20px;border:1px solid #d5ded7;border-radius:16px;background:#fbfcf9;text-decoration:none;transition:.22s ease}.spot-card:hover{transform:translateY(-2px);border-color:#7d9488;box-shadow:0 18px 40px #14333812;background:#fff}.spot-rank{width:36px;height:36px;border:1px solid #cad4cd;border-radius:50%;display:grid;place-items:center;font:italic 14px Georgia,serif;color:#788985}.spot-topline{display:flex;align-items:center;gap:12px;color:#687a77;font-size:13px}.activity-pill{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:30px;background:#e6f7c3;color:#426315;font-size:11px;font-weight:750}.activity-pill i{width:6px;height:6px;border-radius:50%;background:#6d9f32}.spot-main h3{font:400 25px Georgia,serif;margin:8px 0}.spot-meta{display:flex;gap:16px;color:#71817f;font-size:13px}.bait-line{display:flex;gap:11px;align-items:center;margin-top:18px;padding-top:15px;border-top:1px solid #e2e8e2}.lure-dot,.best-lure i{display:block;width:12px;height:28px;border-radius:50% 50% 43% 43%;background:var(--orange);transform:rotate(22deg);box-shadow:inset -4px 0 #091e2250}.bait-line div{display:flex;flex-direction:column;gap:2px}.bait-line div span{color:#82908e;font-size:11px;text-transform:uppercase;letter-spacing:.09em}.bait-line strong{font-size:14px}.spot-stats{border-left:1px solid #e2e8e2;padding-left:18px;display:grid;grid-template-columns:1fr 1fr;gap:18px 10px;align-content:center}.spot-stats div{display:flex;flex-direction:column}.spot-stats strong{font:400 20px Georgia,serif}.spot-stats span{font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:#7c8d89}.card-arrow{position:absolute;right:18px;top:50%;transform:translateY(-50%);font-size:28px;color:#91a09c}
|
||||||
.detail-card{position:sticky;top:116px;margin:0;background:var(--deep);color:#f5f8f3;border:0;border-radius:18px;padding:27px;overflow:hidden}.detail-card:before{content:"";position:absolute;width:420px;height:420px;border:1px solid #ffffff0f;border-radius:50%;left:48%;top:-210px;box-shadow:0 0 0 48px #ffffff08,0 0 0 96px #ffffff05}.detail-card>*{position:relative;z-index:1}.detail-head{display:flex;justify-content:space-between}.detail-head .overline,.best-lure .overline{color:#a7b6b3}.detail-head h2{font-size:30px}.detail-head h2 em{color:var(--lime);font-size:22px}.detail-head>a{width:40px;height:40px;display:grid;place-items:center;border:1px solid #ffffff34;border-radius:50%;text-decoration:none}.detail-score{display:grid;grid-template-columns:126px 1fr;gap:20px;align-items:center;padding:34px 0 29px}.score-ring{width:122px;height:122px;display:grid;place-items:center;border-radius:50%;background:conic-gradient(var(--lime) var(--score),#ffffff16 0);position:relative}.score-ring:after{content:"";position:absolute;inset:8px;background:var(--deep);border-radius:50%}.score-ring>div{z-index:1;text-align:center;display:flex;flex-direction:column}.score-ring strong{font:400 39px/1 Georgia,serif}.score-ring span{color:#a9b8b5;font-size:10px;text-transform:uppercase}.detail-score>div>span{color:#a9b8b5;font-size:11px;text-transform:uppercase;letter-spacing:.1em}.detail-score>div>strong{display:block;margin:5px 0 8px;font:400 24px Georgia,serif;color:var(--lime)}.detail-score p{color:#b7c3c0;font-size:13px;line-height:1.5;margin:0}.metric-grid{display:grid;grid-template-columns:1fr 1fr;border:1px solid #ffffff1c;border-radius:12px;overflow:hidden}.metric-grid>div{min-height:91px;padding:15px;display:grid;grid-template-columns:23px 1fr;gap:3px 8px;border-bottom:1px solid #ffffff1c}.metric-grid>div:nth-child(odd){border-right:1px solid #ffffff1c}.metric-grid>div:nth-last-child(-n+2){border-bottom:0}.metric-grid>div>span{grid-row:1/3;color:var(--lime)}.metric-grid small{color:#9cadaa;text-transform:uppercase;font-size:9px}.metric-grid strong{font:400 17px Georgia,serif}.best-lure{margin-top:28px}.best-lure>div{display:grid;grid-template-columns:18px 1fr auto;gap:11px;align-items:center;padding:15px 0;border-bottom:1px solid #ffffff17}.best-lure>div span{color:var(--lime);font-size:12px}.confidence-note{display:flex;gap:10px;padding:16px;background:#ffffff0a;border-radius:10px;margin-top:22px;font-size:12px;line-height:1.45;color:#aebcba}.confidence-note>span:first-child{color:var(--lime)}.confidence-note strong{color:#fff}
|
.detail-card{position:sticky;top:116px;margin:0;background:var(--deep);color:#f5f8f3;border:0;border-radius:18px;padding:27px;overflow:hidden}.detail-card:before{content:"";position:absolute;width:420px;height:420px;border:1px solid #ffffff0f;border-radius:50%;left:48%;top:-210px;box-shadow:0 0 0 48px #ffffff08,0 0 0 96px #ffffff05}.detail-card>*{position:relative;z-index:1}.detail-head{display:flex;justify-content:space-between}.detail-head .overline,.best-lure .overline{color:#a7b6b3}.detail-head h2{font-size:30px}.detail-head h2 em{color:var(--lime);font-size:22px}.detail-head>a{width:40px;height:40px;display:grid;place-items:center;border:1px solid #ffffff34;border-radius:50%;text-decoration:none}.detail-score{display:grid;grid-template-columns:126px 1fr;gap:20px;align-items:center;padding:34px 0 29px}.score-ring{width:122px;height:122px;display:grid;place-items:center;border-radius:50%;background:conic-gradient(var(--lime) var(--score),#ffffff16 0);position:relative}.score-ring:after{content:"";position:absolute;inset:8px;background:var(--deep);border-radius:50%}.score-ring>div{z-index:1;text-align:center;display:flex;flex-direction:column}.score-ring strong{font:400 39px/1 Georgia,serif}.score-ring span{color:#a9b8b5;font-size:10px;text-transform:uppercase}.detail-score>div>span{color:#a9b8b5;font-size:11px;text-transform:uppercase;letter-spacing:.1em}.detail-score>div>strong{display:block;margin:5px 0 8px;font:400 24px Georgia,serif;color:var(--lime)}.detail-score p{color:#b7c3c0;font-size:13px;line-height:1.5;margin:0}.metric-grid{display:grid;grid-template-columns:1fr 1fr;border:1px solid #ffffff1c;border-radius:12px;overflow:hidden}.metric-grid>div{min-height:91px;padding:15px;display:grid;grid-template-columns:23px 1fr;gap:3px 8px;border-bottom:1px solid #ffffff1c}.metric-grid>div:nth-child(odd){border-right:1px solid #ffffff1c}.metric-grid>div:nth-last-child(-n+2){border-bottom:0}.metric-grid>div>span{grid-row:1/3;color:var(--lime)}.metric-grid small{color:#9cadaa;text-transform:uppercase;font-size:9px}.metric-grid strong{font:400 17px Georgia,serif}.best-lure{margin-top:28px}.best-lure>div{display:grid;grid-template-columns:18px 1fr auto;gap:11px;align-items:center;padding:15px 0;border-bottom:1px solid #ffffff17}.best-lure>div span{color:var(--lime);font-size:12px}.confidence-note{display:flex;gap:10px;padding:16px;background:#ffffff0a;border-radius:10px;margin-top:22px;font-size:12px;line-height:1.45;color:#aebcba}.confidence-note>span:first-child{color:var(--lime)}.confidence-note strong{color:#fff}
|
||||||
.state{min-height:240px;display:grid;place-items:center;align-content:center;text-align:center;border:1px dashed #b9c7bf;border-radius:16px;color:#71817f}.state h2{font:400 28px Georgia,serif;color:var(--deep);margin:0}.how-it-works{border-top:1px solid #cfd8d1;padding:85px 0 105px;display:grid;grid-template-columns:.8fr 1.2fr;gap:70px}.how-it-works h2{font-size:47px;line-height:1.05}.principles{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.principles article{padding-top:24px;border-top:2px solid #294b4e}.principles article>span{font:italic 16px Georgia,serif;color:#82928f}.principles h3{margin:28px 0 8px;font:400 22px Georgia,serif}.principles p{font-size:14px;line-height:1.55;color:#657572}
|
.data-quality{padding:4px 8px;border-radius:20px;background:#fff0c7;color:#73550c;font-size:10px;font-weight:750}.data-note{margin:12px 0 0;color:#667875;font-size:12px;line-height:1.45}.state{min-height:240px;display:grid;place-items:center;align-content:center;text-align:center;border:1px dashed #b9c7bf;border-radius:16px;color:#71817f}.state h2{font:400 28px Georgia,serif;color:var(--deep);margin:0}.state p{max-width:560px;line-height:1.5}.error-state{border-color:#d8aaa1}.error-state a{color:#842f25}.compact-state{min-height:180px}.how-it-works{border-top:1px solid #cfd8d1;padding:85px 0 105px;display:grid;grid-template-columns:.8fr 1.2fr;gap:70px}.how-it-works h2{font-size:47px;line-height:1.05}.principles{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.principles article{padding-top:24px;border-top:2px solid #294b4e}.principles article>span{font:italic 16px Georgia,serif;color:#82928f}.principles h3{margin:28px 0 8px;font:400 22px Georgia,serif}.principles p{font-size:14px;line-height:1.55;color:#657572}
|
||||||
.records-hero,.form-hero{width:min(1360px,calc(100% - 64px));margin:auto;padding:76px 0 48px;display:flex;align-items:end;justify-content:space-between;gap:70px}.records-hero h1,.form-hero h1{font-size:clamp(58px,7vw,108px);margin-bottom:0}.records-hero>div:last-child,.form-hero>p{max-width:500px;color:#627370;line-height:1.65}.source-status{display:grid;grid-template-columns:auto 1fr;gap:4px 9px}.source-status small{grid-column:2}.status-dot{width:9px;height:9px;border-radius:50%;background:#82928f}.status-dot.success{background:#83b83a}.record-filters,.report-form{width:min(1360px,calc(100% - 64px));margin:0 auto 55px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:18px;box-shadow:0 30px 80px #16383c12}.record-filters{display:flex;gap:14px;background:var(--deep)}.record-filters label{flex:1}.record-filters input,.report-form input,.report-form textarea,.report-form select{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #d6dfd7;border-radius:9px;background:#f3f6f1;color:var(--deep)}.record-table{width:min(1360px,calc(100% - 64px));margin:auto;background:#fff;border-radius:16px;overflow:hidden}.record-row{display:grid;grid-template-columns:1.2fr .65fr 1.1fr 1.5fr 1fr .75fr;gap:14px;padding:20px;border-top:1px solid #e3e9e3}.record-head{background:#e4ebe3;border:0}.record-head span{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:#647572}.record-row>span,.record-row>time{font-size:13px;color:#60716e}.official-note{width:min(1360px,calc(100% - 64px));margin:20px auto 100px;color:#647572;font-size:12px}.form-hero{align-items:center}.form-hero>div{}.form-hero>p{font-size:17px}.notice{width:min(1360px,calc(100% - 64px));margin:0 auto 18px;padding:16px;border-radius:10px}.notice.success{background:#dff0c3;color:#426315}.notice.error{background:#f5d8d2;color:#8a3025}.report-form{max-width:900px;padding:34px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px 16px}.report-form label{display:block;color:#60716e}.report-form .wide{display:block;margin-top:20px}.report-form input:focus,.report-form textarea:focus,.report-form select:focus{outline:none;border-color:#839d4b;box-shadow:0 0 0 3px #c9f45b45}.privacy{font-size:12px;color:#647572}.honeypot{position:absolute;left:-9999px}.back{display:inline-block;margin:40px max(32px,calc((100% - 1360px)/2)) 10px;color:#60716e}.spot-hero,.periods,.detail-grid{width:min(1180px,calc(100% - 64px));margin-inline:auto}.spot-hero{padding:50px;background:var(--deep);color:#fff;border-radius:18px;display:flex;justify-content:space-between}.spot-hero h1{font-size:70px}.spot-hero p{color:#a9b8b5}.pin{width:160px;height:160px;border:1px solid #ffffff34;border-radius:50%;display:grid;place-items:center;color:var(--lime);font:400 35px Georgia}.periods{display:grid;grid-template-columns:repeat(3,1fr);margin-top:20px;background:#fff;border-radius:14px;overflow:hidden}.periods>div{text-align:center;padding:25px;border-right:1px solid #e2e8e2}.periods strong{display:block;font:400 32px Georgia;color:#537014}.periods span{font-size:12px;color:#647572}.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:24px;padding-bottom:80px}.catch-list article{display:flex;justify-content:space-between;padding:16px 0;border-bottom:1px solid #d5ded7}.catch-list article>div{display:flex;flex-direction:column}.catch-list article>div:last-child{text-align:right}.catch-list span{font-size:12px;color:#647572}.detail-grid aside{margin-top:50px;padding:25px;background:#fff;border-radius:14px}.detail-grid aside li{padding:10px 0;border-bottom:1px solid #e2e8e2}.note{font-size:12px;color:#647572}
|
.records-hero,.form-hero{width:min(1360px,calc(100% - 64px));margin:auto;padding:76px 0 48px;display:flex;align-items:end;justify-content:space-between;gap:70px}.records-hero h1,.form-hero h1{font-size:clamp(58px,7vw,108px);margin-bottom:0}.records-hero>div:last-child,.form-hero>p{max-width:500px;color:#627370;line-height:1.65}.source-status{display:grid;grid-template-columns:auto 1fr;gap:4px 9px}.source-status small{grid-column:2}.status-dot{width:9px;height:9px;border-radius:50%;background:#82928f}.status-dot.success{background:#83b83a}.record-filters,.report-form{width:min(1360px,calc(100% - 64px));margin:0 auto 55px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:18px;box-shadow:0 30px 80px #16383c12}.record-filters{display:flex;gap:14px;background:var(--deep)}.record-filters label{flex:1}.record-filters input,.report-form input,.report-form textarea,.report-form select{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #d6dfd7;border-radius:9px;background:#f3f6f1;color:var(--deep)}.record-table{width:min(1360px,calc(100% - 64px));margin:auto;background:#fff;border-radius:16px;overflow:hidden}.record-row{display:grid;grid-template-columns:1.2fr .65fr 1.1fr 1.5fr 1fr .75fr;gap:14px;padding:20px;border-top:1px solid #e3e9e3}.record-head{background:#e4ebe3;border:0}.record-head span{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:#647572}.record-row>span,.record-row>time{font-size:13px;color:#60716e}.official-note{width:min(1360px,calc(100% - 64px));margin:20px auto 100px;color:#647572;font-size:12px}.form-hero{align-items:center}.form-hero>div{}.form-hero>p{font-size:17px}.notice{width:min(1360px,calc(100% - 64px));margin:0 auto 18px;padding:16px;border-radius:10px}.notice.success{background:#dff0c3;color:#426315}.notice.error{background:#f5d8d2;color:#8a3025}.report-form{max-width:900px;padding:34px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px 16px}.report-form label{display:block;color:#60716e}.report-form .wide{display:block;margin-top:20px}.report-form input:focus,.report-form textarea:focus,.report-form select:focus{outline:none;border-color:#839d4b;box-shadow:0 0 0 3px #c9f45b45}.privacy{font-size:12px;color:#647572}.honeypot{position:absolute;left:-9999px}.back{display:inline-block;margin:40px max(32px,calc((100% - 1360px)/2)) 10px;color:#60716e}.spot-hero,.periods,.detail-grid{width:min(1180px,calc(100% - 64px));margin-inline:auto}.spot-hero{padding:50px;background:var(--deep);color:#fff;border-radius:18px;display:flex;justify-content:space-between}.spot-hero h1{font-size:70px}.spot-hero p{color:#a9b8b5}.pin{width:160px;height:160px;border:1px solid #ffffff34;border-radius:50%;display:grid;place-items:center;color:var(--lime);font:400 35px Georgia}.periods{display:grid;grid-template-columns:repeat(3,1fr);margin-top:20px;background:#fff;border-radius:14px;overflow:hidden}.periods>div{text-align:center;padding:25px;border-right:1px solid #e2e8e2}.periods strong{display:block;font:400 32px Georgia;color:#537014}.periods span{font-size:12px;color:#647572}.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:24px;padding-bottom:80px}.catch-list article{display:flex;justify-content:space-between;padding:16px 0;border-bottom:1px solid #d5ded7}.catch-list article>div{display:flex;flex-direction:column}.catch-list article>div:last-child{text-align:right}.catch-list span{font-size:12px;color:#647572}.detail-grid aside{margin-top:50px;padding:25px;background:#fff;border-radius:14px}.detail-grid aside li{padding:10px 0;border-bottom:1px solid #e2e8e2}.note{font-size:12px;color:#647572}
|
||||||
|
.notice.warning{background:#fff0c7;color:#73550c}.screenshot-retry{width:min(900px,calc(100% - 64px));margin:0 auto 24px;padding:22px;background:#fff;border:1px solid #e2c975;border-radius:12px}.screenshot-retry label{color:#60716e}.screenshot-retry input{display:block;width:100%;margin:8px 0 14px}.screenshot-retry button{border:0;border-radius:9px;padding:13px 20px;background:var(--deep);color:#fff;font-weight:750}
|
||||||
.moderation-app{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-login{display:flex;gap:14px;align-items:end;padding:25px;background:var(--deep);border-radius:16px}.admin-login label{flex:1;color:#a8b7b4;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.admin-login input{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #ffffff29;border-radius:9px;background:#ffffff0c;color:#fff}.admin-login button,.moderation-actions button{height:48px;padding:0 24px;border:0;border-radius:9px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-app>.privacy{margin:10px 4px 25px}.moderation-app>.notice{width:100%}.moderation-list{display:grid;gap:18px}.moderation-card{display:grid;grid-template-columns:1fr 300px;gap:25px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:16px}.moderation-summary h2{font:400 32px Georgia,serif;margin:12px 0 4px}.moderation-summary>p{color:#647572;margin:0 0 20px}.moderation-summary dl{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:0}.moderation-summary dl div{border-top:1px solid #e1e8e2;padding-top:10px}.moderation-summary dt{font-size:10px;text-transform:uppercase;color:#7a8a87}.moderation-summary dd{margin:3px 0 0}.moderation-summary blockquote{margin:18px 0 0;padding:13px;border-left:3px solid var(--lime);background:#f2f6ef;color:#566865}.moderation-proof img,.no-proof{width:100%;height:210px;object-fit:cover;border-radius:11px}.no-proof{display:grid;place-items:center;background:#edf2ec;color:#71817f}.moderation-actions{grid-column:1/-1;display:flex;align-items:end;gap:14px;border-top:1px solid #e1e8e2;padding-top:18px}.moderation-actions label{flex:1;color:#60716e;font-size:12px;text-transform:uppercase;letter-spacing:.08em}.moderation-actions textarea{display:block;width:100%;margin-top:6px;padding:10px;border:1px solid #d4ddd6;border-radius:8px;resize:vertical}.moderation-actions>div{display:flex;gap:8px}.moderation-actions .reject{background:#f0d4ce;color:#842f25}.moderation-actions .delete{background:transparent;color:#842f25;border:1px solid #d8aaa1}.moderation-actions button:disabled{opacity:.55}
|
.moderation-app{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-login{display:flex;gap:14px;align-items:end;padding:25px;background:var(--deep);border-radius:16px}.admin-login label{flex:1;color:#a8b7b4;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.admin-login input{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #ffffff29;border-radius:9px;background:#ffffff0c;color:#fff}.admin-login button,.moderation-actions button{height:48px;padding:0 24px;border:0;border-radius:9px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-app>.privacy{margin:10px 4px 25px}.moderation-app>.notice{width:100%}.moderation-list{display:grid;gap:18px}.moderation-card{display:grid;grid-template-columns:1fr 300px;gap:25px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:16px}.moderation-summary h2{font:400 32px Georgia,serif;margin:12px 0 4px}.moderation-summary>p{color:#647572;margin:0 0 20px}.moderation-summary dl{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:0}.moderation-summary dl div{border-top:1px solid #e1e8e2;padding-top:10px}.moderation-summary dt{font-size:10px;text-transform:uppercase;color:#7a8a87}.moderation-summary dd{margin:3px 0 0}.moderation-summary blockquote{margin:18px 0 0;padding:13px;border-left:3px solid var(--lime);background:#f2f6ef;color:#566865}.moderation-proof img,.no-proof{width:100%;height:210px;object-fit:cover;border-radius:11px}.no-proof{display:grid;place-items:center;background:#edf2ec;color:#71817f}.moderation-actions{grid-column:1/-1;display:flex;align-items:end;gap:14px;border-top:1px solid #e1e8e2;padding-top:18px}.moderation-actions label{flex:1;color:#60716e;font-size:12px;text-transform:uppercase;letter-spacing:.08em}.moderation-actions textarea{display:block;width:100%;margin-top:6px;padding:10px;border:1px solid #d4ddd6;border-radius:8px;resize:vertical}.moderation-actions>div{display:flex;gap:8px}.moderation-actions .reject{background:#f0d4ce;color:#842f25}.moderation-actions .delete{background:transparent;color:#842f25;border:1px solid #d8aaa1}.moderation-actions button:disabled{opacity:.55}
|
||||||
footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32px,calc((100vw - 1360px)/2));display:grid;grid-template-columns:1fr 1fr auto;align-items:center;gap:28px}footer .brand-mark{border-color:#ffffff32}footer p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)}
|
footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32px,calc((100vw - 1360px)/2));display:grid;grid-template-columns:1fr 1fr auto;align-items:center;gap:28px}footer .brand-mark{border-color:#ffffff32}footer p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)}
|
||||||
@media(max-width:1040px){.content-grid{width:min(100% - 36px,900px)}.topbar{width:calc(100% - 36px);grid-template-columns:1fr auto}.live-badge{display:none}.intro{grid-template-columns:1fr;gap:34px;padding-top:54px}.lake-card{height:280px}.dashboard{grid-template-columns:1fr}.detail-card{position:relative;top:auto}.how-it-works{grid-template-columns:1fr}.records-hero,.form-hero{display:block}.records-hero>div:last-child,.form-hero>p{margin-top:25px}}
|
@media(max-width:1040px){.content-grid{width:min(100% - 36px,900px)}.topbar{width:calc(100% - 36px);grid-template-columns:1fr auto}.live-badge{display:none}.intro{grid-template-columns:1fr;gap:34px;padding-top:54px}.lake-card{height:280px}.dashboard{grid-template-columns:1fr}.detail-card{position:relative;top:auto}.how-it-works{grid-template-columns:1fr}.records-hero,.form-hero{display:block}.records-hero>div:last-child,.form-hero>p{margin-top:25px}}
|
||||||
|
|||||||
@@ -9,3 +9,32 @@ test("player can open an active spot", async ({ page }) => {
|
|||||||
await expect(page.getByRole("heading", { name: "Точка 110:103" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Точка 110:103" })).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("submitted catch appears publicly only after moderation", async ({ page }) => {
|
||||||
|
const player = `E2E Player ${Date.now()}`;
|
||||||
|
const x = 1000 + Date.now() % 8000;
|
||||||
|
const y = x + 1;
|
||||||
|
const spotTestId = `spot-${x}-${y}`;
|
||||||
|
|
||||||
|
await page.goto("/report");
|
||||||
|
await page.getByLabel("Координата X *").fill(String(x));
|
||||||
|
await page.getByLabel("Координата Y *").fill(String(y));
|
||||||
|
await page.getByLabel("Вес, граммы *").fill("6789");
|
||||||
|
await page.getByLabel("Ник игрока").fill(player);
|
||||||
|
await page.getByRole("button", { name: "Отправить на проверку" }).click();
|
||||||
|
await expect(page.getByText("Улов отправлен на модерацию. Спасибо!")).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByTestId(spotTestId)).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto("/admin/moderation");
|
||||||
|
await page.getByLabel("Административный токен").fill("change-me-in-production");
|
||||||
|
await page.getByRole("button", { name: "Открыть очередь" }).click();
|
||||||
|
const report = page.locator(".moderation-card").filter({ hasText: player });
|
||||||
|
await expect(report).toBeVisible();
|
||||||
|
await report.getByRole("button", { name: "Одобрить" }).click();
|
||||||
|
await expect(report).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByTestId(spotTestId)).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
+26
-13
@@ -34,19 +34,19 @@
|
|||||||
- [x] Добавить удаление пользовательского сообщения администратором с аудитом действия (обезличивание записи, удаление объекта MinIO, миграция `0006`).
|
- [x] Добавить удаление пользовательского сообщения администратором с аудитом действия (обезличивание записи, удаление объекта MinIO, миграция `0006`).
|
||||||
- [x] Заменить in-memory rate limit на общее хранилище, пригодное для нескольких API-процессов и перезапусков (PostgreSQL, HMAC-отпечаток без хранения исходного IP, миграция `0007`).
|
- [x] Заменить in-memory rate limit на общее хранилище, пригодное для нескольких API-процессов и перезапусков (PostgreSQL, HMAC-отпечаток без хранения исходного IP, миграция `0007`).
|
||||||
- [x] Валидировать одновременно содержимое, MIME, расширение и лимит изображения; добавить тесты каждого отказа.
|
- [x] Валидировать одновременно содержимое, MIME, расширение и лимит изображения; добавить тесты каждого отказа.
|
||||||
- [ ] Добавить сквозной тест: отправка → pending → модерация → появление одобренного улова в публичной статистике.
|
- [x] Добавить сквозной тест: отправка → pending → модерация → появление одобренного улова в публичной статистике (Compose/Playwright: `2 passed`).
|
||||||
- [ ] Добавить понятные состояния успеха и ошибок загрузки в форму, включая отдельную ошибку скриншота без потери уже созданной заявки.
|
- [x] Добавить понятные состояния успеха и ошибок загрузки в форму, включая отдельную ошибку скриншота без потери уже созданной заявки (повторная загрузка по ID сохранённой заявки).
|
||||||
|
|
||||||
Критерий готовности: полный пользовательский сценарий проходит через браузер, а модератору не нужен ручной вызов API.
|
Критерий готовности: полный пользовательский сценарий проходит через браузер, а модератору не нужен ручной вызов API.
|
||||||
|
|
||||||
## Этап 4 — индекс клёва
|
## Этап 4 — индекс клёва
|
||||||
|
|
||||||
- [ ] Сверить текущую формулу активности и уверенности с разделом 9 спецификации и зафиксировать формулу в `docs/activity-index.md`.
|
- [x] Сверить текущую формулу активности и уверенности с разделом 11 спецификации и зафиксировать формулу в `docs/activity-index.md`.
|
||||||
- [ ] Покрыть unit-тестами затухание по свежести, вес официальных и пользовательских источников, дубликаты и вклад разных игроков.
|
- [x] Покрыть unit-тестами затухание по свежести, доверие к официальным и пользовательским источникам, повторные сообщения одного игрока и вклад разных игроков.
|
||||||
- [ ] Не учитывать pending/rejected записи и доказать это тестами.
|
- [x] Не учитывать pending/rejected/удалённые записи и доказать это тестами.
|
||||||
- [ ] Добавить детерминированные агрегаты для окон 6, 12, 24 и 72 часа.
|
- [x] Добавить детерминированные агрегаты для окон 6, 12, 24 и 72 часа.
|
||||||
- [ ] На карточке и странице точки показывать человекочитаемое объяснение оценки и объём данных, на котором она основана.
|
- [x] На карточке и странице точки показывать человекочитаемое объяснение оценки и объём данных, на котором она основана (unit-тест объяснения и Astro build).
|
||||||
- [ ] Реализовать и проверить состояния «данных мало», «данных нет», «источник недоступен» и ошибки валидации фильтров.
|
- [x] Реализовать состояния «данных мало», «данных нет», «источник недоступен» и ошибки валидации фильтров (Astro build; браузерная проверка войдёт в общий прогон фильтров).
|
||||||
- [ ] Проверить фильтры главной страницы сквозным тестом на desktop и mobile.
|
- [ ] Проверить фильтры главной страницы сквозным тестом на desktop и mobile.
|
||||||
|
|
||||||
Критерий готовности: оценка объяснима, воспроизводима тестами и никогда не маскирует недостаток или устаревание данных.
|
Критерий готовности: оценка объяснима, воспроизводима тестами и никогда не маскирует недостаток или устаревание данных.
|
||||||
@@ -64,6 +64,19 @@
|
|||||||
- [ ] Выбрать лицензию кода и политику использования данных.
|
- [ ] Выбрать лицензию кода и политику использования данных.
|
||||||
- [ ] Заменить демонстрационные секреты и определить целевое размещение перед внешней публикацией.
|
- [ ] Заменить демонстрационные секреты и определить целевое размещение перед внешней публикацией.
|
||||||
|
|
||||||
|
## Источники данных и согласование
|
||||||
|
|
||||||
|
- [x] Провести аудит подключённых источников, потенциальных поставщиков и всех существующих парсеров; результат записан в `docs/data-source-audit.md`.
|
||||||
|
- [x] Проверить оба официальных HTML-парсера на актуальной странице и добавить общий контрактный тест эквивалентности.
|
||||||
|
- [ ] Добавить `data_source` и языковые/версионные алиасы рыб, водоёмов и приманок до подключения второго автоматического источника.
|
||||||
|
- [ ] Вынести общий официальный DOM-парсер, устранив дублирование исследовательской и продуктивной реализации.
|
||||||
|
- [ ] Добавить отдельную фикстуру и безопасный ручной импорт недельных официальных рекордов одной категории.
|
||||||
|
- [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 — пилот
|
||||||
|
|
||||||
- [ ] Согласовать первые категории рекордов, водоёмы и виды рыб.
|
- [ ] Согласовать первые категории рекордов, водоёмы и виды рыб.
|
||||||
@@ -75,12 +88,12 @@
|
|||||||
|
|
||||||
## Ближайший рабочий пакет
|
## Ближайший рабочий пакет
|
||||||
|
|
||||||
Следующим завершается этап 3:
|
Этап 3 завершён. Следующий пакет продолжает этап 4:
|
||||||
|
|
||||||
1. сквозной тест `отправка → pending → модерация → публичная статистика`;
|
1. сквозная проверка фильтров главной страницы на desktop и mobile;
|
||||||
2. раздельные состояния успеха, ошибки создания заявки и ошибки загрузки скриншота без потери заявки;
|
2. переход к health/readiness PostgreSQL, MinIO, API и импорта;
|
||||||
3. финальная проверка этапа 3 в Docker и браузере на desktop и 390 px;
|
3. структурированные логи без пользовательских секретов;
|
||||||
4. переход к документации и тестам индекса клёва из этапа 4.
|
4. CI для тестов, Astro build, E2E и миграций.
|
||||||
|
|
||||||
После каждого пункта необходимо:
|
После каждого пункта необходимо:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Индекс активности RF4 Spotter
|
||||||
|
|
||||||
|
Индекс — сравнительная оценка свежести подтверждённых наблюдений, а не вероятность поклёвки. Он рассчитывается отдельно для комбинации «водоём + точка + рыба» в выбранном окне 6, 12, 24 или 72 часа.
|
||||||
|
|
||||||
|
## Какие данные участвуют
|
||||||
|
|
||||||
|
В расчёт входят только одобренные и не удалённые записи с координатами точки. `pending`, `rejected`, удалённые записи и официальные рекорды без координат не участвуют. Фильтры водоёма, рыбы и способа ловли применяются до агрегации.
|
||||||
|
|
||||||
|
Официальный импорт устраняет точные дубликаты по `source_external_id`. Несколько пользовательских сообщений остаются отдельными наблюдениями, но одинаковое имя игрока после удаления пробелов и приведения регистра учитывается в `unique_players` только один раз. Пустое имя игроком не считается. Поэтому один игрок может увеличить объём и свежесть данных, но не может имитировать несколько независимых источников.
|
||||||
|
|
||||||
|
## Формула активности
|
||||||
|
|
||||||
|
Для каждого сообщения с возрастом `age_hours` и доверием к источнику `source_confidence` от 0 до 100:
|
||||||
|
|
||||||
|
```text
|
||||||
|
freshness_i = exp(-age_hours_i / 18)
|
||||||
|
weighted_reports = sum(freshness_i * source_confidence_i / 100)
|
||||||
|
unique_players = количество уникальных непустых player_name
|
||||||
|
trophy_bonus = min(1, trophy_count / 3)
|
||||||
|
|
||||||
|
activity_score = round(
|
||||||
|
55 * min(1, weighted_reports / 12)
|
||||||
|
+ 25 * min(1, unique_players / 6)
|
||||||
|
+ 20 * trophy_bonus
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Период полураспада вклада по свежести равен примерно 12,5 часа. Трофей определяется по порогу `trophy_weight_g` конкретного вида рыбы.
|
||||||
|
|
||||||
|
## Формула уверенности
|
||||||
|
|
||||||
|
```text
|
||||||
|
confidence_score = round(
|
||||||
|
45 * min(1, approved_reports / 10)
|
||||||
|
+ 35 * min(1, unique_players / 5)
|
||||||
|
+ 20 * average_source_confidence / 100
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Обе оценки ограничены диапазоном 0–100 составляющими формулы. На карточке рядом с числом показываются число уловов, число уникальных игроков, время последнего подтверждения и пометка «Данных мало» при числе наблюдений меньше трёх.
|
||||||
|
|
||||||
|
## Воспроизводимость и ограничения
|
||||||
|
|
||||||
|
Расчёт использует время сервера в UTC; в тестах опорное время задаётся явно. Граница окна включительна: запись ровно на границе входит в расчёт. Формула соответствует разделу 11 `RF4_MVP_SPEC.md` и должна быть откалибрована после накопления реальных данных. Она пока не выявляет семантически похожие пользовательские сообщения от разных имён и не заменяет модерацию.
|
||||||
@@ -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. Не хранить и не проксировать изображения без отдельного условия разрешения; на первом этапе достаточно исходной ссылки.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Аудит источников и парсеров
|
||||||
|
|
||||||
|
Дата проверки: **3 сентября 2026 года**. Аудит охватывает код репозитория, один контрольный запрос к официальной таблице и публично описанные возможности потенциальных источников. Владелец RF4 Spotter позднее подтвердил наличие разрешений на получение данных RF4DB и RF4-STAT; технический результат пилота описан в `docs/community-source-pilot.md`.
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
Сейчас у проекта **два реально работающих канала данных**, но только **один автоматический внешний источник**:
|
||||||
|
|
||||||
|
| Канал | Статус | Что даёт | Координаты | Доверие |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Официальная таблица `rf4game.de` | подключена, HTML-импорт | рекорд, рыба, вес, водоём, приманка, игрок, дата | нет | 100 |
|
||||||
|
| Собственная форма RF4 Spotter | подключена, после модерации | обычный улов, точка, снасть, игрок, скриншот | да | назначается приложением |
|
||||||
|
|
||||||
|
`manual_import` существует в модели и seed, но отдельного пользовательского CSV/JSON-импортера нет. Seed — демонстрационные данные, а не источник. MinIO хранит доказательство пользовательской записи и также не является самостоятельным источником.
|
||||||
|
|
||||||
|
## Текущие парсеры
|
||||||
|
|
||||||
|
В репозитории два HTML-парсера одной официальной таблицы:
|
||||||
|
|
||||||
|
1. `rf4_research.records.parse_records_html` — исследовательский адаптер и CLI;
|
||||||
|
2. `app.importer.parse_html` — продуктивный адаптер с HTTP-кэшем, повторами, журналом и сохранением.
|
||||||
|
|
||||||
|
Оба ожидают один DOM-контракт `div.records.flex_table`, одинаковый порядок шести колонок и одинаково нормализуют вес, дату и пустые поля. Добавлен общий контрактный тест: на одной фикстуре парсеры обязаны вернуть одинаковые кортежи полей и оба обязаны отклонить изменённую структуру колонок.
|
||||||
|
|
||||||
|
Контрольный HTML `https://rf4game.de/records/region/RU/` имел размер 1 147 837 байт. Оба парсера успешно прочитали **1260 записей**, результаты по восьми общим полям полностью совпали. Временный HTML в репозиторий не добавлен.
|
||||||
|
|
||||||
|
### Обнаруженные ограничения согласования
|
||||||
|
|
||||||
|
- Дублирование кода двух парсеров остаётся риском. Контрактный тест защищает результат, но при следующем рефакторинге общую чистую функцию разбора лучше вынести в пакет, доступный и исследовательскому CLI, и API-контейнеру.
|
||||||
|
- Источник настроен на немецкий домен. Регион `RU` фильтрует игроков, но названия рыб, водоёмов и приманок остаются немецкими. Они не совпадают с русскими названиями пользовательской формы и могут создать параллельные сущности.
|
||||||
|
- Русский URL `https://rf4game.ru/records/region/RU/` при контрольном запросе вернул небольшую JavaScript-защитную страницу без таблицы; текущий парсер корректно завершился ошибкой `records table not found`. Обход защиты не рассматривается.
|
||||||
|
- `source_external_id` надёжно убирает повтор одной и той же локализованной строки, но локализованные копии одной записи получат разные хеши. Нельзя считать разные языковые домены независимыми подтверждениями.
|
||||||
|
- Официальная запись не содержит координат и поэтому не участвует в индексе конкретной точки. Автоматически связывать её с точкой нельзя.
|
||||||
|
- Для новых агрегаторов одного enum `source_type` недостаточно: нужен стабильный `source_system`, внешний ID внутри этого источника, исходная ссылка и отдельное правило доверия.
|
||||||
|
|
||||||
|
## Где можно получить новые данные
|
||||||
|
|
||||||
|
### Приоритет A — тот же официальный источник
|
||||||
|
|
||||||
|
Официальная навигация публикует абсолютные и недельные таблицы, а также категории `records`, `ultralight`, `recordslight`, `bottomlight`, `sea` и `telestick`. Это расширение охвата существующего источника, а не независимые подтверждения. Текущий DOM-парсер, вероятно, можно переиспользовать, но каждую комбинацию нужно сначала проверить отдельной фикстурой. Регулярный обход нельзя включать без согласования допустимой частоты с владельцем сайта.
|
||||||
|
|
||||||
|
Рекомендуемый первый эксперимент — **недельные RU-рекорды одной категории**. Они полезнее абсолютных для свежести, не требуют новой схемы, но всё ещё не дают координат.
|
||||||
|
|
||||||
|
### Приоритет A — согласованные каналы сообщества
|
||||||
|
|
||||||
|
Telegram, Discord и VK могут давать свежие координаты, оснастку, игровое время и скриншоты. Подключать следует только конкретные каналы, администраторы которых письменно разрешили импорт. Для каждого сообщения нужны permalink/message ID, время публикации, имя канала, версия правил и возможность удалить запись по запросу.
|
||||||
|
|
||||||
|
Наиболее безопасная реализация — бот или форма, куда автор сам пересылает сообщение и подтверждает распознанные поля. Это лучше скрытого чтения групп и позволяет использовать существующую очередь модерации.
|
||||||
|
|
||||||
|
### Приоритет B — RF4DB (разрешение подтверждено владельцем проекта)
|
||||||
|
|
||||||
|
`https://rf4db.com/ru` показывает актуальные пользовательские уловы, координаты, игровое время, погоду, снасть и изображения; публичная страница заявляет 19 водоёмов, 252 вида рыб и тысячи точек. Страница об источниках поясняет, что игровые справочники взяты из игры, а точки и комментарии собраны игроками в открытом доступе.
|
||||||
|
|
||||||
|
Серверный HTML пригоден для адаптера; пилотный fail-closed парсер добавлен. Перед регулярным запуском нужно сохранить подтверждение разрешения и его условия: атрибуцию, частоту, удаление и допустимость ссылок на изображения.
|
||||||
|
|
||||||
|
### Приоритет B — RF4-STAT (разрешение подтверждено владельцем проекта)
|
||||||
|
|
||||||
|
`https://rf4-stat.ru/help/` сообщает, что рекорды берутся с официального сайта, а точки и посты — из VK, Discord, Telegram и собственной формы. Сервис обновляет записи регулярно, часть доступа является Premium.
|
||||||
|
|
||||||
|
Пилотные парсеры публичных `/fishing/` и `/posts/` добавлены с соблюдением `Crawl-delay`. Заблокированные координаты, погода, комментарии и интерактивная статистика не извлекаются. Данные, пришедшие туда с официального сайта, нельзя считать вторым независимым подтверждением.
|
||||||
|
|
||||||
|
### Приоритет C — справочники
|
||||||
|
|
||||||
|
Списки рыб, водоёмов, снастей, трофейных весов и переводов полезны для канонизации, но не для оценки текущего клёва. Источниками-кандидатами являются официальные страницы/патчноуты и разрешённый справочный экспорт партнёра. Нужны версия игры, язык и устойчивый внутренний ключ; сопоставление только по отображаемому имени недостаточно.
|
||||||
|
|
||||||
|
## Что не использовать
|
||||||
|
|
||||||
|
- перехват трафика клиента, внедрение в игру и закрытые протоколы;
|
||||||
|
- боты автоматической рыбалки и данные, полученные с нарушением правил игры;
|
||||||
|
- обход JavaScript-защиты, CAPTCHA, авторизации или Premium-доступа;
|
||||||
|
- массовое копирование чужих изображений и пользовательских постов без разрешения;
|
||||||
|
- разные локализации официальной записи как разные подтверждения.
|
||||||
|
|
||||||
|
## Схема согласования новых адаптеров
|
||||||
|
|
||||||
|
Каждый адаптер должен выдавать промежуточную запись со следующими группами полей:
|
||||||
|
|
||||||
|
```text
|
||||||
|
provenance: source_system, source_external_id, source_url, observed_at, fetched_at
|
||||||
|
identity: fish_external_id/name/language, waterbody_external_id/name/language
|
||||||
|
catch: weight_g, caught_at, game_time, bait, method, rig, retrieve
|
||||||
|
spot: x, y или null
|
||||||
|
actor: player_name или null
|
||||||
|
evidence: screenshot_url/key, raw_payload
|
||||||
|
quality: moderation_status, source_confidence
|
||||||
|
```
|
||||||
|
|
||||||
|
До записи в `catch_report` должны последовательно выполняться:
|
||||||
|
|
||||||
|
1. валидация контракта источника;
|
||||||
|
2. нормализация единиц, времени и пустых значений;
|
||||||
|
3. сопоставление рыб/водоёмов по таблице алиасов с языком и версией игры;
|
||||||
|
4. дедупликация внутри `source_system`;
|
||||||
|
5. выявление кросс-источниковых копий без автоматического объединения сомнительных записей;
|
||||||
|
6. назначение доверия и модерации по политике источника;
|
||||||
|
7. сохранение происхождения и минимально необходимого сырого фрагмента.
|
||||||
|
|
||||||
|
## Рекомендуемый порядок работ
|
||||||
|
|
||||||
|
1. Добавить сущности `data_source` и `entity_alias`; включить источник в уникальный ключ внешней записи.
|
||||||
|
2. Вынести общий официальный DOM-парсер и оставить два тонких клиента вокруг него.
|
||||||
|
3. Добавить фикстуру недельной таблицы и проверить одну официальную категорию без расписания.
|
||||||
|
4. Согласовать экспорт/API с RF4DB и RF4-STAT; до ответа не писать их парсеры.
|
||||||
|
5. Выбрать один добровольный Telegram/Discord/VK-канал для пилота и зафиксировать согласие/правила удаления.
|
||||||
|
6. Только затем добавлять адаптер, контрактные тесты и калибровку `source_confidence`.
|
||||||
|
|
||||||
|
## Проверенные ссылки
|
||||||
|
|
||||||
|
- официальный абсолютный рейтинг: <https://rf4game.de/records/region/RU/>
|
||||||
|
- официальный недельный рейтинг: <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/>
|
||||||
|
- исследовательский проект: <https://github.com/hurfy/rf4-api>
|
||||||
@@ -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>")
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.importer import ImportSourceError, parse_html
|
||||||
|
from rf4_research.records import RecordsParseError, parse_records_html
|
||||||
|
|
||||||
|
|
||||||
|
FIXTURE = Path(__file__).parent / "fixtures" / "records_ru_sample.html"
|
||||||
|
|
||||||
|
|
||||||
|
def _research_contract(html: str) -> list[tuple[object, ...]]:
|
||||||
|
records = parse_records_html(
|
||||||
|
html,
|
||||||
|
region="RU",
|
||||||
|
category="records",
|
||||||
|
source_url="fixture://records",
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
(row.region, row.category, row.player, row.fish, row.weight_g, row.waterbody, row.bait, row.record_date)
|
||||||
|
for row in records
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _production_contract(html: str) -> list[tuple[object, ...]]:
|
||||||
|
records = parse_html(html, region="RU", category="records")
|
||||||
|
return [
|
||||||
|
(row.region, row.category, row.player, row.fish, row.weight_g, row.waterbody, row.bait, row.record_date)
|
||||||
|
for row in records
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_and_production_parsers_emit_the_same_contract() -> None:
|
||||||
|
html = FIXTURE.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert _research_contract(html) == _production_contract(html)
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_parsers_reject_a_changed_column_contract() -> None:
|
||||||
|
html = FIXTURE.read_text(encoding="utf-8").replace('class="col data"', 'class="col changed"', 1)
|
||||||
|
|
||||||
|
with pytest.raises(RecordsParseError, match="records columns changed"):
|
||||||
|
_research_contract(html)
|
||||||
|
with pytest.raises(ImportSourceError, match="record columns changed"):
|
||||||
|
_production_contract(html)
|
||||||
Reference in New Issue
Block a user