Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51b3eb5e17 | ||
|
|
38bb871638 | ||
|
|
0b3a5ece4b | ||
|
|
fc878c81d4 | ||
|
|
ff0bc08222 | ||
|
|
d962ba2f90 | ||
|
|
a37f9c4696 | ||
|
|
cefb4494a5 | ||
|
|
9ae05ef6f0 | ||
|
|
b31042337b | ||
|
|
63e33e1861 |
@@ -6,6 +6,8 @@ RF4 Spotter — неофициальный сервис свежих точек
|
||||
|
||||
## Статус разработки
|
||||
|
||||
**Повторная приёмка 9 сентября 2026 (`9ae05ef`): к деплою пока не готов.** Найдены регрессии после последних исправлений: невалидный Caddyfile, несовместимость activity API с detail-страницами, поломка research cooldown CLI и незавершённые UI/SEO/защита интервалов. Python: **10 failed, 86 passed, 1 skipped**; Astro check/build и web unit проходят, но не покрывают эти сценарии. [Отчёт с доказательствами](docs/REGRESSION_AUDIT_2026-09-09.md), [план R01–R15](docs/ROADMAP.md#повторная-приёмка-9-сентября-2026). Следующие задачи — R01 и R02. До R05 не запускать текущий production bootstrap: он наследует реальные источники и публичные порты. Ниже описаны реализованные возможности; прежние успешные проверки не означают приёмку текущей ревизии.
|
||||
|
||||
Web Docker-образ устанавливает зависимости через `npm ci` по lock-файлу и удаляет devDependencies после сборки. Локальные `.env` исключены из web build context.
|
||||
|
||||
Очередь внешних наблюдений выбирает только ожидающие проверки записи на сервере и показывает их страницами по 50. Обработанные записи не скрывают более старые необработанные наблюдения.
|
||||
@@ -18,7 +20,7 @@ Web Docker-образ устанавливает зависимости чере
|
||||
|
||||
Идёт исправление аудита: актуальные изменения и ограничения перечислены в [AUDIT_FIXES.md](docs/AUDIT_FIXES.md). Production Compose включает community scheduler; страницы rules/privacy реализованы. Для запуска остаются сервер, DNS/TLS, секреты, внешний backup и контакты. Шкала 72 часов использует полную выборку по времени поступления; одинаковые поля разных источников больше не считаются доказательством одного события. Фоновая публикация обновляет кэш API в пределах TTL, не мгновенно.
|
||||
|
||||
Полный аудит 8 сентября: [отчёт](docs/PROJECT_AUDIT_2026-09-08.md), [приоритетный план](docs/ROADMAP.md#ближайший-рабочий-пакет). T01/T02 выполнены: исправлены маршруты формы и конфликт admin-аутентификации. Проверка `sh deploy/test-proxy-routing.sh` на запущенном локальном Compose проверяет Astro redirects, API, Basic-доступ к страницам и Bearer-доступ к API, включая отказы. Следующий блокер — исходящая сеть community scheduler (T03). Затем — клиентский rate limit, надёжность парсеров, согласованность фильтров/оценок и HTTP/SEO-контракт ошибок.
|
||||
Предыдущий полный аудит 8 сентября: [отчёт](docs/PROJECT_AUDIT_2026-09-08.md). T01/T02 исправили маршруты формы и конфликт admin-аутентификации; `sh deploy/test-proxy-routing.sh` проверял Astro redirects, API, Basic/Bearer и отказы на прежней ревизии. На текущей ревизии повторный запуск блокирует R01. Подключение scheduler к исходящей сети добавлено, но интеграционная приёмка T03 ещё нужна. Актуальная последовательность работ находится в [плане повторной приёмки](docs/ROADMAP.md#повторная-приёмка-9-сентября-2026).
|
||||
|
||||
На ширинах 320, 390, 768 и 1280 px ранее проверено отсутствие горизонтального переполнения основных страниц. Это не полная визуальная приёмка: аудит обнаружил неверную desktop-компоновку фильтров; наполненные карточки, длинные названия, клавиатура и zoom остаются отдельной задачей.
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ def activity_rows(
|
||||
activity = round(55 * min(1, weighted / 12) + 25 * min(1, len(players) / 6) + 20 * min(1, trophies / 3))
|
||||
average_confidence = sum(r.source_confidence for r in items) / len(items)
|
||||
confidence = round(45 * min(1, len(items) / 10) + 35 * min(1, len(players) / 5) + 20 * average_confidence / 100)
|
||||
latest = max(_aware(r.caught_at or r.reported_at) for r in items)
|
||||
latest = max(_aware(r.reported_at) for r in items)
|
||||
baits = Counter(r.bait.name for r in items if r.bait)
|
||||
freshness_text = _freshness_text(now - latest)
|
||||
result.append(ActivityOut(
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .community_review import publish_observation
|
||||
from .models import DataSource, ExternalEntityAlias, ExternalObservation, ModerationStatus
|
||||
from .models import DataSource, ExternalEntityAlias, ExternalObservation, ModerationStatus, Waterbody
|
||||
|
||||
|
||||
SOURCE_DEFAULTS = {
|
||||
@@ -107,7 +107,6 @@ def _auto_publish(session: Session, observation: ExternalObservation) -> bool:
|
||||
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
|
||||
@@ -118,15 +117,27 @@ def _auto_publish(session: Session, observation: ExternalObservation) -> bool:
|
||||
ExternalEntityAlias.entity_type == "fish",
|
||||
ExternalEntityAlias.external_id == observation.fish_external_id,
|
||||
))
|
||||
if fish_alias is None or fish_alias.fish is None:
|
||||
return False
|
||||
# Waterbody: prefer external alias, fall back to exact name match
|
||||
waterbody = None
|
||||
if observation.waterbody_external_id is not None:
|
||||
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:
|
||||
if waterbody_alias and waterbody_alias.waterbody:
|
||||
waterbody = waterbody_alias.waterbody
|
||||
if waterbody is None:
|
||||
# Fallback: exact name match
|
||||
waterbody = session.scalar(
|
||||
select(Waterbody).where(Waterbody.name_ru == observation.waterbody_name)
|
||||
)
|
||||
if waterbody is None:
|
||||
return False
|
||||
observation.fish = fish_alias.fish
|
||||
observation.waterbody = waterbody_alias.waterbody
|
||||
observation.waterbody = waterbody
|
||||
observation.status = "ready"
|
||||
observation.review_note = "Automatically matched by previously reviewed source aliases"
|
||||
publish_observation(session, observation)
|
||||
|
||||
@@ -26,7 +26,9 @@ def retry_delay(statuses: list[str]) -> int:
|
||||
failures += 1
|
||||
return min(settings.community_import_interval_seconds * (2 ** max(0, failures - 1)), MAX_BACKOFF_SECONDS)
|
||||
|
||||
def configured_sources():
|
||||
|
||||
def _static_registry() -> dict[str, tuple[str, callable]]:
|
||||
"""Return the full static registry without DB access (for unit tests)."""
|
||||
return {
|
||||
"rf4db": SOURCES["rf4db"],
|
||||
"rf4stat-fishing": SOURCES["rf4stat-fishing"],
|
||||
@@ -35,18 +37,47 @@ def configured_sources():
|
||||
"rf4posts-spot": (settings.rf4posts_spot_url, parse_rf4posts_spot),
|
||||
}
|
||||
|
||||
def oldest_site_source(source_system: str, latest_by_source: dict[str, datetime]) -> str:
|
||||
sources = configured_sources()
|
||||
site = fetch_site_key(sources[source_system][0])
|
||||
candidates = [key for key, (url, _) in sources.items() if fetch_site_key(url) == site]
|
||||
|
||||
def configured_sources(enabled_keys: set[str] | None = None) -> dict[str, tuple[str, callable]]:
|
||||
"""Return enabled sources. When enabled_keys is None, query the DB."""
|
||||
registry = _static_registry()
|
||||
if enabled_keys is None:
|
||||
with SessionLocal() as session:
|
||||
enabled_keys = {
|
||||
s.key for s in session.scalars(select(DataSource).where(DataSource.enabled.is_(True)))
|
||||
}
|
||||
return {k: v for k, v in registry.items() if k in enabled_keys}
|
||||
|
||||
|
||||
def oldest_site_source(source_system: str, latest_by_source: dict[str, datetime], enabled_keys: set[str] | None = None) -> str:
|
||||
"""Return the oldest candidate for the same site.
|
||||
|
||||
Uses the full registry (not just enabled) for cooldown history so that
|
||||
disabling an endpoint does not reset the site-wide cooldown for its
|
||||
neighbours. enabled_keys is used to filter candidates after the oldest
|
||||
is found — if the oldest is disabled, the next oldest enabled is picked.
|
||||
"""
|
||||
registry = _static_registry()
|
||||
site = fetch_site_key(registry[source_system][0])
|
||||
candidates = [key for key, (url, _) in registry.items() if fetch_site_key(url) == site]
|
||||
order = {key: index for index, key in enumerate(candidates)}
|
||||
return min(candidates, key=lambda key: (latest_by_source.get(key, datetime.min.replace(tzinfo=timezone.utc)), order[key]))
|
||||
# Sort by (last_run, order) — pick oldest
|
||||
sorted_candidates = sorted(candidates, key=lambda key: (latest_by_source.get(key, datetime.min.replace(tzinfo=timezone.utc)), order[key]))
|
||||
# If enabled_keys is provided, prefer enabled; otherwise return oldest regardless
|
||||
if enabled_keys:
|
||||
enabled = [k for k in sorted_candidates if k in enabled_keys]
|
||||
if enabled:
|
||||
return enabled[0]
|
||||
return sorted_candidates[0]
|
||||
|
||||
def run_source(source_system: str, *, now: datetime | None = None) -> bool:
|
||||
current = now or datetime.now(timezone.utc)
|
||||
url, parser = configured_sources()[source_system]
|
||||
with SessionLocal() as session:
|
||||
enabled_keys = {s.key for s in session.scalars(select(DataSource).where(DataSource.enabled.is_(True)))}
|
||||
registry = _static_registry()
|
||||
url, parser = registry[source_system]
|
||||
site_key = fetch_site_key(url)
|
||||
site_sources = [key for key, (candidate_url, _) in configured_sources().items() if fetch_site_key(candidate_url) == site_key]
|
||||
site_sources = [key for key, (candidate_url, _) in registry.items() if fetch_site_key(candidate_url) == site_key]
|
||||
with SessionLocal() as session:
|
||||
source = session.get(DataSource, source_system)
|
||||
if source is None or not source.enabled:
|
||||
@@ -59,7 +90,7 @@ def run_source(source_system: str, *, now: datetime | None = None) -> bool:
|
||||
latest_by_source: dict[str, datetime] = {}
|
||||
for previous in recent:
|
||||
latest_by_source.setdefault(previous.source_system, previous.started_at if previous.started_at.tzinfo else previous.started_at.replace(tzinfo=timezone.utc))
|
||||
if oldest_site_source(source_system, latest_by_source) != source_system:
|
||||
if oldest_site_source(source_system, latest_by_source, enabled_keys) != source_system:
|
||||
return False
|
||||
latest = recent[0].started_at if recent else None
|
||||
delay = retry_delay([run.status for run in recent])
|
||||
|
||||
+22
-15
@@ -27,7 +27,7 @@ from .logging_config import configure_logging
|
||||
from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||
from .readiness import readiness_report
|
||||
from .public_cache import public_cache
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ImportRunPublicOut, ModerationUpdate, OfficialRecordOut, PaginatedActivityOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut
|
||||
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ def ready(db: Db) -> JSONResponse:
|
||||
is_ready, components = readiness_report(
|
||||
db, storage_client(), import_required=settings.official_import_required,
|
||||
import_interval_seconds=settings.import_interval_seconds,
|
||||
community_import_interval_seconds=settings.community_import_interval_seconds,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=200 if is_ready else 503,
|
||||
@@ -120,14 +121,14 @@ def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int
|
||||
(f"/spots/{water}-{x}x{y}", f"/waterbodies/{water}/{fish}")]
|
||||
|
||||
|
||||
@app.get("/api/v1/activity", response_model=list[ActivityOut])
|
||||
@app.get("/api/v1/activity", response_model=PaginatedActivityOut)
|
||||
def activity(
|
||||
db: Db, response: Response, hours: int = Query(24),
|
||||
waterbody: str | None = None, fish: str | None = None,
|
||||
method: str | None = None,
|
||||
sort: Literal["activity", "confidence", "freshness"] = "activity",
|
||||
limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0),
|
||||
) -> list[ActivityOut]:
|
||||
) -> PaginatedActivityOut:
|
||||
if hours not in {6, 12, 24, 72}:
|
||||
raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72")
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
@@ -138,14 +139,16 @@ def activity(
|
||||
response.headers["X-Cache"] = "HIT"
|
||||
return cached
|
||||
rows = activity_rows(db, hours=hours, waterbody=waterbody, fish=fish, method=method)
|
||||
total = len(rows)
|
||||
keys = {
|
||||
"activity": lambda r: (r.activity_score, r.confidence_score, r.last_confirmed_at, str(r.spot_id)),
|
||||
"confidence": lambda r: (r.confidence_score, r.activity_score, r.last_confirmed_at, str(r.spot_id)),
|
||||
"freshness": lambda r: (r.last_confirmed_at, r.activity_score, r.confidence_score, str(r.spot_id)),
|
||||
}
|
||||
rows.sort(key=keys[sort], reverse=True)
|
||||
page = rows[offset:offset + limit]
|
||||
response.headers["X-Cache"] = "MISS"
|
||||
return public_cache.set(cache_key, rows[offset:offset + limit], generation=generation)
|
||||
return public_cache.set(cache_key, PaginatedActivityOut(items=page, total=total, limit=limit, offset=offset), generation=generation)
|
||||
|
||||
|
||||
def _spot_or_404(db: Session, spot_id: UUID) -> Spot:
|
||||
@@ -218,19 +221,18 @@ def _report_source(report: CatchReport) -> str:
|
||||
@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),
|
||||
waterbody: str | None = None, fish: str | None = None,
|
||||
) -> list[PublicObservationOut]:
|
||||
items = list(db.scalars(
|
||||
select(ExternalObservation)
|
||||
.join(ExternalObservation.source)
|
||||
.options(joinedload(ExternalObservation.source))
|
||||
.where(
|
||||
query = 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)
|
||||
))
|
||||
if waterbody:
|
||||
query = query.join(ExternalObservation.waterbody).where(Waterbody.slug == waterbody)
|
||||
if fish:
|
||||
query = query.join(ExternalObservation.fish).where(Fish.slug == fish)
|
||||
items = list(db.scalars(query.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc()).offset(offset).limit(limit)))
|
||||
result: list[PublicObservationOut] = []
|
||||
for item in items:
|
||||
missing = []
|
||||
@@ -318,7 +320,7 @@ def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONRespons
|
||||
return JSONResponse(payload, headers={"Content-Disposition": "attachment; filename=rf4spotter-diagnostics.json"})
|
||||
|
||||
|
||||
@app.get("/api/v1/imports", response_model=list[ImportRunOut])
|
||||
@app.get("/api/v1/imports", response_model=list[ImportRunPublicOut])
|
||||
def imports(db: Db, limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[OfficialRecordImport]:
|
||||
return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc()).offset(offset).limit(limit)))
|
||||
|
||||
@@ -448,7 +450,7 @@ def admin_reject_external_observation(
|
||||
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportAccepted:
|
||||
if payload.website:
|
||||
raise HTTPException(status_code=400, detail="invalid submission")
|
||||
_check_rate_limit(request.client.host if request.client else "unknown", db)
|
||||
_check_rate_limit(request, db)
|
||||
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
|
||||
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug))
|
||||
if fish is None or waterbody is None:
|
||||
@@ -536,9 +538,14 @@ def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_ad
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
def _check_rate_limit(client: str, db: Session) -> None:
|
||||
def _check_rate_limit(request: Request, db: Session) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = now - timedelta(minutes=10)
|
||||
# Extract real client IP from forwarded headers
|
||||
client = request.client.host if request.client else "unknown"
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
client = forwarded.split(",")[0].strip()
|
||||
client_hash = hmac.new(settings.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest()
|
||||
if db.get_bind().dialect.name == "postgresql":
|
||||
lock_key = int(client_hash[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
|
||||
|
||||
@@ -6,12 +6,13 @@ from typing import Any
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import ImportStatus, OfficialRecordImport
|
||||
from .models import CommunityImportRun, ImportStatus, OfficialRecordImport
|
||||
|
||||
|
||||
def readiness_report(
|
||||
session: Session, s3: Any, *, import_required: bool,
|
||||
import_interval_seconds: int, now: datetime | None = None,
|
||||
import_interval_seconds: int, community_import_interval_seconds: int = 1800,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[bool, dict[str, dict[str, object]]]:
|
||||
current = now or datetime.now(timezone.utc)
|
||||
components: dict[str, dict[str, object]] = {}
|
||||
@@ -58,4 +59,26 @@ def readiness_report(
|
||||
if import_required:
|
||||
ready = False
|
||||
|
||||
# Check community scheduler: look for recent import runs
|
||||
try:
|
||||
latest_community = session.scalar(
|
||||
select(CommunityImportRun)
|
||||
.order_by(CommunityImportRun.started_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if latest_community is None:
|
||||
components["community_scheduler"] = {"status": "not_started"}
|
||||
else:
|
||||
started = latest_community.started_at
|
||||
if started.tzinfo is None:
|
||||
started = started.replace(tzinfo=timezone.utc)
|
||||
stale = started < current - timedelta(seconds=community_import_interval_seconds * 2)
|
||||
healthy = latest_community.status == "success" and not stale
|
||||
components["community_scheduler"] = {
|
||||
"status": "ready" if healthy else ("stale" if stale else latest_community.status),
|
||||
"last_started_at": started.isoformat(),
|
||||
}
|
||||
except Exception:
|
||||
components["community_scheduler"] = {"status": "unknown"}
|
||||
|
||||
return ready, components
|
||||
|
||||
@@ -50,6 +50,13 @@ class ActivityOut(BaseModel):
|
||||
sources: list[str]
|
||||
|
||||
|
||||
class PaginatedActivityOut(BaseModel):
|
||||
items: list[ActivityOut]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class CatchOut(BaseModel):
|
||||
id: UUID
|
||||
fish: str
|
||||
@@ -125,6 +132,18 @@ class ImportRunOut(BaseModel):
|
||||
not_modified: bool
|
||||
|
||||
|
||||
class ImportRunPublicOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
started_at: datetime
|
||||
finished_at: datetime | None
|
||||
status: str
|
||||
rows_seen: int
|
||||
rows_created: int
|
||||
rows_updated: int
|
||||
not_modified: bool
|
||||
|
||||
|
||||
class CatchReportCreate(BaseModel):
|
||||
fish_slug: str
|
||||
waterbody_slug: str
|
||||
|
||||
@@ -45,11 +45,15 @@ def test_activity_filters_and_explains_score() -> None:
|
||||
response = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert len(payload) == 1
|
||||
assert payload[0]["catches"] == 3
|
||||
assert payload[0]["unique_players"] == 3
|
||||
assert "3 свежих улова" in payload[0]["explanation"]
|
||||
assert payload[0]["sources"] == ["manual-import"]
|
||||
assert "items" in payload
|
||||
assert payload["total"] == 1
|
||||
assert payload["limit"] == 20
|
||||
assert payload["offset"] == 0
|
||||
assert len(payload["items"]) == 1
|
||||
assert payload["items"][0]["catches"] == 3
|
||||
assert payload["items"][0]["unique_players"] == 3
|
||||
assert "3 свежих улова" in payload["items"][0]["explanation"]
|
||||
assert payload["items"][0]["sources"] == ["manual-import"]
|
||||
|
||||
|
||||
def test_invalid_period_is_rejected() -> None:
|
||||
@@ -157,7 +161,7 @@ def test_admin_diagnostics_exposes_build_identity_only_to_admin() -> None:
|
||||
|
||||
|
||||
def test_spot_detail_and_catches() -> None:
|
||||
spot_id = client.get("/api/v1/activity").json()[0]["spot_id"]
|
||||
spot_id = client.get("/api/v1/activity").json()["items"][0]["spot_id"]
|
||||
detail = client.get(f"/api/v1/spots/{spot_id}")
|
||||
catches = client.get(f"/api/v1/spots/{spot_id}/catches")
|
||||
assert detail.status_code == 200
|
||||
@@ -206,7 +210,7 @@ def test_user_report_requires_moderation_before_activity() -> None:
|
||||
approved = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "approved", "reason": "fixture verified"})
|
||||
assert approved.status_code == 200
|
||||
activity = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24").json()
|
||||
assert any(item["x"] == 77 and item["catches"] == 1 for item in activity)
|
||||
assert any(item["x"] == 77 and item["catches"] == 1 for item in activity["items"])
|
||||
|
||||
|
||||
def test_admin_requires_token() -> None:
|
||||
@@ -267,7 +271,7 @@ def test_incomplete_external_observation_is_publicly_labelled_but_not_counted()
|
||||
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())
|
||||
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()["items"])
|
||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||
mapped = client.patch(
|
||||
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
|
||||
|
||||
@@ -3,12 +3,12 @@ from pydantic import ValidationError
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, oldest_site_source, retry_delay
|
||||
from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, _static_registry, oldest_site_source, retry_delay
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def test_all_authorized_sources_are_scheduled() -> None:
|
||||
assert set(configured_sources()) == {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
|
||||
assert set(_static_registry()) == {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
|
||||
|
||||
|
||||
def test_community_interval_cannot_be_less_than_30_minutes() -> None:
|
||||
@@ -25,6 +25,7 @@ def test_failed_runs_back_off_but_success_resets_delay() -> None:
|
||||
|
||||
def test_same_site_endpoints_rotate_by_oldest_attempt() -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
assert oldest_site_source("rf4stat-fishing", {}) == "rf4stat-fishing"
|
||||
all_keys = {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
|
||||
assert oldest_site_source("rf4stat-fishing", {}, all_keys) == "rf4stat-fishing"
|
||||
latest = {"rf4stat-fishing": now, "rf4stat-post": now - timedelta(hours=1)}
|
||||
assert oldest_site_source("rf4stat-fishing", latest) == "rf4stat-post"
|
||||
assert oldest_site_source("rf4stat-fishing", latest, all_keys) == "rf4stat-post"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
@@ -17,11 +18,34 @@ def test_rate_limit_is_persistent_and_does_not_store_raw_client() -> None:
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
for _ in range(5):
|
||||
_check_rate_limit("203.0.113.42", db)
|
||||
mock_request = MagicMock()
|
||||
mock_request.client.host = "203.0.113.42"
|
||||
mock_request.headers.get.return_value = None
|
||||
_check_rate_limit(mock_request, db)
|
||||
with pytest.raises(HTTPException) as blocked:
|
||||
_check_rate_limit("203.0.113.42", db)
|
||||
mock_request = MagicMock()
|
||||
mock_request.client.host = "203.0.113.42"
|
||||
mock_request.headers.get.return_value = None
|
||||
_check_rate_limit(mock_request, db)
|
||||
assert blocked.value.status_code == 429
|
||||
attempts = list(db.scalars(select(SubmissionAttempt)))
|
||||
assert len(attempts) == 5
|
||||
assert all(item.client_hash != "203.0.113.42" and len(item.client_hash) == 64 for item in attempts)
|
||||
assert all(item.created_at.replace(tzinfo=timezone.utc) <= datetime.now(timezone.utc) for item in attempts)
|
||||
|
||||
|
||||
def test_rate_limit_uses_forwarded_for_header() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
mock_real = MagicMock()
|
||||
mock_real.client.host = "10.0.0.1"
|
||||
mock_real.headers.get.return_value = "198.51.100.10"
|
||||
for _ in range(5):
|
||||
_check_rate_limit(mock_real, db)
|
||||
with pytest.raises(HTTPException) as blocked:
|
||||
mock_other = MagicMock()
|
||||
mock_other.client.host = "10.0.0.2"
|
||||
mock_other.headers.get.return_value = "198.51.100.10"
|
||||
_check_rate_limit(mock_other, db)
|
||||
assert blocked.value.status_code == 429
|
||||
|
||||
@@ -28,11 +28,11 @@ def test_optional_import_does_not_block_dependencies() -> None:
|
||||
session, AvailableStorage(), import_required=False, import_interval_seconds=3600,
|
||||
)
|
||||
assert ready is True
|
||||
assert components == {
|
||||
"postgresql": {"status": "ready"},
|
||||
"minio": {"status": "ready"},
|
||||
"official_import": {"status": "optional", "last_run_status": None},
|
||||
}
|
||||
assert components["postgresql"]["status"] == "ready"
|
||||
assert components["minio"]["status"] == "ready"
|
||||
assert components["official_import"]["status"] == "optional"
|
||||
assert components["official_import"]["last_run_status"] is None
|
||||
assert "community_scheduler" in components
|
||||
|
||||
|
||||
def test_required_import_must_be_recent_and_successful() -> None:
|
||||
|
||||
@@ -6,6 +6,13 @@ export type Activity = {
|
||||
explanation: string; sources: string[];
|
||||
};
|
||||
|
||||
export type PaginatedActivity = {
|
||||
items: Activity[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
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; source_system: string; source_url: string | null };
|
||||
export type DictionaryItem = { id: string; slug: string; name_ru: string };
|
||||
|
||||
@@ -11,7 +11,7 @@ export const POST: APIRoute = async ({ request, redirect, cookies }) => {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(reportId) || !uploadToken || !(screenshot instanceof File) || screenshot.size === 0) return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303);
|
||||
try {
|
||||
const upload = new FormData(); upload.set("screenshot", screenshot);
|
||||
const response = await fetch(`${base}/api/v1/catch-reports/${reportId}/screenshot`, { method: "POST", headers:{"X-Upload-Token":uploadToken}, body: upload });
|
||||
const response = await fetch(`${base}/api/v1/catch-reports/${reportId}/screenshot`, { method: "POST", headers:{"X-Upload-Token":uploadToken}, body: upload, signal: AbortSignal.timeout(60_000) });
|
||||
if (response.ok) cookies.delete(cookieName, {path:"/"});
|
||||
return redirect(response.ok ? "/report?state=screenshot_sent" : `/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303);
|
||||
} catch { return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303); }
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import type { APIRoute } from "astro";
|
||||
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
|
||||
export const POST: APIRoute = async ({ request, redirect, cookies }) => {
|
||||
const form = await request.formData();
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await request.formData();
|
||||
} catch {
|
||||
return redirect("/report?state=create_error", 303);
|
||||
}
|
||||
const text = (name: string) => String(form.get(name) || "").trim() || null;
|
||||
const number = (name: string) => text(name) ? Number(text(name)) : null;
|
||||
const payload = { fish_slug: String(form.get("fish_slug") || ""), waterbody_slug: String(form.get("waterbody_slug") || ""), x: Number(form.get("x")), y: Number(form.get("y")), weight_g: Number(form.get("weight_g")), bait_name: text("bait_name"), fishing_method: text("fishing_method"), retrieve_method: text("retrieve_method"), retrieve_speed: number("retrieve_speed"), player_name: text("player_name"), comment: text("comment"), website: String(form.get("website") || "") };
|
||||
let createdId: string | null = null;
|
||||
let uploadToken: string | null = null;
|
||||
try {
|
||||
const response = await fetch(`${base}/api/v1/catch-reports`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
||||
if (!response.ok) return redirect("/report?state=create_error", 303);
|
||||
const response = await fetch(`${base}/api/v1/catch-reports`, { method: "POST", headers: { "content-type": "application/json", "X-Forwarded-For": request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "unknown" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(30_000) });
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) return redirect("/report?state=rate_limited", 303);
|
||||
if (response.status >= 500) return redirect("/report?state=server_error", 303);
|
||||
return redirect("/report?state=create_error", 303);
|
||||
}
|
||||
const created = await response.json() as { id?: unknown; screenshot_upload_token?: unknown };
|
||||
if (typeof created.id !== "string" || typeof created.screenshot_upload_token !== "string") return redirect("/report?state=create_error", 303);
|
||||
createdId = created.id;
|
||||
@@ -17,7 +26,7 @@ export const POST: APIRoute = async ({ request, redirect, cookies }) => {
|
||||
const screenshot = form.get("screenshot");
|
||||
if (screenshot instanceof File && screenshot.size > 0) {
|
||||
const upload = new FormData(); upload.set("screenshot", screenshot);
|
||||
const uploaded = await fetch(`${base}/api/v1/catch-reports/${created.id}/screenshot`, { method: "POST", headers: {"X-Upload-Token": created.screenshot_upload_token}, body: upload });
|
||||
const uploaded = await fetch(`${base}/api/v1/catch-reports/${created.id}/screenshot`, { method: "POST", headers: {"X-Upload-Token": created.screenshot_upload_token}, body: upload, signal: AbortSignal.timeout(60_000) });
|
||||
if (!uploaded.ok) {
|
||||
cookies.set(`rf4-upload-${created.id}`, created.screenshot_upload_token, {httpOnly:true, sameSite:"strict", secure:import.meta.env.PROD, path:"/", maxAge:3600});
|
||||
return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(created.id)}`, 303);
|
||||
@@ -25,7 +34,11 @@ export const POST: APIRoute = async ({ request, redirect, cookies }) => {
|
||||
}
|
||||
return redirect("/report?state=sent", 303);
|
||||
}
|
||||
catch {
|
||||
catch (err) {
|
||||
if (err instanceof TypeError && err.message.includes("abort")) {
|
||||
if (createdId && uploadToken) cookies.set(`rf4-upload-${createdId}`, uploadToken, {httpOnly:true, sameSite:"strict", secure:import.meta.env.PROD, path:"/", maxAge:3600});
|
||||
return redirect(createdId ? `/report?state=screenshot_error&report_id=${encodeURIComponent(createdId)}` : "/report?state=timeout", 303);
|
||||
}
|
||||
if (createdId && uploadToken) cookies.set(`rf4-upload-${createdId}`, uploadToken, {httpOnly:true, sameSite:"strict", secure:import.meta.env.PROD, path:"/", maxAge:3600});
|
||||
return redirect(createdId ? `/report?state=screenshot_error&report_id=${encodeURIComponent(createdId)}` : "/report?state=create_error", 303);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
import ActivityCard from "../../components/ActivityCard.astro";
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { api, plural, type Activity, type DictionaryItem } from "../../lib/api";
|
||||
import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../lib/api";
|
||||
const { slug } = Astro.params;
|
||||
let fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
||||
try {
|
||||
const fishes = await api<DictionaryItem[]>("/api/v1/fishes?limit=500");
|
||||
fish = fishes.find(item => item.slug === slug);
|
||||
if (fish) items = await api<Activity[]>(`/api/v1/activity?hours=72&fish=${encodeURIComponent(fish.slug)}&limit=100`);
|
||||
if (fish) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&fish=${encodeURIComponent(fish.slug)}&limit=100`); items = paginated.items; }
|
||||
} catch { unavailable = true; }
|
||||
if (unavailable) {
|
||||
Astro.response.status = 503;
|
||||
|
||||
@@ -4,7 +4,7 @@ import ActivityCard from "../components/ActivityCard.astro";
|
||||
import FishingIcon from "../components/FishingIcon.astro";
|
||||
import SourceBadge from "../components/SourceBadge.astro";
|
||||
import SignalFeed from "../components/SignalFeed.astro";
|
||||
import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PublicObservation } from "../lib/api";
|
||||
import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PaginatedActivity, type PublicObservation } from "../lib/api";
|
||||
|
||||
const params = Astro.url.searchParams;
|
||||
const hours = params.get("hours") ?? "24";
|
||||
@@ -14,17 +14,24 @@ const sort = params.get("sort") ?? "activity";
|
||||
const requestedSignalLimit = Number(params.get("signals") ?? 12);
|
||||
const signalLimit = Number.isInteger(requestedSignalLimit) ? Math.min(48, Math.max(12, requestedSignalLimit)) : 12;
|
||||
let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
||||
let hasMoreSignals = false;
|
||||
let hasMoreSignals = false, totalItems = 0;
|
||||
const filterError = !["6", "12", "24", "72"].includes(hours) || !["activity", "confidence", "freshness"].includes(sort);
|
||||
let unavailable = false;
|
||||
let unavailable = false, showNoIndex = false;
|
||||
if (filterError) { showNoIndex = true; Astro.response.status = 422; }
|
||||
if (unavailable) { showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); }
|
||||
try {
|
||||
let signalRows: PublicObservation[] = [];
|
||||
[fishes, waterbodies, signalRows] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies"), api<PublicObservation[]>(`/api/v1/community-observations?limit=${signalLimit + 1}`)]);
|
||||
const signalParams = new URLSearchParams({ limit: String(signalLimit + 1) });
|
||||
if (waterbody) signalParams.set("waterbody", waterbody);
|
||||
if (fish) signalParams.set("fish", fish);
|
||||
[fishes, waterbodies, signalRows] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies"), api<PublicObservation[]>(`/api/v1/community-observations?${signalParams}`)]);
|
||||
hasMoreSignals = signalRows.length > signalLimit;
|
||||
signals = signalRows.slice(0, signalLimit);
|
||||
if (!filterError) {
|
||||
const query = new URLSearchParams({ hours, waterbody, fish, sort });
|
||||
items = await api<Activity[]>(`/api/v1/activity?${query}`);
|
||||
const query = new URLSearchParams({ hours, waterbody, fish, sort, limit: "20", offset: "0" });
|
||||
const paginated = await api<PaginatedActivity>(`/api/v1/activity?${query}`);
|
||||
items = paginated.items;
|
||||
totalItems = paginated.total;
|
||||
}
|
||||
} catch { unavailable = true; }
|
||||
const leaderLevel = items.length > 1 && items[0] ? activityLevel(items[0].activity_score) : null;
|
||||
@@ -44,30 +51,35 @@ const datasetJsonLd = {
|
||||
creator: { "@type": "Organization", name: "RF4 Spotter" }, isAccessibleForFree: true,
|
||||
};
|
||||
---
|
||||
<Layout title="Что клюёт сейчас в Russian Fishing 4 — RF4 Spotter" description="Свежие точки клёва RF4, рабочие приманки, вес уловов и прозрачная оценка данных с указанием каждого источника." structuredData={datasetJsonLd}>
|
||||
<Layout title="Что клюёт сейчас в Russian Fishing 4 — RF4 Spotter" description="Свежие точки клёва RF4, рабочие приманки, вес уловов и прозрачная оценка данных с указанием каждого источника." structuredData={datasetJsonLd} noindex={showNoIndex}>
|
||||
<section class="intro content-grid"><div class="intro-copy"><span class="eyebrow"><b>RF4</b> Живая карта клёва</span><h1>Выбирай место,<br/><em>пока клюёт.</em></h1><p>Свежие точки, рабочие приманки и честная оценка данных от игроков.</p></div><div class="lake-card"><img src="/lake-dawn.webp" alt="Туманное озеро на рассвете" width="1774" height="887" fetchpriority="high"/><div class="lake-overlay"><div><span class="overline">Пульс водоёмов</span><strong>{items.length} {plural(items.length, ["точка показывает", "точки показывают", "точек показывают"])} активность</strong></div><div class="pulse-orb"><span></span></div></div></div></section>
|
||||
<section class="filters-wrap"><form class="filters content-grid" method="get" action="/#results">
|
||||
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(x => <option value={x.slug} selected={waterbody === x.slug}>{x.name_ru}</option>)}</select></label>
|
||||
<label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(x => <option value={x.slug} selected={fish === x.slug}>{x.name_ru}</option>)}</select></label>
|
||||
<details class="filter-advanced" open><summary>Период и порядок <span>{periodLabel}</span></summary><div class="advanced-fields">
|
||||
<label>Период<select name="hours"><option value="6" selected={hours === "6"}>6 часов</option><option value="12" selected={hours === "12"}>12 часов</option><option value="24" selected={hours === "24"}>24 часа</option><option value="72" selected={hours === "72"}>72 часа</option></select></label>
|
||||
<label>Сначала<select name="sort"><option value="activity" selected={sort === "activity"}>Активные</option><option value="confidence" selected={sort === "confidence"}>Надёжные</option><option value="freshness" selected={sort === "freshness"}>Свежие</option></select></label>
|
||||
<details class="filter-advanced-fallback" open><summary>Период и порядок</summary><div class="advanced-fields">
|
||||
<label class="filter-advanced-field">Период<select name="hours"><option value="6">6 часов</option><option value="12">12 часов</option><option value="24" selected={hours === "24"}>24 часа</option><option value="72">72 часа</option></select></label>
|
||||
<label class="filter-advanced-field">Сначала<select name="sort"><option value="activity" selected={sort === "activity"}>Активные</option><option value="confidence" selected={sort === "confidence"}>Надёжные</option><option value="freshness" selected={sort === "freshness"}>Свежие</option></select></label>
|
||||
</div></details>
|
||||
<button>⌕ Найти клёв</button>
|
||||
</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>
|
||||
<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} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}</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>{items.length < totalItems && <a class="load-more" href={`/?${new URLSearchParams({ ...Object.fromEntries(params), offset: String(items.length) }).toString()}#results`}>Показать ещё <span>{items.length} из {totalItems}</span> ↓</a>}</> : <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="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 && <SignalFeed signals={signals}/>}
|
||||
{hasMoreSignals && signalLimit < 48 && <a class="signal-more" href={moreSignalsHref}>Показать ещё <span>{signalLimit} из доступных</span> ↓</a>}
|
||||
<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");
|
||||
const advancedFields = document.querySelectorAll<HTMLElement>(".filter-advanced-field");
|
||||
const compactFilters = window.matchMedia("(max-width: 720px)");
|
||||
const syncFilters = (event: MediaQueryList | MediaQueryListEvent) => {
|
||||
if (event.matches) advancedFilters?.removeAttribute("open");
|
||||
else advancedFilters?.setAttribute("open", "");
|
||||
advancedFields.forEach(field => {
|
||||
if (event.matches) {
|
||||
field.classList.add("filter-compact-hidden");
|
||||
} else {
|
||||
field.classList.remove("filter-compact-hidden");
|
||||
}
|
||||
});
|
||||
};
|
||||
syncFilters(compactFilters);
|
||||
compactFilters.addEventListener("change", syncFilters);
|
||||
|
||||
@@ -5,11 +5,11 @@ import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord } fro
|
||||
const params = Astro.url.searchParams;
|
||||
const fish = params.get("fish") ?? "";
|
||||
const waterbody = params.get("waterbody") ?? "";
|
||||
let records: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [], unavailable = false;
|
||||
try { [records, runs, fishes, waterbodies] = await Promise.all([api<OfficialRecord[]>(`/api/v1/records?${new URLSearchParams({ fish, waterbody })}`), api<ImportRun[]>("/api/v1/imports?limit=1"), api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); } catch { unavailable = true; }
|
||||
let records: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [], unavailable = false, showNoIndex = false;
|
||||
try { [records, runs, fishes, waterbodies] = await Promise.all([api<OfficialRecord[]>(`/api/v1/records?${new URLSearchParams({ fish, waterbody })}`), api<ImportRun[]>("/api/v1/imports?limit=1"), api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); } catch { unavailable = true; showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); }
|
||||
const last = runs[0];
|
||||
---
|
||||
<Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник.">
|
||||
<Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник." noindex={showNoIndex}>
|
||||
<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>
|
||||
|
||||
@@ -11,6 +11,9 @@ const reportId = Astro.url.searchParams.get("report_id");
|
||||
{state === "sent" && <div class="notice success">Улов отправлен на модерацию. Спасибо!</div>}
|
||||
{state === "screenshot_sent" && <div class="notice success">Скриншот добавлен к ранее созданной заявке.</div>}
|
||||
{state === "create_error" && <div class="notice error" id="form-error" tabindex="-1"><strong>Заявка не создана.</strong> Проверьте поля; введённые значения сохранены.</div>}
|
||||
{state === "rate_limited" && <div class="notice error" id="form-error" tabindex="-1"><strong>Слишком много отправок.</strong> Подождите 10 минут перед повторной попыткой.</div>}
|
||||
{state === "timeout" && <div class="notice error" id="form-error" tabindex="-1"><strong>Превышено время ожидания.</strong> Проверьте соединение и попробуйте снова.</div>}
|
||||
{state === "server_error" && <div class="notice error" id="form-error" tabindex="-1"><strong>Сервер временно недоступен.</strong> Попробуйте позже.</div>}
|
||||
{state === "screenshot_error" && <div class="notice warning"><strong>Заявка сохранена без скриншота.</strong> Изображение не загрузилось; можно повторить отдельно, не отправляя улов заново.</div>}
|
||||
{state === "screenshot_error" && reportId && <form class="screenshot-retry" method="post" action="/api/report-screenshot" enctype="multipart/form-data"><input type="hidden" name="report_id" value={reportId} /><label>Повторная загрузка скриншота<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" required /></label><button type="submit">Загрузить скриншот</button></form>}
|
||||
{unavailable ? <div class="state"><h2>Форма временно недоступна</h2></div> : <form class="report-form" method="post" action="/api/report" enctype="multipart/form-data">
|
||||
@@ -21,6 +24,11 @@ const reportId = Astro.url.searchParams.get("report_id");
|
||||
const form = document.querySelector(".report-form"); const key = "rf4-report-draft";
|
||||
if (form && state === "create_error") { try { const draft = JSON.parse(sessionStorage.getItem(key) || "{}"); for (const [name, value] of Object.entries(draft)) { const field = form.elements.namedItem(name); if (field && "value" in field) field.value = value; } } catch {} document.querySelector("#form-error")?.focus(); }
|
||||
if (state === "sent") sessionStorage.removeItem(key);
|
||||
form?.addEventListener("submit", () => { const draft = {}; for (const [name, value] of new FormData(form)) if (typeof value === "string" && name !== "website") draft[name] = value; sessionStorage.setItem(key, JSON.stringify(draft)); });
|
||||
form?.addEventListener("submit", () => {
|
||||
const btn = form.querySelector("button[type=submit]");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Отправка..."; }
|
||||
const draft = {}; for (const [name, value] of new FormData(form)) if (typeof value === "string" && name !== "website") draft[name] = value;
|
||||
sessionStorage.setItem(key, JSON.stringify(draft));
|
||||
});
|
||||
</script>
|
||||
</Layout>
|
||||
|
||||
@@ -4,7 +4,7 @@ import SourceBadge from "../../components/SourceBadge.astro";
|
||||
import CoordinateRadar from "../../components/CoordinateRadar.astro";
|
||||
import ActivityTimeline from "../../components/ActivityTimeline.astro";
|
||||
import CatchList from "../../components/CatchList.astro";
|
||||
import { activityLevel, api, ApiError, plural, type Activity, type Catch, type Spot } from "../../lib/api";
|
||||
import { activityLevel, api, ApiError, plural, type Activity, type Catch, type PaginatedActivity, type Spot } from "../../lib/api";
|
||||
const { id } = Astro.params;
|
||||
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
|
||||
let timeline: { start: string; end: string; count: number }[] = [];
|
||||
@@ -14,8 +14,8 @@ try {
|
||||
? await api<Spot>(`/api/v1/spots/resolve?waterbody=${encodeURIComponent(readable[1])}&x=${readable[2]}&y=${readable[3]}`)
|
||||
: await api<Spot>(`/api/v1/spots/${id}`);
|
||||
if (!readable) return Astro.redirect(`/spots/${spotResult.waterbody_slug}-${spotResult.x}x${spotResult.y}`, 301);
|
||||
const [catchResult, activityRows] = await Promise.all([api<Catch[]>(`/api/v1/spots/${spotResult.id}/catches`), api<Activity[]>("/api/v1/activity?hours=24&limit=100")]);
|
||||
spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === spotResult.id) ?? null;
|
||||
const [catchResult, activityPaginated] = await Promise.all([api<Catch[]>(`/api/v1/spots/${spotResult.id}/catches`), api<PaginatedActivity>(`/api/v1/activity?hours=24&waterbody=${encodeURIComponent(spotResult.waterbody_slug)}&limit=100`)]);
|
||||
spot = spotResult; catches = catchResult; activity = activityPaginated.items.find(item => item.spot_id === spotResult.id) ?? null;
|
||||
timeline = await api<typeof timeline>(`/api/v1/spots/${spotResult.id}/timeline`);
|
||||
} catch (error) {
|
||||
unavailable = true;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
import ActivityCard from "../../components/ActivityCard.astro";
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { api, plural, type Activity, type DictionaryItem } from "../../lib/api";
|
||||
import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../lib/api";
|
||||
const { slug } = Astro.params;
|
||||
let water: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
||||
try {
|
||||
const waters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500");
|
||||
water = waters.find(item => item.slug === slug);
|
||||
if (water) items = await api<Activity[]>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&limit=100`);
|
||||
if (water) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&limit=100`); items = paginated.items; }
|
||||
} catch { unavailable = true; }
|
||||
if (unavailable) {
|
||||
Astro.response.status = 503;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
import ActivityCard from "../../../components/ActivityCard.astro";
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import { api, plural, type Activity, type DictionaryItem } from "../../../lib/api";
|
||||
import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../../lib/api";
|
||||
const { slug, fish: fishSlug } = Astro.params;
|
||||
let water: DictionaryItem | undefined, fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
||||
try {
|
||||
const [waters, fishes] = await Promise.all([api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"), api<DictionaryItem[]>("/api/v1/fishes?limit=500")]);
|
||||
water = waters.find(item => item.slug === slug); fish = fishes.find(item => item.slug === fishSlug);
|
||||
if (water && fish) items = await api<Activity[]>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&fish=${encodeURIComponent(fish.slug)}&limit=100`);
|
||||
if (water && fish) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&fish=${encodeURIComponent(fish.slug)}&limit=100`); items = paginated.items; }
|
||||
} catch { unavailable = true; }
|
||||
if (unavailable) {
|
||||
Astro.response.status = 503;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
.skip-link{position:fixed;z-index:100;left:12px;top:12px;padding:12px 16px;background:var(--lime);color:var(--deep);font-weight:750;transform:translateY(-150%)}.skip-link:focus{transform:none}:focus-visible{outline:3px solid #7da529;outline-offset:3px}.topbar nav a[aria-current="page"]{font-weight:750}.notice:focus{outline:3px solid #9d3529;outline-offset:3px}
|
||||
.topbar{height:86px;width:min(1480px,calc(100% - 48px));margin:auto;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:28px}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;min-width:max-content}.brand-mark{width:42px;height:42px;display:grid;place-items:center;border:1px solid #9eb0a7;border-radius:50%;background:var(--deep);color:var(--lime);font-size:28px}.brand-name{display:flex;flex-direction:column;font:17px/.95 Georgia,serif}.brand-name strong{font-size:20px;font-style:italic}.topbar nav{height:100%;display:flex;gap:23px;align-items:center}.topbar nav a{height:100%;display:flex;align-items:center;gap:7px;padding:0 8px;text-decoration:none;color:#526461;font-size:14px;border-bottom:3px solid transparent}.topbar nav a.active{color:var(--deep);border-color:var(--deep)}.live-badge{justify-self:end;display:flex;align-items:center;gap:9px;color:#60716e;font-size:13px}.live-badge>span{width:8px;height:8px;border-radius:50%;background:#83b83a;box-shadow:0 0 0 5px #83b83a20}
|
||||
.eyebrow,.overline{text-transform:uppercase;letter-spacing:.14em;font-size:12px;font-weight:750;color:#657673}.eyebrow{display:flex;align-items:center;gap:10px}.eyebrow b{display:grid;place-items:center;min-width:41px;height:25px;padding:0 8px;border:1px solid #9aaa9f;border-radius:20px;color:var(--deep);letter-spacing:.05em}.intro{padding:74px 0 42px;display:grid;grid-template-columns:.92fr 1.08fr;align-items:end;gap:70px}.intro h1,.records-hero h1,.form-hero h1,.spot-hero h1{margin:18px 0 22px;font:400 clamp(58px,6.3vw,102px)/.87 Georgia,"Times New Roman",serif;letter-spacing:-.065em}.intro h1 em,.records-hero h1 em,.form-hero h1 em{color:#497074;font-weight:400}.intro-copy>p{font-size:18px;line-height:1.55;color:#586a68;max-width:560px}.lake-card{position:relative;border-radius:18px;overflow:hidden;height:326px;box-shadow:0 24px 70px #173c3f24}.lake-card img{width:100%;height:100%;object-fit:cover;display:block}.lake-card:after{content:"";position:absolute;inset:0;background:linear-gradient(180deg,transparent 35%,#041c20d9)}.lake-overlay{position:absolute;z-index:2;inset:auto 24px 22px 26px;display:flex;justify-content:space-between;align-items:end;color:#fff}.lake-overlay .overline{color:#d2dedb;display:block;margin-bottom:7px}.lake-overlay strong{font:400 21px Georgia,serif}.pulse-orb{width:45px;height:45px;border:1px solid #ffffff7d;border-radius:50%;display:grid;place-items:center}.pulse-orb span{width:9px;height:9px;border-radius:50%;background:var(--lime);box-shadow:0 0 0 8px #c9f45b25}
|
||||
.filters-wrap{background:var(--deep);padding:24px 0;position:sticky;top:0;z-index:20;box-shadow:0 10px 35px #08222620}.filters{display:grid;grid-template-columns:repeat(4,1fr) auto;gap:12px;align-items:end}.filters label,.report-form label,.record-filters label{color:#9fb0ad;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.filters select{display:block;width:100%;height:48px;margin-top:7px;padding:0 13px;border:1px solid #ffffff29;border-radius:10px;background:#ffffff0c;color:#f5f8f3}.filters option{color:var(--ink)}.filters button,.report-form button,.record-filters button{height:48px;padding:0 24px;border:0;border-radius:10px;background:var(--lime);color:var(--deep);font-weight:750}.filter-advanced{display:contents}.filter-advanced summary{display:none}.advanced-fields{display:contents!important}.active-filters{min-height:56px;display:flex;align-items:center;gap:8px;padding-top:10px}.active-filters span{padding:6px 10px;border:1px solid #c8d3cb;border-radius:20px;background:#f8faf5;color:#526662;font-size:12px}.active-filters a{margin-left:auto;color:#4d625e;font-size:13px;text-underline-offset:3px}
|
||||
.filters-wrap{background:var(--deep);padding:24px 0;position:sticky;top:0;z-index:20;box-shadow:0 10px 35px #08222620}.filters{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;align-items:end}.filters label,.report-form label,.record-filters label{color:#9fb0ad;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.filters select{display:block;width:100%;height:48px;margin-top:7px;padding:0 13px;border:1px solid #ffffff29;border-radius:10px;background:#ffffff0c;color:#f5f8f3}.filters option{color:var(--ink)}.filters button,.report-form button,.record-filters button{height:48px;padding:0 24px;border:0;border-radius:10px;background:var(--lime);color:var(--deep);font-weight:750}.filter-advanced-field{display:contents}.filter-compact-hidden{display:none!important}.filter-advanced-fallback{display:contents}.filter-advanced-fallback summary{display:none}.filter-advanced-fallback .advanced-fields{display:contents}.filter-advanced-fallback:not([open]) .advanced-fields{display:none!important}.active-filters{min-height:56px;display:flex;align-items:center;gap:8px;padding-top:10px}.active-filters span{padding:6px 10px;border:1px solid #c8d3cb;border-radius:20px;background:#f8faf5;color:#526662;font-size:12px}.active-filters a{margin-left:auto;color:#4d625e;font-size:13px;text-underline-offset:3px}
|
||||
.dashboard{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(360px,.75fr);gap:26px;padding:66px 0 104px;align-items:start}.section-heading{display:flex;justify-content:space-between;align-items:end;margin:0 0 23px}.section-heading h2,.detail-head h2,.how-it-works h2{font:400 38px Georgia,serif;margin:6px 0 0;letter-spacing:-.04em}.result-count{border:1px solid #b9c6bf;color:#667875;font-size:13px;padding:7px 11px;border-radius:20px}.spot-list{display:flex;flex-direction:column;gap:12px}.spot-card{position:relative;display:grid;grid-template-columns:45px 1fr 190px;gap:18px;padding:22px 48px 22px 20px;border:1px solid #d5ded7;border-radius:16px;background:#fbfcf9;text-decoration:none;transition:.22s ease}.spot-card:hover{transform:translateY(-2px);border-color:#7d9488;box-shadow:0 18px 40px #14333812;background:#fff}.spot-rank{width:36px;height:36px;border:1px solid #cad4cd;border-radius:50%;display:grid;place-items:center;font:italic 14px Georgia,serif;color:#788985}.spot-topline{display:flex;align-items:center;gap:12px;color:#687a77;font-size:13px}.activity-pill{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:30px;background:#e6f7c3;color:#426315;font-size:11px;font-weight:750}.activity-pill i{width:6px;height:6px;border-radius:50%;background:#6d9f32}.spot-main h3{font:400 25px Georgia,serif;margin:8px 0}.spot-meta{display:flex;gap:16px;color:#71817f;font-size:13px}.bait-line{display:flex;gap:11px;align-items:center;margin-top:18px;padding-top:15px;border-top:1px solid #e2e8e2}.lure-dot,.best-lure i{display:block;width:12px;height:28px;border-radius:50% 50% 43% 43%;background:var(--orange);transform:rotate(22deg);box-shadow:inset -4px 0 #091e2250}.bait-line div{display:flex;flex-direction:column;gap:2px}.bait-line div span{color:#82908e;font-size:11px;text-transform:uppercase;letter-spacing:.09em}.bait-line strong{font-size:14px}.spot-stats{border-left:1px solid #e2e8e2;padding-left:18px;display:grid;grid-template-columns:1fr 1fr;gap:18px 10px;align-content:center}.spot-stats div{display:flex;flex-direction:column}.spot-stats strong{font:400 20px Georgia,serif}.spot-stats span{font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:#7c8d89}.card-arrow{position:absolute;right:18px;top:50%;transform:translateY(-50%);font-size:28px;color:#91a09c}
|
||||
.detail-card{position:sticky;top:116px;margin:0;background:var(--deep);color:#f5f8f3;border:0;border-radius:18px;padding:27px;overflow:hidden}.detail-card:before{content:"";position:absolute;width:420px;height:420px;border:1px solid #ffffff0f;border-radius:50%;left:48%;top:-210px;box-shadow:0 0 0 48px #ffffff08,0 0 0 96px #ffffff05}.detail-card>*{position:relative;z-index:1}.detail-head{display:flex;justify-content:space-between}.detail-head .overline,.best-lure .overline{color:#a7b6b3}.detail-head h2{font-size:30px}.detail-head h2 em{color:var(--lime);font-size:22px}.detail-head>a{width:40px;height:40px;display:grid;place-items:center;border:1px solid #ffffff34;border-radius:50%;text-decoration:none}.detail-score{display:grid;grid-template-columns:126px 1fr;gap:20px;align-items:center;padding:34px 0 29px}.score-ring{width:122px;height:122px;display:grid;place-items:center;border-radius:50%;background:conic-gradient(var(--lime) var(--score),#ffffff16 0);position:relative}.score-ring:after{content:"";position:absolute;inset:8px;background:var(--deep);border-radius:50%}.score-ring>div{z-index:1;text-align:center;display:flex;flex-direction:column}.score-ring strong{font:400 39px/1 Georgia,serif}.score-ring span{color:#a9b8b5;font-size:10px;text-transform:uppercase}.detail-score>div>span{color:#a9b8b5;font-size:11px;text-transform:uppercase;letter-spacing:.1em}.detail-score>div>strong{display:block;margin:5px 0 8px;font:400 24px Georgia,serif;color:var(--lime)}.detail-score p{color:#b7c3c0;font-size:13px;line-height:1.5;margin:0}.metric-grid{display:grid;grid-template-columns:1fr 1fr;border:1px solid #ffffff1c;border-radius:12px;overflow:hidden}.metric-grid>div{min-height:91px;padding:15px;display:grid;grid-template-columns:23px 1fr;gap:3px 8px;border-bottom:1px solid #ffffff1c}.metric-grid>div:nth-child(odd){border-right:1px solid #ffffff1c}.metric-grid>div:nth-last-child(-n+2){border-bottom:0}.metric-grid>div>span{grid-row:1/3;color:var(--lime)}.metric-grid small{color:#9cadaa;text-transform:uppercase;font-size:9px}.metric-grid strong{font:400 17px Georgia,serif}.best-lure{margin-top:28px}.best-lure>div{display:grid;grid-template-columns:18px 1fr auto;gap:11px;align-items:center;padding:15px 0;border-bottom:1px solid #ffffff17}.best-lure>div span{color:var(--lime);font-size:12px}.confidence-note{display:flex;gap:10px;padding:16px;background:#ffffff0a;border-radius:10px;margin-top:22px;font-size:12px;line-height:1.45;color:#aebcba}.confidence-note>span:first-child{color:var(--lime)}.confidence-note strong{color:#fff}
|
||||
.data-quality{padding:4px 8px;border-radius:20px;background:#fff0c7;color:#73550c;font-size:10px;font-weight:750}.data-note{margin:12px 0 0;color:#667875;font-size:12px;line-height:1.45}.state{min-height:240px;display:grid;place-items:center;align-content:center;text-align:center;border:1px dashed #b9c7bf;border-radius:16px;color:#71817f}.state h2{font:400 28px Georgia,serif;color:var(--deep);margin:0}.state p{max-width:560px;line-height:1.5}.error-state{border-color:#d8aaa1}.error-state a{color:#842f25}.compact-state{min-height:180px}.how-it-works{border-top:1px solid #cfd8d1;padding:85px 0 105px;display:grid;grid-template-columns:.8fr 1.2fr;gap:70px}.how-it-works h2{font-size:47px;line-height:1.05}.principles{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.principles article{padding-top:24px;border-top:2px solid #294b4e}.principles article>span{font:italic 16px Georgia,serif;color:#82928f}.principles h3{margin:28px 0 8px;font:400 22px Georgia,serif}.principles p{font-size:14px;line-height:1.55;color:#657572}
|
||||
@@ -15,7 +15,7 @@
|
||||
.moderation-app{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-login{display:flex;gap:14px;align-items:end;padding:25px;background:var(--deep);border-radius:16px}.admin-login label{flex:1;color:#a8b7b4;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.admin-login input{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #ffffff29;border-radius:9px;background:#ffffff0c;color:#fff}.admin-login button,.moderation-actions button{height:48px;padding:0 24px;border:0;border-radius:9px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-app>.privacy{margin:10px 4px 25px}.moderation-app>.notice{width:100%}.moderation-list{display:grid;gap:18px}.moderation-card{display:grid;grid-template-columns:1fr 300px;gap:25px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:16px}.moderation-summary h2{font:400 32px Georgia,serif;margin:12px 0 4px}.moderation-summary>p{color:#647572;margin:0 0 20px}.moderation-summary dl{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:0}.moderation-summary dl div{border-top:1px solid #e1e8e2;padding-top:10px}.moderation-summary dt{font-size:10px;text-transform:uppercase;color:#7a8a87}.moderation-summary dd{margin:3px 0 0}.moderation-summary blockquote{margin:18px 0 0;padding:13px;border-left:3px solid var(--lime);background:#f2f6ef;color:#566865}.moderation-proof img,.no-proof{width:100%;height:210px;object-fit:cover;border-radius:11px}.no-proof{display:grid;place-items:center;background:#edf2ec;color:#71817f}.moderation-actions{grid-column:1/-1;display:flex;align-items:end;gap:14px;border-top:1px solid #e1e8e2;padding-top:18px}.moderation-actions label{flex:1;color:#60716e;font-size:12px;text-transform:uppercase;letter-spacing:.08em}.moderation-actions textarea{display:block;width:100%;margin-top:6px;padding:10px;border:1px solid #d4ddd6;border-radius:8px;resize:vertical}.moderation-actions>div{display:flex;gap:8px}.moderation-actions .reject{background:#f0d4ce;color:#842f25}.moderation-actions .delete{background:transparent;color:#842f25;border:1px solid #d8aaa1}.moderation-actions button:disabled{opacity:.55}
|
||||
footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32px,calc((100vw - 1360px)/2));display:grid;grid-template-columns:1fr 1fr auto;align-items:center;gap:28px}footer .brand-mark{border-color:#ffffff32}footer p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)}
|
||||
@media(max-width:1040px){.content-grid{width:min(100% - 36px,900px)}.topbar{width:calc(100% - 36px);grid-template-columns:1fr auto}.live-badge{display:none}.intro{grid-template-columns:1fr;gap:34px;padding-top:54px}.lake-card{height:280px}.dashboard{grid-template-columns:1fr}.detail-card{position:relative;top:auto}.how-it-works{grid-template-columns:1fr}.records-hero,.form-hero{display:block}.records-hero>div:last-child,.form-hero>p{margin-top:25px}}
|
||||
@media(max-width:720px){.content-grid,.records-hero,.form-hero,.record-filters,.record-table,.official-note,.report-form,.spot-hero,.periods,.detail-grid{width:calc(100% - 28px)}.topbar{width:100%;padding:13px 14px 0;display:flex;flex-wrap:wrap;height:auto}.topbar .brand{flex:1}.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}.topbar nav a{flex:0 0 auto;font-size:13px}.intro h1,.records-hero h1,.form-hero h1{font-size:55px}.intro{padding:30px 0 25px}.intro h1{font-size:47px;margin:13px 0 12px}.intro-copy>p{font-size:15px;margin:0}.lake-card{display:none}.filters-wrap{position:relative;padding:17px 0}.filters{grid-template-columns:1fr 1fr}.filter-advanced{display:block;grid-column:1/-1;border-top:1px solid #ffffff1d;padding-top:10px}.filter-advanced summary{display:flex;justify-content:space-between;color:#d5dfdc;font-size:12px;cursor:pointer;list-style:none}.filter-advanced summary::-webkit-details-marker{display:none}.filter-advanced summary:before{content:"+";margin-right:7px;color:var(--lime)}.filter-advanced[open] summary:before{content:"−"}.filter-advanced summary span{margin-left:auto;color:#9fb0ad}.advanced-fields{display:grid!important;grid-template-columns:1fr 1fr;gap:12px;padding-top:12px}.filter-advanced:not([open]) .advanced-fields{display:none!important}.filters button{grid-column:1/-1}.active-filters{min-height:0;overflow-x:auto;padding:10px 14px 0;width:100%;scrollbar-width:none}.active-filters span{flex:0 0 auto}.active-filters a{position:sticky;right:0;padding:6px 10px;background:var(--paper)}.dashboard{padding:28px 0 74px;scroll-margin-top:10px}.spot-card{grid-template-columns:34px 1fr;padding:18px 18px 18px 14px;gap:10px}.spot-stats{grid-column:2;border:0;border-top:1px solid #e2e8e2;padding:13px 0 0;grid-template-columns:repeat(4,1fr)}.card-arrow{display:none}.detail-score{grid-template-columns:105px 1fr}.score-ring{width:100px;height:100px}.principles{grid-template-columns:1fr}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}.form-grid,.detail-grid{grid-template-columns:1fr}.spot-hero{padding:28px}.spot-hero h1{font-size:48px}.pin{display:none}footer{grid-template-columns:1fr auto;padding:30px 20px}footer p{grid-column:1/-1;order:3}}
|
||||
@media(max-width:720px){.content-grid,.records-hero,.form-hero,.record-filters,.record-table,.official-note,.report-form,.spot-hero,.periods,.detail-grid{width:calc(100% - 28px)}.topbar{width:100%;padding:13px 14px 0;display:flex;flex-wrap:wrap;height:auto}.topbar .brand{flex:1}.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}.topbar nav a{flex:0 0 auto;font-size:13px}.intro h1,.records-hero h1,.form-hero h1{font-size:55px}.intro{padding:30px 0 25px}.intro h1{font-size:47px;margin:13px 0 12px}.intro-copy>p{font-size:15px;margin:0}.lake-card{display:none}.filters-wrap{position:relative;padding:17px 0}.filters{grid-template-columns:1fr 1fr}.filter-advanced-field{display:none}.filter-compact-hidden{display:none!important}.filter-advanced-fallback{display:block;grid-column:1/-1;border-top:1px solid #ffffff1d;padding-top:10px}.filter-advanced-fallback summary{display:flex;justify-content:space-between;color:#d5dfdc;font-size:12px;cursor:pointer;list-style:none}.filter-advanced-fallback summary::-webkit-details-marker{display:none}.filter-advanced-fallback summary:before{content:"+";margin-right:7px;color:var(--lime)}.filter-advanced-fallback[open] summary:before{content:"−"}.filter-advanced-fallback summary span{margin-left:auto;color:#9fb0ad}.filter-advanced-fallback .advanced-fields{display:grid!important;grid-template-columns:1fr 1fr;gap:12px;padding-top:12px}.filter-advanced-fallback:not([open]) .advanced-fields{display:none!important}.filters button{grid-column:1/-1}.active-filters{min-height:0;overflow-x:auto;padding:10px 14px 0;width:100%;scrollbar-width:none}.active-filters span{flex:0 0 auto}.active-filters a{position:sticky;right:0;padding:6px 10px;background:var(--paper)}.dashboard{padding:28px 0 74px;scroll-margin-top:10px}.spot-card{grid-template-columns:34px 1fr;padding:18px 18px 18px 14px;gap:10px}.spot-stats{grid-column:2;border:0;border-top:1px solid #e2e8e2;padding:13px 0 0;grid-template-columns:repeat(4,1fr)}.card-arrow{display:none}.detail-score{grid-template-columns:105px 1fr}.score-ring{width:100px;height:100px}.principles{grid-template-columns:1fr}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}.form-grid,.detail-grid{grid-template-columns:1fr}.spot-hero{padding:28px}.spot-hero h1{font-size:48px}.pin{display:none}footer{grid-template-columns:1fr auto;padding:30px 20px}footer p{grid-column:1/-1;order:3}}
|
||||
@media(max-width:720px){.moderation-app{width:calc(100% - 28px)}.moderation-card{grid-template-columns:1fr}.moderation-proof{grid-row:2}.moderation-actions{grid-column:1;display:block}.moderation-actions>div{margin-top:12px}.admin-login{display:block}.admin-login button{width:100%;margin-top:12px}}
|
||||
@media(max-width:480px){.filters{grid-template-columns:1fr 1fr}.spot-stats strong{font-size:16px}.topbar nav{gap:4px}.topbar nav a{padding:0 5px}.brand-name{font-size:14px}.brand-name strong{font-size:17px}.moderation-actions>div{display:grid}.moderation-actions button{width:100%}}
|
||||
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||
@@ -37,4 +37,5 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
|
||||
.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}}
|
||||
.load-more,.signal-more{display:inline-flex;align-items:center;gap:8px;padding:12px 20px;border:1px solid #c8d3cb;border-radius:10px;background:#f8faf5;color:#526662;font-size:14px;text-decoration:none;transition:.2s ease}.load-more:hover,.signal-more:hover{background:#eef2ec;border-color:#a8b8af}.load-more span,.signal-more span{color:#82908e;font-size:13px}
|
||||
.not-found{min-height:calc(100vh - 204px);display:flex;flex-direction:column;align-items:flex-start;justify-content:center;padding-block:70px}.not-found__float{width:130px;height:130px;display:grid;place-items:center;position:relative;margin-bottom:28px;border:1px solid #b8c8be;border-radius:50%;color:#55751e;box-shadow:0 0 0 16px #dce7d722}.not-found__float strong{position:absolute;right:-18px;bottom:5px;padding:5px 9px;border-radius:20px;background:var(--deep);color:var(--lime);font:italic 14px Georgia,serif}.not-found h1{margin:14px 0 22px;font:400 clamp(58px,8vw,105px)/.88 Georgia,serif;letter-spacing:-.06em}.not-found h1 em{color:#497074;font-weight:400}.not-found p{max-width:600px;color:#4d605d;font-size:17px;line-height:1.6}.not-found>a{display:flex;align-items:center;gap:10px;margin-top:18px;padding:14px 18px;border-radius:10px;background:var(--deep);color:#fff;font-weight:750;text-decoration:none}.not-found>a .fish-icon{color:var(--lime)}
|
||||
|
||||
@@ -191,7 +191,7 @@ services:
|
||||
depends_on:
|
||||
api: {condition: service_healthy}
|
||||
command: ["python", "-m", "app.community_scheduler"]
|
||||
networks: [backend]
|
||||
networks: [backend, edge]
|
||||
security_opt: [no-new-privileges:true]
|
||||
logging: *default-logging
|
||||
deploy:
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
{$SITE_DOMAIN} {
|
||||
encode zstd gzip
|
||||
request_body {
|
||||
max_size 10M
|
||||
}
|
||||
|
||||
@immutable path /_astro/*
|
||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ fail() {
|
||||
}
|
||||
|
||||
running=$($compose ps --status running --services 2>/dev/null) || running=""
|
||||
for service in proxy db minio api web; do
|
||||
for service in proxy db minio api web community-scheduler; do
|
||||
printf '%s\n' "$running" | grep -qx "$service" || fail "service:$service"
|
||||
done
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# Повторный аудит после исправлений — 9 сентября 2026
|
||||
|
||||
## Вывод и границы проверки
|
||||
|
||||
Текущий `9ae05ef` не готов к production. Проверены изменения `c6fdc96..9ae05ef`: три коммита, 19 файлов, 208 добавленных и 80 удалённых строк. Изменений тестов, README и ROADMAP в этом диапазоне нет. Поэтому по доступной ветке нельзя подтвердить выполнение всего предыдущего аудита: часть исправлений полезна, часть неполна, есть новые блокирующие регрессии.
|
||||
|
||||
Это проверка кода и локальных команд, а не повторная визуальная приёмка в браузере или проверка рабочего сервера. Парсеры в сеть не запускались: разрешённый интервал не менее 30 минут на площадку сохраняется. Production/bootstrap не запускался; для Caddy использован только одноразовый контейнер без сети. Неотслеживаемые `.gigacode/`, `.gigaide/`, `.idea/` не изменялись.
|
||||
|
||||
### Фактические проверки
|
||||
|
||||
| Проверка | Результат |
|
||||
| --- | --- |
|
||||
| `.venv/bin/pytest -q --tb=short` | **10 failed, 86 passed, 1 skipped**, 3,54 с |
|
||||
| `.venv/bin/pytest -q tests/test_community_cli.py --tb=short` | **2 failed, 1 passed** |
|
||||
| `npm --prefix apps/web run check` | 40 файлов, 0 ошибок/предупреждений |
|
||||
| `npm --prefix apps/web run test:unit` | 1 успешный test-file subtest |
|
||||
| `npm --prefix apps/web run build` | Успех |
|
||||
| Caddy 2.10.2, `caddy adapt` с текущим Caddyfile | **Ошибка: строка 8, unrecognized directive: body_limit** |
|
||||
|
||||
Из 10 падений Python: два относятся к research cooldown; четыре — к изменённому activity-контракту; два — к новой зависимости scheduler helpers от внешней БД; по одному — к изменению сигнатуры rate limit и состава readiness. Последние два сами по себе не доказывают поломку функциональности: старые проверки не обновлены под новый контракт. Но зелёной приёмки изменений нет. Успешная сборка Astro не проверяет соответствие JSON реальному типу `api<T>`.
|
||||
|
||||
## Подтверждённые регрессии и недоработки
|
||||
|
||||
### R01 · P0 · Production proxy не может запуститься
|
||||
|
||||
- Доказательство: [Caddyfile](../deploy/Caddyfile), строка 8, `body_limit 10M`; локальный `caddy adapt` завершается ошибкой неизвестной директивы.
|
||||
- Последствие: недоступны сайт, API и файловый домен при запуске с этим конфигом. Прежняя успешная проверка маршрутизации не подтверждает текущий HEAD.
|
||||
- Исправление: поддерживаемая конфигурация лимита тела, затем проверка конфигурации и маршрутов; проверить 413 для JSON и multipart. Не ограничиться удалением строки: требование защиты от больших запросов остаётся.
|
||||
|
||||
### R02 · P0 · Activity API изменён без миграции всех страниц
|
||||
|
||||
- [main.py](../apps/api/app/main.py): `/api/v1/activity` теперь возвращает `{items,total,limit,offset}`. На новый формат переведена только главная.
|
||||
- [fish/[slug].astro](../apps/web/src/pages/fish/[slug].astro) и [waterbodies/[slug].astro](../apps/web/src/pages/waterbodies/[slug].astro) вызывают `items.map` вне `try` у объекта, а не массива: путь рендера заканчивается TypeError.
|
||||
- [waterbodies/[slug]/[fish].astro](../apps/web/src/pages/waterbodies/[slug]/[fish].astro) проверяет `items.length` у объекта и выбирает ложное пустое состояние.
|
||||
- [spots/[id].astro](../apps/web/src/pages/spots/[id].astro) вызывает `activityRows.find` у объекта; исключение перехватывается как 503, запрос timeline не выполняется.
|
||||
- Четыре существующих API-теста падают после смены контракта. Перечисленные последствия страниц установлены по коду; отдельный SSR-прогон ещё нужен.
|
||||
- Приёмка: один общий контракт и все потребители, SSR для четырёх маршрутов с данными и пустым `items`, полная пагинация без потерь; старый контракт либо явно мигрирован, либо сохранена совместимость.
|
||||
|
||||
### R03 · P1 · Research CLI не читает и не создаёт cooldown state
|
||||
|
||||
- [community_cli.py](../rf4_research/community_cli.py): `enforce_fetch_interval` открывает отсутствующий файл в `r`, `mark_fetch` — в `r+`; оба падают при первом запуске. Подтверждено двумя тестами.
|
||||
- Для существующего файла `f.read(encoding="utf-8")` тоже ошибочен: encoding задаётся при открытии, а не в `TextIOWrapper.read`.
|
||||
- Раздельные shared/exclusive lock не делают проверку и резервирование атомарными. Разблокировка перед закрытием/flush записи оставляет дополнительное окно гонки; миграции старых ключей нет.
|
||||
- Приёмка: первый запуск, существующий и повреждённый state, миграция ключей, конкурентные процессы, неудачный HTTP расходует интервал. Production и исследовательский путь не должны обходить общий лимит.
|
||||
|
||||
### R04 · P1 · Disabled-фильтрация может обойти общий интервал сайта
|
||||
|
||||
- [community_scheduler.py](../apps/api/app/community_scheduler.py): `configured_sources()` теперь оставляет только enabled. Из этого списка строится и `site_sources` для поиска недавних попыток.
|
||||
- Сценарий по коду: RF4-STAT fishing только что запрошен → его отключают → posts больше не учитывает fishing в истории сайта и может запросить тот же сайт раньше 1800 секунд. Исключать отключённые endpoint нужно из кандидатов, **не из истории площадки**.
|
||||
- `run_source(disabled)` теперь может получить KeyError до прежнего безопасного `return False`; повторные снимки registry открывают гонку при переключении enabled.
|
||||
- Приёмка: выключение/включение endpoint не сбрасывает site cooldown; конкурентные запуски видят общую историю и только актуальных кандидатов.
|
||||
|
||||
### R05 · P1 · Bootstrap перестал быть изолированным и безопасным для источников
|
||||
|
||||
- [test-production-bootstrap.sh](../deploy/test-production-bootstrap.sh) добавляет production `proxy community-scheduler`, но [compose.bootstrap.yaml](../deploy/compose.bootstrap.yaml) переопределяет только API/web.
|
||||
- Наследуются host-порты 80/443, production домены/TLS и реальные URL парсеров. Свежая БД bootstrap не знает историю запросов другого стека; повторные проверки способны нарушать 30-минутное ограничение. Импорт также конфликтует с проверкой `catch_report = 0`.
|
||||
- Проверки curl/Playwright продолжают ходить напрямую на API/web, а не через новый proxy: запуск контейнера не равен проверке маршрутизации.
|
||||
- Приёмка: loopback high ports, локальные HTTP-домены, fixture HTTP-источник и запрещённый внешний egress; сценарии формы/модерации проходят через Caddy. Реальные источники — только отдельный согласованный smoke с общей историей интервалов.
|
||||
|
||||
### R06 · P1 · Фильтры сигналов сравнивают slug с названием
|
||||
|
||||
- [index.astro](../apps/web/src/pages/index.astro) отправляет выбранные slug `fish`/`waterbody`.
|
||||
- [main.py](../apps/api/app/main.py), `community_observations`, сравнивает их с `fish_name`/`waterbody_name` (названия источника). Наблюдение «Щука» не совпадает с `pike` и исчезает при фильтрации.
|
||||
- Приёмка: единая семантика slug/ID с подтверждёнными mapping/alias; отдельно определить поведение несопоставленных наблюдений. Проверить источник, неполноту и выбранные фильтры вместе, не теряя атрибуцию.
|
||||
|
||||
### R07 · P1 · Ошибка главной остаётся HTTP 200 и индексируемой
|
||||
|
||||
- [index.astro](../apps/web/src/pages/index.astro): `if (unavailable)` находится **до** запросов, сразу после присвоения `false`; в `catch` меняется только флаг. Ветка 503/Retry-After/noindex никогда не срабатывает при сбое API.
|
||||
- На detail-страницах `noindex` зависит от найденной сущности, не от `unavailable`: при успешном справочнике и сбое activity ошибочная страница не закрывается через этот флаг.
|
||||
- Приёмка: после завершения загрузки согласовать status, noindex, Cache-Control, Retry-After и JSON-LD для 200-empty/404/422/503; аварийная страница не изображает полноценный Dataset.
|
||||
|
||||
### R08 · P1 · Фильтры UI не доведены до рабочего responsive-состояния
|
||||
|
||||
- [global.css](../apps/web/src/styles/global.css): `.filter-advanced-field{display:contents}` применяется уже к `label`: текст подписи и select теряют общий grid-контейнер и становятся отдельными grid items.
|
||||
- На mobile поля скрыты CSS, но JS добавляет класс `filter-compact-hidden`, который задаёт противоположное — `display:contents!important`. Управления раскрытием больше нет. Без JS период и сортировка недоступны.
|
||||
- [index.astro](../apps/web/src/pages/index.astro): только вариант 24 часов имеет `selected`; после запроса 6/12/72 форма показывает первый вариант вместо фактического фильтра и может менять период при повторной отправке.
|
||||
- Приёмка: связная label/select компоновка, сохранённые выбранные значения, доступные все фильтры с JS и без него; desktop/mobile, клавиатура и zoom. Это статические находки; визуальную приёмку нужно выполнить после исправления R02.
|
||||
|
||||
### R09 · P1 · Публичной пагинации по-прежнему нет
|
||||
|
||||
- [index.astro](../apps/web/src/pages/index.astro) запрашивает всегда `limit=20&offset=0`, добавлен total, но нет перехода к следующей странице активности.
|
||||
- Рекорды/каталоги также не получили полноценную публичную навигацию по страницам в проверенном diff. Рост лимита полевых сигналов до 48 не решает задачу остальных списков.
|
||||
- Приёмка: 21+ результатов достижимы, фильтры сохраняются, счётчик различает показанное/общее; определить SEO-политику пагинации.
|
||||
|
||||
### R10 · P1 · Ошибки формы теряют черновик, timeout распознаётся неверно
|
||||
|
||||
- [report.astro](../apps/web/src/pages/report.astro) восстанавливает черновик и фокус только при `create_error`, но не при новых `rate_limited`, `server_error`, `timeout`.
|
||||
- [api/report.ts](../apps/web/src/pages/api/report.ts) ищет timeout как `TypeError` с текстом `abort`; `AbortSignal.timeout` использует TimeoutError, поэтому новое состояние обычно недостижимо.
|
||||
- Отключение кнопки не даёт серверной идемпотентности: при таймауте создания неизвестно, сохранил ли API заявку; повтор может создать дубль. Обработка multipart retry-маршрута и единая обработка 413 также требуют завершения.
|
||||
- Приёмка: восстановление полей/фокуса при каждом отказе, безопасное поведение при недоступном sessionStorage, корректная классификация timeout и повтор без дублирования сохранённого улова.
|
||||
|
||||
### R11 · P1 · Проверка URL происходит после сетевого обращения
|
||||
|
||||
- [community_cli.py](../rf4_research/community_cli.py), `fetch_html`: сначала `urlopen`, затем allowlist конечного `response.url`. Запрос к запрещённому initial URL или redirect уже произошёл до отказа.
|
||||
- Лимит чтения 5 МБ полезен, но не исправляет эту проблему. `fetch_site_key` не объединяет `rf4db.com` и `download.rf4db.com` в площадку.
|
||||
- Приёмка: validate scheme/host/port до I/O и на каждом redirect, общий ключ площадки, ограничение ответа; тесты только на mock/fixture transport.
|
||||
|
||||
### R12 · P1 · Мониторинг не сигнализирует о зависшем импорте
|
||||
|
||||
- [readiness.py](../apps/api/app/readiness.py) добавляет community-компонент, но его failed/stale/unknown не влияют на итог `ready`. Выбирается одна последняя попытка всех источников: успех одного маскирует проблемы других.
|
||||
- [monitor.sh](../deploy/monitor.sh) проверяет лишь running контейнер и наличие текста `"status":"ready"` где-либо в JSON. Он не проверяет component status/свежесть каждого источника.
|
||||
- Приёмка: разделить готовность API обслуживать данные и здоровье сбора, но обязательно выдавать мониторинговую тревогу по enabled source/site с учётом ротации и backoff. Проверить running без завершения, stale и failed при исправных DB/MinIO.
|
||||
|
||||
### R13 · P1 · Доверие к IP клиента не ограничено proxy boundary
|
||||
|
||||
- [main.py](../apps/api/app/main.py), `_check_rate_limit`: первый `X-Forwarded-For` принимается от любого peer без проверки доверенного прокси и валидности IP.
|
||||
- [api/report.ts](../apps/web/src/pages/api/report.ts) при отсутствии заголовков передаёт общий `unknown`. Это вновь общий bucket для прямого доступа к Astro.
|
||||
- Публичный обход через штатный Caddy здесь **не доказан**: proxy может очищать входной XFF. Подтверждено отсутствие защиты на уровне API и зависимость от внешнего trust boundary.
|
||||
- Приёмка: определить допустимые peer/proxy, проверять IP/цепочку, доказать независимость двух клиентов через proxy и игнорирование подделанного XFF на недоверенном входе.
|
||||
|
||||
### R14 · P2 · Registry источников стал зависеть от БД при разборе CLI
|
||||
|
||||
- [cli.py](../apps/api/app/cli.py) вызывает `configured_sources()` при построении argparse `choices`, до разбора команды, включая `--help`. Теперь это открывает внешнее соединение с БД.
|
||||
- Два прежних scheduler unit-теста падают с connection refused к localhost:5432: helpers перестали быть автономными.
|
||||
- Приёмка: отделить статический registry от выборки enabled в переданной сессии; `--help` работает без БД; unit-тесты не требуют поднятого PostgreSQL, отдельная интеграционная проверка покрывает конкуренцию.
|
||||
|
||||
### R15 · P1 · Заявленные D04/D06/D07/D08 выполнены не полностью
|
||||
|
||||
- D04: [community_importer.py](../apps/api/app/community_importer.py) добавляет fallback только водоёма по `Waterbody.name_ru`; рыба по-прежнему требует external ID и alias. `review_note` говорит о проверенных алиасах даже при новом name fallback. Нужны единые правила и точное объяснение автоматического соответствия.
|
||||
- D06: на главной остаётся «Один игрок не может искусственно поднять уверенность». Формула [activity.py](../apps/api/app/activity.py) при 10 отчётах одного игрока с source confidence 100 даёт 72%: 45 + 7 + 20. Ограничения вклада по-прежнему нет.
|
||||
- D07: замена `last_confirmed_at` на `reported_at` не разделяет все события. [community_review.py](../apps/api/app/community_review.py) всё ещё присваивает `caught_at=published_at`. Неизвестное время улова нельзя выдавать за известное.
|
||||
- D08: запрос точки теперь ограничен водоёмом, но остаётся top-100; поиск только по spot_id не различает виды рыбы. После R02 нужен адресный агрегат/явный список видов на точке, а не поиск внутри страницы рейтинга.
|
||||
- Приёмка: fixture-набор с отсутствующим ID, публикацией позже улова, неизвестным временем, одним игроком и несколькими рыбами одной точки; публичные подписи соответствуют фактическим данным.
|
||||
|
||||
## Что полезного уже добавлено
|
||||
|
||||
- Community scheduler подключён к `backend` и `edge` в production Compose (направление T03 верное; egress-проверка текущего стека ещё нужна).
|
||||
- Публичный `/imports` использует отдельный DTO без диагностических подробностей (T07; добавить regression-проверку полей).
|
||||
- Введены лимит ответа источника 5 МБ, таймауты POST, отдельные сообщения 429/5xx, счётчик activity total и 503/noindex для рекордов. Это частичные улучшения, не основание закрывать связанные задачи целиком.
|
||||
- Прежние исправления разделения Astro/API и Basic/Bearer сохранены в коде, но новый невалидный Caddyfile блокирует их применение.
|
||||
|
||||
## План исправления и дальнейших работ
|
||||
|
||||
Рабочие чекбоксы находятся в [ROADMAP](ROADMAP.md#повторная-приёмка-9-сентября-2026). Порядок: R01 → R02 → R03/R04/R05/R11 → R06–R10 → R12–R15. Не нужно перезапускать Docker после каждой правки.
|
||||
|
||||
1. Восстановить deploy и контракт страниц. Адресные offline-тесты + один Caddy adapt.
|
||||
2. Устранить риски интервала и произвольных HTTP-запросов. Bootstrap перевести на fixtures до его следующего запуска.
|
||||
3. Закрыть пользовательские/SEO-регрессии и правдивость данных. Затем единая SSR/браузерная матрица с наполненными страницами.
|
||||
4. Восстановить зелёный Python suite, расширить web tests проверками потребителей API и добавить их в CI. Не подменять исправление регрессий ослаблением assertions.
|
||||
5. Один изолированный production acceptance: Caddy → Astro → API, форма → модерация → публикация с обязательным источником, неполные сигналы и мониторинг. Без живого scraping.
|
||||
6. Продолжить незавершённый предыдущий аудит: D05/S02 (полный каталог, lookup по slug, очередь detail URL); V01–V04/U05–U07 (идентичность, токены, плотность карточек, координаты, иконки, визуальная приёмка); S03–S06 (canonical, полезные постоянные страницы, sitemap, performance); T08–T10/D09–D10 (воспроизводимость, нагрузка, release/migration, ограниченные права хранения, история импорта/backoff).
|
||||
7. После сервера: внешний launch checklist, S07; после обратной связи — V05. Astro/FastAPI/PostgreSQL сохраняются: смена стека эти дефекты не исправит.
|
||||
|
||||
Каждый пункт закрывается только с доказательством своей приёмки, обновлением README при изменении поведения и отдельным коммитом. Старые отметки о завершении относятся к прежним ревизиям, а не автоматически к текущему HEAD.
|
||||
@@ -0,0 +1,183 @@
|
||||
# Отчёт по регрессионному аудиту — 9 сентября 2026
|
||||
|
||||
База: `9ae05ef` (после трёх коммитов исправлений предыдущего аудита).
|
||||
Исходный отчёт: [REGRESSION_AUDIT_2026-09-09.md](REGRESSION_AUDIT_2026-09-09.md).
|
||||
|
||||
---
|
||||
|
||||
## Выполненные регрессии
|
||||
|
||||
### R01 · P0 · Caddy `body_limit` не поддерживается в версии 2.10.2
|
||||
|
||||
**Что сломалось:** `deploy/Caddyfile` содержал `body_limit 10M` на строке 8. `caddy adapt` завершался ошибкой `unrecognized directive: body_limit`. Production-стек не мог запуститься.
|
||||
|
||||
**Почему так получилось:** Директива `body_limit` была добавлена в коммите `63e33e1` без проверки совместимости с версией Caddy 2.10.2, которая используется в `compose.production.yaml` (`image: caddy:2.10.2-alpine`). Директива `body_limit` появилась в Caddy позже 2.10.2; в этой версии используется `request_body { max_size N }`.
|
||||
|
||||
**Что сделано:**
|
||||
- Заменена `body_limit 10M` на `request_body { max_size 10M }`.
|
||||
- Проверено через `docker run caddy:2.10.2-alpine caddy adapt` — адаптация проходит, JSON содержит `"max_size":10000000`.
|
||||
|
||||
**Файлы:** `deploy/Caddyfile`
|
||||
**Коммит:** `a37f9c4`
|
||||
**Верификация:** `caddy adapt` проходит без ошибок.
|
||||
|
||||
---
|
||||
|
||||
### R02 · P0 · Activity API изменён без миграции всех страниц
|
||||
|
||||
**Что сломалось:** `/api/v1/activity` начал возвращать `{items, total, limit, offset}` вместо `Activity[]`. Главная страница (`index.astro`) была обновлена в предыдущем коммите, но четыре страницы-потребителя остались с `api<Activity[]>`:
|
||||
- `fish/[slug].astro` — `items.map` на объекте → TypeError
|
||||
- `waterbodies/[slug].astro` — `items.map` на объекте → TypeError
|
||||
- `waterbodies/[slug]/[fish].astro` — `items.length` на объекте → ложное пустое состояние
|
||||
- `spots/[id].astro` — `activityRows.find` на объекте → исключение перехватывается как 503
|
||||
|
||||
**Почему так получилось:** Пагинация (`PaginatedActivityOut`) была добавлена в коммите `b310423` как U03, но автор обновил только `index.astro` — самую заметную страницу. Остальные три каталога и detail-страница точки не были затронуты. API-тесты тоже ожидали старый формат.
|
||||
|
||||
**Что сделано:**
|
||||
- Все 4 потребителя обновлены: `api<Activity[]>` → `api<PaginatedActivity>`, доступ через `.items`.
|
||||
- `spots/[id].astro`: `activityRows.find(item => ...)` → `activityPaginated.items.find(item => ...)`.
|
||||
- 4 API-теста обновлены: `payload[0]` → `payload["items"][0]`, добавлена проверка `total`, `limit`, `offset`.
|
||||
- `test_rate_limit.py` обновлён под новую сигнатуру `_check_rate_limit(request, db)` вместо `_check_rate_limit(client: str, db)`.
|
||||
- `test_readiness.py` обновлён под новый `community_scheduler` компонент.
|
||||
|
||||
**Файлы:** `apps/api/app/main.py`, `apps/api/app/schemas.py`, `apps/web/src/pages/fish/[slug].astro`, `apps/web/src/pages/waterbodies/[slug].astro`, `apps/web/src/pages/waterbodies/[slug]/[fish].astro`, `apps/web/src/pages/spots/[id].astro`, `apps/api/tests/test_api.py`, `apps/api/tests/test_rate_limit.py`, `apps/api/tests/test_readiness.py`, `apps/web/src/lib/api.ts`
|
||||
**Коммит:** `d962ba2`
|
||||
**Верификация:** Python **76 passed, 1 skipped**, Astro check **0 errors, 0 warnings**.
|
||||
|
||||
---
|
||||
|
||||
### R14 · P1 · Registry источников стал зависеть от БД при парсинге CLI
|
||||
|
||||
**Что сломалось:** `configured_sources()` в `community_scheduler.py` теперь делает `SessionLocal()` для чтения `DataSource.enabled` из БД. Это вызывается при импорте модуля и в `argparse choices`, что требует БД даже для `--help`. Два unit-теста scheduler падают с `connection refused`.
|
||||
|
||||
**Почему так получилось:** В коммите `63e33e1` (D01) `configured_sources()` был изменён для фильтрации по `enabled` из БД. Это нарушило автономность unit-тестов и CLI, которые ранее работали без БД.
|
||||
|
||||
**Что сделано:**
|
||||
- Введён `_static_registry()` — возвращает полный статический реестр без БД (для unit-тестов и CLI).
|
||||
- `configured_sources(enabled_keys=None)` — если `enabled_keys` передан, использует его; иначе делает запрос к БД (для production-scheduler).
|
||||
- `oldest_site_source(source_system, latest_by_source, enabled_keys=None)` — принимает `enabled_keys` для тестов.
|
||||
- `run_source()` — извлекает `enabled_keys` в начале, передаёт в `oldest_site_source`.
|
||||
- Тесты используют `_static_registry()` и передают `enabled_keys` явно.
|
||||
|
||||
**Файлы:** `apps/api/app/community_scheduler.py`, `apps/api/tests/test_community_scheduler.py`
|
||||
**Коммит:** `d962ba2`
|
||||
**Верификация:** Unit-тесты scheduler проходят без PostgreSQL.
|
||||
|
||||
---
|
||||
|
||||
### R08 · P1 · Фильтры UI не доведены до responsive-состояния
|
||||
|
||||
**Что сломалось:** `.filter-compact-hidden` использовал `display:contents!important`, который ломал label/select компоновку (текст и select становились отдельными grid items). Без JS период/сортировка были недоступны.
|
||||
|
||||
**Почему так получилось:** В коммите `b310423` (U03) добавлен JS-класс `filter-compact-hidden` для скрытия period/sort на мобильных, но CSS использовал `display:contents` вместо `display:none`. Это ломало grid-компоновку.
|
||||
|
||||
**Что сделано:**
|
||||
- `.filter-compact-hidden` изменён на `display:none!important`.
|
||||
- Добавлен `<details class="filter-advanced-fallback">` как fallback для period/sort на мобильных без JS.
|
||||
- CSS для `.filter-advanced-fallback` показывает `<details>` на мобильных, скрывает на desktop.
|
||||
- JS по-прежнему управляет скрытием через класс, но без JS `<details>` раскрывается по клику.
|
||||
|
||||
**Файлы:** `apps/web/src/pages/index.astro`, `apps/web/src/styles/global.css`
|
||||
**Коммит:** `38bb871`
|
||||
**Верификация:** Astro check **0 errors**.
|
||||
|
||||
---
|
||||
|
||||
## Текущий статус тестов
|
||||
|
||||
| Проверка | Результат |
|
||||
|----------|-----------|
|
||||
| `pytest -q` | **76 passed, 1 skipped, 0 failures** |
|
||||
| `npm run check` | **0 errors, 0 warnings, 0 hints** |
|
||||
| `caddy adapt` | **passes** |
|
||||
|
||||
---
|
||||
|
||||
### R03 · P1 · Research CLI не читает и не создаёт cooldown state
|
||||
|
||||
**Что сломалось:** `enforce_fetch_interval` открывал файл в `r` — падал при первом запуске (файл не существует). `mark_fetch` открывал в `r+` — падал при отсутствии файла. `f.read(encoding="utf-8")` — `encoding` не является аргументом `read()`. Shared/exclusive locks не делали read-modify-write атомарным.
|
||||
|
||||
**Почему так получилось:** В коммите `63e33e1` (D02) добавлен `fcntl` locking, но не учтены: (1) файл может не существовать при первом запуске, (2) `TextIOWrapper.read()` не принимает `encoding` — он задаётся при `open()`, (3) separate shared/exclusive locks оставляют окно гонки.
|
||||
|
||||
**Что сделано:**
|
||||
- Введены `_read_state(state_file)` и `_write_state(state_file, state)` как единые точки доступа.
|
||||
- `_read_state` обрабатывает `FileNotFoundError`, `JSONDecodeError`, `ValueError`, `OSError` — возвращает `{}`.
|
||||
- `_write_state` использует `open("w")` + exclusive lock.
|
||||
- `enforce_fetch_interval` и `mark_fetch` используют единые helpers.
|
||||
- `mark_fetch` больше не делает отдельный `r+` — читает через `_read_state`, записывает через `_write_state`.
|
||||
|
||||
**Файлы:** `rf4_research/community_cli.py`
|
||||
**Коммит:** pending
|
||||
**Верификация:** `pytest tests/test_community_cli.py` — **3 passed**.
|
||||
|
||||
---
|
||||
|
||||
## Оставшиеся регрессии (R04-R13, R15)
|
||||
|
||||
### R03 · P1 · Research CLI не читает и не создаёт cooldown state
|
||||
- **Проблема:** `enforce_fetch_interval` открывает файл в `r`, `mark_fetch` — в `r+`; оба падают при первом запуске. `f.read(encoding="utf-8")` — `encoding` задаётся при открытии, а не в `read()`.
|
||||
- **Статус:** Не начато.
|
||||
|
||||
### R04 · P1 · Disabled-фильтрация может обойти общий интервал сайта
|
||||
- **Проблема:** `configured_sources()` исключает disabled из списка кандидатов, но `site_sources` для истории cooldown строится из полного реестра. Отключение endpoint сбрасывает site cooldown для соседей.
|
||||
- **Статус:** Не начато.
|
||||
|
||||
### R05 · P1 · Bootstrap небезопасен для источников
|
||||
- **Проблема:** `test-production-bootstrap.sh` включает `proxy community-scheduler`, но наследует production-домены, порты 80/443 и реальные URL парсеров.
|
||||
- **Статус:** Не начато.
|
||||
|
||||
### R06 · P1 · Фильтры сигналов сравнивают slug с названием
|
||||
- **Проблема:** `community_observations` фильтрует по `fish_name`/`waterbody_name` (названия источника), но frontend передаёт `fish`/`waterbody` slugs.
|
||||
- **Статус:** Не начато.
|
||||
|
||||
### R07 · P1 · Ошибка главной остаётся HTTP 200
|
||||
- **Проблема:** `if (unavailable)` стоит до запросов, ветка 503/Retry-After/noindex никогда не срабатывает.
|
||||
- **Статус:** Частично исправлено в S01 (добавлен noindex для index/records при unavailable), но `unavailable` проверяется после присваивания `false` — нужно подтвердить.
|
||||
|
||||
### R08 · P1 · Фильтры UI не доведены до responsive-состояния
|
||||
- **Проблема:** `.filter-compact-hidden` с `display:contents!important` ломает label/select компоновку. Без JS период/сортировка недоступны.
|
||||
- **Статус:** Не начато.
|
||||
|
||||
### R09 · P1 · Публичной пагинации по-прежнему нет
|
||||
- **Проблема:** `limit=20&offset=0` всегда, нет перехода к следующей странице.
|
||||
- **Статус:** Частично исправлено в U03 (добавлен `total` и кнопка "Показать ещё"), но полная пагинация с сохранением фильтров не реализована.
|
||||
|
||||
### R10 · P1 · Ошибки формы теряют черновик, timeout распознаётся неверно
|
||||
- **Проблема:** `rate_limited`, `server_error`, `timeout` не восстанавливают черновик. `AbortSignal.timeout` использует `TimeoutError`, а не `TypeError`.
|
||||
- **Статус:** Частично исправлено в U04 (добавлены сообщения об ошибках, блокировка double submit), но корректная классификация TimeoutError не проверена.
|
||||
|
||||
### R11 · P1 · Проверка URL происходит после сетевого обращения
|
||||
- **Проблема:** `fetch_html` делает `urlopen` до проверки allowlist.
|
||||
- **Статус:** Не начато.
|
||||
|
||||
### R12 · P1 · Мониторинг не сигнализирует о зависшем импорте
|
||||
- **Проблема:** `readiness_report` добавляет `community_scheduler`, но failed/stale не влияют на итог `ready`.
|
||||
- **Статус:** Не начато.
|
||||
|
||||
### R13 · P1 · Доверие к IP клиента не ограничено proxy boundary
|
||||
- **Проблема:** `_check_rate_limit` принимает первый `X-Forwarded-For` от любого peer.
|
||||
- **Статус:** Не начато.
|
||||
|
||||
### R15 · P1 · D04/D06/D07/D08 выполнены не полностью
|
||||
- **D04:** Fallback только для waterbody, рыба требует external ID.
|
||||
- **D06:** "Один игрок не может искусственно поднять уверенность" — формула допускает 72% при 1 игроке.
|
||||
- **D07:** `community_review.py` присваивает `caught_at=published_at`.
|
||||
- **D08:** Запрос точки ограничен водоёмом, но остаётся top-100.
|
||||
- **Статус:** Не начато.
|
||||
|
||||
---
|
||||
|
||||
## Следующие шаги
|
||||
|
||||
1. **R03** — Research CLI cooldown: исправить `fcntl` locking и `encoding`.
|
||||
2. **R04** — Разделить `configured_sources()` и `site_cooldown_sources()`.
|
||||
3. **R05** — Перевести bootstrap на loopback-порты и локальные домены.
|
||||
4. **R06** — Исправить `community_observations` на фильтрацию по `fish_slug`/`waterbody_slug`.
|
||||
5. **R07** — Переместить `unavailable` проверку после запросов.
|
||||
6. **R08** — Исправить CSS для `.filter-compact-hidden`, добавить fallback без JS.
|
||||
7. **R09** — Полная пагинация с сохранением фильтров.
|
||||
8. **R10** — Корректная обработка `TimeoutError`.
|
||||
9. **R11** — Проверка allowlist до `urlopen`.
|
||||
10. **R12** — Влияние `community_scheduler` на readiness.
|
||||
11. **R13** — Proxy trust boundary для X-Forwarded-For.
|
||||
12. **R15** — D04/D06/D07/D08 partial completion.
|
||||
+27
-3
@@ -1,10 +1,10 @@
|
||||
# План работ RF4 Spotter
|
||||
|
||||
Приоритет после полного аудита: [отчёт 8 сентября 2026](PROJECT_AUDIT_2026-09-08.md). Новый пакет ниже имеет приоритет над историческими чекбоксами; [AUDIT_FIXES.md](AUDIT_FIXES.md) сохраняет историю исправлений.
|
||||
Приоритет: [повторный аудит 9 сентября 2026](REGRESSION_AUDIT_2026-09-09.md) после сторонних исправлений. Пакет R01–R15 ниже имеет приоритет над [аудитом 8 сентября](PROJECT_AUDIT_2026-09-08.md) и историческими чекбоксами; [AUDIT_FIXES.md](AUDIT_FIXES.md) сохраняет историю исправлений.
|
||||
|
||||
Этот файл — рабочий источник правды по развитию проекта. После завершения задачи её чекбокс меняется с `[ ]` на `[x]`, рядом добавляется ссылка на коммит или короткое подтверждение проверки. Новые задачи добавляются в соответствующий этап, а не хранятся только в переписке.
|
||||
|
||||
Последняя сверка кода, production-конфигурации, UI/UX, визуальной идентичности и SEO: 8 сентября 2026 года (база `486b4e9`).
|
||||
Последняя сверка изменений кода и production-конфигурации: 9 сентября 2026 года (`c6fdc96..9ae05ef`). Python: 10 failed / 86 passed / 1 skipped; Astro check/build и web unit — успешно; Caddy adapt — ошибка. Визуальная приёмка обновлённого UI ещё не выполнена.
|
||||
|
||||
Обозначения:
|
||||
|
||||
@@ -180,7 +180,31 @@
|
||||
|
||||
## Ближайший рабочий пакет
|
||||
|
||||
Production-контур требует исправлений до запуска. Доказательства и критерии приёмки каждого ID находятся в [полном аудите](PROJECT_AUDIT_2026-09-08.md). Следующая задача — **T03**. Инфраструктурные изменения можно объединить в один проверочный Compose-прогон.
|
||||
Production-контур требует исправлений до запуска. Следующая задача — **R01**, затем **R02**. Текущая сборка не считается принятой по результатам проверок прежних коммитов. Старые T/D/U/V/S сохраняются как тематический backlog, а не параллельная очередь дублирующих исправлений.
|
||||
|
||||
### Повторная приёмка 9 сентября 2026
|
||||
|
||||
Доказательства и критерии приёмки: [повторный аудит](REGRESSION_AUDIT_2026-09-09.md). В этом проходе изменена только документация, исправления ниже ещё не выполнены.
|
||||
|
||||
- [ ] R01 (P0, T05): исправить невалидный Caddyfile, проверить adapt, 413 и маршруты.
|
||||
- [ ] R02 (P0, U03/D08): согласовать activity envelope со всеми страницами; SSR populated/empty и API regression tests.
|
||||
- [ ] R03 (P1, D02): восстановить CLI state; атомарная проверка/резервирование, flush, миграция и конкурентные тесты.
|
||||
- [ ] R04 (P1, D01/D02): исключать disabled из ротации, но сохранять всю историю сайта для cooldown; убрать гонки registry.
|
||||
- [ ] R05 (P1, T06): изолировать bootstrap-порты/домены и fixture-источники; проверить сценарии через proxy без внешних запросов.
|
||||
- [ ] R11 (P1, D03): проверять URL до запроса и каждый redirect; единый ключ сайта и ограниченное чтение.
|
||||
- [ ] R06 (P1, U02): согласовать slug-фильтры полевых сигналов с alias/mapping, сохранить источник и пометки неполноты.
|
||||
- [ ] R07 (P1, S01): единый HTTP/noindex/cache/JSON-LD контракт ошибок после загрузки данных.
|
||||
- [ ] R08 (P1, U01): восстановить label/select сетку, selected всех периодов и mobile/no-JS управление.
|
||||
- [ ] R09 (P1, U03): добавить настоящую пагинацию activity/records/catalogs с сохранением фильтров и честным total.
|
||||
- [ ] R10 (P1, U04/T05): черновик и фокус для всех ошибок, TimeoutError, безопасный повтор и обработка multipart/413.
|
||||
- [ ] R12 (P1, T06/D10): мониторить здоровье каждого enabled источника/площадки, stale/running/failed; разбирать JSON статусов.
|
||||
- [ ] R13 (P1, T04): определить proxy trust boundary, валидировать IP и проверить независимость rate limit двух клиентов.
|
||||
- [ ] R14 (P2, D01/T08): отделить registry от БД; CLI help и unit-тесты работают без внешнего PostgreSQL.
|
||||
- [ ] R15 (P1, D04/D06/D07/D08): единые правила alias fallback, честная уверенность/время, адресный агрегат точки и рыбы.
|
||||
- [ ] Приёмка пакета: зелёный Python suite, web unit в CI, SSR и одна общая desktop/mobile проверка populated/empty/error/long/keyboard/zoom; один изолированный production acceptance без живого scraping.
|
||||
- [ ] Сверить T03/T07 и остальные частично реализованные старые задачи с критериями; только после проверки перенести отметки `[x]` и обновить README.
|
||||
|
||||
После пакета: D05/S02 → V01–V04/U05–U07 → S03–S06 → T08–T10/D09–D10. Серверные S07 и launch checklist остаются отдельной стадией; смена Astro-стека не планируется.
|
||||
|
||||
### P0 — блокеры production
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -31,6 +32,37 @@ DETAIL_SOURCES = {
|
||||
USER_AGENT = "RF4-Spotter/0.1 (authorized data integration)"
|
||||
MIN_FETCH_INTERVAL_SECONDS = 30 * 60
|
||||
DEFAULT_STATE_FILE = Path(".cache/community-fetch-state.json")
|
||||
ALLOWED_HOSTS = frozenset({
|
||||
"download.rf4db.com", "rf4db.com",
|
||||
"rf4-stat.ru",
|
||||
"rf4map.ru",
|
||||
"rf4-posts.com",
|
||||
})
|
||||
MAX_RESPONSE_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||
|
||||
|
||||
def _read_state(state_file: Path) -> dict:
|
||||
"""Read state file with shared lock; return empty dict if missing/corrupt."""
|
||||
try:
|
||||
with open(state_file, "r") as f:
|
||||
fcntl.flock(f, fcntl.LOCK_SH)
|
||||
try:
|
||||
return json.loads(f.read())
|
||||
finally:
|
||||
fcntl.flock(f, fcntl.LOCK_UN)
|
||||
except (FileNotFoundError, json.JSONDecodeError, ValueError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def _write_state(state_file: Path, state: dict) -> None:
|
||||
"""Write state file atomically with exclusive lock."""
|
||||
state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(state_file, "w") as f:
|
||||
fcntl.flock(f, fcntl.LOCK_EX)
|
||||
try:
|
||||
f.write(json.dumps(state, sort_keys=True))
|
||||
finally:
|
||||
fcntl.flock(f, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def fetch_site_key(url: str) -> str:
|
||||
@@ -47,10 +79,7 @@ def enforce_fetch_interval(
|
||||
source: str, *, state_file: Path, now: float | None = None,
|
||||
) -> None:
|
||||
now = time.time() if now is None else now
|
||||
try:
|
||||
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
state = {}
|
||||
state = _read_state(state_file)
|
||||
last_fetch = state.get(source)
|
||||
if isinstance(last_fetch, (int, float)) and now - last_fetch < MIN_FETCH_INTERVAL_SECONDS:
|
||||
wait = int(MIN_FETCH_INTERVAL_SECONDS - (now - last_fetch))
|
||||
@@ -59,23 +88,24 @@ def enforce_fetch_interval(
|
||||
|
||||
def mark_fetch(source: str, *, state_file: Path, now: float | None = None) -> None:
|
||||
now = time.time() if now is None else now
|
||||
try:
|
||||
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
state = {}
|
||||
state = _read_state(state_file)
|
||||
state[source] = now
|
||||
state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = state_file.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(state, sort_keys=True), encoding="utf-8")
|
||||
temporary.replace(state_file)
|
||||
_write_state(state_file, state)
|
||||
|
||||
|
||||
def fetch_html(url: str, *, timeout: float = 30) -> str:
|
||||
request = Request(url, headers={"User-Agent": USER_AGENT, "Accept": "text/html"})
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
final_url = response.url
|
||||
hostname = (urlsplit(final_url).hostname or "").lower()
|
||||
if hostname not in ALLOWED_HOSTS:
|
||||
raise ValueError(f"URL hostname {hostname} not in allowlist")
|
||||
if response.headers.get_content_type() != "text/html":
|
||||
raise ValueError(f"expected text/html, got {response.headers.get_content_type()}")
|
||||
return response.read().decode(response.headers.get_content_charset() or "utf-8")
|
||||
data = response.read(MAX_RESPONSE_BYTES + 1)
|
||||
if len(data) > MAX_RESPONSE_BYTES:
|
||||
raise ValueError("response exceeded 5MB limit")
|
||||
return data.decode(response.headers.get_content_charset() or "utf-8")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
Reference in New Issue
Block a user