Compare commits
4
Commits
7e60daed6f
...
486016d977
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
486016d977 | ||
|
|
0701383c28 | ||
|
|
4e037eb7e8 | ||
|
|
233c5887eb |
@@ -13,5 +13,8 @@ OFFICIAL_RECORDS_CATEGORY=records
|
||||
# true только если scheduler официального импорта обязателен для readiness
|
||||
OFFICIAL_IMPORT_REQUIRED=false
|
||||
IMPORT_INTERVAL_SECONDS=3600
|
||||
COMMUNITY_IMPORT_INTERVAL_SECONDS=1800
|
||||
RF4MAP_POINT_URL=https://rf4map.ru/points/275
|
||||
RF4POSTS_SPOT_URL=https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee
|
||||
RATE_LIMIT_SECRET=change-rate-limit-secret
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
@@ -12,7 +12,13 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
|
||||
|
||||
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва.
|
||||
|
||||
Базовый SEO-контур готов для `rf4spotter.ru`: страницы имеют уникальные метаданные, canonical, Open Graph/Twitter Card, фирменное изображение 1200×630 и JSON-LD; доступны динамические `/robots.txt` и `/sitemap.xml`, административные и ошибочные страницы закрыты от индексации, добавлена собственная страница 404. Индексируемые страницы рыб/водоёмов остаются следующим пакетом в roadmap.
|
||||
Базовый SEO-контур готов для `rf4spotter.ru`: страницы имеют уникальные метаданные, canonical, Open Graph/Twitter Card, фирменное изображение 1200×630 и JSON-LD; доступны динамические `/robots.txt` и `/sitemap.xml`, административные и ошибочные страницы закрыты от индексации, добавлена собственная страница 404. Индексируемые каталоги рыб и водоёмов, detail-страницы и сочетания водоём + рыба строятся из актуального разрешённого справочника и включаются в sitemap.
|
||||
|
||||
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL.
|
||||
|
||||
Все пять community-парсеров подключены к отдельному scheduler-процессу. Состояние запусков и ошибок хранится в PostgreSQL, параллельный запуск одного источника блокируется, минимальный интервал жёстко ограничен 1800 секундами. Локально процесс включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
|
||||
|
||||
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
|
||||
|
||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
|
||||
|
||||
@@ -116,7 +122,7 @@ docker compose up --build
|
||||
## Что реализовано
|
||||
|
||||
- FastAPI и SQLAlchemy 2;
|
||||
- PostgreSQL 17 и миграции Alembic до `0012`;
|
||||
- PostgreSQL 17 и миграции Alembic до `0013`;
|
||||
- идемпотентный seed с двумя точками и свежими демо-уловами;
|
||||
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
|
||||
- `GET /api/v1/spots/{id}` и `/catches`;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add persistent community scheduler journal."""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0013"
|
||||
down_revision = "0012"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table("community_import_run",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("source_system", sa.String(50), sa.ForeignKey("data_source.key"), nullable=False),
|
||||
sa.Column("source_url", sa.Text(), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("status", sa.String(30), nullable=False),
|
||||
sa.Column("rows_seen", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("rows_created", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("rows_updated", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("error_summary", sa.Text()),
|
||||
)
|
||||
op.create_index("ix_community_import_run_source_system", "community_import_run", ["source_system"])
|
||||
op.create_index("ix_community_import_run_started_at", "community_import_run", ["started_at"])
|
||||
op.create_index("ix_community_import_run_status", "community_import_run", ["status"])
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("community_import_run")
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from rf4_research.community_cli import SOURCES, fetch_html
|
||||
from rf4_research.community_sources import parse_rf4map_point, parse_rf4posts_spot
|
||||
from .community_importer import stage_observations
|
||||
from .config import settings
|
||||
from .database import SessionLocal
|
||||
from .logging_config import configure_logging
|
||||
from .models import CommunityImportRun, DataSource
|
||||
|
||||
logger = logging.getLogger("rf4.community_scheduler")
|
||||
MAX_BACKOFF_SECONDS = 24 * 60 * 60
|
||||
|
||||
def retry_delay(statuses: list[str]) -> int:
|
||||
failures = 0
|
||||
for status in statuses:
|
||||
if status != "failed":
|
||||
break
|
||||
failures += 1
|
||||
return min(settings.community_import_interval_seconds * (2 ** max(0, failures - 1)), MAX_BACKOFF_SECONDS)
|
||||
|
||||
def configured_sources():
|
||||
return {
|
||||
"rf4db": SOURCES["rf4db"],
|
||||
"rf4stat-fishing": SOURCES["rf4stat-fishing"],
|
||||
"rf4stat-post": (SOURCES["rf4stat-posts"][0], SOURCES["rf4stat-posts"][1]),
|
||||
"rf4map": (settings.rf4map_point_url, parse_rf4map_point),
|
||||
"rf4posts-spot": (settings.rf4posts_spot_url, parse_rf4posts_spot),
|
||||
}
|
||||
|
||||
def run_source(source_system: str, *, now: datetime | None = None) -> bool:
|
||||
current = now or datetime.now(timezone.utc)
|
||||
url, parser = configured_sources()[source_system]
|
||||
with SessionLocal() as session:
|
||||
source = session.get(DataSource, source_system)
|
||||
if source is None or not source.enabled:
|
||||
return False
|
||||
recent = list(session.scalars(select(CommunityImportRun).where(CommunityImportRun.source_system == source_system).order_by(CommunityImportRun.started_at.desc()).limit(8)))
|
||||
latest = recent[0].started_at if recent else None
|
||||
delay = retry_delay([run.status for run in recent])
|
||||
if latest and (latest if latest.tzinfo else latest.replace(tzinfo=timezone.utc)) > current - timedelta(seconds=delay):
|
||||
return False
|
||||
if session.bind and session.bind.dialect.name == "postgresql" and not session.scalar(text("select pg_try_advisory_xact_lock(hashtext(:key))"), {"key": f"community:{source_system}"}):
|
||||
return False
|
||||
run = CommunityImportRun(source_system=source_system, source_url=url, started_at=current, status="running")
|
||||
session.add(run); session.commit()
|
||||
try:
|
||||
html = fetch_html(url)
|
||||
records = parser(html, source_url=url) if source_system in {"rf4map", "rf4posts-spot"} else parser(html)
|
||||
created, updated = stage_observations(session, [asdict(item) for item in records])
|
||||
run.status, run.rows_seen, run.rows_created, run.rows_updated = "success", len(records), created, updated
|
||||
except Exception as exc:
|
||||
run.status, run.error_summary = "failed", f"{type(exc).__name__}: {str(exc)[:500]}"
|
||||
logger.exception("community import failed", extra={"event":"community_import_failed", "source_system":source_system})
|
||||
run.finished_at = datetime.now(timezone.utc); session.commit()
|
||||
return True
|
||||
|
||||
def main() -> None:
|
||||
configure_logging(settings.log_level)
|
||||
while True:
|
||||
for source_system in configured_sources():
|
||||
run_source(source_system)
|
||||
time.sleep(60)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -24,6 +24,9 @@ class Settings(BaseSettings):
|
||||
retention_audit_days: int = Field(default=365, ge=90)
|
||||
retention_published_payload_days: int = Field(default=365, ge=90)
|
||||
import_interval_seconds: int = Field(default=3600, ge=3600)
|
||||
community_import_interval_seconds: int = Field(default=1800, ge=1800)
|
||||
rf4map_point_url: str = "https://rf4map.ru/points/275"
|
||||
rf4posts_spot_url: str = "https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee"
|
||||
rate_limit_secret: str = "change-rate-limit-secret"
|
||||
log_level: str = "INFO"
|
||||
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:4321", "http://127.0.0.1:4321"])
|
||||
|
||||
+39
-2
@@ -24,9 +24,9 @@ from .config import settings
|
||||
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation
|
||||
from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize
|
||||
from .logging_config import configure_logging
|
||||
from .models import Bait, BaitKind, CatchReport, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||
from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||
from .readiness import readiness_report
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SpotOut, WaterbodyOut
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut
|
||||
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
|
||||
|
||||
|
||||
@@ -135,6 +135,19 @@ def _spot_or_404(db: Session, spot_id: UUID) -> Spot:
|
||||
return spot
|
||||
|
||||
|
||||
@app.get("/api/v1/spots/resolve", response_model=SpotOut)
|
||||
def resolve_spot(
|
||||
db: Db, waterbody: str, x: int = Query(ge=-10_000, le=10_000),
|
||||
y: int = Query(ge=-10_000, le=10_000),
|
||||
) -> SpotOut:
|
||||
spot = db.scalar(select(Spot).options(joinedload(Spot.waterbody)).join(Spot.waterbody).where(
|
||||
Waterbody.slug == waterbody, Spot.x == x, Spot.y == y,
|
||||
))
|
||||
if spot is None:
|
||||
raise HTTPException(status_code=404, detail="spot not found")
|
||||
return spot_detail(spot.id, db)
|
||||
|
||||
|
||||
@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)
|
||||
@@ -196,6 +209,30 @@ def community_observations(
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/v1/source-status", response_model=list[SourceStatusOut])
|
||||
def source_status(db: Db) -> list[SourceStatusOut]:
|
||||
now = datetime.now(timezone.utc)
|
||||
result = []
|
||||
for source in db.scalars(select(DataSource).order_by(DataSource.name)):
|
||||
runs = list(db.scalars(select(CommunityImportRun).where(CommunityImportRun.source_system == source.key).order_by(CommunityImportRun.started_at.desc()).limit(20)))
|
||||
latest = runs[0] if runs else None
|
||||
success = next((run for run in runs if run.status == "success"), None)
|
||||
if not source.enabled:
|
||||
state = "disabled"
|
||||
elif latest is None:
|
||||
state = "waiting"
|
||||
elif latest.status == "failed":
|
||||
state = "source_changed" if "CommunityParseError" in (latest.error_summary or "") else "temporarily_limited"
|
||||
elif _aware(latest.started_at) < now - timedelta(seconds=settings.community_import_interval_seconds * 2):
|
||||
state = "stale"
|
||||
else:
|
||||
state = "healthy"
|
||||
result.append(SourceStatusOut(source_system=source.key, name=source.name, status=state,
|
||||
last_started_at=latest.started_at if latest else None, last_success_at=success.started_at if success else None,
|
||||
observations=db.scalar(select(func.count()).select_from(ExternalObservation).where(ExternalObservation.source_system == source.key)) or 0))
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/v1/records", response_model=list[OfficialRecordOut])
|
||||
def records(
|
||||
db: Db, fish: str | None = None, waterbody: str | None = None,
|
||||
|
||||
@@ -148,6 +148,20 @@ class DataSource(Base):
|
||||
enabled: Mapped[bool] = mapped_column(default=True)
|
||||
|
||||
|
||||
class CommunityImportRun(Base):
|
||||
__tablename__ = "community_import_run"
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
source_system: Mapped[str] = mapped_column(ForeignKey("data_source.key"), index=True)
|
||||
source_url: Mapped[str] = mapped_column(Text)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
status: Mapped[str] = mapped_column(String(30), index=True)
|
||||
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)
|
||||
|
||||
|
||||
class ExternalObservation(Base):
|
||||
__tablename__ = "external_observation"
|
||||
__table_args__ = (UniqueConstraint("source_system", "source_external_id"),)
|
||||
|
||||
@@ -221,3 +221,12 @@ class ExternalObservationPublished(BaseModel):
|
||||
observation_id: UUID
|
||||
catch_report_id: UUID
|
||||
status: str
|
||||
|
||||
|
||||
class SourceStatusOut(BaseModel):
|
||||
source_system: str
|
||||
name: str
|
||||
status: str
|
||||
last_started_at: datetime | None
|
||||
last_success_at: datetime | None
|
||||
observations: int
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.database import Base, get_session
|
||||
from app.community_importer import stage_observations
|
||||
from app.importer import ImportAlreadyRunning
|
||||
from app.main import app
|
||||
from app.models import Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody
|
||||
from app.models import Bait, BaitKind, CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody
|
||||
|
||||
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
@@ -66,6 +66,17 @@ def test_list_pagination_and_filter_validation() -> None:
|
||||
assert client.get("/api/v1/admin/catch-reports?limit=101", headers=headers).status_code == 422
|
||||
|
||||
|
||||
def test_public_source_status_hides_internal_details() -> None:
|
||||
with Session(engine) as db:
|
||||
if db.get(DataSource, "rf4db") is None:
|
||||
db.add(DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=True))
|
||||
db.commit()
|
||||
response = client.get("/api/v1/source-status")
|
||||
assert response.status_code == 200
|
||||
assert response.json()
|
||||
assert all("error_summary" not in item and "source_url" not in item for item in response.json())
|
||||
|
||||
|
||||
def test_liveness_does_not_probe_dependencies() -> None:
|
||||
response = client.get("/health?token=must-not-be-logged")
|
||||
assert response.json() == {"status": "ok"}
|
||||
@@ -83,6 +94,9 @@ def test_spot_detail_and_catches() -> None:
|
||||
assert catches.status_code == 200
|
||||
assert len(catches.json()) == 3
|
||||
assert catches.json()[0]["source_system"] == "manual-import"
|
||||
resolved = client.get("/api/v1/spots/resolve?waterbody=test-lake&x=10&y=20")
|
||||
assert resolved.status_code == 200
|
||||
assert resolved.json()["id"] == spot_id
|
||||
|
||||
|
||||
def test_records_list_is_empty_before_import() -> None:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, retry_delay
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def test_all_authorized_sources_are_scheduled() -> None:
|
||||
assert set(configured_sources()) == {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
|
||||
|
||||
|
||||
def test_community_interval_cannot_be_less_than_30_minutes() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(community_import_interval_seconds=1799)
|
||||
|
||||
|
||||
def test_failed_runs_back_off_but_success_resets_delay() -> None:
|
||||
assert retry_delay(["failed"]) == 1800
|
||||
assert retry_delay(["failed", "failed", "failed"]) == 7200
|
||||
assert retry_delay(["failed"] * 20) == MAX_BACKOFF_SECONDS
|
||||
assert retry_delay(["success", "failed"]) == 1800
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
import type { Activity } from "../lib/api";
|
||||
import { activityLevel, ago, kg, plural } from "../lib/api";
|
||||
import { activityLevel, ago, kg, plural, spotPath } from "../lib/api";
|
||||
import FishingIcon from "./FishingIcon.astro";
|
||||
import SourceBadge from "./SourceBadge.astro";
|
||||
const { item } = Astro.props as { item: Activity };
|
||||
const level = activityLevel(item.activity_score);
|
||||
const limited = item.catches < 3;
|
||||
---
|
||||
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={`/spots/${item.spot_id}`}>
|
||||
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={spotPath(item)}>
|
||||
<span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
|
||||
<div class="spot-main"><div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill" data-activity-level={level.short}><i></i>{level.short}</span>{limited && <span class="data-quality">Данных мало</span>}</div><h3>{item.fish}</h3><div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div><div class="source-strip" aria-label="Источники данных">{item.sources.map(source => <SourceBadge source={source}/>)}</div><p class="data-note">{item.explanation}</p><div class="bait-line"><FishingIcon name="lure" size={25}/><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div></div>
|
||||
<div class="spot-stats"><div><strong>{item.catches}</strong><span>{plural(item.catches, ["улов", "улова", "уловов"])}</span></div><div><strong>{item.unique_players}</strong><span>{plural(item.unique_players, ["игрок", "игрока", "игроков"])}</span></div><div><strong>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{item.confidence_score}%</strong><span>уверенность</span></div></div><span class="card-arrow"><FishingIcon name="arrow" size={22}/></span>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
---
|
||||
import "../styles/global.css";
|
||||
import "../styles/catalog.css";
|
||||
import "../styles/source-status.css";
|
||||
import FishingIcon from "../components/FishingIcon.astro";
|
||||
const {
|
||||
title = "RF4 Spotter — свежие точки клёва Russian Fishing 4",
|
||||
@@ -51,10 +53,10 @@ const jsonLd = JSON.stringify({
|
||||
<a class="skip-link" href="#main-content">Перейти к содержимому</a>
|
||||
<header class="topbar">
|
||||
<a href="/" class="brand" aria-label="Ни хвоста, ни чешуи"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a>
|
||||
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
|
||||
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/waterbodies") || path.startsWith("/fish")}} aria-current={path.startsWith("/waterbodies") || path.startsWith("/fish") ? "page" : undefined} href="/waterbodies"><FishingIcon name="ripple"/> <span>Каталог</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
|
||||
<p class="live-badge"><span></span> Свежие данные и честная оценка</p>
|
||||
</header>
|
||||
<main id="main-content" tabindex="-1"><slot /></main>
|
||||
<footer><a href="/" class="brand"><span class="brand-mark"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a><p>Неофициальный проект для игроков Russian Fishing 4. <a href="/rules">Правила</a> · <a href="/privacy">Конфиденциальность</a></p><span>НХНЧ · 2026</span></footer>
|
||||
<footer><a href="/" class="brand"><span class="brand-mark"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a><p>Неофициальный проект для игроков Russian Fishing 4. <a href="/status">Статус</a> · <a href="/rules">Правила</a> · <a href="/privacy">Конфиденциальность</a></p><span>НХНЧ · 2026</span></footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,6 +12,9 @@ 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; source_system: string };
|
||||
export type PublicObservation = { id: string; source_system: string; source_name: string; source_url: string; fish_name: string; waterbody_name: string; x: number | null; y: number | null; weight_g: number | null; last_seen_at: string; missing_fields: string[]; quality: "incomplete" | "unverified" };
|
||||
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 };
|
||||
export type SourceStatus = { source_system: string; name: string; status: "healthy" | "stale" | "temporarily_limited" | "source_changed" | "waiting" | "disabled"; last_started_at: string | null; last_success_at: string | null; observations: number };
|
||||
|
||||
export const spotPath = (item: Pick<Activity, "waterbody_slug" | "x" | "y">) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`;
|
||||
|
||||
export { activityLevel, ago, kg, plural } from "./presentation";
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import ActivityCard from "../../components/ActivityCard.astro";
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { api, plural, type Activity, type DictionaryItem } from "../../lib/api";
|
||||
const { slug } = Astro.params;
|
||||
let fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
||||
try {
|
||||
const fishes = await api<DictionaryItem[]>("/api/v1/fishes?limit=500");
|
||||
fish = fishes.find(item => item.slug === slug);
|
||||
if (fish) items = await api<Activity[]>(`/api/v1/activity?hours=72&fish=${encodeURIComponent(fish.slug)}&limit=100`);
|
||||
} catch { unavailable = true; }
|
||||
if (!fish && !unavailable) Astro.response.status = 404;
|
||||
const waters = [...new Map(items.map(item => [item.waterbody_slug, item.waterbody])).entries()];
|
||||
const schema = fish ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Где ловить ${fish.name_ru} в RF4`, url:`https://rf4spotter.ru/fish/${fish.slug}` } : null;
|
||||
---
|
||||
<Layout title={fish ? `Где ловить ${fish.name_ru} в RF4 — свежие точки` : "Рыба не найдена — RF4 Spotter"} description={fish ? `Свежие точки ловли ${fish.name_ru} в Russian Fishing 4: водоёмы, координаты, приманки, активность и источники наблюдений.` : "Такого вида рыбы нет в каталоге RF4 Spotter."} noindex={!fish} structuredData={schema}>
|
||||
<a class="back" href="/fish">← Все виды рыб</a>
|
||||
<section class="catalog-hero content-grid"><span class="overline">Свежие данные за 72 часа</span><h1>{fish?.name_ru ?? "Рыба не найдена"}</h1>{fish && <p>{items.length} {plural(items.length,["активная точка","активные точки","активных точек"])} на {waters.length} {plural(waters.length,["водоёме","водоёмах","водоёмах"])}.</p>}</section>
|
||||
{unavailable ? <div class="state content-grid"><h2>Данные временно недоступны</h2></div> : !fish ? <div class="state content-grid"><h2>Такой рыбы нет в справочнике</h2><a href="/fish">Открыть каталог</a></div> : <section class="catalog-results content-grid"><aside><span class="overline">Водоёмы</span>{waters.length ? <nav>{waters.map(([waterSlug,name]) => <a href={`/waterbodies/${waterSlug}/${fish!.slug}`}>{name}</a>)}</nav> : <p>Свежих подтверждённых водоёмов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <div class="state"><h2>Свежих точек пока нет</h2><p>Проверьте позже или посмотрите полевые сигналы на главной.</p></div>}</div></section>}
|
||||
</Layout>
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { api, type DictionaryItem } from "../../lib/api";
|
||||
let fishes: DictionaryItem[] = [], unavailable = false;
|
||||
try { fishes = await api<DictionaryItem[]>("/api/v1/fishes?limit=500"); } catch { unavailable = true; }
|
||||
---
|
||||
<Layout title="Все виды рыб Russian Fishing 4 — RF4 Spotter" description="Каталог рыб RF4 со свежими точками, уловами, приманками и прозрачными источниками данных.">
|
||||
<section class="catalog-hero content-grid"><span class="overline">Справочник RF4</span><h1>Рыбы</h1><p>Выберите вид, чтобы увидеть свежие подтверждённые точки и полевые сигналы.</p></section>
|
||||
{unavailable ? <div class="state content-grid"><h2>Каталог временно недоступен</h2></div> : <nav class="catalog-grid content-grid" aria-label="Виды рыб">{fishes.map(fish => <a href={`/fish/${fish.slug}`}><span>Вид рыбы</span><strong>{fish.name_ru}</strong><i>→</i></a>)}</nav>}
|
||||
</Layout>
|
||||
@@ -1,14 +1,18 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { api, type Activity } from "../lib/api";
|
||||
import { api, spotPath, type Activity, type DictionaryItem } from "../lib/api";
|
||||
|
||||
const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, character => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ })[character] ?? character);
|
||||
|
||||
export const GET: APIRoute = async ({ site }) => {
|
||||
const origin = site?.origin ?? import.meta.env.PUBLIC_SITE_URL ?? "https://rf4spotter.ru";
|
||||
const paths = new Set(["/", "/records", "/report", "/rules", "/privacy"]);
|
||||
const paths = new Set(["/", "/records", "/report", "/status", "/rules", "/privacy"]);
|
||||
try {
|
||||
const activity = await api<Activity[]>("/api/v1/activity?hours=72&limit=100");
|
||||
activity.forEach(item => paths.add(`/spots/${item.spot_id}`));
|
||||
const [activity, fishes, waters] = await Promise.all([api<Activity[]>("/api/v1/activity?hours=72&limit=100"), api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<DictionaryItem[]>("/api/v1/waterbodies?limit=500")]);
|
||||
paths.add("/fish"); paths.add("/waterbodies");
|
||||
fishes.forEach(item => paths.add(`/fish/${item.slug}`));
|
||||
waters.forEach(item => paths.add(`/waterbodies/${item.slug}`));
|
||||
activity.forEach(item => paths.add(spotPath(item)));
|
||||
activity.forEach(item => paths.add(`/waterbodies/${item.waterbody_slug}/${item.fish_slug}`));
|
||||
} catch {
|
||||
// A temporary API outage must not make the static part of the sitemap unavailable.
|
||||
}
|
||||
|
||||
@@ -5,14 +5,19 @@ import { activityLevel, api, kg, plural, type Activity, type Catch, type Spot }
|
||||
const { id } = Astro.params;
|
||||
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
|
||||
try {
|
||||
const [spotResult, catchResult, activityRows] = await Promise.all([api<Spot>(`/api/v1/spots/${id}`), api<Catch[]>(`/api/v1/spots/${id}/catches`), api<Activity[]>("/api/v1/activity?hours=24&limit=100")]);
|
||||
spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === id) ?? null;
|
||||
const readable = id?.match(/^(.+)-(-?\d+)x(-?\d+)$/);
|
||||
const spotResult = readable
|
||||
? await api<Spot>(`/api/v1/spots/resolve?waterbody=${encodeURIComponent(readable[1])}&x=${readable[2]}&y=${readable[3]}`)
|
||||
: await api<Spot>(`/api/v1/spots/${id}`);
|
||||
if (!readable) return Astro.redirect(`/spots/${spotResult.waterbody_slug}-${spotResult.x}x${spotResult.y}`, 301);
|
||||
const [catchResult, activityRows] = await Promise.all([api<Catch[]>(`/api/v1/spots/${spotResult.id}/catches`), api<Activity[]>("/api/v1/activity?hours=24&limit=100")]);
|
||||
spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === spotResult.id) ?? null;
|
||||
} catch { unavailable = true; }
|
||||
const level = activity ? activityLevel(activity.activity_score) : null;
|
||||
const spotDescription = spot ? `Свежие уловы и активность на точке ${spot.x}:${spot.y}, ${spot.waterbody}: рыба, вес, приманки и источники данных.` : "Данные точки ловли Russian Fishing 4.";
|
||||
const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Сейчас клюёт", item: "https://rf4spotter.ru/" },
|
||||
{ "@type": "ListItem", position: 2, name: `${spot.waterbody} ${spot.x}:${spot.y}`, item: `https://rf4spotter.ru/spots/${id}` },
|
||||
{ "@type": "ListItem", position: 2, name: `${spot.waterbody} ${spot.x}:${spot.y}`, item: `https://rf4spotter.ru/spots/${spot.waterbody_slug}-${spot.x}x${spot.y}` },
|
||||
] } : null;
|
||||
---
|
||||
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs}>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import SourceBadge from "../components/SourceBadge.astro";
|
||||
import { ago, api, plural, type SourceStatus } from "../lib/api";
|
||||
let sources: SourceStatus[] = [], unavailable = false;
|
||||
try { sources = await api<SourceStatus[]>("/api/v1/source-status"); } catch { unavailable = true; }
|
||||
const labels = { healthy:"Актуален", stale:"Данные устарели", temporarily_limited:"Временная пауза", source_changed:"Источник изменился", waiting:"Ожидает запуска", disabled:"Выключен" };
|
||||
---
|
||||
<Layout title="Состояние источников — RF4 Spotter" description="Текущее безопасное состояние источников данных RF4 Spotter.">
|
||||
<section class="catalog-hero content-grid"><span class="overline">Прозрачность данных</span><h1>Статус источников</h1><p>Пауза или изменение страницы не удаляют уже опубликованные данные.</p></section>
|
||||
{unavailable ? <div class="state content-grid"><h2>Статус временно недоступен</h2></div> : <section class="source-health-grid content-grid">{sources.map(source => <article data-status={source.status}><div><SourceBadge source={source.source_system}/><span class="health-state"><i></i>{labels[source.status]}</span></div><h2>{source.name}</h2><strong>{source.observations} {plural(source.observations,["наблюдение","наблюдения","наблюдений"])}</strong><p>{source.last_success_at ? `Последнее успешное обновление ${ago(source.last_success_at)}` : "Успешных запусков пока нет"}</p></article>)}</section>}
|
||||
</Layout>
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import ActivityCard from "../../components/ActivityCard.astro";
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { api, plural, type Activity, type DictionaryItem } from "../../lib/api";
|
||||
const { slug } = Astro.params;
|
||||
let water: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
||||
try {
|
||||
const waters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500");
|
||||
water = waters.find(item => item.slug === slug);
|
||||
if (water) items = await api<Activity[]>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&limit=100`);
|
||||
} catch { unavailable = true; }
|
||||
if (!water && !unavailable) Astro.response.status = 404;
|
||||
const fishes = [...new Map(items.map(item => [item.fish_slug, item.fish])).entries()];
|
||||
const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Что ловить на ${water.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water.slug}` } : null;
|
||||
---
|
||||
<Layout title={water ? `${water.name_ru} в RF4 — рыба и свежие точки` : "Водоём не найден — RF4 Spotter"} description={water ? `${water.name_ru} в Russian Fishing 4: свежие координаты, активные виды рыб, приманки и источники наблюдений.` : "Такого водоёма нет в каталоге RF4 Spotter."} noindex={!water} structuredData={schema}>
|
||||
<a class="back" href="/waterbodies">← Все водоёмы</a>
|
||||
<section class="catalog-hero content-grid"><span class="overline">Свежие данные за 72 часа</span><h1>{water?.name_ru ?? "Водоём не найден"}</h1>{water && <p>{items.length} {plural(items.length,["активная точка","активные точки","активных точек"])} для {fishes.length} {plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.</p>}</section>
|
||||
{unavailable ? <div class="state content-grid"><h2>Данные временно недоступны</h2></div> : !water ? <div class="state content-grid"><h2>Такого водоёма нет в справочнике</h2><a href="/waterbodies">Открыть каталог</a></div> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <a href={`/waterbodies/${water!.slug}/${fishSlug}`}>{name}</a>)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <div class="state"><h2>Свежих точек пока нет</h2><p>Проверьте позже или посмотрите полевые сигналы на главной.</p></div>}</div></section>}
|
||||
</Layout>
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import ActivityCard from "../../../components/ActivityCard.astro";
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import { api, plural, type Activity, type DictionaryItem } from "../../../lib/api";
|
||||
const { slug, fish: fishSlug } = Astro.params;
|
||||
let water: DictionaryItem | undefined, fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
||||
try {
|
||||
const [waters, fishes] = await Promise.all([api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"), api<DictionaryItem[]>("/api/v1/fishes?limit=500")]);
|
||||
water = waters.find(item => item.slug === slug); fish = fishes.find(item => item.slug === fishSlug);
|
||||
if (water && fish) items = await api<Activity[]>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&fish=${encodeURIComponent(fish.slug)}&limit=100`);
|
||||
} catch { unavailable = true; }
|
||||
if ((!water || !fish) && !unavailable) Astro.response.status = 404;
|
||||
const valid = Boolean(water && fish);
|
||||
const schema = valid ? { "@type":"CollectionPage", name:`${fish!.name_ru} на ${water!.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water!.slug}/${fish!.slug}` } : null;
|
||||
---
|
||||
<Layout title={valid ? `${fish!.name_ru} на ${water!.name_ru} в RF4 — точки и приманки` : "Страница не найдена — RF4 Spotter"} description={valid ? `Где ловить ${fish!.name_ru} на ${water!.name_ru} в Russian Fishing 4: свежие координаты, приманки, активность и источники.` : "Такого сочетания рыбы и водоёма нет в каталоге."} noindex={!valid} structuredData={schema}>
|
||||
<a class="back" href={water ? `/waterbodies/${water.slug}` : "/waterbodies"}>← К водоёму</a>
|
||||
<section class="catalog-hero content-grid"><span class="overline">{water?.name_ru ?? "Каталог RF4"}</span><h1>{fish?.name_ru ?? "Данные не найдены"}</h1>{valid && <p>{items.length} {plural(items.length,["свежая точка","свежие точки","свежих точек"])} за последние 72 часа.</p>}</section>
|
||||
{unavailable ? <div class="state content-grid"><h2>Данные временно недоступны</h2></div> : !valid ? <div class="state content-grid"><h2>Такого сочетания нет в справочнике</h2><a href="/">Вернуться на главную</a></div> : <section class="catalog-results catalog-results--single content-grid"><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <div class="state"><h2>Свежих точек пока нет</h2><p>Данные появятся после новых подтверждённых наблюдений.</p></div>}</div></section>}
|
||||
</Layout>
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { api, type DictionaryItem } from "../../lib/api";
|
||||
let waters: DictionaryItem[] = [], unavailable = false;
|
||||
try { waters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"); } catch { unavailable = true; }
|
||||
---
|
||||
<Layout title="Все водоёмы Russian Fishing 4 — RF4 Spotter" description="Каталог водоёмов RF4 со свежими точками, рыбами, приманками и прозрачными источниками данных.">
|
||||
<section class="catalog-hero content-grid"><span class="overline">Карта водоёмов RF4</span><h1>Водоёмы</h1><p>Откройте водоём, чтобы увидеть активные виды рыб и последние подтверждённые точки.</p></section>
|
||||
{unavailable ? <div class="state content-grid"><h2>Каталог временно недоступен</h2></div> : <nav class="catalog-grid content-grid" aria-label="Водоёмы">{waters.map(water => <a href={`/waterbodies/${water.slug}`}><span>Водоём</span><strong>{water.name_ru}</strong><i>→</i></a>)}</nav>}
|
||||
</Layout>
|
||||
@@ -0,0 +1 @@
|
||||
.catalog-hero{padding:68px 0 42px;border-bottom:1px solid #cbd6ce}.catalog-hero h1{margin:12px 0 10px;font:400 clamp(58px,7vw,94px)/.9 Georgia,serif;letter-spacing:-.055em}.catalog-hero p{color:#4d605d;font-size:16px}.catalog-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;padding:34px 0 100px}.catalog-grid>a{display:grid;grid-template-columns:1fr auto;gap:7px;padding:22px;border:1px solid #d3ddd5;border-radius:14px;background:#fbfcf9;text-decoration:none;transition:.18s ease}.catalog-grid>a:hover{transform:translateY(-2px);border-color:#81958a;box-shadow:0 14px 30px #14333812}.catalog-grid span{grid-column:1/-1;color:#657673;font-size:10px;text-transform:uppercase;letter-spacing:.1em}.catalog-grid strong{font:400 22px Georgia,serif}.catalog-grid i{font-style:normal;color:#6d8d2a}.catalog-results{display:grid;grid-template-columns:230px 1fr;gap:26px;padding:34px 0 100px}.catalog-results aside{padding:20px;background:#fff;border:1px solid #d6dfd7;border-radius:14px;align-self:start}.catalog-results aside nav{display:flex;flex-direction:column;margin-top:12px}.catalog-results aside a{padding:10px 0;border-bottom:1px solid #e1e8e2;color:#405552;font-size:13px}.catalog-results>div{display:grid;gap:12px}.catalog-results--single{display:block;max-width:960px}.catalog-results .spot-card{grid-template-columns:45px 1fr 190px}@media(max-width:900px){.catalog-grid{grid-template-columns:repeat(2,1fr)}.catalog-results{grid-template-columns:1fr}.catalog-results aside nav{display:grid;grid-template-columns:repeat(2,1fr);gap:0 16px}}@media(max-width:720px){.catalog-grid{grid-template-columns:1fr;padding-bottom:70px}.catalog-results{padding-bottom:70px}.catalog-results .spot-card{grid-template-columns:34px 1fr}.topbar nav{gap:8px}.topbar nav a{font-size:12px}}
|
||||
@@ -0,0 +1 @@
|
||||
.source-health-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:13px;padding:34px 0 100px}.source-health-grid article{padding:21px;border:1px solid #d3ddd5;border-radius:14px;background:#fff}.source-health-grid article>div{display:flex;justify-content:space-between;gap:8px}.source-health-grid h2{font:400 23px Georgia,serif;margin:18px 0 6px}.source-health-grid strong{font-size:13px}.source-health-grid p{color:#526662;font-size:12px}.health-state{display:flex;align-items:center;gap:6px;font-size:10px;font-weight:750;color:#526662}.health-state i{width:8px;height:8px;border-radius:50%;background:#c59a36}.source-health-grid [data-status="healthy"] .health-state i{background:#79aa36}.source-health-grid [data-status="source_changed"] .health-state i{background:#bc5748}.source-health-grid [data-status="disabled"]{opacity:.65}@media(max-width:900px){.source-health-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:620px){.source-health-grid{grid-template-columns:1fr}}
|
||||
@@ -166,6 +166,35 @@ services:
|
||||
resources:
|
||||
limits: {cpus: "0.75", memory: 512M}
|
||||
|
||||
community-scheduler:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/api/Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DEPLOYMENT_ENVIRONMENT: production
|
||||
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL}
|
||||
ADMIN_TOKEN: ${ADMIN_TOKEN:?Set ADMIN_TOKEN}
|
||||
CORS_ORIGINS: '["https://${SITE_DOMAIN:?Set SITE_DOMAIN}"]'
|
||||
S3_PUBLIC_ENDPOINT_URL: https://${FILES_DOMAIN:?Set FILES_DOMAIN}
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
|
||||
SEED_DEMO_DATA: "false"
|
||||
RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:?Set RATE_LIMIT_SECRET}
|
||||
COMMUNITY_IMPORT_INTERVAL_SECONDS: ${COMMUNITY_IMPORT_INTERVAL_SECONDS:-1800}
|
||||
RF4MAP_POINT_URL: ${RF4MAP_POINT_URL:-https://rf4map.ru/points/275}
|
||||
RF4POSTS_SPOT_URL: ${RF4POSTS_SPOT_URL:-https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
depends_on:
|
||||
api: {condition: service_healthy}
|
||||
command: ["python", "-m", "app.community_scheduler"]
|
||||
networks: [backend]
|
||||
security_opt: [no-new-privileges:true]
|
||||
logging: *default-logging
|
||||
deploy:
|
||||
resources:
|
||||
limits: {cpus: "0.50", memory: 384M}
|
||||
|
||||
storage-tool:
|
||||
image: minio/mc:RELEASE.2025-07-21T05-28-08Z
|
||||
profiles: [tools]
|
||||
|
||||
@@ -107,6 +107,22 @@ services:
|
||||
condition: service_healthy
|
||||
command: ["python", "-m", "app.scheduler"]
|
||||
|
||||
community-scheduler:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/api/Dockerfile
|
||||
profiles: ["scheduler"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://rf4:rf4_local@db:5432/rf4_spotter
|
||||
COMMUNITY_IMPORT_INTERVAL_SECONDS: ${COMMUNITY_IMPORT_INTERVAL_SECONDS:-1800}
|
||||
RF4MAP_POINT_URL: ${RF4MAP_POINT_URL:-https://rf4map.ru/points/275}
|
||||
RF4POSTS_SPOT_URL: ${RF4POSTS_SPOT_URL:-https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
depends_on:
|
||||
api: {condition: service_healthy}
|
||||
command: ["python", "-m", "app.community_scheduler"]
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
|
||||
@@ -26,7 +26,7 @@ curl -fsS "http://127.0.0.1:$BOOTSTRAP_API_PORT/ready" >/dev/null
|
||||
curl -fsS "http://127.0.0.1:$BOOTSTRAP_WEB_PORT/" >/dev/null
|
||||
curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^x-frame-options: DENY'
|
||||
curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^cross-origin-opener-policy: same-origin'
|
||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')" = "0012"
|
||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')" = "0013"
|
||||
index_count=$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c "select count(*) from pg_indexes where schemaname = 'public' and indexname in ('ix_catch_report_activity_lookup','ix_catch_report_spot_feed','ix_catch_report_moderation_queue','ix_catch_report_official_records','ix_official_import_source_status_started','ix_external_observation_review_queue','ix_submission_attempt_client_created','ix_moderation_event_created_at')")
|
||||
test "$index_count" = "8"
|
||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from fish')" = "2"
|
||||
|
||||
+6
-5
@@ -118,8 +118,8 @@
|
||||
- [x] Добавить JSON-LD: `WebSite`, `Dataset` и `BreadcrumbList` на соответствующих страницах (7 сентября 2026).
|
||||
- [x] Сделать собственную полезную страницу 404 с возвратом к свежим точкам (7 сентября 2026).
|
||||
- [x] Явно отдавать `noindex` на административных страницах и состояниях ошибок (7 сентября 2026).
|
||||
- [ ] Добавить человекочитаемые постоянные URL точек без поломки существующих UUID-ссылок.
|
||||
- [ ] Создать индексируемые страницы `/fish/{slug}`, `/waterbodies/{slug}` и сочетания водоём + рыба с уникальным содержимым.
|
||||
- [x] Добавить постоянные URL точек `/spots/{waterbody}-{x}x{y}`, перевести карточки и sitemap, а старые UUID-ссылки сохранить через 301 redirect (7 сентября 2026).
|
||||
- [x] Создать индексируемые каталоги и страницы `/fish/{slug}`, `/waterbodies/{slug}` и сочетания водоём + рыба с уникальными метаданными и включением в sitemap (7 сентября 2026).
|
||||
|
||||
### Графические элементы и объяснение данных
|
||||
|
||||
@@ -135,8 +135,9 @@
|
||||
|
||||
### Производительность, данные и эксплуатация
|
||||
|
||||
- [ ] Добавить управляемый scheduler всех разрешённых community-парсеров с cooldown ≥30 минут, PostgreSQL lock, backoff и журналом запусков.
|
||||
- [ ] Показывать публичное безопасное состояние источников: актуален, устарел, временно ограничен или изменился DOM.
|
||||
- [x] Добавить scheduler всех пяти разрешённых community-парсеров с устойчивым cooldown ≥30 минут, PostgreSQL lock, журналом запусков и opt-in локальным профилем; backoff после повторных ошибок остаётся отдельным улучшением (7 сентября 2026).
|
||||
- [x] Добавить экспоненциальный backoff community scheduler после повторных ошибок от 30 минут до 24 часов со сбросом после успеха (7 сентября 2026).
|
||||
- [x] Показывать на `/status` безопасное состояние источников: актуален, устарел, временно ограничен, изменился DOM, ожидает запуска или выключен (7 сентября 2026).
|
||||
- [ ] Добавить короткое серверное кэширование публичных GET API и проверить корректную инвалидацию после публикации.
|
||||
- [ ] Настроить долгий immutable cache для хешированных assets и разумный cache для изображений/favicon.
|
||||
- [ ] Добавить пагинацию или «Показать ещё» для публичной ленты полевых сигналов.
|
||||
@@ -145,7 +146,7 @@
|
||||
- [ ] Контролировать рост PostgreSQL и MinIO с порогами предупреждений.
|
||||
- [ ] Добавить фоновую проверку битых исходных ссылок с соблюдением лимитов источников.
|
||||
- [ ] Показывать commit SHA/версию в readiness и административной диагностике.
|
||||
- [ ] Добавить публичную `/status` без внутренних адресов, секретов и лишних деталей.
|
||||
- [x] Добавить публичную `/status` без внутренних адресов, секретов и текстов ошибок (7 сентября 2026).
|
||||
- [ ] После появления сервера подключить privacy-friendly аналитику без cookies либо собственные агрегированные счётчики.
|
||||
- [ ] Зафиксировать нагрузочный бюджет и проверить p95 публичных API на целевом сервере.
|
||||
|
||||
|
||||
@@ -32,9 +32,9 @@
|
||||
- RF4DB: один запрос списка за ручной запуск; detail-страницы запрашиваются только адресно, без массового обхода.
|
||||
- RF4MAP: не чаще одного запроса раз в 30 минут.
|
||||
- RF4 Posts: не чаще одного запроса раз в 30 минут.
|
||||
- все пять разрешённых community-адаптеров включены в реестре staging; регулярные scheduler-задачи остаются выключены;
|
||||
- все пять разрешённых community-адаптеров включены; scheduler хранит cooldown в PostgreSQL и не запускает источник чаще одного раза в 30 минут;
|
||||
- при `429`, `403`, изменении DOM или неожиданном типе ответа сбор прекращается без попытки обхода ограничения.
|
||||
|
||||
## Что ещё требуется зафиксировать
|
||||
|
||||
Перед внешней публикацией необходимо приложить стороны и носитель исходного разрешения, обязательную формулировку атрибуции, срок хранения и процедуру удаления. Для RF4MAP/RF4 Posts точный минимальный интервал уже зафиксирован; автоматический scheduler пока не добавлен.
|
||||
Перед внешней публикацией необходимо приложить стороны и носитель исходного разрешения, обязательную формулировку атрибуции, срок хранения и процедуру удаления. Для RF4MAP/RF4 Posts минимальный интервал зафиксирован и технически ограничен scheduler до 30 минут.
|
||||
|
||||
Reference in New Issue
Block a user