Compare commits

..
2 Commits
Author SHA1 Message Date
ik 9275799ce7 feat: display provenance and incomplete signals
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-07 13:21:15 +07:00
ik 7217356594 feat: enable safe community auto publishing 2026-09-07 13:00:58 +07:00
23 changed files with 266 additions and 35 deletions
+5 -3
View File
@@ -8,7 +8,9 @@ RF4 Spotter — неофициальный сервис свежих точек
Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. Автоматические импорты внешних источников выключены. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений и публичные страницы правил/privacy. Функциональный 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). Подробный план и актуальные чекбоксы находятся в [`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; - FastAPI и SQLAlchemy 2;
- PostgreSQL 17 и миграции Alembic до `0011`; - PostgreSQL 17 и миграции Alembic до `0012`;
- идемпотентный seed с двумя точками и свежими демо-уловами; - идемпотентный seed с двумя точками и свежими демо-уловами;
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки; - `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
- `GET /api/v1/spots/{id}` и `/catches`; - `GET /api/v1/spots/{id}` и `/catches`;
@@ -213,7 +215,7 @@ Production-логи структурированы в JSON и не содерж
## Известные ограничения альфы ## Известные ограничения альфы
- нет пользовательских аккаунтов, OCR, Telegram-бота и уведомлений о клёве; - нет пользовательских аккаунтов, OCR, Telegram-бота и уведомлений о клёве;
- community-источники требуют ручной проверки; неполные наблюдения не публикуются; - community-источники автоматически публикуют только полные наблюдения с подтверждёнными алиасами; новые соответствия требуют ручной проверки, неполные наблюдения не публикуются;
- offset pagination рассчитана на пилотные объёмы, не на бесконечную ленту; - offset pagination рассчитана на пилотные объёмы, не на бесконечную ленту;
- Lighthouse LCP локального прогона — 9,3 с; оптимизация изображения/CDN остаётся после размещения; - Lighthouse LCP локального прогона — 9,3 с; оптимизация изображения/CDN остаётся после размещения;
- один сервер остаётся точкой отказа, поэтому обязательны внешний backup и мониторинг; - один сервер остаётся точкой отказа, поэтому обязательны внешний 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')")
+12
View File
@@ -69,10 +69,22 @@ def activity_rows(
max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest, max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest,
activity_score=activity, confidence_score=confidence, activity_score=activity, confidence_score=confidence,
explanation=_explanation(len(items), len(players), freshness_text, activity, 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) 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: def _aware(value: datetime) -> datetime:
return value if value.tzinfo else value.replace(tzinfo=timezone.utc) return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
+39 -2
View File
@@ -8,7 +8,8 @@ from urllib.parse import urlparse
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import DataSource, ExternalObservation from .community_review import publish_observation
from .models import DataSource, ExternalEntityAlias, ExternalObservation
SOURCE_DEFAULTS = { SOURCE_DEFAULTS = {
@@ -36,6 +37,7 @@ def stage_observations(
) -> tuple[int, int]: ) -> tuple[int, int]:
fetched_at = fetched_at or datetime.now(timezone.utc) fetched_at = fetched_at or datetime.now(timezone.utc)
created = updated = 0 created = updated = 0
touched: list[ExternalObservation] = []
for raw in records: for raw in records:
payload = _json_payload(raw) payload = _json_payload(raw)
source_system = _required(payload, "source_system", 50) source_system = _required(payload, "source_system", 50)
@@ -45,7 +47,7 @@ def stage_observations(
source = session.get(DataSource, source_system) source = session.get(DataSource, source_system)
if source is None: if source is None:
name, base_url, confidence = SOURCE_DEFAULTS[source_system] 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.add(source)
session.flush() session.flush()
observation = session.scalar(select(ExternalObservation).where( observation = session.scalar(select(ExternalObservation).where(
@@ -78,10 +80,45 @@ def stage_observations(
for key, value in values.items(): for key, value in values.items():
setattr(observation, key, value) setattr(observation, key, value)
updated += 1 updated += 1
touched.append(observation)
session.commit() session.commit()
for observation in touched:
_auto_publish(session, observation)
return created, updated 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]: def _json_payload(raw: dict[str, Any]) -> dict[str, Any]:
if not isinstance(raw, dict): if not isinstance(raw, dict):
raise CommunityImportError("each observation must be an object") raise CommunityImportError("each observation must be an object")
+46 -3
View File
@@ -24,9 +24,9 @@ from .config import settings
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation
from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize
from .logging_config import configure_logging 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 .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 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]: def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]:
_spot_or_404(db, spot_id) _spot_or_404(db, spot_id)
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit))) reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), 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]) @app.get("/api/v1/records", response_model=list[OfficialRecordOut])
+1 -1
View File
@@ -145,7 +145,7 @@ class DataSource(Base):
name: Mapped[str] = mapped_column(String(100)) name: Mapped[str] = mapped_column(String(100))
base_url: Mapped[str] = mapped_column(Text) base_url: Mapped[str] = mapped_column(Text)
default_confidence: Mapped[int] default_confidence: Mapped[int]
enabled: Mapped[bool] = mapped_column(default=False) enabled: Mapped[bool] = mapped_column(default=True)
class ExternalObservation(Base): class ExternalObservation(Base):
+19
View File
@@ -47,6 +47,7 @@ class ActivityOut(BaseModel):
activity_score: int activity_score: int
confidence_score: int confidence_score: int
explanation: str explanation: str
sources: list[str]
class CatchOut(BaseModel): class CatchOut(BaseModel):
@@ -59,6 +60,8 @@ class CatchOut(BaseModel):
reported_at: datetime reported_at: datetime
retrieve_method: str | None retrieve_method: str | None
retrieve_speed: int | None retrieve_speed: int | None
source_system: str
source_url: str | None
class SpotOut(BaseModel): class SpotOut(BaseModel):
@@ -85,6 +88,22 @@ class OfficialRecordOut(BaseModel):
category: str | None category: str | None
region: str | None region: str | None
source_url: 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): class ImportRunOut(BaseModel):
+11 -1
View File
@@ -49,6 +49,7 @@ def test_activity_filters_and_explains_score() -> None:
assert payload[0]["catches"] == 3 assert payload[0]["catches"] == 3
assert payload[0]["unique_players"] == 3 assert payload[0]["unique_players"] == 3
assert "3 свежих улова" in payload[0]["explanation"] assert "3 свежих улова" in payload[0]["explanation"]
assert payload[0]["sources"] == ["manual-import"]
def test_invalid_period_is_rejected() -> None: 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 detail.json()["catches_24h"] == 3
assert catches.status_code == 200 assert catches.status_code == 200
assert len(catches.json()) == 3 assert len(catches.json()) == 3
assert catches.json()[0]["source_system"] == "manual-import"
def test_records_list_is_empty_before_import() -> None: 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"} 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: with Session(engine) as db:
stage_observations(db, [{ stage_observations(db, [{
"source_system": "rf4db", "source_external_id": "review-incomplete", "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( observation_id = db.scalar(select(ExternalObservation.id).where(
ExternalObservation.source_external_id == "review-incomplete" 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"} headers = {"Authorization": "Bearer change-me-in-production"}
mapped = client.patch( mapped = client.patch(
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers, 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"}, json={"reason": "weight is absent"},
) )
assert rejected.json()["status"] == "rejected" 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: def test_admin_can_start_and_list_official_import(monkeypatch) -> None:
+53 -5
View File
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
from app.community_importer import CommunityImportError, stage_observations from app.community_importer import CommunityImportError, stage_observations
from app.database import Base 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 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 item.last_seen_at.replace(tzinfo=timezone.utc) == second
assert db.scalar(select(func.count()).select_from(ExternalObservation)) == 1 assert db.scalar(select(func.count()).select_from(ExternalObservation)) == 1
source = db.get(DataSource, "rf4db") 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: 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) 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")]) created, updated = stage_observations(db, [record("rf4map"), record("rf4posts-spot")])
assert (created, updated) == (2, 0) assert (created, updated) == (2, 0)
assert db.get(DataSource, "rf4map").enabled is False assert db.get(DataSource, "rf4map").enabled is True
assert db.get(DataSource, "rf4posts-spot").enabled is False 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: def test_parser_json_can_be_staged_without_losing_provenance(db: Session) -> None:
+2 -1
View File
@@ -2,12 +2,13 @@
import type { Activity } from "../lib/api"; import type { Activity } from "../lib/api";
import { activityLevel, ago, kg, plural } from "../lib/api"; import { activityLevel, ago, kg, plural } from "../lib/api";
import FishingIcon from "./FishingIcon.astro"; import FishingIcon from "./FishingIcon.astro";
import SourceBadge from "./SourceBadge.astro";
const { item } = Astro.props as { item: Activity }; const { item } = Astro.props as { item: Activity };
const level = activityLevel(item.activity_score); const level = activityLevel(item.activity_score);
const limited = item.catches < 3; const limited = item.catches < 3;
--- ---
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={`/spots/${item.spot_id}`}> <a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={`/spots/${item.spot_id}`}>
<span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span> <span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
<div class="spot-main"><div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill" 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> <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> </a>
+16
View File
@@ -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>}
+4 -3
View File
@@ -3,13 +3,14 @@ export type Activity = {
fish: string; x: number; y: number; best_bait: string | null; catches: number; fish: string; x: number; y: number; best_bait: string | null; catches: number;
unique_players: number; average_weight_g: number; max_weight_g: number; unique_players: number; average_weight_g: number; max_weight_g: number;
last_confirmed_at: string; activity_score: number; confidence_score: 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 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 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 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"; export { activityLevel, ago, kg, plural } from "./presentation";
+6 -4
View File
@@ -2,18 +2,19 @@
import Layout from "../layouts/Layout.astro"; import Layout from "../layouts/Layout.astro";
import ActivityCard from "../components/ActivityCard.astro"; import ActivityCard from "../components/ActivityCard.astro";
import FishingIcon from "../components/FishingIcon.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 params = Astro.url.searchParams;
const hours = params.get("hours") ?? "24"; const hours = params.get("hours") ?? "24";
const waterbody = params.get("waterbody") ?? ""; const waterbody = params.get("waterbody") ?? "";
const fish = params.get("fish") ?? ""; const fish = params.get("fish") ?? "";
const sort = params.get("sort") ?? "activity"; const sort = params.get("sort") ?? "activity";
let items: Activity[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = []; let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
const filterError = !["6", "12", "24", "72"].includes(hours) || !["activity", "confidence", "freshness"].includes(sort); const filterError = !["6", "12", "24", "72"].includes(hours) || !["activity", "confidence", "freshness"].includes(sort);
let unavailable = false; let unavailable = false;
try { 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) { if (!filterError) {
const query = new URLSearchParams({ hours, waterbody, fish, sort }); const query = new URLSearchParams({ hours, waterbody, fish, sort });
items = await api<Activity[]>(`/api/v1/activity?${query}`); items = await api<Activity[]>(`/api/v1/activity?${query}`);
@@ -39,8 +40,9 @@ const filtersChanged = Boolean(waterbody || fish || hours !== "24" || sort !== "
</form></section> </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> <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> <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> </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> <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> <script>
const advancedFilters = document.querySelector<HTMLDetailsElement>(".filter-advanced"); const advancedFilters = document.querySelector<HTMLDetailsElement>(".filter-advanced");
+2 -1
View File
@@ -1,5 +1,6 @@
--- ---
import Layout from "../layouts/Layout.astro"; import Layout from "../layouts/Layout.astro";
import SourceBadge from "../components/SourceBadge.astro";
import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord } from "../lib/api"; import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord } from "../lib/api";
const params = Astro.url.searchParams; const params = Astro.url.searchParams;
const fish = params.get("fish") ?? ""; 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> <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> <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> <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> <p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p>
</Layout> </Layout>
+2 -1
View File
@@ -1,5 +1,6 @@
--- ---
import Layout from "../../layouts/Layout.astro"; 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"; import { activityLevel, api, kg, plural, type Activity, type Catch, type Spot } from "../../lib/api";
const { id } = Astro.params; const { id } = Astro.params;
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false; 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> : <> {unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><div class="pin">{spot.x}<span>:</span>{spot.y}</div></section> <section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><div class="pin">{spot.x}<span>:</span>{spot.y}</div></section>
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div> <div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
<section class="detail-grid"><div><div class="section-heading"><h2>Последние уловы</h2></div>{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> </Layout>
+6
View File
@@ -28,3 +28,9 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
/* WCAG AA secondary text on light surfaces */ /* 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} .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} .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("Координата Y *").fill(String(y));
await page.getByLabel("Вес, граммы *").fill("4321"); await page.getByLabel("Вес, граммы *").fill("4321");
await page.getByLabel(/Я отправляю собственное наблюдение/).check(); await page.getByLabel(/Я отправляю собственное наблюдение/).check();
await page.getByText("Дополнительные сведения").click();
await page.getByLabel("Ник игрока").fill(player); await page.getByLabel("Ник игрока").fill(player);
await page.getByRole("button", { name: "Отправить на проверку" }).click(); await page.getByRole("button", { name: "Отправить на проверку" }).click();
await expect(page.getByText("Улов отправлен на модерацию. Спасибо!")).toBeVisible(); await expect(page.getByText("Улов отправлен на модерацию. Спасибо!")).toBeVisible();
+3
View File
@@ -6,11 +6,13 @@ test("player can open an active spot", async ({ page }) => {
const activeSpots = page.locator(".spot-card[data-testid]"); const activeSpots = page.locator(".spot-card[data-testid]");
await expect(activeSpots).not.toHaveCount(0); await expect(activeSpots).not.toHaveCount(0);
const activeSpot = activeSpots.first(); 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"); 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 expect(page.locator(".detail-score [data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
await activeSpot.click(); await activeSpot.click();
await expect(page.getByRole("heading", { name: /^Точка / })).toBeVisible(); await expect(page.getByRole("heading", { name: /^Точка / })).toBeVisible();
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 ?? ""); 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("Координата X *").fill(String(x));
await page.getByLabel("Координата Y *").fill(String(y)); await page.getByLabel("Координата Y *").fill(String(y));
await page.getByLabel("Вес, граммы *").fill("6789"); await page.getByLabel("Вес, граммы *").fill("6789");
await page.getByText("Дополнительные сведения").click();
await page.getByLabel("Ник игрока").fill(player); await page.getByLabel("Ник игрока").fill(player);
await page.getByLabel(/Я отправляю собственное наблюдение/).check(); await page.getByLabel(/Я отправляю собственное наблюдение/).check();
await page.getByRole("button", { name: "Отправить на проверку" }).click(); await page.getByRole("button", { name: "Отправить на проверку" }).click();
+1 -1
View File
@@ -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 "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 '^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' 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')") 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 "$index_count" = "8"
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from fish')" = "2" test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from fish')" = "2"
+2
View File
@@ -65,6 +65,8 @@
- [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками. - [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками.
- [x] Проверить списочные API по требованию раздела 12: все выдачи имеют ограниченные `limit`/`offset`, детерминированный tie-breaker и типизированные фильтры; фильтр категории рекордов перенесён до пагинации. - [x] Проверить списочные API по требованию раздела 12: все выдачи имеют ограниченные `limit`/`offset`, детерминированный tie-breaker и типизированные фильтры; фильтр категории рекордов перенесён до пагинации.
- [x] Добавить составные индексы PostgreSQL для activity, модерации, официальных рекордов, staging, импорта, аудита и очистки rate limit (миграция `0011`); бюджет и процедура проверки планов зафиксированы в `docs/query-performance.md`. - [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] Провести 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] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно.
- [x] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавлены настраиваемая dry-run-first очистка, тест и `docs/data-retention.md`. - [x] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавлены настраиваемая dry-run-first очистка, тест и `docs/data-retention.md`.
+5 -5
View File
@@ -64,14 +64,14 @@ Detail-страница дополнительно содержит ветер,
- тесты всех извлекаемых полей, locked-координат и отказа на постороннем HTML; - тесты всех извлекаемых полей, 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. Сохранить подтверждение разрешения и согласованные лимиты запросов. 1. Сохранить подтверждение разрешения и согласованные лимиты запросов.
2. Расширять добавленные алиасы рыб и водоёмов по мере ручной проверки новых значений. 2. Расширять добавленные алиасы рыб и водоёмов по мере ручной проверки новых значений.
3. Согласовать начальные уровни доверия для RF4DB и двух каналов RF4-STAT (в staging записаны консервативные значения 70/65/60, но источники выключены). 3. При необходимости пересмотреть консервативные начальные уровни доверия 70/65/60/55/50.
4. Проверить правила публикации на небольшой вручную подтверждённой выборке. 4. Расширять контрольную выборку автопубликации по мере появления полных наблюдений.
5. Не хранить и не проксировать изображения без отдельного условия разрешения; на первом этапе достаточно исходной ссылки. 5. Не хранить и не проксировать изображения без отдельного условия разрешения; на первом этапе достаточно исходной ссылки.
## Staging ## Staging
@@ -80,6 +80,6 @@ RF4MAP и RF4 Posts остаются исследовательскими: на
Загрузка принимает только известные источники, HTTPS-ссылки соответствующего домена и значения в допустимых диапазонах. Все записи получают статус `staged`; таблица не связана с расчётом активности. Тест доказывает путь `HTML → ExternalCatch → JSON → external_observation` без потери provenance. Загрузка принимает только известные источники, 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 неполных записей не опубликованы.
+5 -3
View File
@@ -9,8 +9,9 @@
- Разрешённый технический контур: получение публичного HTML, извлечение текстовых сведений об уловах, хранение исходной ссылки и нормализованных полей в закрытом staging. - Разрешённый технический контур: получение публичного HTML, извлечение текстовых сведений об уловах, хранение исходной ссылки и нормализованных полей в закрытом staging.
- Изображения: не скачиваются, не проксируются и не архивируются без отдельного явно зафиксированного условия. - Изображения: не скачиваются, не проксируются и не архивируются без отдельного явно зафиксированного условия.
- Атрибуция: опубликованный улов обязан сохранять `source_system`, внешний ID и прямой `source_url` в provenance. - Атрибуция: опубликованный улов обязан сохранять `source_system`, внешний ID и прямой `source_url` в provenance.
- Публичное отображение: каждая запись и агрегат обязаны показывать источник; неполные staging-наблюдения разрешено отображать сразу с прямой ссылкой, явной пометкой неполноты и перечнем отсутствующих полей, но без влияния на индекс активности.
- Объединение источников: автоматическое склеивание RF4DB и RF4-STAT запрещено, пока нет надёжного общего идентификатора. - Объединение источников: автоматическое склеивание RF4DB и RF4-STAT запрещено, пока нет надёжного общего идентификатора.
- Публикация: только вручную, после канонического сопоставления; обязательны рыба, водоём, координаты и вес. - Публикация: автоматически по ранее вручную подтверждённым алиасам источника либо вручную; обязательны рыба, водоём, координаты и вес.
## RF4MAP и RF4 Posts ## RF4MAP и RF4 Posts
@@ -19,7 +20,8 @@
- Частота: не чаще одного получения страницы каждого источника раз в 30 минут. - Частота: не чаще одного получения страницы каждого источника раз в 30 минут.
- Разрешённый контур: публичные HTML-страницы, нормализация в `ExternalCatch` и закрытый staging; запрещённые в `robots.txt` API не запрашиваются. - Разрешённый контур: публичные HTML-страницы, нормализация в `ExternalCatch` и закрытый staging; запрещённые в `robots.txt` API не запрашиваются.
- Изображения: сохраняются только исходные URL доказательств; файлы не скачиваются и не проксируются. - Изображения: сохраняются только исходные URL доказательств; файлы не скачиваются и не проксируются.
- Публикация: только вручную и только при выполнении общего требования рыба + водоём + координаты + вес. Текущие записи RF4MAP/RF4 Posts не имеют веса и остаются в staging. - Атрибуция и неполнота: на каждой публичной карточке показываются источник и ссылка; неполные наблюдения могут появляться сразу, если визуально отделены от подтверждённых данных и не участвуют в расчёте активности.
- Публикация: автоматически по подтверждённым алиасам либо вручную и только при выполнении общего требования рыба + водоём + координаты + вес. Текущие записи RF4MAP/RF4 Posts не имеют веса и остаются в staging.
- Семантика RF4 Posts: несколько видов одного поста остаются связанными с одним UUID точки и не считаются отдельными подтверждёнными взвешиваниями. - Семантика RF4 Posts: несколько видов одного поста остаются связанными с одним UUID точки и не считаются отдельными подтверждёнными взвешиваниями.
## Лимиты запросов ## Лимиты запросов
@@ -30,7 +32,7 @@
- RF4DB: один запрос списка за ручной запуск; detail-страницы запрашиваются только адресно, без массового обхода. - RF4DB: один запрос списка за ручной запуск; detail-страницы запрашиваются только адресно, без массового обхода.
- RF4MAP: не чаще одного запроса раз в 30 минут. - RF4MAP: не чаще одного запроса раз в 30 минут.
- RF4 Posts: не чаще одного запроса раз в 30 минут. - RF4 Posts: не чаще одного запроса раз в 30 минут.
- регулярные scheduler-задачи для обоих источников выключены; - все пять разрешённых community-адаптеров включены в реестре staging; регулярные scheduler-задачи остаются выключены;
- при `429`, `403`, изменении DOM или неожиданном типе ответа сбор прекращается без попытки обхода ограничения. - при `429`, `403`, изменении DOM или неожиданном типе ответа сбор прекращается без попытки обхода ограничения.
## Что ещё требуется зафиксировать ## Что ещё требуется зафиксировать
+2 -1
View File
@@ -14,7 +14,8 @@ RF4 Spotter обрабатывает официальные публичные
- IP не хранится: rate limit использует HMAC-отпечаток с секретом сервера; - IP не хранится: rate limit использует HMAC-отпечаток с секретом сервера;
- из изображений удаляются EXIF и прочие метаданные; - из изображений удаляются EXIF и прочие метаданные;
- query string, authorization headers и пользовательские payload не попадают в HTTP-лог; - query string, authorization headers и пользовательские payload не попадают в HTTP-лог;
- community-наблюдения сначала поступают в изолированный staging и публикуются только после ручной проверки. - community-наблюдения сначала поступают в staging; неполные можно сразу видеть в отдельной публичной ленте с источником и перечнем отсутствующих полей, но они не влияют на индекс активности. Полные записи автоматически входят в основной индекс только по ранее вручную подтверждённым алиасам источника, остальные требуют ручной проверки.
- любая карточка улова, рекорда или агрегированной активности показывает первоисточник либо полный список вошедших в агрегат источников.
## Источники и атрибуция ## Источники и атрибуция