Build Dockerized MVP scaffold and records importer
This commit is contained in:
@@ -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
|
||||
@@ -2,3 +2,10 @@ __pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
test-results/
|
||||
playwright-report/
|
||||
*.egg-info/
|
||||
*.db
|
||||
|
||||
@@ -1,25 +1,99 @@
|
||||
# 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
|
||||
python -m pip install -e .
|
||||
python -m rf4_research.records \
|
||||
--url https://rf4game.de/records/region/RU/ \
|
||||
--region RU \
|
||||
--category records
|
||||
```
|
||||
|
||||
Команда делает один HTTP-запрос и печатает типизированные записи в JSON. Это исследовательский адаптер, а не готовый импортёр: в нём пока нет повторов, кэша, транзакций и дедупликации.
|
||||
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
python -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
Подтверждённая структура источника, ограничения и риски описаны в [docs/data-sources.md](docs/data-sources.md).
|
||||
Команда делает один HTTP-запрос и печатает типизированные записи в JSON. Это ещё не продуктивный импортёр: в нём нет повторов, кэша, транзакций и дедупликации. Подтверждённая структура источника и риски описаны в [docs/data-sources.md](docs/data-sources.md).
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""RF4 Spotter API."""
|
||||
@@ -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} ч. назад"
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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() == []
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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 },
|
||||
});
|
||||
Generated
+5300
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)} ч назад`;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict"
|
||||
}
|
||||
@@ -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:
|
||||
@@ -4,5 +4,13 @@ version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
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]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = [".", "apps/api"]
|
||||
|
||||
Reference in New Issue
Block a user