Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9275799ce7 | ||
|
|
7217356594 |
@@ -8,7 +8,9 @@ RF4 Spotter — неофициальный сервис свежих точек
|
||||
|
||||
Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. Автоматические импорты внешних источников выключены. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений и публичные страницы правил/privacy.
|
||||
|
||||
RF4DB/RF4-STAT/RF4MAP/RF4 Posts принимаются только в изолированный staging и не влияют на индекс без ручного сопоставления и публикации. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации.
|
||||
RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации.
|
||||
|
||||
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва.
|
||||
|
||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
|
||||
|
||||
@@ -112,7 +114,7 @@ docker compose up --build
|
||||
## Что реализовано
|
||||
|
||||
- FastAPI и SQLAlchemy 2;
|
||||
- PostgreSQL 17 и миграции Alembic до `0011`;
|
||||
- PostgreSQL 17 и миграции Alembic до `0012`;
|
||||
- идемпотентный seed с двумя точками и свежими демо-уловами;
|
||||
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
|
||||
- `GET /api/v1/spots/{id}` и `/catches`;
|
||||
@@ -213,7 +215,7 @@ Production-логи структурированы в JSON и не содерж
|
||||
## Известные ограничения альфы
|
||||
|
||||
- нет пользовательских аккаунтов, OCR, Telegram-бота и уведомлений о клёве;
|
||||
- community-источники требуют ручной проверки; неполные наблюдения не публикуются;
|
||||
- community-источники автоматически публикуют только полные наблюдения с подтверждёнными алиасами; новые соответствия требуют ручной проверки, неполные наблюдения не публикуются;
|
||||
- offset pagination рассчитана на пилотные объёмы, не на бесконечную ленту;
|
||||
- Lighthouse LCP локального прогона — 9,3 с; оптимизация изображения/CDN остаётся после размещения;
|
||||
- один сервер остаётся точкой отказа, поэтому обязательны внешний backup и мониторинг;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Enable all authorized community sources in the staging registry."""
|
||||
from alembic import op
|
||||
|
||||
revision = "0012"
|
||||
down_revision = "0011"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
INSERT INTO data_source (key, name, base_url, default_confidence, enabled) VALUES
|
||||
('rf4db', 'RF4DB', 'https://rf4db.com', 70, true),
|
||||
('rf4stat-fishing', 'RF4-STAT fishing', 'https://rf4-stat.ru/fishing/', 65, true),
|
||||
('rf4stat-post', 'RF4-STAT posts', 'https://rf4-stat.ru/posts/', 60, true),
|
||||
('rf4map', 'RF4MAP', 'https://rf4map.ru', 55, true),
|
||||
('rf4posts-spot', 'RF4 Posts spots', 'https://rf4-posts.com', 50, true)
|
||||
ON CONFLICT (key) DO UPDATE SET enabled = EXCLUDED.enabled
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("UPDATE data_source SET enabled = false WHERE key IN ('rf4db', 'rf4stat-fishing', 'rf4stat-post', 'rf4map', 'rf4posts-spot')")
|
||||
@@ -69,10 +69,22 @@ def activity_rows(
|
||||
max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest,
|
||||
activity_score=activity, confidence_score=confidence,
|
||||
explanation=_explanation(len(items), len(players), freshness_text, activity, confidence),
|
||||
sources=sorted({_source_system(item) for item in items}),
|
||||
))
|
||||
return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True)
|
||||
|
||||
|
||||
def _source_system(report: CatchReport) -> str:
|
||||
provenance = (report.raw_payload or {}).get("provenance", {})
|
||||
if isinstance(provenance, dict) and provenance.get("source_system"):
|
||||
return str(provenance["source_system"])
|
||||
if report.source_type.value == "official_record":
|
||||
return "rf4-official"
|
||||
if report.source_type.value == "user":
|
||||
return "players"
|
||||
return "manual-import"
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ from urllib.parse import urlparse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import DataSource, ExternalObservation
|
||||
from .community_review import publish_observation
|
||||
from .models import DataSource, ExternalEntityAlias, ExternalObservation
|
||||
|
||||
|
||||
SOURCE_DEFAULTS = {
|
||||
@@ -36,6 +37,7 @@ def stage_observations(
|
||||
) -> tuple[int, int]:
|
||||
fetched_at = fetched_at or datetime.now(timezone.utc)
|
||||
created = updated = 0
|
||||
touched: list[ExternalObservation] = []
|
||||
for raw in records:
|
||||
payload = _json_payload(raw)
|
||||
source_system = _required(payload, "source_system", 50)
|
||||
@@ -45,7 +47,7 @@ def stage_observations(
|
||||
source = session.get(DataSource, source_system)
|
||||
if source is None:
|
||||
name, base_url, confidence = SOURCE_DEFAULTS[source_system]
|
||||
source = DataSource(key=source_system, name=name, base_url=base_url, default_confidence=confidence, enabled=False)
|
||||
source = DataSource(key=source_system, name=name, base_url=base_url, default_confidence=confidence, enabled=True)
|
||||
session.add(source)
|
||||
session.flush()
|
||||
observation = session.scalar(select(ExternalObservation).where(
|
||||
@@ -78,10 +80,45 @@ def stage_observations(
|
||||
for key, value in values.items():
|
||||
setattr(observation, key, value)
|
||||
updated += 1
|
||||
touched.append(observation)
|
||||
session.commit()
|
||||
for observation in touched:
|
||||
_auto_publish(session, observation)
|
||||
return created, updated
|
||||
|
||||
|
||||
def _auto_publish(session: Session, observation: ExternalObservation) -> bool:
|
||||
"""Publish only complete observations covered by previously reviewed aliases."""
|
||||
if (
|
||||
observation.status not in {"staged", "mapped", "ready"}
|
||||
or not observation.source.enabled
|
||||
or observation.fish_external_id is None
|
||||
or observation.waterbody_external_id is None
|
||||
or observation.x is None
|
||||
or observation.y is None
|
||||
or observation.weight_g is None
|
||||
):
|
||||
return False
|
||||
fish_alias = session.scalar(select(ExternalEntityAlias).where(
|
||||
ExternalEntityAlias.source_system == observation.source_system,
|
||||
ExternalEntityAlias.entity_type == "fish",
|
||||
ExternalEntityAlias.external_id == observation.fish_external_id,
|
||||
))
|
||||
waterbody_alias = session.scalar(select(ExternalEntityAlias).where(
|
||||
ExternalEntityAlias.source_system == observation.source_system,
|
||||
ExternalEntityAlias.entity_type == "waterbody",
|
||||
ExternalEntityAlias.external_id == observation.waterbody_external_id,
|
||||
))
|
||||
if fish_alias is None or fish_alias.fish is None or waterbody_alias is None or waterbody_alias.waterbody is None:
|
||||
return False
|
||||
observation.fish = fish_alias.fish
|
||||
observation.waterbody = waterbody_alias.waterbody
|
||||
observation.status = "ready"
|
||||
observation.review_note = "Automatically matched by previously reviewed source aliases"
|
||||
publish_observation(session, observation)
|
||||
return True
|
||||
|
||||
|
||||
def _json_payload(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
raise CommunityImportError("each observation must be an object")
|
||||
|
||||
+46
-3
@@ -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, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||
from .models import Bait, BaitKind, CatchReport, 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, SpotOut, WaterbodyOut
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SpotOut, WaterbodyOut
|
||||
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
|
||||
|
||||
|
||||
@@ -150,7 +150,50 @@ def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
|
||||
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, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
|
||||
return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed) for r in reports]
|
||||
return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed, source_system=_report_source(r), source_url=r.source_url) for r in reports]
|
||||
|
||||
|
||||
def _report_source(report: CatchReport) -> str:
|
||||
provenance = (report.raw_payload or {}).get("provenance", {})
|
||||
if isinstance(provenance, dict) and provenance.get("source_system"):
|
||||
return str(provenance["source_system"])
|
||||
if report.source_type == SourceType.official_record:
|
||||
return "rf4-official"
|
||||
if report.source_type == SourceType.user:
|
||||
return "players"
|
||||
return "manual-import"
|
||||
|
||||
|
||||
@app.get("/api/v1/community-observations", response_model=list[PublicObservationOut])
|
||||
def community_observations(
|
||||
db: Db, limit: int = Query(12, ge=1, le=50), offset: int = Query(0, ge=0),
|
||||
) -> list[PublicObservationOut]:
|
||||
items = list(db.scalars(
|
||||
select(ExternalObservation)
|
||||
.join(ExternalObservation.source)
|
||||
.options(joinedload(ExternalObservation.source))
|
||||
.where(
|
||||
ExternalObservation.catch_report_id.is_(None),
|
||||
ExternalObservation.status != "rejected",
|
||||
DataSource.enabled.is_(True),
|
||||
)
|
||||
.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc())
|
||||
.offset(offset).limit(limit)
|
||||
))
|
||||
result: list[PublicObservationOut] = []
|
||||
for item in items:
|
||||
missing = []
|
||||
if item.x is None or item.y is None:
|
||||
missing.append("координаты")
|
||||
if item.weight_g is None:
|
||||
missing.append("вес")
|
||||
result.append(PublicObservationOut(
|
||||
id=item.id, source_system=item.source_system, source_name=item.source.name,
|
||||
source_url=item.source_url, fish_name=item.fish_name, waterbody_name=item.waterbody_name,
|
||||
x=item.x, y=item.y, weight_g=item.weight_g, last_seen_at=item.last_seen_at,
|
||||
missing_fields=missing, quality="incomplete" if missing else "unverified",
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/v1/records", response_model=list[OfficialRecordOut])
|
||||
|
||||
@@ -145,7 +145,7 @@ class DataSource(Base):
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
base_url: Mapped[str] = mapped_column(Text)
|
||||
default_confidence: Mapped[int]
|
||||
enabled: Mapped[bool] = mapped_column(default=False)
|
||||
enabled: Mapped[bool] = mapped_column(default=True)
|
||||
|
||||
|
||||
class ExternalObservation(Base):
|
||||
|
||||
@@ -47,6 +47,7 @@ class ActivityOut(BaseModel):
|
||||
activity_score: int
|
||||
confidence_score: int
|
||||
explanation: str
|
||||
sources: list[str]
|
||||
|
||||
|
||||
class CatchOut(BaseModel):
|
||||
@@ -59,6 +60,8 @@ class CatchOut(BaseModel):
|
||||
reported_at: datetime
|
||||
retrieve_method: str | None
|
||||
retrieve_speed: int | None
|
||||
source_system: str
|
||||
source_url: str | None
|
||||
|
||||
|
||||
class SpotOut(BaseModel):
|
||||
@@ -85,6 +88,22 @@ class OfficialRecordOut(BaseModel):
|
||||
category: str | None
|
||||
region: str | None
|
||||
source_url: str | None
|
||||
source_system: str = "rf4-official"
|
||||
|
||||
|
||||
class PublicObservationOut(BaseModel):
|
||||
id: UUID
|
||||
source_system: str
|
||||
source_name: str
|
||||
source_url: str
|
||||
fish_name: str
|
||||
waterbody_name: str
|
||||
x: int | None
|
||||
y: int | None
|
||||
weight_g: int | None
|
||||
last_seen_at: datetime
|
||||
missing_fields: list[str]
|
||||
quality: str
|
||||
|
||||
|
||||
class ImportRunOut(BaseModel):
|
||||
|
||||
@@ -49,6 +49,7 @@ def test_activity_filters_and_explains_score() -> None:
|
||||
assert payload[0]["catches"] == 3
|
||||
assert payload[0]["unique_players"] == 3
|
||||
assert "3 свежих улова" in payload[0]["explanation"]
|
||||
assert payload[0]["sources"] == ["manual-import"]
|
||||
|
||||
|
||||
def test_invalid_period_is_rejected() -> None:
|
||||
@@ -81,6 +82,7 @@ def test_spot_detail_and_catches() -> None:
|
||||
assert detail.json()["catches_24h"] == 3
|
||||
assert catches.status_code == 200
|
||||
assert len(catches.json()) == 3
|
||||
assert catches.json()[0]["source_system"] == "manual-import"
|
||||
|
||||
|
||||
def test_records_list_is_empty_before_import() -> None:
|
||||
@@ -164,7 +166,7 @@ def test_external_observation_requires_mapping_and_complete_data_before_publicat
|
||||
assert {alias.entity_type for alias in aliases} == {"fish", "waterbody"}
|
||||
|
||||
|
||||
def test_incomplete_external_observation_stays_out_of_public_data() -> None:
|
||||
def test_incomplete_external_observation_is_publicly_labelled_but_not_counted() -> None:
|
||||
with Session(engine) as db:
|
||||
stage_observations(db, [{
|
||||
"source_system": "rf4db", "source_external_id": "review-incomplete",
|
||||
@@ -174,6 +176,13 @@ def test_incomplete_external_observation_stays_out_of_public_data() -> None:
|
||||
observation_id = db.scalar(select(ExternalObservation.id).where(
|
||||
ExternalObservation.source_external_id == "review-incomplete"
|
||||
))
|
||||
public = client.get("/api/v1/community-observations")
|
||||
assert public.status_code == 200
|
||||
signal = next(item for item in public.json() if item["id"] == str(observation_id))
|
||||
assert signal["source_system"] == "rf4db"
|
||||
assert signal["quality"] == "incomplete"
|
||||
assert signal["missing_fields"] == ["вес"]
|
||||
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json())
|
||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||
mapped = client.patch(
|
||||
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
|
||||
@@ -186,6 +195,7 @@ def test_incomplete_external_observation_stays_out_of_public_data() -> None:
|
||||
json={"reason": "weight is absent"},
|
||||
)
|
||||
assert rejected.json()["status"] == "rejected"
|
||||
assert all(item["id"] != str(observation_id) for item in client.get("/api/v1/community-observations").json())
|
||||
|
||||
|
||||
def test_admin_can_start_and_list_official_import(monkeypatch) -> None:
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.community_importer import CommunityImportError, stage_observations
|
||||
from app.database import Base
|
||||
from app.models import DataSource, ExternalObservation
|
||||
from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
|
||||
from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ def test_staging_is_idempotent_and_preserves_first_seen(db: Session) -> None:
|
||||
assert item.last_seen_at.replace(tzinfo=timezone.utc) == second
|
||||
assert db.scalar(select(func.count()).select_from(ExternalObservation)) == 1
|
||||
source = db.get(DataSource, "rf4db")
|
||||
assert source is not None and source.enabled is False
|
||||
assert source is not None and source.enabled is True
|
||||
|
||||
|
||||
def test_external_ids_are_isolated_by_source(db: Session) -> None:
|
||||
@@ -73,12 +73,60 @@ def test_external_ids_are_isolated_by_source(db: Session) -> None:
|
||||
assert (created, updated) == (2, 0)
|
||||
|
||||
|
||||
def test_research_sources_can_enter_disabled_staging(db: Session) -> None:
|
||||
def test_research_sources_enter_enabled_staging(db: Session) -> None:
|
||||
created, updated = stage_observations(db, [record("rf4map"), record("rf4posts-spot")])
|
||||
|
||||
assert (created, updated) == (2, 0)
|
||||
assert db.get(DataSource, "rf4map").enabled is False
|
||||
assert db.get(DataSource, "rf4posts-spot").enabled is False
|
||||
assert db.get(DataSource, "rf4map").enabled is True
|
||||
assert db.get(DataSource, "rf4posts-spot").enabled is True
|
||||
|
||||
|
||||
def test_complete_observation_with_reviewed_aliases_is_published(db: Session) -> None:
|
||||
source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=True)
|
||||
fish = Fish(slug="pike", name_ru="Щука")
|
||||
waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
|
||||
db.add_all([source, fish, waterbody])
|
||||
db.flush()
|
||||
now = datetime.now(timezone.utc)
|
||||
db.add_all([
|
||||
ExternalEntityAlias(source_system="rf4db", entity_type="fish", external_id="pike", external_name="Щука", fish=fish, updated_at=now),
|
||||
ExternalEntityAlias(source_system="rf4db", entity_type="waterbody", external_id="test-lake", external_name="Тестовое озеро", waterbody=waterbody, updated_at=now),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
assert stage_observations(db, [record() | {"weight_g": 5_000}]) == (1, 0)
|
||||
|
||||
item = db.scalar(select(ExternalObservation))
|
||||
assert item is not None
|
||||
assert item.status == "published"
|
||||
assert item.catch_report is not None
|
||||
assert item.catch_report.fish_id == fish.id
|
||||
assert item.catch_report.waterbody_id == waterbody.id
|
||||
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
|
||||
|
||||
assert stage_observations(db, [record() | {"weight_g": 5_000}]) == (0, 1)
|
||||
db.refresh(item)
|
||||
assert item.status == "published"
|
||||
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
|
||||
|
||||
|
||||
def test_auto_publication_requires_enabled_source(db: Session) -> None:
|
||||
source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=False)
|
||||
fish = Fish(slug="pike", name_ru="Щука")
|
||||
waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
|
||||
db.add_all([source, fish, waterbody])
|
||||
db.flush()
|
||||
now = datetime.now(timezone.utc)
|
||||
db.add_all([
|
||||
ExternalEntityAlias(source_system="rf4db", entity_type="fish", external_id="pike", external_name="Щука", fish=fish, updated_at=now),
|
||||
ExternalEntityAlias(source_system="rf4db", entity_type="waterbody", external_id="test-lake", external_name="Тестовое озеро", waterbody=waterbody, updated_at=now),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
stage_observations(db, [record() | {"weight_g": 5_000}])
|
||||
|
||||
item = db.scalar(select(ExternalObservation))
|
||||
assert item is not None and item.status == "staged" and item.catch_report is None
|
||||
|
||||
|
||||
def test_parser_json_can_be_staged_without_losing_provenance(db: Session) -> None:
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import type { Activity } from "../lib/api";
|
||||
import { activityLevel, ago, kg, plural } 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}`}>
|
||||
<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><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-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>
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
const { source, href, tone = "source" } = Astro.props as { source: string; href?: string | null; tone?: "source" | "incomplete" | "verified" };
|
||||
const labels: Record<string, string> = {
|
||||
"rf4-official": "RF4 · официальный",
|
||||
rf4db: "RF4DB",
|
||||
"rf4stat-fishing": "RF4-STAT · уловы",
|
||||
"rf4stat-post": "RF4-STAT · посты",
|
||||
rf4map: "RF4MAP",
|
||||
"rf4posts-spot": "RF4 Posts",
|
||||
players: "Игроки RF4",
|
||||
"manual-import": "Архивный импорт",
|
||||
};
|
||||
const label = labels[source] ?? source;
|
||||
const mark = source === "players" ? "♟" : source === "rf4-official" ? "★" : "↗";
|
||||
---
|
||||
{href ? <a class="source-chip" data-source={source} data-tone={tone} href={href} target="_blank" rel="noreferrer" title={`Открыть источник: ${label}`}><i>{mark}</i><span>{label}</span></a> : <span class="source-chip" data-source={source} data-tone={tone}><i>{mark}</i><span>{label}</span></span>}
|
||||
@@ -3,13 +3,14 @@ export type Activity = {
|
||||
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;
|
||||
explanation: string; sources: 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 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; source_system: string; source_url: string | 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 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 { activityLevel, ago, kg, plural } from "./presentation";
|
||||
|
||||
@@ -2,18 +2,19 @@
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import ActivityCard from "../components/ActivityCard.astro";
|
||||
import FishingIcon from "../components/FishingIcon.astro";
|
||||
import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem } from "../lib/api";
|
||||
import SourceBadge from "../components/SourceBadge.astro";
|
||||
import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PublicObservation } 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 items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
||||
const filterError = !["6", "12", "24", "72"].includes(hours) || !["activity", "confidence", "freshness"].includes(sort);
|
||||
let unavailable = false;
|
||||
try {
|
||||
[fishes, waterbodies] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]);
|
||||
[fishes, waterbodies, signals] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies"), api<PublicObservation[]>("/api/v1/community-observations?limit=12")]);
|
||||
if (!filterError) {
|
||||
const query = new URLSearchParams({ hours, waterbody, fish, sort });
|
||||
items = await api<Activity[]>(`/api/v1/activity?${query}`);
|
||||
@@ -39,8 +40,9 @@ const filtersChanged = Boolean(waterbody || fish || hours !== "24" || sort !== "
|
||||
</form></section>
|
||||
<div class="active-filters content-grid" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить</a>}</div>
|
||||
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} {plural(items.length, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a href="/">Сбросить фильтры</a></div> : unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте позже.</p></div> : items.length ? <div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
|
||||
{items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="detail-score"><div class="float-gauge" style={`--level:${items[0].activity_score}%`} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><FishingIcon name="lure" size={25}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
|
||||
{items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class="float-gauge" style={`--level:${items[0].activity_score}%`} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><FishingIcon name="lure" size={25}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
|
||||
</section>
|
||||
{signals.length > 0 && <section class="signal-section content-grid" aria-labelledby="signals-title"><div class="signal-heading"><div><span class="overline">Сырые данные источников</span><h2 id="signals-title">Полевые сигналы</h2><p>Показываем сразу, даже если часть полей отсутствует. Эти карточки не участвуют в расчёте активности до полного подтверждения.</p></div><span class="signal-count">{signals.length} новых</span></div><div class="signal-grid">{signals.map(signal => <article class="signal-card" data-quality={signal.quality}><div class="signal-card__top"><SourceBadge source={signal.source_system} href={signal.source_url}/><span class="quality-chip"><i>{signal.quality === "incomplete" ? "!" : "?"}</i> {signal.quality === "incomplete" ? "Неполные данные" : "Ждёт проверки"}</span></div><h3>{signal.fish_name}</h3><p>{signal.waterbody_name}</p><dl><div><dt>Точка</dt><dd>{signal.x == null || signal.y == null ? "не указана" : `${signal.x}:${signal.y}`}</dd></div><div><dt>Вес</dt><dd>{signal.weight_g == null ? "не указан" : kg(signal.weight_g)}</dd></div><div><dt>Получено</dt><dd>{ago(signal.last_seen_at)}</dd></div></dl><p class="missing-note"><span>{signal.missing_fields.length ? "Не хватает:" : "Статус:"}</span> {signal.missing_fields.join(", ") || "проверяется соответствие справочнику"}</p></article>)}</div></section>}
|
||||
<section class="how-it-works content-grid"><div><span class="overline">Как читать данные</span><h2>Не обещаем рыбу.<br/>Показываем факты.</h2></div><div class="principles"><article><span>01</span><h3>Свежесть</h3><p>Чем старше сообщение, тем меньше оно влияет на активность.</p></article><article><span>02</span><h3>Разные игроки</h3><p>Десять уловов одного человека не равны десяти подтверждениям.</p></article><article><span>03</span><h3>Уверенность</h3><p>Каждая оценка объясняет, сколько данных за ней стоит.</p></article></div></section>
|
||||
<script>
|
||||
const advancedFilters = document.querySelector<HTMLDetailsElement>(".filter-advanced");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import SourceBadge from "../components/SourceBadge.astro";
|
||||
import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord } from "../lib/api";
|
||||
const params = Astro.url.searchParams;
|
||||
const fish = params.get("fish") ?? "";
|
||||
@@ -12,6 +13,6 @@ const last = runs[0];
|
||||
<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>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><button>Фильтровать</button>{(fish || waterbody) && <a href="/records">Сбросить</a>}</form>
|
||||
<div class="section-heading content-grid"><div><span class="overline">Официальный источник</span><h2>Последние записи</h2></div><span class="result-count">{records.length} показано</span></div>
|
||||
{unavailable ? <div class="state content-grid"><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 data-label="Рыба">{record.fish}</strong><strong data-label="Вес">{kg(record.weight_g)}</strong><span data-label="Водоём">{record.waterbody}</span><span data-label="Приманка">{record.bait ?? "—"}</span><span data-label="Игрок">{record.player_name ?? "—"}</span><time data-label="Дата">{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time></article>)}</div> : <div class="state content-grid"><h2>Рекорды ещё не импортированы</h2><p>Для выбранных условий записей пока нет.</p></div>}
|
||||
{unavailable ? <div class="state content-grid"><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 data-label="Рыба">{record.fish}</strong><strong data-label="Вес">{kg(record.weight_g)}</strong><span data-label="Водоём">{record.waterbody}</span><span data-label="Приманка">{record.bait ?? "—"}</span><span data-label="Игрок">{record.player_name ?? "—"}</span><span data-label="Дата и источник" class="record-provenance"><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time><SourceBadge source={record.source_system} href={record.source_url}/></span></article>)}</div> : <div class="state content-grid"><h2>Рекорды ещё не импортированы</h2><p>Для выбранных условий записей пока нет.</p></div>}
|
||||
<p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p>
|
||||
</Layout>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import SourceBadge from "../../components/SourceBadge.astro";
|
||||
import { activityLevel, api, kg, plural, type Activity, type Catch, type Spot } from "../../lib/api";
|
||||
const { id } = Astro.params;
|
||||
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
|
||||
@@ -14,6 +15,6 @@ const level = activity ? activityLevel(activity.activity_score) : null;
|
||||
{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>{catches.length ? <div class="catch-list">{catches.map(item => <article><div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span></div><div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span></div></article>)}</div> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}</div><aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><p>{activity.explanation}</p><p class="note">Основано на {activity.catches} {plural(activity.catches, ["наблюдении", "наблюдениях", "наблюдениях"])} от {activity.unique_players} {plural(activity.unique_players, ["игрока", "игроков", "игроков"])}. Уверенность: {activity.confidence_score}%.</p></> : <p>За последние 24 часа данных для расчёта нет.</p>}<span class="eyebrow">Лучшие приманки</span>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li>{name}</li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside></section>
|
||||
<section class="detail-grid"><div><div class="section-heading"><h2>Последние уловы</h2></div>{catches.length ? <div class="catch-list">{catches.map(item => <article><div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span><SourceBadge source={item.source_system} href={item.source_url}/></div><div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span></div></article>)}</div> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}</div><aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><div class="source-strip">{activity.sources.map(source => <SourceBadge source={source}/>)}</div><p>{activity.explanation}</p><p class="note">Основано на {activity.catches} {plural(activity.catches, ["наблюдении", "наблюдениях", "наблюдениях"])} от {activity.unique_players} {plural(activity.unique_players, ["игрока", "игроков", "игроков"])}. Уверенность: {activity.confidence_score}%.</p></> : <p>За последние 24 часа данных для расчёта нет.</p>}<span class="eyebrow">Лучшие приманки</span>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li>{name}</li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside></section>
|
||||
</>}
|
||||
</Layout>
|
||||
|
||||
@@ -28,3 +28,9 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
|
||||
/* WCAG AA secondary text on light surfaces */
|
||||
.eyebrow,.overline,.result-count,.spot-rank,.spot-topline,.spot-meta,.bait-line div span,.spot-stats span,.data-note,.state,.principles p,.principles article>span,.official-note,.privacy,.field-help,.report-form legend span,.optional-fields summary span,.record-row>span,.record-row>time{color:#4d605d}
|
||||
.consent{display:flex!important;align-items:flex-start;gap:10px;margin:20px 0;text-transform:none!important;letter-spacing:0!important;font-size:14px!important;line-height:1.45}.consent input{width:20px;height:20px;margin:0;flex:none}.legal-page{width:min(760px,calc(100% - 32px));margin:60px auto 100px}.legal-page h1{font:400 clamp(48px,7vw,82px)/.95 Georgia,serif;letter-spacing:-.05em}.legal-page h2{margin-top:36px;font:400 28px Georgia,serif}.legal-page p,.legal-page li{color:#405552;line-height:1.7}.legal-page a{text-underline-offset:3px}
|
||||
|
||||
/* Provenance badges and immediate incomplete-data feed */
|
||||
.source-strip{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}.source-chip{--chip:#315f63;display:inline-flex!important;grid-template-columns:none!important;align-items:center;gap:6px;width:max-content;padding:4px 9px 4px 5px;border:1px solid color-mix(in srgb,var(--chip) 34%,transparent);border-radius:999px;background:color-mix(in srgb,var(--chip) 10%,#fff);color:var(--chip)!important;font:750 10px/1.2 Inter,sans-serif!important;letter-spacing:.025em;text-decoration:none;text-transform:none;white-space:nowrap}.source-chip i{width:18px;height:18px;display:grid;place-items:center;border-radius:50%;background:var(--chip);color:#fff;font:700 9px/1 Inter,sans-serif}.source-chip[href]{transition:transform .18s ease,box-shadow .18s ease}.source-chip[href]:hover{transform:translateY(-1px);box-shadow:0 5px 14px #102f3220}.source-chip[data-source="rf4-official"]{--chip:#9a6a22}.source-chip[data-source="rf4db"]{--chip:#306f8f}.source-chip[data-source^="rf4stat"]{--chip:#6951a3}.source-chip[data-source="rf4map"]{--chip:#36785a}.source-chip[data-source="rf4posts-spot"]{--chip:#b05a3e}.source-chip[data-source="players"]{--chip:#55701d}.detail-card .source-chip{background:#ffffff0b;color:#e9f1ee!important;border-color:#ffffff2b}.record-provenance{display:flex;flex-direction:column;align-items:flex-start;gap:7px}.record-provenance .source-chip:before{content:none!important}.catch-list .source-chip{margin-top:7px}
|
||||
.signal-section{padding:0 0 100px}.signal-heading{display:flex;align-items:end;justify-content:space-between;gap:35px;padding-top:68px;border-top:1px solid #cbd6ce;margin-bottom:25px}.signal-heading h2{font:400 46px/.95 Georgia,serif;letter-spacing:-.045em;margin:8px 0 10px}.signal-heading p{max-width:700px;color:#4d605d;line-height:1.55;margin:0}.signal-count{padding:7px 12px;border:1px solid #c2cec5;border-radius:999px;color:#526662;font-size:12px;white-space:nowrap}.signal-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:13px}.signal-card{position:relative;overflow:hidden;padding:20px;border:1px solid #d6cda9;border-radius:15px;background:linear-gradient(145deg,#fffdf5,#f8f5e9);box-shadow:0 10px 25px #443d1a0a}.signal-card:after{content:"";position:absolute;width:80px;height:80px;right:-40px;bottom:-40px;border:1px solid #bdac6b55;border-radius:50%;box-shadow:0 0 0 13px #bdac6b14}.signal-card__top{display:flex;justify-content:space-between;align-items:center;gap:8px}.quality-chip{display:inline-flex;align-items:center;gap:5px;color:#76570c;font-size:10px;font-weight:750}.quality-chip i{width:18px;height:18px;display:grid;place-items:center;border-radius:50%;background:#f2d77e;font-style:normal}.signal-card h3{font:400 24px Georgia,serif;margin:17px 0 3px}.signal-card>p{margin:0;color:#576966;font-size:13px}.signal-card dl{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:18px 0}.signal-card dl div{padding-top:9px;border-top:1px solid #d9d3b9}.signal-card dt{color:#776f56;font-size:9px;text-transform:uppercase;letter-spacing:.08em}.signal-card dd{margin:4px 0 0;font:400 14px Georgia,serif}.signal-card .missing-note{position:relative;z-index:1;padding:9px 11px;border-radius:8px;background:#efe5bd;color:#6b550f;font-size:11px}.missing-note span{font-weight:800}
|
||||
@media(max-width:1040px){.signal-grid{grid-template-columns:repeat(2,1fr)}}
|
||||
@media(max-width:720px){.signal-heading{display:block;padding-top:45px}.signal-count{display:inline-block;margin-top:14px}.signal-grid{grid-template-columns:1fr}.signal-card__top{align-items:flex-start}.source-strip{margin-bottom:4px}}
|
||||
|
||||
@@ -12,6 +12,7 @@ test("production bootstrap supports submission and moderation", async ({ page, r
|
||||
await page.getByLabel("Координата Y *").fill(String(y));
|
||||
await page.getByLabel("Вес, граммы *").fill("4321");
|
||||
await page.getByLabel(/Я отправляю собственное наблюдение/).check();
|
||||
await page.getByText("Дополнительные сведения").click();
|
||||
await page.getByLabel("Ник игрока").fill(player);
|
||||
await page.getByRole("button", { name: "Отправить на проверку" }).click();
|
||||
await expect(page.getByText("Улов отправлен на модерацию. Спасибо!")).toBeVisible();
|
||||
|
||||
@@ -6,11 +6,13 @@ test("player can open an active spot", async ({ page }) => {
|
||||
const activeSpots = page.locator(".spot-card[data-testid]");
|
||||
await expect(activeSpots).not.toHaveCount(0);
|
||||
const activeSpot = activeSpots.first();
|
||||
await expect(activeSpot.locator(".source-chip")).not.toHaveCount(0);
|
||||
const level = await activeSpot.locator("[data-activity-level]").getAttribute("data-activity-level");
|
||||
await expect(page.locator(".detail-score [data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
|
||||
await activeSpot.click();
|
||||
await expect(page.getByRole("heading", { name: /^Точка / })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible();
|
||||
await expect(page.locator(".catch-list .source-chip")).not.toHaveCount(0);
|
||||
await expect(page.locator("[data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
|
||||
});
|
||||
|
||||
@@ -24,6 +26,7 @@ test("submitted catch appears publicly only after moderation", async ({ page })
|
||||
await page.getByLabel("Координата X *").fill(String(x));
|
||||
await page.getByLabel("Координата Y *").fill(String(y));
|
||||
await page.getByLabel("Вес, граммы *").fill("6789");
|
||||
await page.getByText("Дополнительные сведения").click();
|
||||
await page.getByLabel("Ник игрока").fill(player);
|
||||
await page.getByLabel(/Я отправляю собственное наблюдение/).check();
|
||||
await page.getByRole("button", { name: "Отправить на проверку" }).click();
|
||||
|
||||
@@ -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')" = "0011"
|
||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')" = "0012"
|
||||
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"
|
||||
|
||||
@@ -65,6 +65,8 @@
|
||||
- [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками.
|
||||
- [x] Проверить списочные API по требованию раздела 12: все выдачи имеют ограниченные `limit`/`offset`, детерминированный tie-breaker и типизированные фильтры; фильтр категории рекордов перенесён до пагинации.
|
||||
- [x] Добавить составные индексы PostgreSQL для activity, модерации, официальных рекордов, staging, импорта, аудита и очистки rate limit (миграция `0011`); бюджет и процедура проверки планов зафиксированы в `docs/query-performance.md`.
|
||||
- [x] Включить все пять разрешённых community-источников и автоматическую публикацию полных наблюдений по ранее подтверждённым алиасам без межисточникового склеивания (миграция `0012`, 7 сентября 2026).
|
||||
- [x] Добавить обязательную публичную атрибуцию записей и агрегатов, а также отдельную ленту неполных community-наблюдений с явным перечнем отсутствующих полей без влияния на индекс активности (7 сентября 2026).
|
||||
- [x] Провести security-проверку admin-аутентификации, CORS, headers, загрузок, контейнерных пользователей и секретов: двойная защита admin web/API, constant-time token, no-store, non-root API/web и отдельные MinIO root/app credentials; остаточные ограничения записаны в `docs/security-review.md`.
|
||||
- [x] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно.
|
||||
- [x] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавлены настраиваемая dry-run-first очистка, тест и `docs/data-retention.md`.
|
||||
|
||||
@@ -64,14 +64,14 @@ Detail-страница дополнительно содержит ветер,
|
||||
- тесты всех извлекаемых полей, locked-координат и отказа на постороннем HTML;
|
||||
- живой контрольный прогон без сохранения персональных данных и изображений в репозиторий.
|
||||
|
||||
RF4MAP и RF4 Posts остаются исследовательскими: на живых HTML-снимках получены соответственно 30 индивидуальных наблюдений и 6 видов рыб одной точки. Оба результата укладываются в nullable-контракт, имеют отдельные пространства ID и не смешиваются с RF4DB/RF4-STAT. Разрешение и минимальный интервал 30 минут подтверждены; источники разрешены для staging, но остаются выключенными. RF4 Posts трактуется как агрегированная точка, а не индивидуальный улов.
|
||||
На живых HTML-снимках RF4MAP и RF4 Posts получены соответственно 30 индивидуальных наблюдений и 6 видов рыб одной точки. Оба результата укладываются в nullable-контракт, имеют отдельные пространства ID и не смешиваются с RF4DB/RF4-STAT. Разрешение и минимальный интервал 30 минут подтверждены; все источники включены миграцией `0012`. RF4 Posts трактуется как агрегированная точка, а не индивидуальный улов.
|
||||
|
||||
## Перед продуктивным импортом
|
||||
|
||||
1. Сохранить подтверждение разрешения и согласованные лимиты запросов.
|
||||
2. Расширять добавленные алиасы рыб и водоёмов по мере ручной проверки новых значений.
|
||||
3. Согласовать начальные уровни доверия для RF4DB и двух каналов RF4-STAT (в staging записаны консервативные значения 70/65/60, но источники выключены).
|
||||
4. Проверить правила публикации на небольшой вручную подтверждённой выборке.
|
||||
3. При необходимости пересмотреть консервативные начальные уровни доверия 70/65/60/55/50.
|
||||
4. Расширять контрольную выборку автопубликации по мере появления полных наблюдений.
|
||||
5. Не хранить и не проксировать изображения без отдельного условия разрешения; на первом этапе достаточно исходной ссылки.
|
||||
|
||||
## Staging
|
||||
@@ -80,6 +80,6 @@ RF4MAP и RF4 Posts остаются исследовательскими: на
|
||||
|
||||
Загрузка принимает только известные источники, HTTPS-ссылки соответствующего домена и значения в допустимых диапазонах. Все записи получают статус `staged`; таблица не связана с расчётом активности. Тест доказывает путь `HTML → ExternalCatch → JSON → external_observation` без потери provenance.
|
||||
|
||||
Контрольная загрузка в PostgreSQL создала 139 staging-записей: 23 `rf4db` с координатами, 100 `rf4stat-fishing` с весом и 16 `rf4stat-post` с весом. Немедленный повтор дал `created=0, updated=139`, подтвердив идемпотентность на реальной БД. Все три источника остались `enabled=false`.
|
||||
Контрольная загрузка в PostgreSQL создала 139 staging-записей: 23 `rf4db` с координатами, 100 `rf4stat-fishing` с весом и 16 `rf4stat-post` с весом. Немедленный повтор дал `created=0, updated=139`, подтвердив идемпотентность на реальной БД. Миграция `0012` включает все пять разрешённых источников.
|
||||
|
||||
Миграция `0009` добавляет устойчивые алиасы внешних рыб и водоёмов, канонические ссылки и состояние ручной проверки. Страница `/admin/external-sources` позволяет сопоставить, отклонить или явно опубликовать наблюдение. Публикация создаёт одобренный `catch_report` с исходной ссылкой и полным provenance, но только если одновременно известны канонические сущности, координаты и вес. Поэтому текущие 139 неполных записей после миграции остались в staging.
|
||||
Миграция `0009` добавляет устойчивые алиасы внешних рыб и водоёмов, канонические ссылки и состояние ручной проверки. Страница `/admin/external-sources` позволяет сопоставить, отклонить или явно опубликовать наблюдение. После миграции `0012` повторный импорт автоматически публикует запись только при одновременном наличии подтверждённых алиасов конкретного источника, координат и веса; остальные записи остаются в staging. Поэтому текущие 139 неполных записей не опубликованы.
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
- Разрешённый технический контур: получение публичного HTML, извлечение текстовых сведений об уловах, хранение исходной ссылки и нормализованных полей в закрытом staging.
|
||||
- Изображения: не скачиваются, не проксируются и не архивируются без отдельного явно зафиксированного условия.
|
||||
- Атрибуция: опубликованный улов обязан сохранять `source_system`, внешний ID и прямой `source_url` в provenance.
|
||||
- Публичное отображение: каждая запись и агрегат обязаны показывать источник; неполные staging-наблюдения разрешено отображать сразу с прямой ссылкой, явной пометкой неполноты и перечнем отсутствующих полей, но без влияния на индекс активности.
|
||||
- Объединение источников: автоматическое склеивание RF4DB и RF4-STAT запрещено, пока нет надёжного общего идентификатора.
|
||||
- Публикация: только вручную, после канонического сопоставления; обязательны рыба, водоём, координаты и вес.
|
||||
- Публикация: автоматически по ранее вручную подтверждённым алиасам источника либо вручную; обязательны рыба, водоём, координаты и вес.
|
||||
|
||||
## RF4MAP и RF4 Posts
|
||||
|
||||
@@ -19,7 +20,8 @@
|
||||
- Частота: не чаще одного получения страницы каждого источника раз в 30 минут.
|
||||
- Разрешённый контур: публичные HTML-страницы, нормализация в `ExternalCatch` и закрытый staging; запрещённые в `robots.txt` API не запрашиваются.
|
||||
- Изображения: сохраняются только исходные URL доказательств; файлы не скачиваются и не проксируются.
|
||||
- Публикация: только вручную и только при выполнении общего требования рыба + водоём + координаты + вес. Текущие записи RF4MAP/RF4 Posts не имеют веса и остаются в staging.
|
||||
- Атрибуция и неполнота: на каждой публичной карточке показываются источник и ссылка; неполные наблюдения могут появляться сразу, если визуально отделены от подтверждённых данных и не участвуют в расчёте активности.
|
||||
- Публикация: автоматически по подтверждённым алиасам либо вручную и только при выполнении общего требования рыба + водоём + координаты + вес. Текущие записи RF4MAP/RF4 Posts не имеют веса и остаются в staging.
|
||||
- Семантика RF4 Posts: несколько видов одного поста остаются связанными с одним UUID точки и не считаются отдельными подтверждёнными взвешиваниями.
|
||||
|
||||
## Лимиты запросов
|
||||
@@ -30,7 +32,7 @@
|
||||
- RF4DB: один запрос списка за ручной запуск; detail-страницы запрашиваются только адресно, без массового обхода.
|
||||
- RF4MAP: не чаще одного запроса раз в 30 минут.
|
||||
- RF4 Posts: не чаще одного запроса раз в 30 минут.
|
||||
- регулярные scheduler-задачи для обоих источников выключены;
|
||||
- все пять разрешённых community-адаптеров включены в реестре staging; регулярные scheduler-задачи остаются выключены;
|
||||
- при `429`, `403`, изменении DOM или неожиданном типе ответа сбор прекращается без попытки обхода ограничения.
|
||||
|
||||
## Что ещё требуется зафиксировать
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@ RF4 Spotter обрабатывает официальные публичные
|
||||
- IP не хранится: rate limit использует HMAC-отпечаток с секретом сервера;
|
||||
- из изображений удаляются EXIF и прочие метаданные;
|
||||
- query string, authorization headers и пользовательские payload не попадают в HTTP-лог;
|
||||
- community-наблюдения сначала поступают в изолированный staging и публикуются только после ручной проверки.
|
||||
- community-наблюдения сначала поступают в staging; неполные можно сразу видеть в отдельной публичной ленте с источником и перечнем отсутствующих полей, но они не влияют на индекс активности. Полные записи автоматически входят в основной индекс только по ранее вручную подтверждённым алиасам источника, остальные требуют ручной проверки.
|
||||
- любая карточка улова, рекорда или агрегированной активности показывает первоисточник либо полный список вошедших в агрегат источников.
|
||||
|
||||
## Источники и атрибуция
|
||||
|
||||
|
||||
Reference in New Issue
Block a user