Build Dockerized MVP scaffold and records importer

This commit is contained in:
ik
2026-09-02 20:29:02 +07:00
parent a6f91a1329
commit d3a45248ef
39 changed files with 6638 additions and 13 deletions
+3
View File
@@ -0,0 +1,3 @@
DATABASE_URL=postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter
PUBLIC_API_URL=http://localhost:8000
API_INTERNAL_URL=http://api:8000
+7
View File
@@ -2,3 +2,10 @@ __pycache__/
*.py[cod] *.py[cod]
.pytest_cache/ .pytest_cache/
.venv/ .venv/
node_modules/
dist/
.astro/
test-results/
playwright-report/
*.egg-info/
*.db
+87 -13
View File
@@ -1,25 +1,99 @@
# RF4 Spotter # RF4 Spotter
RF4 Spotter — неофициальный сервис свежих точек и статистики клёва для Russian Fishing 4. Сейчас в репозитории выполнен только этап 0: исследование публичного источника официальных рекордов. Сайт и продуктовый backend ещё не создавались. RF4 Spotter — неофициальный сервис свежих точек и статистики клёва для Russian Fishing 4. Этап 1 содержит контейнерный каркас, демоданные, read-only API, главную страницу и страницу точки. Реальный импорт официальных рекордов пока остаётся отдельным исследовательским адаптером этапа 0.
## Исследовательский парсер ## Запуск через Docker
Требуется Python 3.11+ и `beautifulsoup4`: Требуются Docker Engine и Docker Compose. Это основной и рекомендуемый сценарий:
```bash
docker compose up --build
```
После успешного запуска:
- сайт: <http://localhost:4321>;
- OpenAPI: <http://localhost:8000/docs>;
- проверка API: <http://localhost:8000/health>.
Контейнер API сам выполняет `alembic upgrade head`, затем идемпотентный seed. PostgreSQL хранит данные в именованном volume `postgres_data`.
Остановка:
```bash
docker compose down
```
Удаление volume и повторное создание чистой базы — только когда данные больше не нужны:
```bash
docker compose down --volumes
docker compose up --build
```
Переменные и локальные значения по умолчанию перечислены в [.env.example](.env.example). Секретов в репозитории нет.
## Что реализовано
- FastAPI и SQLAlchemy 2;
- PostgreSQL 17 и начальная миграция Alembic;
- идемпотентный seed с двумя точками и свежими демо-уловами;
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
- `GET /api/v1/spots/{id}` и `/catches`;
- справочники рыб, водоёмов и приманок;
- Astro SSR-интерфейс с адаптивной главной и страницей точки;
- объяснимые индексы активности и уверенности по формуле спецификации;
- состояния «нет данных» и «источник недоступен».
- идемпотентный импорт официальных записей с журналом запусков;
- публичная страница `/records` с источником и временем последнего импорта.
Все пользовательские ники и уловы в seed демонстрационные.
## Проверка проекта
Backend и исследовательский парсер:
```bash
python3 -m venv .venv
.venv/bin/pip install -r apps/api/requirements.txt
.venv/bin/pip install -e .
.venv/bin/pytest apps/api/tests tests -q
```
Frontend:
```bash
cd apps/web
npm install
npm run build
npm audit --omit=dev
```
E2E после запуска Compose:
```bash
cd apps/web
npx playwright install chromium
npm run test:e2e
```
## Импорт официальных рекордов
Однократный контейнерный запуск после старта базы:
```bash
docker compose --profile tools run --rm importer
```
Импорт делает до трёх ограниченных попыток, проверяет DOM-контракт и не удаляет ранее сохранённые данные при сбое. Повторный запуск обновляет совпавшие записи по SHA-256 ключу и не создаёт дубликаты. Автоматическое расписание намеренно ещё не включено: сначала требуется согласовать допустимость регулярного опроса официального сайта.
## Исследовательский парсер официальных рекордов
```bash ```bash
python -m pip install -e .
python -m rf4_research.records \ python -m rf4_research.records \
--url https://rf4game.de/records/region/RU/ \ --url https://rf4game.de/records/region/RU/ \
--region RU \ --region RU \
--category records --category records
``` ```
Команда делает один HTTP-запрос и печатает типизированные записи в JSON. Это исследовательский адаптер, а не готовый импортёр: в нём пока нет повторов, кэша, транзакций и дедупликации. Команда делает один HTTP-запрос и печатает типизированные записи в JSON. Это ещё не продуктивный импортёр: в нём нет повторов, кэша, транзакций и дедупликации. Подтверждённая структура источника и риски описаны в [docs/data-sources.md](docs/data-sources.md).
## Проверка
```bash
python -m unittest discover -s tests -v
```
Подтверждённая структура источника, ограничения и риски описаны в [docs/data-sources.md](docs/data-sources.md).
+8
View File
@@ -0,0 +1,8 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["sh", "-c", "alembic upgrade head && python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
+30
View File
@@ -0,0 +1,30 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+psycopg://rf4:rf4_local@db:5432/rf4_spotter
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.database import Base
from app import models # noqa: F401
config = context.config
if config.config_file_name:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", os.environ.get("DATABASE_URL", config.get_main_option("sqlalchemy.url")))
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(url=config.get_main_option("sqlalchemy.url"), target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
run_migrations_offline() if context.is_offline_mode() else run_migrations_online()
+20
View File
@@ -0,0 +1,20 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+31
View File
@@ -0,0 +1,31 @@
"""Initial demo schema."""
from alembic import op
import sqlalchemy as sa
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
bait_kind = sa.Enum("bait", "lure", "unknown", name="baitkind")
source_type = sa.Enum("official_record", "user", "manual_import", name="sourcetype")
moderation = sa.Enum("pending", "approved", "rejected", name="moderationstatus")
op.create_table("fish", sa.Column("id", sa.Uuid(), primary_key=True), sa.Column("slug", sa.String(100), unique=True, nullable=False), sa.Column("name_ru", sa.String(200), unique=True, nullable=False), sa.Column("trophy_weight_g", sa.Integer()))
op.create_table("waterbody", sa.Column("id", sa.Uuid(), primary_key=True), sa.Column("slug", sa.String(100), unique=True, nullable=False), sa.Column("name_ru", sa.String(200), unique=True, nullable=False), sa.Column("unlock_level", sa.Integer()))
op.create_table("bait", sa.Column("id", sa.Uuid(), primary_key=True), sa.Column("name", sa.String(200), nullable=False), sa.Column("normalized_name", sa.String(200), unique=True, nullable=False), sa.Column("kind", bait_kind, nullable=False))
op.create_table("spot", sa.Column("id", sa.Uuid(), primary_key=True), sa.Column("waterbody_id", sa.Uuid(), sa.ForeignKey("waterbody.id"), nullable=False), sa.Column("x", sa.Integer(), nullable=False), sa.Column("y", sa.Integer(), nullable=False), sa.Column("description", sa.Text()), sa.UniqueConstraint("waterbody_id", "x", "y"))
op.create_table("catch_report", sa.Column("id", sa.Uuid(), primary_key=True), sa.Column("fish_id", sa.Uuid(), sa.ForeignKey("fish.id"), nullable=False), sa.Column("spot_id", sa.Uuid(), sa.ForeignKey("spot.id")), sa.Column("waterbody_id", sa.Uuid(), sa.ForeignKey("waterbody.id"), nullable=False), sa.Column("bait_id", sa.Uuid(), sa.ForeignKey("bait.id")), sa.Column("weight_g", sa.Integer(), nullable=False), sa.Column("fishing_method", sa.String(50)), sa.Column("rig_type", sa.String(100)), sa.Column("retrieve_method", sa.String(100)), sa.Column("retrieve_speed", sa.Integer()), sa.Column("game_time", sa.Time()), sa.Column("caught_at", sa.DateTime(timezone=True)), sa.Column("reported_at", sa.DateTime(timezone=True), nullable=False), sa.Column("player_name", sa.String(100)), sa.Column("source_type", source_type, nullable=False), sa.Column("source_confidence", sa.Integer(), nullable=False), sa.Column("moderation_status", moderation, nullable=False))
op.create_index("ix_catch_report_activity", "catch_report", ["moderation_status", "reported_at"])
def downgrade() -> None:
op.drop_index("ix_catch_report_activity", table_name="catch_report")
op.drop_table("catch_report")
op.drop_table("spot")
op.drop_table("bait")
op.drop_table("waterbody")
op.drop_table("fish")
for name in ("moderationstatus", "sourcetype", "baitkind"):
sa.Enum(name=name).drop(op.get_bind(), checkfirst=True)
@@ -0,0 +1,37 @@
"""Official record import fields and run journal."""
from alembic import op
import sqlalchemy as sa
revision = "0002"
down_revision = "0001"
branch_labels = None
depends_on = None
def upgrade() -> None:
status = sa.Enum("running", "success", "partial", "failed", name="importstatus")
op.add_column("catch_report", sa.Column("source_url", sa.Text()))
op.add_column("catch_report", sa.Column("source_external_id", sa.String(64)))
op.add_column("catch_report", sa.Column("raw_payload", sa.JSON()))
op.create_unique_constraint("uq_catch_report_source_external_id", "catch_report", ["source_external_id"])
op.create_table(
"official_record_import",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True)),
sa.Column("status", status, nullable=False),
sa.Column("source_url", sa.Text(), nullable=False),
sa.Column("rows_seen", sa.Integer(), nullable=False),
sa.Column("rows_created", sa.Integer(), nullable=False),
sa.Column("rows_updated", sa.Integer(), nullable=False),
sa.Column("error_summary", sa.Text()),
)
def downgrade() -> None:
op.drop_table("official_record_import")
op.drop_constraint("uq_catch_report_source_external_id", "catch_report", type_="unique")
op.drop_column("catch_report", "raw_payload")
op.drop_column("catch_report", "source_external_id")
op.drop_column("catch_report", "source_url")
sa.Enum(name="importstatus").drop(op.get_bind(), checkfirst=True)
+1
View File
@@ -0,0 +1 @@
"""RF4 Spotter API."""
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import math
from collections import Counter
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from .models import CatchReport, ModerationStatus
from .schemas import ActivityOut
def activity_rows(
session: Session,
*,
hours: int,
waterbody: str | None = None,
fish: str | None = None,
method: str | None = None,
) -> list[ActivityOut]:
now = datetime.now(timezone.utc)
query = (
select(CatchReport)
.options(
joinedload(CatchReport.fish), joinedload(CatchReport.waterbody),
joinedload(CatchReport.spot), joinedload(CatchReport.bait),
)
.where(
CatchReport.moderation_status == ModerationStatus.approved,
CatchReport.spot_id.is_not(None),
CatchReport.reported_at >= now - timedelta(hours=hours),
)
)
reports = list(session.scalars(query))
if waterbody:
reports = [r for r in reports if r.waterbody.slug == waterbody]
if fish:
reports = [r for r in reports if r.fish.slug == fish]
if method:
reports = [r for r in reports if r.fishing_method == method]
groups: dict[tuple[object, object], list[CatchReport]] = {}
for report in reports:
groups.setdefault((report.spot_id, report.fish_id), []).append(report)
result: list[ActivityOut] = []
for items in groups.values():
first = items[0]
players = {r.player_name.strip().casefold() for r in items if r.player_name and r.player_name.strip()}
freshness = [math.exp(-max(0.0, (now - _aware(r.reported_at)).total_seconds()) / 3600 / 18) for r in items]
weighted = sum(value * r.source_confidence / 100 for value, r in zip(freshness, items))
trophies = sum(bool(r.fish.trophy_weight_g and r.weight_g >= r.fish.trophy_weight_g) for r in items)
activity = round(55 * min(1, weighted / 12) + 25 * min(1, len(players) / 6) + 20 * min(1, trophies / 3))
average_confidence = sum(r.source_confidence for r in items) / len(items)
confidence = round(45 * min(1, len(items) / 10) + 35 * min(1, len(players) / 5) + 20 * average_confidence / 100)
latest = max(_aware(r.caught_at or r.reported_at) for r in items)
baits = Counter(r.bait.name for r in items if r.bait)
freshness_text = _freshness_text(now - latest)
result.append(ActivityOut(
spot_id=first.spot.id, waterbody_slug=first.waterbody.slug,
waterbody=first.waterbody.name_ru, fish_slug=first.fish.slug,
fish=first.fish.name_ru, x=first.spot.x, y=first.spot.y,
best_bait=baits.most_common(1)[0][0] if baits else None,
catches=len(items), unique_players=len(players),
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,
activity_score=activity, confidence_score=confidence,
explanation=f"{len(items)} свежих уловов от {len(players)} игроков. Последнее подтверждение {freshness_text}." + (" Данных мало." if len(items) < 3 else ""),
))
return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True)
def _aware(value: datetime) -> datetime:
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
def _freshness_text(delta: timedelta) -> str:
minutes = max(0, round(delta.total_seconds() / 60))
if minutes < 60:
return f"{minutes} мин. назад"
return f"{minutes // 60} ч. назад"
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
import argparse
from .database import SessionLocal
from .importer import import_records
def main() -> int:
parser = argparse.ArgumentParser(prog="python -m app.cli")
sub = parser.add_subparsers(dest="command", required=True)
command = sub.add_parser("import-records")
command.add_argument("--url", default="https://rf4game.de/records/region/RU/")
command.add_argument("--region", default="RU")
command.add_argument("--category", default="records")
args = parser.parse_args()
with SessionLocal() as session:
run = import_records(session, url=args.url, region=args.region, category=args.category)
print(f"import {run.status.value}: seen={run.rows_seen} created={run.rows_created} updated={run.rows_updated}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+9
View File
@@ -0,0 +1,9 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str = "postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter"
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
settings = Settings()
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from .config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
def get_session() -> Generator[Session, None, None]:
with SessionLocal() as session:
yield session
+7
View File
@@ -0,0 +1,7 @@
"""Compatibility imports; new code should use :mod:`app.database`."""
from .database import Base, SessionLocal, engine, get_session
get_db = get_session
__all__ = ["Base", "SessionLocal", "engine", "get_db", "get_session"]
+187
View File
@@ -0,0 +1,187 @@
from __future__ import annotations
import hashlib
import re
import time as time_module
from dataclasses import asdict, dataclass
from datetime import date, datetime, time, timezone
import httpx
from bs4 import BeautifulSoup, Tag
from sqlalchemy import select
from sqlalchemy.orm import Session
from .models import (
Bait, BaitKind, CatchReport, Fish, ImportStatus, ModerationStatus,
OfficialRecordImport, SourceType, Waterbody,
)
USER_AGENT = "RF4-Spotter/0.1 (public records importer)"
class ImportSourceError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class RawRecord:
region: str
category: str
player: str | None
fish: str
weight_g: int
waterbody: str
bait: str | None
record_date: date
def normalize(value: str) -> str:
return " ".join(value.replace("\xa0", " ").replace("", "-").replace("", "-").split()).casefold()
def slugify(value: str) -> str:
compact = re.sub(r"[^a-z0-9а-яё]+", "-", normalize(value), flags=re.IGNORECASE).strip("-")
return compact or hashlib.sha256(value.encode()).hexdigest()[:16]
def external_id(record: RawRecord) -> str:
parts = [record.region, record.category, record.player or "", record.fish, str(record.weight_g), record.waterbody, record.bait or "", record.record_date.isoformat()]
return hashlib.sha256("|".join(normalize(part) for part in parts).encode()).hexdigest()
def parse_weight(raw: str) -> int:
value = " ".join(raw.replace("\xa0", " ").split()).lower()
match = re.fullmatch(r"([\d .,']+)\s*(kg|g)", value)
if not match:
raise ImportSourceError(f"unsupported weight {raw!r}")
number, unit = match.groups()
number = number.replace(" ", "").replace("'", "").replace("", "")
if unit == "g":
return int(number.replace(".", "").replace(",", ""))
if "," in number and "." not in number:
number = number.replace(",", ".")
return round(float(number) * 1000)
def _text(node: Tag | None) -> str:
return node.get_text(" ", strip=True) if node else ""
def parse_html(html: str, *, region: str, category: str) -> list[RawRecord]:
soup = BeautifulSoup(html, "html.parser")
table = soup.select_one("div.records.flex_table")
if table is None:
raise ImportSourceError("records table not found")
header = table.select_one(":scope > .row.header")
expected = ["fish", "weight", "location", "bait", "gamername", "data"]
cells = header.find_all("div", recursive=False) if header else []
actual = [next((key for key in expected if key in cell.get("class", [])), "") for cell in cells]
if actual != expected:
raise ImportSourceError(f"record columns changed: {actual}")
records: list[RawRecord] = []
for group in table.select(":scope > .rows > .row > .records_subtable"):
group_header = group.select_one(":scope > .row.header")
if group_header is None:
continue
fish = _text(group_header.select_one(".fish .text"))
rows = [group_header, *group.select(":scope > .rows > .row")]
for row in rows:
bait_node = row.select_one(".bait_icon")
bait = bait_node.get("title", "").strip() if bait_node else ""
try:
record_date = datetime.strptime(_text(row.select_one(".data")), "%d.%m.%y").date()
except ValueError as exc:
raise ImportSourceError("record date format changed") from exc
records.append(RawRecord(region.upper(), category, _text(row.select_one(".gamername")) or None, fish, parse_weight(_text(row.select_one(".weight"))), _text(row.select_one(".location")), bait or None, record_date))
if not records:
raise ImportSourceError("records table is empty")
return records
def fetch_records(url: str, *, region: str, category: str) -> list[RawRecord]:
with httpx.Client(timeout=20, follow_redirects=True, headers={"User-Agent": USER_AGENT, "Accept": "text/html"}) as client:
for attempt in range(3):
try:
response = client.get(url)
response.raise_for_status()
if "text/html" not in response.headers.get("content-type", ""):
raise ImportSourceError("source did not return HTML")
return parse_html(response.text, region=region, category=category)
except (httpx.HTTPError, ImportSourceError):
if attempt == 2:
raise
time_module.sleep(2 ** attempt)
raise AssertionError("unreachable")
def import_records(session: Session, *, url: str, region: str, category: str, html: str | None = None) -> OfficialRecordImport:
run = OfficialRecordImport(started_at=datetime.now(timezone.utc), status=ImportStatus.running, source_url=url, rows_seen=0, rows_created=0, rows_updated=0)
session.add(run)
session.commit()
try:
records = parse_html(html, region=region, category=category) if html is not None else fetch_records(url, region=region, category=category)
run.rows_seen = len(records)
for raw in records:
key = external_id(raw)
report = session.scalar(select(CatchReport).where(CatchReport.source_external_id == key))
fish = _fish(session, raw.fish)
waterbody = _waterbody(session, raw.waterbody)
bait = _bait(session, raw.bait) if raw.bait else None
payload = asdict(raw) | {"record_date": raw.record_date.isoformat()}
caught = datetime.combine(raw.record_date, time(), tzinfo=timezone.utc)
if report is None:
session.add(CatchReport(fish=fish, waterbody=waterbody, bait=bait, spot=None, weight_g=raw.weight_g, caught_at=caught, reported_at=datetime.now(timezone.utc), player_name=raw.player, source_type=SourceType.official_record, source_url=url, source_external_id=key, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload=payload))
run.rows_created += 1
else:
report.raw_payload = payload
report.source_url = url
run.rows_updated += 1
run.status = ImportStatus.success
run.finished_at = datetime.now(timezone.utc)
session.commit()
return run
except Exception as exc:
session.rollback()
run = session.get(OfficialRecordImport, run.id)
run.status = ImportStatus.failed
run.finished_at = datetime.now(timezone.utc)
run.error_summary = str(exc)[:1000]
session.commit()
raise
def _fish(session: Session, name: str) -> Fish:
item = session.scalar(select(Fish).where(Fish.name_ru == name))
if item is None:
item = Fish(slug=_unique_slug(session, Fish, name), name_ru=name, trophy_weight_g=None)
session.add(item)
return item
def _waterbody(session: Session, name: str) -> Waterbody:
item = session.scalar(select(Waterbody).where(Waterbody.name_ru == name))
if item is None:
item = Waterbody(slug=_unique_slug(session, Waterbody, name), name_ru=name, unlock_level=None)
session.add(item)
return item
def _bait(session: Session, name: str) -> Bait:
normalized = normalize(name)
item = session.scalar(select(Bait).where(Bait.normalized_name == normalized))
if item is None:
item = Bait(name=name, normalized_name=normalized, kind=BaitKind.unknown)
session.add(item)
return item
def _unique_slug(session: Session, model: type[Fish] | type[Waterbody], name: str) -> str:
base = slugify(name)
candidate = base
index = 2
while session.scalar(select(model.id).where(model.slug == candidate)) is not None:
candidate = f"{base}-{index}"
index += 1
return candidate
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from collections import Counter
from datetime import datetime, timedelta, timezone
from typing import Annotated, Literal
from uuid import UUID
from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from .activity import activity_rows
from .database import get_session
from .models import Bait, CatchReport, Fish, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody
from .schemas import ActivityOut, BaitOut, CatchOut, FishOut, ImportRunOut, OfficialRecordOut, SpotOut, WaterbodyOut
app = FastAPI(title="RF4 Spotter API", version="0.1.0")
app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:4321"], allow_methods=["GET"], allow_headers=["*"])
Db = Annotated[Session, Depends(get_session)]
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/v1/fishes", response_model=list[FishOut])
def fishes(db: Db) -> list[Fish]:
return list(db.scalars(select(Fish).order_by(Fish.name_ru)))
@app.get("/api/v1/waterbodies", response_model=list[WaterbodyOut])
def waterbodies(db: Db) -> list[Waterbody]:
return list(db.scalars(select(Waterbody).order_by(Waterbody.name_ru)))
@app.get("/api/v1/baits", response_model=list[BaitOut])
def baits(db: Db) -> list[Bait]:
return list(db.scalars(select(Bait).order_by(Bait.name)))
@app.get("/api/v1/activity", response_model=list[ActivityOut])
def activity(
db: Db, hours: int = Query(24),
waterbody: str | None = None, fish: str | None = None,
method: str | None = None,
sort: Literal["activity", "confidence", "freshness"] = "activity",
limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0),
) -> list[ActivityOut]:
if hours not in {6, 12, 24, 72}:
raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72")
rows = activity_rows(db, hours=hours, waterbody=waterbody, fish=fish, method=method)
keys = {"activity": lambda r: r.activity_score, "confidence": lambda r: r.confidence_score, "freshness": lambda r: r.last_confirmed_at}
rows.sort(key=keys[sort], reverse=True)
return rows[offset:offset + limit]
def _spot_or_404(db: Session, spot_id: UUID) -> Spot:
spot = db.scalar(select(Spot).options(joinedload(Spot.waterbody)).where(Spot.id == spot_id))
if spot is None:
raise HTTPException(status_code=404, detail="spot not found")
return spot
@app.get("/api/v1/spots/{spot_id}", response_model=SpotOut)
def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
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)))
now = datetime.now(timezone.utc)
def count_since(delta: timedelta) -> int:
return sum(_aware(r.reported_at) >= now - delta for r in reports)
bait_counts = Counter(r.bait.name for r in reports if r.bait)
return SpotOut(id=spot.id, waterbody_slug=spot.waterbody.slug, waterbody=spot.waterbody.name_ru, x=spot.x, y=spot.y, description=spot.description, catches_24h=count_since(timedelta(hours=24)), catches_3d=count_since(timedelta(days=3)), catches_7d=count_since(timedelta(days=7)), top_baits=[name for name, _ in bait_counts.most_common(5)])
@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]:
_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)))
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]
@app.get("/api/v1/records", response_model=list[OfficialRecordOut])
def records(
db: Db, fish: str | None = None, waterbody: str | None = None,
category: str | None = None, limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> list[OfficialRecordOut]:
query = select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.official_record)
if fish:
query = query.join(CatchReport.fish).where(Fish.slug == fish)
if waterbody:
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
items = list(db.scalars(query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc()).offset(offset).limit(limit)))
if category:
items = [item for item in items if (item.raw_payload or {}).get("category") == category]
return [OfficialRecordOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, waterbody=r.waterbody.name_ru, bait=r.bait.name if r.bait else None, player_name=r.player_name, record_date=r.caught_at, category=(r.raw_payload or {}).get("category"), region=(r.raw_payload or {}).get("region"), source_url=r.source_url) for r in items]
@app.get("/api/v1/imports", response_model=list[ImportRunOut])
def imports(db: Db, limit: int = Query(20, ge=1, le=100)) -> list[OfficialRecordImport]:
return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc()).limit(limit)))
def _aware(value: datetime) -> datetime:
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
import enum
import uuid
from datetime import datetime, time
from sqlalchemy import JSON, DateTime, Enum, ForeignKey, Integer, String, Text, Time, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
class SourceType(str, enum.Enum):
official_record = "official_record"
user = "user"
manual_import = "manual_import"
class ModerationStatus(str, enum.Enum):
pending = "pending"
approved = "approved"
rejected = "rejected"
class BaitKind(str, enum.Enum):
bait = "bait"
lure = "lure"
unknown = "unknown"
class ImportStatus(str, enum.Enum):
running = "running"
success = "success"
partial = "partial"
failed = "failed"
class Fish(Base):
__tablename__ = "fish"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
slug: Mapped[str] = mapped_column(String(100), unique=True)
name_ru: Mapped[str] = mapped_column(String(200), unique=True)
trophy_weight_g: Mapped[int | None]
class Waterbody(Base):
__tablename__ = "waterbody"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
slug: Mapped[str] = mapped_column(String(100), unique=True)
name_ru: Mapped[str] = mapped_column(String(200), unique=True)
unlock_level: Mapped[int | None]
class Bait(Base):
__tablename__ = "bait"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200))
normalized_name: Mapped[str] = mapped_column(String(200), unique=True)
kind: Mapped[BaitKind] = mapped_column(Enum(BaitKind))
class Spot(Base):
__tablename__ = "spot"
__table_args__ = (UniqueConstraint("waterbody_id", "x", "y"),)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
waterbody_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("waterbody.id"))
x: Mapped[int]
y: Mapped[int]
description: Mapped[str | None] = mapped_column(Text)
waterbody: Mapped[Waterbody] = relationship()
class CatchReport(Base):
__tablename__ = "catch_report"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
fish_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("fish.id"))
spot_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("spot.id"))
waterbody_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("waterbody.id"))
bait_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("bait.id"))
weight_g: Mapped[int]
fishing_method: Mapped[str | None] = mapped_column(String(50))
rig_type: Mapped[str | None] = mapped_column(String(100))
retrieve_method: Mapped[str | None] = mapped_column(String(100))
retrieve_speed: Mapped[int | None]
game_time: Mapped[time | None] = mapped_column(Time)
caught_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
reported_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
player_name: Mapped[str | None] = mapped_column(String(100))
source_type: Mapped[SourceType] = mapped_column(Enum(SourceType))
source_url: Mapped[str | None] = mapped_column(Text)
source_external_id: Mapped[str | None] = mapped_column(String(64), unique=True)
source_confidence: Mapped[int]
moderation_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus))
raw_payload: Mapped[dict | None] = mapped_column(JSON)
fish: Mapped[Fish] = relationship()
spot: Mapped[Spot | None] = relationship()
waterbody: Mapped[Waterbody] = relationship()
bait: Mapped[Bait | None] = relationship()
class OfficialRecordImport(Base):
__tablename__ = "official_record_import"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
status: Mapped[ImportStatus] = mapped_column(Enum(ImportStatus))
source_url: Mapped[str] = mapped_column(Text)
rows_seen: Mapped[int] = mapped_column(default=0)
rows_created: Mapped[int] = mapped_column(default=0)
rows_updated: Mapped[int] = mapped_column(default=0)
error_summary: Mapped[str | None] = mapped_column(Text)
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict
class FishOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
slug: str
name_ru: str
trophy_weight_g: int | None
class WaterbodyOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
slug: str
name_ru: str
unlock_level: int | None
class BaitOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
normalized_name: str
kind: str
class ActivityOut(BaseModel):
spot_id: UUID
waterbody_slug: str
waterbody: str
fish_slug: str
fish: str
x: int
y: int
best_bait: str | None
catches: int
unique_players: int
average_weight_g: int
max_weight_g: int
last_confirmed_at: datetime
activity_score: int
confidence_score: int
explanation: str
class CatchOut(BaseModel):
id: UUID
fish: str
weight_g: int
bait: str | None
player_name: str | None
caught_at: datetime | None
reported_at: datetime
retrieve_method: str | None
retrieve_speed: int | None
class SpotOut(BaseModel):
id: UUID
waterbody_slug: str
waterbody: str
x: int
y: int
description: str | None
catches_24h: int
catches_3d: int
catches_7d: int
top_baits: list[str]
class OfficialRecordOut(BaseModel):
id: UUID
fish: str
weight_g: int
waterbody: str
bait: str | None
player_name: str | None
record_date: datetime | None
category: str | None
region: str | None
source_url: str | None
class ImportRunOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
started_at: datetime
finished_at: datetime | None
status: str
source_url: str
rows_seen: int
rows_created: int
rows_updated: int
error_summary: str | None
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from uuid import UUID
from sqlalchemy import select
from .database import SessionLocal
from .models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody
IDS = {
"vyunok": UUID("10000000-0000-0000-0000-000000000001"),
"kuori": UUID("10000000-0000-0000-0000-000000000002"),
"pike": UUID("20000000-0000-0000-0000-000000000001"),
"trout": UUID("20000000-0000-0000-0000-000000000002"),
"spiker": UUID("30000000-0000-0000-0000-000000000001"),
"shad": UUID("30000000-0000-0000-0000-000000000002"),
"spot1": UUID("40000000-0000-0000-0000-000000000001"),
"spot2": UUID("40000000-0000-0000-0000-000000000002"),
}
def seed() -> None:
with SessionLocal.begin() as db:
if db.scalar(select(Fish.id).limit(1)) is not None:
return
vyunok = Waterbody(id=IDS["vyunok"], slug="vyunok", name_ru="Вьюнок", unlock_level=1)
kuori = Waterbody(id=IDS["kuori"], slug="kuori", name_ru="Куори", unlock_level=16)
pike = Fish(id=IDS["pike"], slug="pike", name_ru="Щука", trophy_weight_g=10_000)
trout = Fish(id=IDS["trout"], slug="lake-trout", name_ru="Озёрная форель", trophy_weight_g=10_000)
spiker = Bait(id=IDS["spiker"], name="Spiker #2 01-015", normalized_name="spiker #2 01-015", kind=BaitKind.lure)
shad = Bait(id=IDS["shad"], name="Salmon T1 Shad 12 005", normalized_name="salmon t1 shad 12 005", kind=BaitKind.lure)
spot1 = Spot(id=IDS["spot1"], waterbody=vyunok, x=110, y=103, description="Кромка травы у северного берега")
spot2 = Spot(id=IDS["spot2"], waterbody=kuori, x=85, y=92, description="Свальчик в глубину")
db.add_all([vyunok, kuori, pike, trout, spiker, shad, spot1, spot2])
now = datetime.now(timezone.utc)
for index in range(12):
db.add(CatchReport(
fish=pike, spot=spot1, waterbody=vyunok, bait=spiker,
weight_g=2_600 + index * 480, fishing_method="spinning",
retrieve_method="равномерная", retrieve_speed=22,
caught_at=now - timedelta(minutes=25 + index * 47),
reported_at=now - timedelta(minutes=20 + index * 47),
player_name=f"DemoPlayer{index % 7 + 1}", source_type=SourceType.manual_import,
source_confidence=80 + index % 3 * 5, moderation_status=ModerationStatus.approved,
))
for index in range(5):
db.add(CatchReport(
fish=trout, spot=spot2, waterbody=kuori, bait=shad,
weight_g=4_200 + index * 900, fishing_method="spinning",
retrieve_method="ступенчатая", retrieve_speed=18,
caught_at=now - timedelta(hours=2 + index * 4),
reported_at=now - timedelta(hours=2 + index * 4),
player_name=f"DemoAngler{index + 1}", source_type=SourceType.manual_import,
source_confidence=85, moderation_status=ModerationStatus.approved,
))
if __name__ == "__main__":
seed()
+9
View File
@@ -0,0 +1,9 @@
alembic==1.16.5
beautifulsoup4==4.15.0
fastapi==0.116.1
httpx==0.28.1
psycopg[binary]==3.2.10
pydantic-settings==2.10.1
pytest==8.4.2
sqlalchemy==2.0.43
uvicorn[standard]==0.35.0
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.database import Base, get_session
from app.main import app
from app.models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
def override_session():
with Session(engine) as session:
yield session
app.dependency_overrides[get_session] = override_session
client = TestClient(app)
def setup_module() -> None:
with Session(engine) as db:
waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро", unlock_level=1)
fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000)
bait = Bait(name="Тестовая приманка", normalized_name="тестовая приманка", kind=BaitKind.lure)
spot = Spot(waterbody=waterbody, x=10, y=20, description="Тестовая точка")
db.add_all([waterbody, fish, bait, spot])
now = datetime.now(timezone.utc)
for index in range(3):
db.add(CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=3000 + index * 1000, fishing_method="spinning", reported_at=now - timedelta(hours=index), caught_at=now - timedelta(hours=index), player_name=f"Player {index}", source_type=SourceType.manual_import, source_confidence=90, moderation_status=ModerationStatus.approved))
db.commit()
def test_activity_filters_and_explains_score() -> None:
response = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24")
assert response.status_code == 200
payload = response.json()
assert len(payload) == 1
assert payload[0]["catches"] == 3
assert payload[0]["unique_players"] == 3
assert "3 свежих уловов" in payload[0]["explanation"]
def test_invalid_period_is_rejected() -> None:
assert client.get("/api/v1/activity?hours=13").status_code == 422
def test_spot_detail_and_catches() -> None:
spot_id = client.get("/api/v1/activity").json()[0]["spot_id"]
detail = client.get(f"/api/v1/spots/{spot_id}")
catches = client.get(f"/api/v1/spots/{spot_id}/catches")
assert detail.status_code == 200
assert detail.json()["catches_24h"] == 3
assert catches.status_code == 200
assert len(catches.json()) == 3
def test_records_list_is_empty_before_import() -> None:
response = client.get("/api/v1/records")
assert response.status_code == 200
assert response.json() == []
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from pathlib import Path
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
from app.database import Base
from app.importer import import_records, parse_html
from app.models import CatchReport, OfficialRecordImport, SourceType
FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.html"
def test_parser_and_import_are_idempotent() -> None:
html = FIXTURE.read_text(encoding="utf-8")
parsed = parse_html(html, region="RU", category="records")
assert len(parsed) == 2
assert parsed[1].weight_g == 2_519_264
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
first = import_records(db, url="fixture://records", region="RU", category="records", html=html)
second = import_records(db, url="fixture://records", region="RU", category="records", html=html)
assert (first.rows_created, first.rows_updated) == (2, 0)
assert (second.rows_created, second.rows_updated) == (0, 2)
assert db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record)) == 2
assert db.scalar(select(func.count()).select_from(OfficialRecordImport)) == 2
+13
View File
@@ -0,0 +1,13 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV HOST=0.0.0.0 PORT=4321 NODE_ENV=production
COPY --from=build /app/dist ./dist
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
server: { host: true, port: 4321 },
});
+5300
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "rf4-spotter-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "astro dev --host 0.0.0.0",
"build": "astro check && astro build",
"start": "node ./dist/server/entry.mjs",
"check": "astro check",
"test:e2e": "playwright test"
},
"dependencies": {
"@astrojs/check": "^0.9.10",
"@astrojs/node": "^11.1.5",
"@playwright/test": "^1.55.0",
"astro": "^7.2.10",
"typescript": "^6.0.0"
},
"devDependencies": {
"@types/node": "^26.4.1"
}
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
use: { baseURL: process.env.WEB_URL || "http://127.0.0.1:4321" },
retries: 0,
});
@@ -0,0 +1,13 @@
---
import type { Activity } from "../lib/api";
import { ago, kg } from "../lib/api";
const { item } = Astro.props as { item: Activity };
---
<article class="card">
<div class="card-top"><div><span class="eyebrow">{item.waterbody}</span><h2>{item.fish}</h2></div><div class="score" aria-label={`Активность ${item.activity_score} из 100`}><strong>{item.activity_score}</strong><span>активность</span></div></div>
<a class="coordinates" href={`/spots/${item.spot_id}`}>Точка {item.x}:{item.y} <span>→</span></a>
<div class="bait"><span>Рабочая приманка</span><strong>{item.best_bait ?? "не указана"}</strong></div>
<dl class="stats"><div><dt>Уловов</dt><dd>{item.catches}</dd></div><div><dt>Игроков</dt><dd>{item.unique_players}</dd></div><div><dt>Средний</dt><dd>{kg(item.average_weight_g)}</dd></div><div><dt>Максимум</dt><dd>{kg(item.max_weight_g)}</dd></div></dl>
<p class="explanation">{item.explanation}</p>
<div class="fresh"><span class="pulse"></span> Обновлено {ago(item.last_confirmed_at)} · уверенность {item.confidence_score}/100</div>
</article>
+13
View File
@@ -0,0 +1,13 @@
---
import "../styles/global.css";
const { title = "RF4 Spotter" } = Astro.props;
---
<!doctype html>
<html lang="ru">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width" /><meta name="description" content="Свежие точки и статистика клёва Russian Fishing 4" /><title>{title}</title></head>
<body>
<header class="site-header"><a href="/" class="brand"><span>RF4</span> Spotter</a><nav><a href="/">Активность</a><a href="/records">Рекорды</a></nav><p>Свежие точки без догадок</p></header>
<main><slot /></main>
<footer>Неофициальный проект. Данные демонстрационные.</footer>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
export type Activity = {
spot_id: string; waterbody_slug: string; waterbody: string; fish_slug: string;
fish: string; x: number; y: number; best_bait: string | null; catches: number;
unique_players: number; average_weight_g: number; max_weight_g: number;
last_confirmed_at: string; activity_score: number; confidence_score: number;
explanation: string;
};
export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: number; y: number; description: string | null; catches_24h: number; catches_3d: number; catches_7d: number; top_baits: string[] };
export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; retrieve_method: string | null; retrieve_speed: number | null };
export type DictionaryItem = { id: string; slug: string; name_ru: string };
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null };
export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null };
const base = import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
export async function api<T>(path: string): Promise<T> {
const response = await fetch(`${base}${path}`);
if (!response.ok) throw new Error(`API ${response.status}`);
return response.json() as Promise<T>;
}
export function kg(grams: number) { return `${(grams / 1000).toFixed(2)} кг`; }
export function ago(value: string) {
const minutes = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 60000));
return minutes < 60 ? `${minutes} мин назад` : `${Math.floor(minutes / 60)} ч назад`;
}
+31
View File
@@ -0,0 +1,31 @@
---
import Layout from "../layouts/Layout.astro";
import ActivityCard from "../components/ActivityCard.astro";
import { api, type Activity, type DictionaryItem } from "../lib/api";
const params = Astro.url.searchParams;
const hours = params.get("hours") ?? "24";
const waterbody = params.get("waterbody") ?? "";
const fish = params.get("fish") ?? "";
const sort = params.get("sort") ?? "activity";
let items: Activity[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
let unavailable = false;
try {
[items, fishes, waterbodies] = await Promise.all([
api<Activity[]>(`/api/v1/activity?hours=${hours}&waterbody=${waterbody}&fish=${fish}&sort=${sort}`),
api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")
]);
} catch { unavailable = true; }
---
<Layout title="Что клюёт сейчас — RF4 Spotter">
<section class="hero"><div><span class="eyebrow">Сводка активности</span><h1>Что клюёт<br/><em>прямо сейчас</em></h1></div><p>Свежие подтверждения, рабочие приманки и честная оценка надёжности данных.</p></section>
<form class="filters" method="get">
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(x => <option value={x.slug} selected={waterbody === x.slug}>{x.name_ru}</option>)}</select></label>
<label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(x => <option value={x.slug} selected={fish === x.slug}>{x.name_ru}</option>)}</select></label>
<label>Период<select name="hours"><option value="6" selected={hours === "6"}>6 часов</option><option value="12" selected={hours === "12"}>12 часов</option><option value="24" selected={hours === "24"}>24 часа</option><option value="72" selected={hours === "72"}>72 часа</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>
</form>
<div class="section-heading"><h2>Активные точки</h2><span>{items.length} комбинации</span></div>
{unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте обновить страницу позже.</p></div> : items.length ? <div class="grid">{items.map(item => <ActivityCard item={item} />)}</div> : <div class="state"><h2>За этот период данных нет</h2><p>Измените фильтры или выберите более длинный период.</p></div>}
</Layout>
+17
View File
@@ -0,0 +1,17 @@
---
import Layout from "../layouts/Layout.astro";
import { api, kg, type ImportRun, type OfficialRecord } from "../lib/api";
const params = Astro.url.searchParams;
const fish = params.get("fish") ?? "";
const waterbody = params.get("waterbody") ?? "";
let records: OfficialRecord[] = [], runs: ImportRun[] = [], unavailable = false;
try { [records, runs] = await Promise.all([api<OfficialRecord[]>(`/api/v1/records?fish=${fish}&waterbody=${waterbody}`), api<ImportRun[]>("/api/v1/imports?limit=1")]); } catch { unavailable = true; }
const last = runs[0];
---
<Layout title="Официальные рекорды — RF4 Spotter">
<section class="records-hero"><div><span class="eyebrow">Публичные данные RF4</span><h1>Официальные<br/><em>рекорды</em></h1></div><div class="source-status"><span class:list={["status-dot", last?.status]}></span><strong>{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}</strong>{last?.finished_at && <small>{new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк</small>}</div></section>
<form class="record-filters" method="get"><label>Slug рыбы<input name="fish" value={fish} placeholder="pike" /></label><label>Slug водоёма<input name="waterbody" value={waterbody} placeholder="vyunok" /></label><button>Фильтровать</button></form>
<div class="section-heading"><h2>Последние записи</h2><span>{records.length} показано</span></div>
{unavailable ? <div class="state"><h2>Источник временно недоступен</h2></div> : records.length ? <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата</span></div>{records.map(record => <article class="record-row"><strong>{record.fish}</strong><strong>{kg(record.weight_g)}</strong><span>{record.waterbody}</span><span>{record.bait ?? "—"}</span><span>{record.player_name ?? "—"}</span><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time></article>)}</div> : <div class="state"><h2>Рекорды ещё не импортированы</h2><p>Запустите <code>python -m app.cli import-records</code>. Пустой результат не подменяется демоданными.</p></div>}
<p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p>
</Layout>
+15
View File
@@ -0,0 +1,15 @@
---
import Layout from "../../layouts/Layout.astro";
import { api, kg, type Catch, type Spot } from "../../lib/api";
const { id } = Astro.params;
let spot: Spot | null = null, catches: Catch[] = [], unavailable = false;
try { [spot, catches] = await Promise.all([api<Spot>(`/api/v1/spots/${id}`), api<Catch[]>(`/api/v1/spots/${id}/catches`)]); } catch { unavailable = true; }
---
<Layout title={spot ? `${spot.waterbody} ${spot.x}:${spot.y} — RF4 Spotter` : "Точка — RF4 Spotter"}>
<a class="back" href="/">← Все активные точки</a>
{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>
<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>
</>}
</Layout>
+22
View File
@@ -0,0 +1,22 @@
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Unbounded:wght@600;700&display=swap');
:root { color-scheme: dark; --bg:#07110f; --panel:#0d1c18; --line:#20332d; --ink:#f2f4e9; --muted:#93a69f; --accent:#d7f45b; --orange:#ff985a; font-family:Manrope,system-ui,sans-serif; }
* { box-sizing:border-box; }
body { margin:0; background:radial-gradient(circle at 15% -10%,#173b30 0,transparent 30rem),var(--bg); color:var(--ink); min-height:100vh; }
a { color:inherit; }
.site-header, main, footer { width:min(1180px,calc(100% - 40px)); margin:auto; }
.site-header { height:86px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }
.brand { text-decoration:none; font-family:Unbounded,sans-serif; font-weight:700; font-size:19px; }.brand span{color:var(--accent)}
.site-header nav{display:flex;gap:24px}.site-header nav a{font-size:13px;color:#bdcbc6;text-decoration:none}.site-header nav a:hover{color:var(--accent)}
.site-header p, footer { color:var(--muted); font-size:13px; } footer{padding:45px 0 30px}
.hero { padding:78px 0 55px; display:grid; grid-template-columns:1.4fr .6fr; align-items:end; gap:30px; }
.hero h1,.spot-hero h1 { font:700 clamp(42px,7vw,86px)/.98 Unbounded,sans-serif; letter-spacing:-.055em; margin:14px 0 0; }.hero h1 em{color:var(--accent);font-style:normal}.hero>p{font-size:18px;line-height:1.65;color:#b9c6c1;max-width:420px}
.eyebrow { color:var(--accent); font-size:11px; font-weight:800; letter-spacing:.14em; text-transform:uppercase; }
.filters { padding:18px; background:#0b1915cc; border:1px solid var(--line); display:grid; grid-template-columns:repeat(4,1fr) auto; gap:12px; border-radius:16px; position:sticky; top:10px; z-index:2; backdrop-filter:blur(16px); }
label { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.08em; } select { display:block; width:100%; margin-top:7px; border:0; color:var(--ink); background:#14251f; padding:12px; border-radius:8px; font:600 14px Manrope; } button{align-self:end;border:0;border-radius:8px;background:var(--accent);color:#102015;font-weight:800;padding:13px 24px;cursor:pointer}
.section-heading { display:flex; align-items:center; justify-content:space-between; margin:50px 0 20px; }.section-heading h2{font:600 22px Unbounded;margin:0}.section-heading span{color:var(--muted);font-size:13px}
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:18px; }.card{padding:28px;background:linear-gradient(145deg,#10231dcf,#0a1714);border:1px solid var(--line);border-radius:18px}.card-top{display:flex;justify-content:space-between;gap:20px}.card h2{font:700 30px Unbounded;margin:8px 0}.score{text-align:center;background:#182b22;border-radius:50%;width:82px;height:82px;display:flex;flex-direction:column;justify-content:center;flex:none}.score strong{font:700 27px Unbounded;color:var(--accent)}.score span{font-size:8px;text-transform:uppercase;color:var(--muted)}
.coordinates{display:flex;justify-content:space-between;background:var(--accent);color:#0b1713;padding:14px 17px;border-radius:9px;text-decoration:none;font-weight:800;margin:20px 0}.bait{display:flex;flex-direction:column;gap:5px}.bait span,.fresh{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;border-block:1px solid var(--line);padding:18px 0;margin:20px 0}.stats div{display:flex;flex-direction:column-reverse}.stats dt{font-size:10px;color:var(--muted)}.stats dd{font-weight:700;margin:0 0 3px}.explanation{min-height:48px;color:#bac8c3;font-size:13px;line-height:1.6}.pulse{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent);margin-right:5px}.state{padding:50px;border:1px dashed #385148;text-align:center;border-radius:16px;color:var(--muted)}.state h1,.state h2{color:var(--ink)}
.back{display:inline-block;margin:45px 0 25px;color:var(--muted);text-decoration:none}.spot-hero{display:flex;justify-content:space-between;align-items:center;padding:40px;background:linear-gradient(130deg,#142c24,#0b1714);border:1px solid var(--line);border-radius:20px}.spot-hero h1{font-size:clamp(36px,6vw,72px)}.spot-hero p{color:var(--muted)}.pin{font:700 40px Unbounded;color:var(--accent);border:1px solid #38523e;border-radius:50%;width:170px;height:170px;display:grid;place-content:center}.pin span{color:var(--orange)}.periods{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--line);border:1px solid var(--line);margin:20px 0;border-radius:14px;overflow:hidden}.periods div{background:var(--panel);padding:25px;text-align:center}.periods strong{display:block;font:700 32px Unbounded;color:var(--accent)}.periods span{font-size:12px;color:var(--muted)}.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:22px}.catch-list article{display:flex;justify-content:space-between;border-bottom:1px solid var(--line);padding:16px 2px}.catch-list article>div{display:flex;flex-direction:column}.catch-list article>div:last-child{text-align:right}.catch-list span{color:var(--muted);font-size:12px;margin-top:4px}aside{background:var(--panel);border:1px solid var(--line);border-radius:15px;padding:25px;margin-top:50px}aside li{padding:11px 0;border-bottom:1px solid var(--line)}.note{font-size:12px;line-height:1.6;color:var(--muted)}
@media(max-width:800px){.site-header p{display:none}.hero{grid-template-columns:1fr;padding-top:50px}.filters{position:static;grid-template-columns:1fr 1fr}.filters button{grid-column:1/-1}.grid,.detail-grid{grid-template-columns:1fr}.stats{grid-template-columns:1fr 1fr}.spot-hero{padding:25px}.pin{display:none}.periods div{padding:18px 8px}.periods strong{font-size:24px}}
@media(max-width:480px){.site-header,main,footer{width:min(100% - 24px,1180px)}.filters{grid-template-columns:1fr}.card{padding:20px}.hero h1{font-size:40px}.periods span{font-size:10px}}
.records-hero{padding:70px 0 45px;display:flex;align-items:end;justify-content:space-between;gap:30px}.records-hero h1{font:700 clamp(42px,7vw,78px)/1 Unbounded;margin:15px 0 0;letter-spacing:-.05em}.records-hero h1 em{color:var(--accent);font-style:normal}.source-status{display:grid;grid-template-columns:auto 1fr;gap:4px 9px;align-items:center;color:#c6d1cd}.source-status small{grid-column:2;color:var(--muted)}.status-dot{width:9px;height:9px;border-radius:50%;background:#66736e}.status-dot.success{background:var(--accent)}.status-dot.failed{background:#ff6f61}.record-filters{display:flex;gap:12px;padding:18px;border:1px solid var(--line);background:var(--panel);border-radius:14px}.record-filters label{flex:1}.record-filters input{display:block;width:100%;margin-top:7px;padding:12px;border:0;border-radius:8px;background:#14251f;color:var(--ink)}.record-table{border:1px solid var(--line);border-radius:14px;overflow:hidden}.record-row{display:grid;grid-template-columns:1.2fr .65fr 1.1fr 1.5fr 1fr .75fr;gap:14px;padding:16px 18px;border-bottom:1px solid var(--line);align-items:center}.record-row:last-child{border:0}.record-row span,.record-row time{font-size:13px;color:#aebdb7}.record-head{background:#14251f;text-transform:uppercase;letter-spacing:.07em}.record-head span{font-size:10px;color:var(--muted)}.official-note{color:var(--muted);font-size:12px;margin-top:20px}.official-note a{color:#bccf61}@media(max-width:800px){.site-header nav{gap:12px}.records-hero{display:block}.source-status{margin-top:30px}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}}
+10
View File
@@ -0,0 +1,10 @@
import { expect, test } from "@playwright/test";
test("player can open an active spot", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Что клюёт прямо сейчас" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Щука" })).toBeVisible();
await page.getByRole("link", { name: /Точка 110:103/ }).click();
await expect(page.getByRole("heading", { name: "Точка 110:103" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible();
});
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/strict"
}
+53
View File
@@ -0,0 +1,53 @@
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: rf4_spotter
POSTGRES_USER: rf4
POSTGRES_PASSWORD: rf4_local
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rf4 -d rf4_spotter"]
interval: 5s
timeout: 3s
retries: 10
volumes:
- postgres_data:/var/lib/postgresql/data
api:
build: ./apps/api
environment:
DATABASE_URL: postgresql+psycopg://rf4:rf4_local@db:5432/rf4_spotter
depends_on:
db:
condition: service_healthy
ports:
- "8000:8000"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 5s
timeout: 3s
retries: 12
web:
build: ./apps/web
environment:
PUBLIC_API_URL: http://localhost:8000
API_INTERNAL_URL: http://api:8000
depends_on:
api:
condition: service_healthy
ports:
- "4321:4321"
importer:
build: ./apps/api
profiles: ["tools"]
environment:
DATABASE_URL: postgresql+psycopg://rf4:rf4_local@db:5432/rf4_spotter
depends_on:
db:
condition: service_healthy
command: ["sh", "-c", "alembic upgrade head && python -m app.cli import-records"]
volumes:
postgres_data:
+8
View File
@@ -4,5 +4,13 @@ version = "0.1.0"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["beautifulsoup4>=4.12,<5"] dependencies = ["beautifulsoup4>=4.12,<5"]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["rf4_research*"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = [".", "apps/api"]