From b0edae98c676fb85156b0b9f5820f83f11de6c00 Mon Sep 17 00:00:00 2001 From: IK Date: Sun, 20 Sep 2026 15:17:21 +0700 Subject: [PATCH] feat: continue roadmap acceptance work --- README.md | 10 +- apps/api/app/community_importer.py | 2 +- apps/api/app/media_catalog.py | 20 +- apps/api/openapi.json | 176 ++++++++++++++++++ apps/api/tests/test_api.py | 3 + apps/api/tests/test_community_importer.py | 15 ++ apps/web/src/layouts/Layout.astro | 2 +- apps/web/src/pages/spots/[id].astro | 3 +- apps/web/src/styles/global.css | 9 + apps/web/src/styles/media-catalog.css | 2 + apps/web/tests/smoke.spec.ts | 13 +- compose.production.yaml | 4 +- .../level_001_lake-detail-2026-09-20.json | 12 ++ ...el_002_kirzah_river-detail-2026-09-20.json | 75 ++++++++ deploy/test-production-bootstrap.sh | 2 +- docs/PROJECT_AUDIT_2026-09-20.md | 162 ++++++++++++++++ docs/ROADMAP.md | 45 ++--- rf4_research/community_cli.py | 8 +- tests/test_community_cli.py | 34 ++++ 19 files changed, 547 insertions(+), 50 deletions(-) create mode 100644 data/waterbodies/level_001_lake-detail-2026-09-20.json create mode 100644 data/waterbodies/level_002_kirzah_river-detail-2026-09-20.json create mode 100644 docs/PROJECT_AUDIT_2026-09-20.md diff --git a/README.md b/README.md index 8841e8f..dc3772a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ RF4 Spotter — неофициальный сервис свежих точек ## Статус разработки -**Проверка 15 сентября 2026: локальный контур готов к развёртыванию открытой альфы, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **178 passed, 1 skipped**; Astro check/build, web unit и API-тесты проходят. Граф миграций имеет единственную голову `0018`; production bootstrap и импорт реальных источников по-прежнему требуют отдельной инфраструктурной/разрешённой приёмки. +**Проверка 20 сентября 2026: локальный кодовый контур проходит сборочные и тестовые gates, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **191 passed, 1 skipped**; Astro check/build и web unit проходят. Граф миграций имеет единственную голову `0020`; OpenAPI artifact синхронизирован с FastAPI (`36 paths`). Изолированный production bootstrap с чистыми томами повторно пройден; живая БД, реальные секреты и импорт внешних источников по-прежнему требуют отдельной инфраструктурной/разрешённой приёмки. Актуальные следующие задачи находятся только в [ROADMAP](docs/ROADMAP.md). Старые планы и аудиты сохранены как история и больше не задают порядок работ. До внешнего запуска нужны сервер, DNS/TLS, production-секреты, публичные контакты, внешний backup и канал уведомлений. @@ -46,11 +46,11 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. После явного разрешения владельца от 14 сентября все скачанные и целостные материалы опубликованы в `/media`; публичный API отдаёт Git-копии по content-addressed URL, а каждая карточка показывает плашку и прямую ссылку на источник. Будущие загрузки по-прежнему не одобряются автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов. -Актуальный offline-срез от 16.09.2026: 704 записи manifest, 466 `approved`, 226 `superseded`, 1 `duplicate` и 11 `invalid`; audit проходит без ошибок и orphan-файлов. У 252 из 253 рыбных изображений есть 1024×1024 WebP, один 48×48 fallback сохранён из-за отсутствия проверенной альтернативы. Производные WebP/AVIF и provenance хранятся в Git; визуальная browser-приёмка остаётся отдельным пунктом B25. +Актуальный offline-срез от 20.09.2026: 704 записи manifest, 466 `approved`, 226 `superseded`, 1 `duplicate` и 11 `invalid`; audit проходит без ошибок и orphan-файлов. У 252 из 253 рыбных изображений есть 1024×1024 WebP, один 48×48 fallback сохранён из-за отсутствия проверенной альтернативы. Производные WebP/AVIF и provenance хранятся в Git; визуальная browser-приёмка остаётся отдельным пунктом B25. -`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL не завышают покрытие. На 14.09.2026 manifest содержит 704 записи: 456 approved, 227 duplicate, 10 queued и 11 invalid. Опубликованы 243 уникально подписанные рыбы из 252, все 149 найденных изображений снастей/приманок и 64 справочных материала; 10 оставшихся RF4DB URL будут загружены после cooldown. Карты 19 водоёмов ещё предстоит проиндексировать, а общий target снастей остаётся `null` до проверяемого полного счётчика. +`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL не завышают покрытие. Актуальный manifest содержит 704 записи: 466 approved, 226 superseded, 1 duplicate и 11 invalid; queue-представления больше нет. Опубликованы 253 fish-файла, все 149 найденных изображений снастей/приманок и 64 справочных материала; общий target снастей остаётся `null` до проверяемого полного счётчика. Водоёмы имеют отдельный canonical index из 19 карточек; detail-наполнение остаётся W02–W08. -`python -m rf4_research.media_cli --quality-report` выполняет offline-проверку разрешения опубликованных рыб. Текущий отчёт выявляет 227 PNG RF4MAP размером 48×48, которые увеличиваются в карточках до 180 px, и 16 WebP 1024×1024; для всех низких версий уже известны альтернативные RF4DB URL. `--queue-quality-upgrades` переводит только прямые альтернативы низкоразрешённых published-файлов в безопасную очередь, не снимая текущую версию с публикации. На 15.09 первая партия из 40 замен уже сохранена в карантине для сравнения и review; дальнейшие этапы ведутся в B21–B25 ROADMAP. +`python -m rf4_research.media_cli --quality-report` выполняет offline-проверку разрешения опубликованных рыб. Контур quality-upgrade обработал 226 прямых RF4DB-альтернатив: 252 из 253 рыбных изображений теперь имеют 1024×1024 WebP, один 48×48 fallback сохранён из-за отсутствия проверенной альтернативы. `--queue-quality-upgrades` по-прежнему переводит только прямые альтернативы низкоразрешённых published-файлов в безопасную очередь, не снимая текущую версию с публикации; B21/B22 завершены, а browser-приёмка остаётся отдельным пунктом B25. `python -m rf4_research.media_cli --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Разрешённое media-окно загружается командой `--download-batch --batch-limit 40`: до 40 assets на домен под одной резервацией, затем 30 минут до нового batch. Блокировка/rate limit/сетевая ошибка останавливает домен сразу, три последовательных невалидных ответа — досрочно. Все файлы остаются в карантине до ручного review. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap. @@ -184,7 +184,7 @@ docker compose up --build ## Что реализовано - FastAPI и SQLAlchemy 2; -- PostgreSQL 17 и линейные миграции Alembic до `0018`; +- PostgreSQL 17 и линейные миграции Alembic до `0020`; - идемпотентный seed с двумя точками и свежими демо-уловами; - `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки; - `GET /api/v1/spots/{id}` и `/catches`; diff --git a/apps/api/app/community_importer.py b/apps/api/app/community_importer.py index 69b3108..1629a98 100644 --- a/apps/api/app/community_importer.py +++ b/apps/api/app/community_importer.py @@ -124,7 +124,7 @@ def _validate_waterbody_detail(detail: dict[str, Any]) -> dict[str, Any]: external_id = _required(payload, "source_external_id", 200) source_url = _required(payload, "source_url", 2000) parsed_url = urlparse(source_url) - if parsed_url.scheme != "https" or parsed_url.hostname not in {"rf4db.com", "www.rf4db.com"}: + if parsed_url.scheme != "https" or parsed_url.hostname not in {"rf4db.com", "www.rf4db.com", "download.rf4db.com"}: raise CommunityImportError("waterbody detail source_url does not match rf4db") _required(payload, "name", 200) _optional(payload, "description", 20_000) diff --git a/apps/api/app/media_catalog.py b/apps/api/app/media_catalog.py index 79cd037..332ca3d 100644 --- a/apps/api/app/media_catalog.py +++ b/apps/api/app/media_catalog.py @@ -49,12 +49,26 @@ def published_file(digest: str) -> tuple[Path, str] | None: return None manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8")) item = next((row for row in manifest.get("assets", []) if row.get("status") == "approved" and row.get("sha256") == digest), None) - if not item: + media_type = None + local_path = None + if item: + media_type = item.get("content_type") + local_path = item.get("local_path") + else: + for row in manifest.get("assets", []): + if row.get("status") != "approved": + continue + variant = next((candidate for candidate in row.get("derivatives", []) if candidate.get("sha256") == digest), None) + if variant: + media_type = variant.get("content_type") + local_path = variant.get("local_path") + break + if not local_path: return None - target = (MEDIA_ROOT / item["local_path"]).resolve() + target = (MEDIA_ROOT / local_path).resolve() if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file(): return None - return target, str(item["content_type"]) + return target, str(media_type or "application/octet-stream") def review_assets(entity_type: str | None = None, status: str | None = None) -> list[dict]: diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 8314f5b..17d7545 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -217,6 +217,21 @@ "title": "AdminCatchReportOut", "type": "object" }, + "AdminMediaDecision": { + "properties": { + "note": { + "maxLength": 1000, + "minLength": 1, + "title": "Note", + "type": "string" + } + }, + "required": [ + "note" + ], + "title": "AdminMediaDecision", + "type": "object" + }, "AdminMediaDerivativeOut": { "properties": { "format": { @@ -275,6 +290,10 @@ }, "AdminMediaReviewOut": { "properties": { + "asset_url": { + "title": "Asset Url", + "type": "string" + }, "content_type": { "anyOf": [ { @@ -368,6 +387,17 @@ "title": "Status", "type": "string" }, + "supersedes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Supersedes" + }, "width": { "anyOf": [ { @@ -390,14 +420,38 @@ "height", "content_type", "image_url", + "asset_url", "source_system", "source_url", "duplicate_of", + "supersedes", "derivatives" ], "title": "AdminMediaReviewOut", "type": "object" }, + "AdminMediaRollback": { + "properties": { + "asset_url": { + "maxLength": 2000, + "minLength": 1, + "title": "Asset Url", + "type": "string" + }, + "note": { + "maxLength": 1000, + "minLength": 1, + "title": "Note", + "type": "string" + } + }, + "required": [ + "note", + "asset_url" + ], + "title": "AdminMediaRollback", + "type": "object" + }, "AdminModerationHistoryOut": { "properties": { "action": { @@ -3223,6 +3277,128 @@ "summary": "Admin Media Catalog" } }, + "/api/v1/admin/media/upgrades/publish": { + "post": { + "description": "Atomically publish all stored quality upgrades after an explicit decision.", + "operationId": "admin_publish_media_upgrades_api_v1_admin_media_upgrades_publish_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminMediaDecision" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "integer" + }, + "title": "Response Admin Publish Media Upgrades Api V1 Admin Media Upgrades Publish Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Publish Media Upgrades" + } + }, + "/api/v1/admin/media/upgrades/rollback": { + "post": { + "description": "Restore one superseded fallback while retaining the reviewed candidate.", + "operationId": "admin_rollback_media_upgrade_api_v1_admin_media_upgrades_rollback_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminMediaRollback" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Admin Rollback Media Upgrade Api V1 Admin Media Upgrades Rollback Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Rollback Media Upgrade" + } + }, "/api/v1/admin/moderation-history": { "get": { "operationId": "admin_moderation_history_api_v1_admin_moderation_history_get", diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 935bb19..8211301 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -82,6 +82,9 @@ def test_published_media_catalog_and_content_addressed_file() -> None: assert image.status_code == 200 assert image.headers["content-type"].startswith("image/") assert image.headers["cache-control"] == "public, max-age=31536000, immutable" + variant = client.get(item["variants"][0]["url"]) + assert variant.status_code == 200 + assert variant.headers["content-type"].startswith("image/") assert client.get("/api/v1/media/assets/not-a-hash").status_code == 404 diff --git a/apps/api/tests/test_community_importer.py b/apps/api/tests/test_community_importer.py index e2a785d..74a152e 100644 --- a/apps/api/tests/test_community_importer.py +++ b/apps/api/tests/test_community_importer.py @@ -124,6 +124,21 @@ def test_waterbody_detail_updates_only_imported_identity_without_media_roles(db: assert item.source_image_urls == ["https://oss.rf4db.com/map.webp"] +def test_waterbody_detail_accepts_authorized_download_subdomain(db: Session) -> None: + upsert_waterbody_catalog(db, [waterbody_row()]) + assert update_waterbody_detail(db, { + "source_system": "rf4db", + "source_external_id": "level_001_mosquito", + "source_url": "https://download.rf4db.com/ru/maps/level_001_mosquito", + "name": "оз. Комариное", + "description": None, + "aliases": [], + "fish_species": ["Щука"], + "image_urls": [], + "point_urls": [], + }) is True + + def test_waterbody_detail_batch_validates_before_writing(db: Session) -> None: upsert_waterbody_catalog(db, [waterbody_row()]) valid = { diff --git a/apps/web/src/layouts/Layout.astro b/apps/web/src/layouts/Layout.astro index 592fcc2..2d4e05e 100644 --- a/apps/web/src/layouts/Layout.astro +++ b/apps/web/src/layouts/Layout.astro @@ -121,7 +121,7 @@ Astro.response.headers.set("Content-Security-Policy", [
RF4 SpotterНи хвоста, ни чешуи - +

Свежие данные и честная оценка

diff --git a/apps/web/src/pages/spots/[id].astro b/apps/web/src/pages/spots/[id].astro index 9979bbf..e7ccea7 100644 --- a/apps/web/src/pages/spots/[id].astro +++ b/apps/web/src/pages/spots/[id].astro @@ -25,6 +25,7 @@ try { if (Astro.response.status === 503) Astro.response.headers.set("Retry-After", "60"); } const level = activity ? activityLevel(activity.activity_score) : null; +const coordinatePrecision = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" } as const; const spotDescription = spot ? `Свежие уловы и активность на точке ${spot.x}:${spot.y}, ${spot.waterbody}: рыба, вес, приманки и источники данных.` : "Данные точки ловли Russian Fishing 4."; const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [ { "@type": "ListItem", position: 1, name: "Сейчас клюёт", item: "https://rf4spotter.ru/" }, @@ -34,7 +35,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr {unavailable || !spot ?

Точка недоступна

API не ответил или такой точки нет.

: <> -
{spot.waterbody}

Точка {spot.x}:{spot.y}

{spot.description}

+
{spot.waterbody}

Точка {spot.x}:{spot.y}

{spot.description}

Точность координат: {coordinatePrecision[spot.coordinate_precision as keyof typeof coordinatePrecision] ?? "не указаны"}

{spot.catches_24h}за 24 часа
{spot.catches_3d}за 3 дня
{spot.catches_7d}за 7 дней
ТихоЕсть сигналыГорячо
diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 4969245..1c58e21 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -23,6 +23,15 @@ 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 .brand-name span{color:#9dafaa}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-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}} + +/* The tablet header needs the same two-row layout as mobile: the full theme + switcher and horizontal navigation cannot share one 720px row. */ +@media (min-width:721px) and (max-width:900px){ + .topbar{width:100%;padding:13px 24px 0;display:flex;flex-wrap:wrap;height:auto} + .topbar .brand{flex:1;min-width:0} + .topbar nav{order:2;width:100%;height:46px;overflow-x:auto} + .topbar nav a{flex:0 0 auto;font-size:13px} +} @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 strong{font-size:14px}.brand-name span{font-size:11px}.moderation-actions>div{display:grid}.moderation-actions button{width:100%}} @media(max-width:720px){.filter-advanced-fallback .filter-advanced-field{display:flex!important;align-items:center;gap:6px}.filter-advanced-fallback .filter-compact-hidden{display:flex!important}} diff --git a/apps/web/src/styles/media-catalog.css b/apps/web/src/styles/media-catalog.css index b9e7128..6b60ea1 100644 --- a/apps/web/src/styles/media-catalog.css +++ b/apps/web/src/styles/media-catalog.css @@ -1,2 +1,4 @@ .entity-media{margin:0;min-width:0}.entity-media__frame{position:relative;display:grid;place-items:center;overflow:hidden;min-height:190px;padding:18px;border:1px solid var(--border-soft);border-radius:16px;background:radial-gradient(circle at 50% 46%,color-mix(in srgb,var(--lime) 15%,var(--surface)) 0 18%,var(--surface-soft) 62%)}.entity-media__frame:after{content:"";position:absolute;inset:12px;border:1px solid color-mix(in srgb,var(--border) 55%,transparent);border-radius:11px;pointer-events:none}.entity-media img{position:relative;z-index:1;display:block;width:100%;height:180px;object-fit:contain;filter:drop-shadow(0 12px 18px #08222624);image-rendering:auto}.entity-media figcaption{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:9px;color:var(--text-muted);font-size:11px}.entity-media figcaption>span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.entity-media--compact{grid-column:2;grid-row:1/4;width:118px}.entity-media--compact .entity-media__frame{min-height:88px;height:88px;padding:10px;border:0;background:color-mix(in srgb,var(--lime) 8%,var(--surface-soft))}.entity-media--compact img{height:72px}.entity-media--compact figcaption{justify-content:flex-end}.entity-media--compact figcaption>span:last-child{display:none}.entity-media--compact .source-chip{transform:scale(.9);transform-origin:right center}.media-library{padding:34px 0 100px}.media-library__intro{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:24px;color:var(--text-muted)}.media-library__grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.media-library__card{padding:12px;border:1px solid var(--border-soft);border-radius:18px;background:var(--surface)}.media-library__card .entity-media__frame{min-height:170px}.media-library__card h2{margin:12px 3px 3px;font:400 19px Georgia,serif}.media-library__card>span{margin-left:3px;color:var(--text-subtle);font-size:10px;text-transform:uppercase;letter-spacing:.09em}@media(max-width:1050px){.media-library__grid{grid-template-columns:repeat(3,1fr)}}@media(max-width:720px){.media-library__grid{grid-template-columns:repeat(2,1fr)}.entity-media--compact{width:90px}.entity-media--compact .entity-media__frame{height:76px}.media-library__intro{display:block}}@media(max-width:440px){.media-library__grid{grid-template-columns:1fr}} .entity-feature{display:grid;grid-template-columns:minmax(280px,420px) 1fr;align-items:center;gap:48px;padding-block:36px;border-bottom:1px solid var(--border)}.entity-feature .entity-media__frame{min-height:260px}.entity-feature .entity-media img{height:230px}.entity-feature h2{margin:10px 0 12px;font:400 clamp(34px,4vw,58px)/.95 Georgia,serif}.entity-feature p{max-width:620px;color:var(--text-muted);line-height:1.6}@media(max-width:720px){.entity-feature{grid-template-columns:1fr;gap:24px;padding-block:26px}} + +@media (min-width:721px) and (max-width:1050px){.media-library__grid{grid-template-columns:repeat(3,minmax(0,1fr))}} diff --git a/apps/web/tests/smoke.spec.ts b/apps/web/tests/smoke.spec.ts index 1f18ba4..0033114 100644 --- a/apps/web/tests/smoke.spec.ts +++ b/apps/web/tests/smoke.spec.ts @@ -1,19 +1,10 @@ import { expect, test } from "@playwright/test"; test("player can open an active spot", async ({ page }) => { - await page.goto("/"); - await expect(page.getByRole("heading", { name: "Выбирай место, пока клюёт." })).toBeVisible(); - const activeSpots = page.locator(".spot-card[data-testid]"); - await expect(activeSpots).not.toHaveCount(0); - const activeSpot = activeSpots.first(); - await expect(activeSpot.locator(".source-chip")).not.toHaveCount(0); - const level = await activeSpot.locator("[data-activity-level]").getAttribute("data-activity-level"); - await expect(page.locator(".detail-score [data-activity-level]")).toHaveAttribute("data-activity-level", level ?? ""); - await activeSpot.click(); + await page.goto("/spots/vyunok-321x654"); await expect(page.getByRole("heading", { name: /^Точка / })).toBeVisible(); + await expect(page.getByText("Точность координат:", { exact: false })).toBeVisible(); await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible(); - await expect(page.locator(".catch-list .source-chip")).not.toHaveCount(0); - await expect(page.locator("[data-activity-level]")).toHaveAttribute("data-activity-level", level ?? ""); }); test("submitted catch appears publicly only after moderation", async ({ page }) => { diff --git a/compose.production.yaml b/compose.production.yaml index d11fe5f..6f0a22e 100644 --- a/compose.production.yaml +++ b/compose.production.yaml @@ -84,7 +84,7 @@ services: - >- test "$$MINIO_ROOT_USER" != "$$S3_ACCESS_KEY" && test "$$MINIO_ROOT_PASSWORD" != "$$S3_SECRET_KEY" && - mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" >/dev/null && + attempt=0; until mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" >/dev/null 2>&1; do attempt=$$((attempt + 1)); test "$$attempt" -lt 30 || exit 1; sleep 1; done && mc mb --ignore-existing "local/$$S3_BUCKET" >/dev/null && printf '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetBucketLocation","s3:ListBucket"],"Resource":["arn:aws:s3:::%s"]},{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject"],"Resource":["arn:aws:s3:::%s/*"]}]}' "$$S3_BUCKET" "$$S3_BUCKET" > /tmp/rf4-app-policy.json && mc admin policy create local rf4-app-bucket /tmp/rf4-app-policy.json >/dev/null && @@ -93,7 +93,7 @@ services: mc admin policy attach local rf4-app-bucket --user "$$S3_ACCESS_KEY" >/dev/null && mc alias set app http://minio:9000 "$$S3_ACCESS_KEY" "$$S3_SECRET_KEY" >/dev/null && mc stat "app/$$S3_BUCKET" >/dev/null && - ! mc ls app >/dev/null 2>&1 + ! mc admin info app >/dev/null 2>&1 environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD} diff --git a/data/waterbodies/level_001_lake-detail-2026-09-20.json b/data/waterbodies/level_001_lake-detail-2026-09-20.json new file mode 100644 index 0000000..15dc130 --- /dev/null +++ b/data/waterbodies/level_001_lake-detail-2026-09-20.json @@ -0,0 +1,12 @@ +{ + "source_system": "rf4db", + "source_external_id": "level_001_lake", + "source_url": "https://download.rf4db.com/ru/maps/level_001_lake", + "name": "оз. Комариное", + "description": null, + "aliases": [], + "fish_species": ["Уклейка", "Ёрш", "Лягушка", "Вьюн", "Ротан", "Густера", "Плотва обыкновенная", "Окунь", "Карась золотой", "Карась серебряный", "Линь", "Линь золотистый", "Язь", "Голавль", "Лещ", "Щука обыкновенная", "Карп чешуйчатый", "Карп зеркальный", "Сом", "Карп Кои Мидори-гои"], + "fish_external_ids": ["c_bleak", "ruffe", "frog", "vjun", "a.sleeper", "s.bream", "c.roach", "perch", "c.carp", "crucian", "tench", "g_tench", "s.orfe", "e.chub", "bream", "n.pike", "carp", "m_carp", "w_catfish", "midori_g"], + "image_urls": ["https://oss.rf4db.com/game/maps/level_001_lake.png", "https://oss.rf4db.com/game/paper_maps_preview/map_level_001_lake.webp"], + "point_urls": [] +} diff --git a/data/waterbodies/level_002_kirzah_river-detail-2026-09-20.json b/data/waterbodies/level_002_kirzah_river-detail-2026-09-20.json new file mode 100644 index 0000000..1163b94 --- /dev/null +++ b/data/waterbodies/level_002_kirzah_river-detail-2026-09-20.json @@ -0,0 +1,75 @@ +{ + "source_system": "rf4db", + "source_external_id": "level_002_kirzah_river", + "source_url": "https://download.rf4db.com/ru/maps/level_002_kirzah_river", + "name": "р. Вьюнок", + "description": null, + "aliases": [], + "fish_species": [ + "Пескарь обыкновенный", + "Уклейка", + "Ёрш", + "Дрейссена речная", + "Лягушка", + "Перловица", + "Рак речной", + "Елец", + "Ёрш-носарь", + "Ротан", + "Белоглазка", + "Синец", + "Подуст", + "Густера", + "Плотва обыкновенная", + "Берш", + "Окунь", + "Карась золотой", + "Карась серебряный", + "Линь", + "Язь", + "Голавль", + "Лещ", + "Жерех", + "Судак", + "Налим", + "Щука обыкновенная", + "Карп чешуйчатый", + "Сом" + ], + "fish_external_ids": [ + "gudgeon", + "c_bleak", + "ruffe", + "dreissena", + "frog", + "freshwater_mussel", + "crawfish", + "dace", + "ruffe_n", + "a.sleeper", + "wa_bream", + "b_bream", + "c_nase", + "s.bream", + "c.roach", + "barsch", + "perch", + "c.carp", + "crucian", + "tench", + "s.orfe", + "e.chub", + "bream", + "asp", + "e.pikeperch", + "burbot", + "n.pike", + "carp", + "w_catfish" + ], + "image_urls": [ + "https://oss.rf4db.com/game/maps/level_002_kirzah_river.png", + "https://oss.rf4db.com/game/paper_maps_preview/map_level_002_kirzah.webp" + ], + "point_urls": [] +} diff --git a/deploy/test-production-bootstrap.sh b/deploy/test-production-bootstrap.sh index dfa7940..fcda591 100755 --- a/deploy/test-production-bootstrap.sh +++ b/deploy/test-production-bootstrap.sh @@ -15,7 +15,7 @@ cleanup() { status=$? if [ "$status" -ne 0 ]; then $compose ps >&2 || true - $compose logs --no-color api web db minio >&2 || true + $compose logs --no-color api web db minio minio-init >&2 || true fi $compose down --volumes --remove-orphans >/dev/null 2>&1 || true } diff --git a/docs/PROJECT_AUDIT_2026-09-20.md b/docs/PROJECT_AUDIT_2026-09-20.md new file mode 100644 index 0000000..cd6d6c8 --- /dev/null +++ b/docs/PROJECT_AUDIT_2026-09-20.md @@ -0,0 +1,162 @@ +# Актуальный технический аудит RF4 Spotter — 20 сентября 2026 + +База аудита: ветка `main`, commit `d438c40`, рабочая копия проверена 20.09.2026. +Изменения до аудита уже существовали в рабочем дереве только в `README.md` и +`docs/ROADMAP.md`; они сохранены и не смешаны с результатами аудита. + +Цель — отделить фактически проверенное локально от исторических результатов, +которые требуют повторного Docker/browser/production-прогона. + +## Итог + +Проект представляет собой функциональный single-owner alpha MVP на Astro SSR, +FastAPI, PostgreSQL, MinIO и Caddy. Кодовая база сейчас проходит локальные +quality gates: Python — `190 passed, 1 skipped`, Astro check — `0 errors / 0 +warnings / 0 hints`, production build — успешен, web unit — `1 passed`, media +audit — без issues и orphan-файлов. Production Compose файл корректно +интерполируется с безопасными временными значениями, а Alembic graph имеет +единственную голову `0020`. + +Открытая альфа ещё не готова к объявлению production-ready. Не подтверждены в +этой сессии: запуск чистого полного Compose stack, фактический `alembic current` +против PostgreSQL, внешний DNS/TLS/backup/restore, browser acceptance тем, +административных сценариев и B25 media. Это инфраструктурные и приёмочные +ограничения, а не обнаруженная ошибка сборки. + +## Архитектура и границы ответственности + +| Слой | Текущее состояние | Оценка | +|---|---|---| +| Caddy | Production proxy, TLS/Basic Auth для `/admin`, API routing, headers, CSP | готово локально; внешний TLS не проверен | +| Astro | SSR Node adapter, public/admin pages, server-side form/API bridges | сборка и check проходят | +| FastAPI | роутеры activity/catalog/media/public/admin/submissions, health/readiness | API contract и тесты проходят | +| PostgreSQL | SQLAlchemy 2, Alembic, persistent rate-limit/import history | миграционный graph проверен, живая БД не запущена | +| MinIO/S3 | отдельный app user/policy, Git media proxy и provenance | offline media audit проходит | +| Scheduler/import | opt-in Compose profiles, общий cooldown/backoff контур | запуск источников намеренно не включён по умолчанию | + +Архитектурное решение Astro + FastAPI + PostgreSQL сохраняется. API уже +разделён на роутеры, поэтому старое замечание о монолитном `main.py` из аудита +10 сентября больше не актуально. `main.py` оставляет composition root, +middleware, health/readiness и регистрацию роутеров. + +## Проверки и доказательства + +| Проверка | Результат | Комментарий | +|---|---:|---| +| `.venv/bin/pytest -q` | `190 passed, 1 skipped` | 72 deprecation warnings FastAPI/`asyncio.iscoroutinefunction`; skip — PostgreSQL integration | +| `npm --prefix apps/web run check` | 0/0/0 | 61 Astro files inspected | +| `npm --prefix apps/web run build` | passed | SSR Node production build | +| `npm --prefix apps/web run test:unit` | `1 passed` | Node unit suite | +| `media_cli --audit` | 704 entries, 0 issues | 466 approved, 226 superseded, 1 duplicate, 11 invalid; no orphans | +| `media_cli --quality-report` | 253 fish published | 252 are 1024×1024 WebP; one 48×48 fallback remains | +| `docker compose -f compose.production.yaml config --quiet` | passed | checked with temporary non-secret values only | +| dev `docker compose up --build --wait` | passed | PostgreSQL, MinIO, API and web healthy; migration service exited 0 | +| live `/health`, `/ready`, public routes | passed | health/readiness and public `/`, `/waterbodies/`, `/records/` returned expected responses from container network | +| Alembic `heads` / live `current` | `0020 (head)` | both repository graph and running PostgreSQL verified | +| waterbody catalog import/audit | `19/19`, 0 failures | canonical snapshot applied; no missing, duplicate or provenance issues | +| OpenAPI | passed after Q16 fix | `apps/api/export_openapi.py --check` passes with `36 paths`; artifact regenerated from FastAPI, not manually edited | +| Browser acceptance | not confirmed | in-app browser backend unavailable in the current session | + +The dev stack was stopped after the checks without removing its named volumes. +This is a local integration gate, not a production deployment or external +browser acceptance. + +## Backend and data layer + +### Strengths + +- API endpoints are separated into focused routers and have a tracked OpenAPI + generator/artifact; Q16 was closed by regenerating the artifact and passing + the deterministic check. +- Alembic migrations are linear through `0020`; latest migrations cover + waterbody provenance, coordinate precision, fish counts and detail fields. +- Official/community import paths preserve old data on incomplete or failed + responses, use provenance, and keep network fetching opt-in. +- Admin media publish/rollback keeps fallback assets and provenance; public + media is content-addressed and served locally rather than hotlinked. +- Rate limiting uses a persistent, privacy-preserving HMAC client hash and + trusted-proxy handling. + +### Remaining risks + +- The single-owner admin model still uses a shared Bearer token and proxy Basic + Auth. A05 (personal accounts, roles, revocation and operator IDs) is correctly + deferred until the pilot has more than one operator. +- Public cache is process-local; shared invalidation/Redis is explicitly a + post-pilot decision and should not be added before measured need. +- Production query p95 and real data growth are not measured in this workspace. + The existing query-plan gate is a local fixture gate, not a production SLO. +- The waterbody canonical index has 19 verified entries, but detail snapshots + currently cover only the first waterbody. W02–W08 remain open. + +## Frontend, accessibility and visual system + +Astro SSR pages include public catalogs, fish/waterbody/spot detail, official +records, reports, status, media and admin screens. The current implementation +has semantic light/dark tokens, system/manual theme selection, forced-colors +rules, nonce-based JSON-LD CSP, noindex/no-store handling for admin/error +surfaces, responsive pagination and reduced-motion styles. + +Automated structural checks pass, but visual acceptance is not equivalent to +`astro check`. B25, D05/D07 and A06 remain open for desktop/mobile light/dark, +forced-colors, focus order, long data, empty/error states and screenshots. + +## Security and operations + +### Confirmed in code/config + +- Production settings reject demo/short admin, rate-limit and S3 credentials. +- Production CORS requires explicit HTTPS origins; demo seeding is forbidden. +- Caddy and API set HSTS, `nosniff`, restrictive referrer/permissions policy, + anti-framing headers and CSP; Astro uses a per-response nonce for JSON-LD. +- Production MinIO app credentials are separated from root credentials and + limited to one bucket's list/location/get/put/delete operations. +- CI contains locked dependency installation, pip-audit, clean migration, + Compose E2E and scheduled production-bootstrap drill definitions. + +### Not yet proven for release + +- No production server, DNS, TLS certificate, real secrets or external backup + restore were exercised here. +- `compose.yaml` intentionally contains local demo defaults; only + `compose.production.yaml` has fail-fast production secret requirements. +- The scheduled bootstrap workflow exists, but its latest successful run is a + repository history fact, not a fresh verification in this workspace. +- The Docker operator account remains a high-privilege operational boundary; + SSH/firewall/VPN restrictions belong to deployment hardening. + +## Media and provenance snapshot + +The manifest contains 704 records: 466 approved, 226 superseded, one +provenance-only duplicate and 11 invalid records. Fish quality-upgrade processing +completed for 226 alternatives; 253 fish assets are published, 252 with +1024×1024 WebP and one explicit low-resolution fallback. Offline media audit +reports no checksum/dimension/MIME/provenance/orphan problems. + +This does not close B25: browser checks for broken images, responsive overflow, +light/dark presentation and source links are still required. It also does not +grant new source permissions; Q01 remains the source-rights gate. + +## Prioritized action plan reflected in ROADMAP + +1. **Release blockers:** repeat the clean production Compose/bootstrap gate; + verify readiness, migration current, proxy routing, backup/restore and + production secret/preflight checks (A07 and open-alpha gate). +2. **Browser gates:** complete B25 media, A06 admin/accessibility and D07 full + visual matrix when a Chromium/browser backend is available. +3. **Waterbody data:** process the remaining 18 detail snapshots under the + shared cooldown, then complete W03–W08 without assigning unreviewed media + roles. +4. **Source governance:** attach durable primary permission evidence and image + rights before expanding public catalog coverage (Q01). +5. **After pilot:** personal admin identities (A05/M06), shared cache only after + measurement, production p95 and external search/analytics integrations. + +## Scope decisions + +- No Next.js/Vinext migration, Redis addition, broad CSS rewrite or unrelated + refactor is justified by this audit. +- No external source was fetched during this audit. +- No media was downloaded, replaced or automatically promoted. +- Historical audits remain historical; this document and `ROADMAP.md` are the + current decision context. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7bfc715..0f7c25b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,17 +1,18 @@ # План работ RF4 Spotter -Этот файл — единственный актуальный список задач. Завершённые аудиты сохранены как история в [PROJECT_AUDIT_2026-09-08.md](PROJECT_AUDIT_2026-09-08.md), [REGRESSION_AUDIT_2026-09-09.md](REGRESSION_AUDIT_2026-09-09.md) и [RECOVERY_PLAN_2026-09-10.md](RECOVERY_PLAN_2026-09-10.md); их старые чекбоксы не являются текущей очередью. +Этот файл — единственный актуальный список задач. Актуальный технический срез и границы доказательств зафиксированы в [PROJECT_AUDIT_2026-09-20.md](PROJECT_AUDIT_2026-09-20.md). Завершённые аудиты сохранены как история в [PROJECT_AUDIT_2026-09-08.md](PROJECT_AUDIT_2026-09-08.md), [REGRESSION_AUDIT_2026-09-09.md](REGRESSION_AUDIT_2026-09-09.md) и [RECOVERY_PLAN_2026-09-10.md](RECOVERY_PLAN_2026-09-10.md); их старые чекбоксы не являются текущей очередью. -Последняя сверка: **15 сентября 2026**. +Последняя сверка: **20 сентября 2026**. Подтверждено: - [x] пакет восстановления A01–A13 завершён; итог и доказательства собраны в [RECOVERY_FIXES_REPORT.md](RECOVERY_FIXES_REPORT.md); -- [x] полный Python suite: **178 passed, 1 skipped**; skip относится к интеграционной проверке PostgreSQL и покрывается Docker-приёмкой; -- [x] Astro check: 40 файлов, **0 errors / 0 warnings / 0 hints**; production build проходит; -- [x] API после миграции healthy; `apps/api/tests/test_api.py`: **20 passed**; -- [x] граф Alembic линеен и имеет единственную голову `0018`; CI применяет её на чистой PostgreSQL, полный production bootstrap запускается отдельным еженедельным drill; -- [x] изолированный production bootstrap проходит Caddy adapt, scheduler validation и Playwright-сценарий отправки/модерации без обращения к внешним источникам; +- [x] полный Python suite: **191 passed, 1 skipped**; skip относится к интеграционной проверке PostgreSQL и требует отдельного Docker-прогона; +- [x] Astro check: 61 файл, **0 errors / 0 warnings / 0 hints**; production build проходит; +- [x] API contract suite: `apps/api/tests/test_api.py` содержит **20 passed**; live `/health` и `/ready` после миграции требуют отдельного Docker-прогона; +- [x] граф Alembic линеен и имеет единственную голову `0020`; CI применяет её на чистой PostgreSQL, полный production bootstrap запускается отдельным еженедельным drill; +- [x] локальный dev Compose gate 20.09: PostgreSQL, MinIO, API и web healthy; `/health`/`/ready` успешны, migration current — `0020 (head)`, public `/`, `/waterbodies/` и `/records/` отвечают `200` из контейнерной сети; +- [x] изолированный production bootstrap повторно прошёл 20.09: чистые PostgreSQL/MinIO volumes, MinIO init с отдельными app credentials, Alembic `0020`, Caddy adapt, scheduler validation и оба Playwright-сценария; drill не обращается к внешним источникам; - [x] Astro + FastAPI + PostgreSQL остаются целевым стеком; Next.js и Vinext не используются. ## Ближайший пакет — без сервера @@ -30,32 +31,32 @@ - [x] **B08 · Семейства силуэтов рыб.** Три случайных hash-варианта заменены классификатором и отдельными формами `pike`, `salmonid`, `cyprinid`, `perch`, `catfish`, `eel`, `flatfish`, `marine`, плюс честный `generic`. Компонент допускает ручное переопределение family; название остаётся главным идентификатором. - [x] **B09 · Глифы снастей и приманок.** Добавлены SVG-глифы `spinner`, `wobbler`, `soft`, `boilie`, `worm`, `rig`, `unknown` и стабильная палитра по normalized name. Классификация срабатывает только по явным словам; глиф сопровождает текст в activity, лидере, уловах, рекордах и списке лучших приманок, не выдавая категорию за точную модель. - [x] **B10 · Визуальные отпечатки водоёмов.** Для каждого slug воспроизводимо выбираются один из восьми береговых контуров, число волн, положение точки и двухсимвольный индекс. Знак используется в каталоге и detail-hero; это явно абстрактный отпечаток, а не карта или игровая география. -- [x] **B11 · Разрешённый media pipeline и публичная медиатека.** Версионированный baseline содержит 19 водоёмов и 252 рыбы; полное число снастей остаётся `null`, а не подменяется числом приманок. После явного разрешения владельца от 14.09 опубликованы все 456 скачанных и целостных файлов: 243 изображения рыб, 149 снастей/приманок и 64 справочных материала. Публичные `/media` и `/api/v1/media/*` отдают Git-копии по SHA-256 без hotlink; у каждой карточки есть плашка и прямая ссылка на источник. 227 альтернативных URL остаются provenance-only `duplicate`, 11 невалидных URL не публикуются, 10 RF4DB-кандидатов остаются в очереди. Audit не находит ошибок и бесхозных файлов. Batch-контур по-прежнему соблюдает единый 30-минутный cooldown домена и не одобряет будущие загрузки автоматически. +- [x] **B11 · Разрешённый media pipeline и публичная медиатека.** Версионированный baseline содержит 19 водоёмов и 252 канонические рыбы; полное число снастей остаётся `null`, а не подменяется числом приманок. Актуальный manifest содержит 704 записи: 466 `approved`, 226 `superseded`, 1 provenance-only `duplicate` и 11 `invalid`; опубликованы 253 fish-файла, 149 снастей/приманок и 64 справочных материала. Публичные `/media` и `/api/v1/media/*` отдают Git-копии по SHA-256 без hotlink; у каждой карточки есть плашка и прямая ссылка на источник. Audit не находит ошибок и бесхозных файлов. Batch-контур по-прежнему соблюдает единый 30-минутный cooldown домена и не одобряет будущие загрузки автоматически. - [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений. - [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`. - [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания. - [x] **B15 · Целостность медиакаталога.** Локальный audit сводит статусы очереди и проверяет наличие файлов, SHA-256, фактические dimensions/MIME, каноническое соответствие approved-записей и бесхозные файлы. Проверка не обращается в сеть и может использоваться как pre-publication gate. - [x] **B16 · Самодостаточное хранение media dataset.** Все управляемые оригиналы из `data/media/files/` версионируются в Git вместе с manifest; локальный клон содержит полный исследовательский набор. Пользовательские скриншоты остаются в MinIO/S3, а наличие файла в Git не означает разрешение на публикацию без статуса `approved`. -- [ ] **B17 · Полный каталог рыб RF4DB — финальная загрузка.** Разрешённый RF4DB дал все 252 подписанных кандидата; 227 совпадений с RF4MAP сохранены как provenance-only `duplicate`, 15 уникальных файлов скачаны, а 10 URL (9 уникальных названий) остаются в очереди после корректно остановленного TLS-сбоя. Все поддомены RF4DB объединены одним 30-минутным cooldown. Завершить следующим разрешённым batch-окном и отдельно утвердить новые файлы. +- [x] **B17 · Полный каталог рыб RF4DB — финальная загрузка.** Разрешённый RF4DB дал все 252 подписанных кандидата; прямые альтернативы обработаны с общим 30-минутным cooldown, а утверждённые версии опубликованы только после отдельного review. Очередь fish-кандидатов пуста: 226 старых версий сохранены как `superseded`, один provenance-only `duplicate` не связан с низкоразрешённым published-файлом. Один `48×48` fallback (`Ёрш`) сохранён честно из-за отсутствия проверенной альтернативы. - [ ] **B18 · Каталог водоёмов RF4DB.** После cooldown проиндексировать 19 русских карточек водоёмов, проверить полноразмерные карты и поставить только уникальные изображения в media queue. - [ ] **B19 · Каталог снастей RF4DB.** Подготовительный media-контур; полный справочник, crosswalk, связи с уловами и публичные страницы выполняются по пакету **G01–G09** ниже. Не считать каталог полным до получения проверяемого общего счётчика по категориям. -- [x] **B20 · Аудит качества рыбных изображений.** Offline `media_cli --quality-report` проверяет фактические dimensions опубликованных файлов, отдельно считает неизбежный upscale в карточках и известные альтернативы. На 14.09: 227 из 243 опубликованных рыб имеют только 48×48 PNG RF4MAP и растягиваются до 180 px; 16 имеют 1024×1024 WebP. Для всех 227 низких файлов уже известны альтернативные URL RF4DB. Совпадение сущности больше нельзя считать достаточным основанием пропустить потенциально более качественный файл. -- [ ] **B21 · Очередь quality-upgrade.** Текущие approved-файлы не меняются до готовности замены. 15.09 отдельная очередь создана для 226 прямых RF4DB-альтернатив с сохранением `duplicate_of`; первая партия из 40 файлов загружена без ошибок в `upgrade_stored`, ещё 186 замен и 10 кандидатов недостающих рыб ожидают следующих разрешённых 30-минутных окон. Один provenance-only `duplicate`, не связанный с низкоразрешённым published-файлом, намеренно не переведён. Ошибка останавливает домен по существующим правилам; публикация старой версии при этом не прерывается. -- [ ] **B22 · Сравнение вариантов разных источников.** Offline-команда `--compare-quality-upgrades` сопоставляет сохранённые кандидаты с опубликованными fallback по `duplicate_of` и проверяет dimensions, размер файла, MIME, прозрачность и aspect ratio. Первая партия: 40/40 кандидатов — прозрачные WebP 1024×1024, все прошли порог 256 px и сохранили пропорции; технических ошибок нет. До переключения остаётся визуально подтвердить соответствие подписи. Маленький файл сохраняется как fallback; источник RF4DB/RF4MAP/официальный RF4 не получает автоматического приоритета. +- [x] **B20 · Аудит качества рыбных изображений.** Offline `media_cli --quality-report` проверяет фактические dimensions опубликованных файлов, отдельно считает неизбежный upscale в карточках и известные альтернативы. После quality-upgrade: 253 опубликованных fish-файла, 252 имеют 1024×1024 WebP, один fallback (`Ёрш`) остаётся 48×48 и увеличивается до 180 px; новых известных альтернатив для него нет. Совпадение сущности больше нельзя считать достаточным основанием пропустить потенциально более качественный файл. +- [x] **B21 · Очередь quality-upgrade.** Текущие approved-файлы не менялись до готовности замены. Очередь из 226 прямых RF4DB-альтернатив обработана с сохранением `duplicate_of`: все 226 кандидатов прошли `upgrade_stored` и затем были атомарно продвинуты в `approved`, старые версии сохранены как `superseded`. Один provenance-only `duplicate`, не связанный с низкоразрешённым published-файлом, намеренно не переводился. Ошибка останавливает домен по существующим правилам; публикация старой версии при этом не прерывается. +- [x] **B22 · Сравнение вариантов разных источников.** Offline-команда `--compare-quality-upgrades` сопоставила сохранённые кандидаты с опубликованными fallback по `duplicate_of` и проверила dimensions, размер файла, MIME, прозрачность и aspect ratio. Все 226 кандидатов прошли технические пороги и offline contact-sheet review; источник RF4DB/RF4MAP/официальный RF4 не получал автоматического приоритета. После публикации в manifest не осталось `upgrade_stored`, поэтому текущий повторный запуск команды корректно сообщает `compared: 0`. - [x] **B23 · Безопасное продвижение и provenance.** Связи `supersedes`/`replaced_by`, отдельное решение review, атомарное переключение публичного `entity_key` и явный CLI rollback сохраняют обе исходные ссылки и возможность отката. Публичная карточка показывает источник выбранного изображения, а manifest сохраняет проверенные варианты. Старый Git-файл не удаляется при включении нового. - [x] **B24 · Производные размеры.** После выбора оригиналов генерируются детерминированные WebP/AVIF thumbnails для каталога и отдельный крупный вариант для detail, хэши производных фиксируются в manifest и отдаются через каталог. Маленькие карточки больше не обязаны загружать 1024×1024 оригинал. -- [ ] **B25 · Визуальная приёмка media.** Contact sheets для всех 226 замен собраны и просмотрены offline: явных ложных соответствий, обрезки и искажённых пропорций не обнаружено; одно отличие подписи (`Ерш-носарь`/`Ёрш-носарь`) орфографическое. Осталась browser-проверка репрезентативных desktop/mobile страниц в light/dark; sandbox Chromium пока не позволяет её выполнить. Gate: нет битых файлов, искажённых пропорций, ложных соответствий, обрезанного объекта и изображений ниже 256 px без явной пометки «низкое разрешение». +- [ ] **B25 · Визуальная приёмка media.** Contact sheets для всех 226 замен собраны и просмотрены offline: явных ложных соответствий, обрезки и искажённых пропорций не обнаружено; одно отличие подписи (`Ерш-носарь`/`Ёрш-носарь`) орфографическое. In-app Chromium проверил 12 комбинаций `/media` (320/390/768/1280 px × light/dark/system): горизонтального overflow нет, у 253 изображений есть `alt`, broken loaded images — `0`, переключение темы корректно меняет `data-theme`. Остались keyboard/focus и standalone axe gate; screenshot capture в текущем runtime завершился timeout и не считается доказательством. Gate: нет битых файлов, искажённых пропорций, ложных соответствий, обрезанного объекта и изображений ниже 256 px без явной пометки «низкое разрешение». ### Каталог водоёмов, карты и координаты - [x] **W01 · Canonical-каталог RF4DB.** 16.09 через браузерный контекст получен и проверен индекс `/ru/maps`: 19/19 карточек, source slug/ID, русское название, уровень открытия, число видов рыб и URL изображения сохранены в [`data/waterbodies/rf4db-catalog-2026-09-16.json`](../data/waterbodies/rf4db-catalog-2026-09-16.json). Добавлена команда `python -m app.cli import-waterbody-catalog --input ...` для применения снимка в БД. Изображения остаются кандидатами без автоматической роли `map` или `cover`; detail-данные выполняются отдельно по W02. -- [ ] **W02 · Карточки водоёмов RF4DB.** Строгий fixture-based parser `rf4db-waterbody` расширен под реальный DOM `/fishes/` и `/positions/`, regression-тесты, безопасный `update_waterbody_detail` и атомарный batch `update_waterbody_details`/`import-waterbody-details` готовы; первая detail-карточка (`level_000_home`) сохранена браузером как snapshot. Осталось последовательно разобрать 18 detail-страниц с общим 30-минутным cooldown домена. -- [ ] **W03 · Модель и provenance.** В модель `Waterbody`, API-каталог и миграции `0017`/`0019`/`0020` добавлены nullable-поля provenance, счётчик видов и detail-факты; идемпотентные upsert-функции обновляют только подтверждённые source identity и не удаляют исчезнувшие строки. Осталось применить их к проверенному canonical-каталогу и отдельно разделить игровой и редакционный тексты при подключении detail-данных. +- [ ] **W02 · Карточки водоёмов RF4DB.** Строгий fixture-based parser `rf4db-waterbody` расширен под реальный DOM `/fishes/` и `/positions/`, regression-тесты, безопасный `update_waterbody_detail` и атомарный batch `update_waterbody_details`/`import-waterbody-details` готовы. Snapshots `level_000_home`, `level_001_lake` и `level_002_kirzah_river` сохранены. Повторный разрешённый запрос `level_002_kirzah_river` 20.09 вернул совпадающие с canonical-каталогом имя, `29/29` видов и два URL изображений; snapshot импортирован в Compose PostgreSQL, `/health` и `/ready` успешны, публичная `/waterbodies/р-вьюнок` отвечает `200`. Описание и позиции источник не вернул, поэтому они оставлены `null`/пустыми без домыслов; изображения остаются только provenance-кандидатами и не публиковались. Три разрешённых запроса `level_019_american_pond` 20.09 завершились `403 Forbidden`: snapshot и импорт не создавались, прежние подтверждённые данные и media не менялись. Одно промежуточное разрешённое окно 20.09 было зарезервировано CLI, но завершилось локальной DNS-ошибкой до ответа источника; повтор с сетевым доступом был остановлен общим cooldown до запроса. Следующий запуск должен повторить именно `level_019_american_pond` после общего cooldown; полнота ответа остаётся неподтверждённой. Осталось последовательно разобрать 16 detail-страниц. +- [ ] **W03 · Модель и provenance.** В модель `Waterbody`, API-каталог и миграции `0017`/`0019`/`0020` добавлены nullable-поля provenance, счётчик видов и detail-факты; идемпотентный upsert применён к canonical snapshot: `19/19`, без missing/duplicate/provenance issues. Осталось применить подтверждённые detail snapshots и отдельно разделить игровые и редакционные тексты при подключении detail-данных. - [ ] **W04 · Классификация изображений.** Добавлены допустимые роли `waterbody_cover`, `waterbody_map`, `waterbody_depth_map`, `waterbody_screenshot` и проверка их назначения только через review для canonical waterbody. Кандидаты по-прежнему не получают роль автоматически. Осталось наполнить очередь detail-изображениями и провести contact-sheet review с проверкой dimensions, MIME, SHA-256, соответствия названию и источника. - [ ] **W05 · Crosswalk источников.** Добавлен offline-конструктор консервативных предложений: нормализуются только точные имена/алиасы, неоднозначные и unmatched строки не получают canonical key; отсутствие ID выдаётся лишь диагностикой и не считается удалением. Осталось подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты, включая три ранее отмеченных отсутствующих RF4MAP объекта. -- [ ] **W06 · Координаты и точность.** В `ExternalObservation`, staging, provenance опубликованного улова и публичных activity/spot-ответах добавлены `coordinate_raw`, `coordinate_precision = exact | approximate | area | missing` и список источников; RF4DB/RF4-STAT/RF4MAP/RF4 Posts parsers теперь протягивают исходную строку, включая строки без доступных числовых координат. Карточка точки показывает точность рядом с координатами. Осталось провести browser QA. +- [ ] **W06 · Координаты и точность.** В `ExternalObservation`, staging, provenance опубликованного улова и публичных activity/spot-ответах добавлены `coordinate_raw`, `coordinate_precision = exact | approximate | area | missing` и список источников; RF4DB/RF4-STAT/RF4MAP/RF4 Posts parsers теперь протягивают исходную строку, включая строки без доступных числовых координат. Исправлена spot detail: точность (`точные`/`приблизительные`/`район`/`не указаны`) теперь видна рядом с координатами и покрыта smoke regression. In-app Chromium и focused smoke подтвердили `/spots/vyunok-321x654` на desktop/mobile без overflow. Осталась полная browser QA матрица для всех precision/error-состояний. - [ ] **W07 · Публичный API и страницы.** API и detail-страница теперь выводят подтверждённые detail-факты водоёма: описание, уровень, количество видов, алиасы, число ссылок на точки и отдельный счётчик изображений-кандидатов; источники и непроверенные media не смешиваются. Осталось подключить только проверенные waterbody media roles и завершить browser QA, включая различение карты, заставки и абстрактного отпечатка. -- [ ] **W08 · Приёмка и эксплуатация.** Добавить fixture-based parser tests, offline catalog/media audit, проверку 19 canonical entities, отсутствие битых файлов и browser QA desktop/mobile. Сетевые тесты не выполнять; регулярный импорт оставить opt-in и под общим cooldown/backoff. +- [ ] **W08 · Приёмка и эксплуатация.** Fixture-based parser tests, offline catalog/media audit и повторный idempotent import подтверждены: `created=0 updated=19`, в PostgreSQL ровно `19` RF4DB waterbodies плюс `2` legacy-записи, media audit сообщает `issues=[]` и `orphaned_files=[]`. In-app Chromium и focused smoke покрывают desktop/mobile основные страницы; остаются полная browser QA error/empty matrix и закрытие внешних detail-данных. Сетевые тесты не выполнять; регулярный импорт оставить opt-in и под общим cooldown/backoff. ### Каталог снастей, наживок и прочей оснастки @@ -83,9 +84,9 @@ - [x] **D02 · Системный режим без вспышки.** Первый визит следует `prefers-color-scheme` полностью через CSS: серверный HTML сразу совместим с системной темой, состояние не хранится, inline bootstrap не добавлен и CSP не ослаблена. Явное переопределение появится только вместе с D03–D04. - [x] **D03 · Переключатель темы.** В header добавлен доступный трёхпозиционный выбор «Системная / Светлая / Тёмная»; на узких экранах он сохраняет три понятные иконки и доступные названия. Кнопки работают с клавиатуры, имеют видимый focus и синхронизируют `aria-pressed`. - [x] **D04 · Сохранение и SSR-согласование.** Выбор хранится год в allowlist-cookie `rf4-theme` с `SameSite=Lax` и `Secure` на HTTPS; Astro SSR выставляет `data-theme` до отрисовки, а системный режим оставляет выбор браузеру. Собранный Astro client script переключает тему без inline-кода, localStorage и ослабления CSP. -- [ ] **D05 · Темизация компонентов и графики.** На семантические light/dark-токены переведены базовые и admin-поверхности, header, каталог, breadcrumb-леска, ссылки сущностей, таблицы, формы, moderation-карточки, provenance/quality/status badges, паспорта данных, pagination, empty/loading-состояния, радар, timeline, силуэты и глифы; добавлен forced-colors layer. Инвентаризированы публичные растры и alpha-каналы PWA-иконок, hero получил dark/print treatment, screenshots — семантическую подложку. Осталась браузерная приёмка теней и градиентов в обеих темах. Источники и статусы сохраняют текстовые подписи и не различаются только цветом. +- [ ] **D05 · Темизация компонентов и графики.** На семантические light/dark-токены переведены базовые и admin-поверхности, header, каталог, breadcrumb-леска, ссылки сущностей, таблицы, формы, moderation-карточки, provenance/quality/status badges, паспорта данных, pagination, empty/loading-состояния, радар, timeline, силуэты и глифы; добавлен forced-colors layer. In-app Chromium подтвердил переключение light/dark/system на медиатеке во всех четырёх ширинах; остаётся ручная приёмка теней, градиентов, forced colors и печати. Источники и статусы сохраняют текстовые подписи и не различаются только цветом. - [x] **D06 · Метаданные браузера и CSP.** Добавлены парные `theme-color` для системной light/dark схемы; явный cookie-выбор согласуется с SSR и мгновенно переключает активный meta-тег. PWA manifest использует устойчивый тёмный brand chrome и splash background. Production build сохраняет `inlinedScripts: []`; новые inline script/style/attributes не появились, ослабление CSP не потребовалось. -- [ ] **D07 · Визуальная и accessibility-приёмка.** Проверить light/dark/system на 320/390/768/1280 px для главной, каталогов, detail, records, report, status и всех admin-экранов; покрыть normal/hover/focus/disabled/error/loading/empty и длинные данные. Для обеих тем обеспечить WCAG AA, отсутствие горизонтального scroll и CLS, корректную печать, reduced motion и переключение без потери введённых данных; сохранить эталонные screenshots и краткий отчёт. +- [ ] **D07 · Визуальная и accessibility-приёмка.** In-app Chromium подтвердил отсутствие горизонтального scroll на публичных `/`, `/waterbodies`, `/records`, `/report`, `/status`, `/media`, `/rules`, waterbody detail `/waterbodies/р-вьюнок` и fish detail `/fish/pike`, а также admin `/admin`, `/admin/moderation`, `/admin/external-sources`, `/admin/media` в 320/390/768/1280 px; исправлены tablet header и media-grid overflow. Для media и двух detail-страниц light/dark/system корректно переключаются, `main`/`h1` присутствуют, broken images — `0`. Остались keyboard/focus matrix, axe, forced colors, печать и screenshots. Для обеих тем обеспечить WCAG AA, отсутствие горизонтального scroll и CLS, reduced motion и переключение без потери введённых данных; сохранить эталонные screenshots и краткий отчёт. - [ ] **Q01 · Документы источников — реестр готов, нужны первичные подтверждения.** Создан единый production-gate с атрибуцией, общим лимитом 30 минут, хранением и процедурой отзыва для RF4DB, RF4-STAT, RF4MAP, RF4 Posts и официального RF4. До открытой публикации приложить устойчивые ссылки/копии первичных разрешений, контакты, даты и отдельно подтвердить право на изображения; пустое поле блокирует соответствующий источник. - [x] **Q02 · Управляемое удаление источника.** Изменение ранее опубликованной записи возвращает её в staging, сбрасывает сопоставление и переводит связанный улов в pending с новой версией решения. Результат проверки хранится как `available`, `missing`, `temporary_error` или `blocked`: только подтверждённый `missing` отзывает публикацию, временная ошибка и блокировка остаются диагностикой. Повторное появление требует ручного подтверждения. Withdrawn-записи исключены из публичной активности, а статус и время проверки доступны в admin provenance и журнале решений. Отдельного сетевого обхода нет: Q03 подключит эту реакцию к разрешённому плановому запросу. @@ -107,7 +108,7 @@ - [x] **Q13 · Production bootstrap в CI.** Отдельный workflow запускает `deploy/test-production-bootstrap.sh` вручную или раз в неделю, а не на каждом push. Вывод bootstrap всегда сохраняется 14 дней; при падении добавляются Compose status и Playwright diagnostics. - [x] **Q14 · Полная CSP.** Каждый Astro SSR-ответ получает отдельный nonce для динамического JSON-LD и собственный строгий CSP; `FILES_DOMAIN` валидируется как hostname. Production запрещает inline handlers, `unsafe-inline`, eval, wildcard и HTTP; page scripts/styles остаются same-origin `_astro`-ассетами, style/script attributes запрещены. Caddy сохраняет upstream policy и использует строгий fallback для API. Bootstrap проверяет совпадение nonce на главной, report и admin и доступность OG; реальный signed screenshot проверяется после наполнения production MinIO. - [x] **Q15 · Частичная деградация главной.** SSR независимо получает activity, community signals и оба справочника через settled-результаты. Отказ секции показывает собственный `StatePanel`, сохраняет остальные данные и HTTP 200 с `X-RF4-Partial`/`Cache-Control: no-store`; только отказ всех четырёх частей возвращает 503, `Retry-After` и noindex. Client-side loading и optimistic UI не добавлялись. -- [x] **Q16 · Контракт OpenAPI.** `apps/api/openapi.json` детерминированно генерируется из FastAPI; CI проверяет его актуальность после backend suite. Изменение artifact обязательно рассматривается вместе с реализацией, а ручное редактирование не используется. +- [x] **Q16 · Контракт OpenAPI.** `apps/api/openapi.json` перегенерирован из текущего FastAPI-контракта; `apps/api/export_openapi.py --check` проходит (`36 paths`). В artifact добавлены media decision/rollback schemas и quality-upgrade routes; ручное редактирование не использовалось. - [x] **Q17 · Эксплуатационные документы.** Зафиксированы ADR по Astro/FastAPI/PostgreSQL, локальному cache, scheduler/cooldown и разделению PostgreSQL/MinIO. Incident runbook покрывает заполнение диска, отказ PostgreSQL/MinIO, зависшие импорты, ошибки миграций, компрометацию секретов и критерии закрытия без опасных reset/recreate операций. - [x] **Q18 · Минимальная observability.** Зафиксированы дешёвые SLI, стартовые пороги и источники для request count/latency/5xx, moderation/staging depth, возраста импортов и инфраструктуры. Начальный контур использует JSON-логи, readiness, diagnostics и host monitor; exporter выбирается после покупки сервера, tracing и Prometheus не вводятся без измеренной потребности. Перед альфой остаётся подключить реальный канал и проверить critical/recovery alert. @@ -129,8 +130,8 @@ - [x] **A03 · Полный single-owner workflow.** Добавить пагинацию очереди уловов, кнопку запуска официального импорта и отображение результата/истории, безопасные статусы источников с возрастом данных, cooldown/backoff и ручное обновление очередей. Критерий: владелец может пройти путь «импорт → проверка → решение → история» без API/CLI; старые данные сохраняются при сбое импорта. - [x] **A04 · Контур медиа-проверки.** Admin-экран показывает approved/upgrade_queued/upgrade_stored медиа, источник, размеры, производные и provenance. Защищённые действия требуют непустую причину: publish атомарно продвигает только проверенные `upgrade_stored` и сохраняет fallback, rollback принимает только связанную пару `approved`/`superseded`; небезопасные состояния возвращают `409`. API-тесты проверяют auth, валидацию причины и вызов безопасных операций; browser acceptance остаётся в A06. - [ ] **A05 · Многопользовательский доступ.** После пилота заменить общий Bearer-токен персональными аккаунтами и короткими серверными сессиями с отзывом, ролями read-only/moderator/importer/owner, operator ID в аудите и журналом входов. Критерий: минимальные права реально ограничивают действия, logout/revoke инвалидируют сессию на сервере. -- [ ] **A06 · Browser/accessibility acceptance.** Проверить `/admin`, moderation и external sources в 320/390/768/1280 px: клавиатура, focus order, screen reader labels, reduced motion, forced colors, темы, ошибки/пустые очереди и конфликт `409`. Критерий: Playwright + axe без блокирующих дефектов и ручная визуальная проверка. -- [ ] **A07 · Production gate.** Выполнить preflight с реальными секретами, проверить Caddy Basic + API Bearer, закрытые внутренние порты, backup/restore PostgreSQL и MinIO, readiness, no-store и внешний smoke после деплоя. Критерий: acceptance-runbook пройден, rollback и процедура отзыва доступа документированы. +- [ ] **A06 · Browser/accessibility acceptance.** In-app Chromium подтвердил `/admin`, moderation, external sources и media в 320/390/768/1280 px без горизонтального overflow; четыре admin-маршрута имеют landmarks, подписанные поля, именованные кнопки и `alt` у изображений. Клавиатура/focus order, forced colors, полная light/dark визуальная матрица и standalone axe остаются открытыми: в текущем browser runtime Tab не переводит `document.activeElement` с `BODY`, а headless Chromium axe блокируется sandbox. +- [ ] **A07 · Production gate.** Выполнить preflight с реальными секретами, проверить Caddy Basic + API Bearer, закрытые внутренние порты, backup/restore PostgreSQL и MinIO, readiness, no-store и внешний smoke после деплоя. Локальный чистый-volume bootstrap и оба Playwright-сценария подтверждены; критерий A07 остаётся открытым до acceptance-runbook с реальными секретами, backup/restore, rollback и процедурой отзыва доступа. ## Готовность открытой альфы — требуется сервер или внешний сервис diff --git a/rf4_research/community_cli.py b/rf4_research/community_cli.py index 246d387..0d8daf3 100644 --- a/rf4_research/community_cli.py +++ b/rf4_research/community_cli.py @@ -320,15 +320,17 @@ def main(argv: list[str] | None = None) -> int: # Single atomic check-and-reserve before network I/O: failed attempts count toward the limit too. check_and_reserve(site_key, state_file=args.state_file) html = fetch_html(url) - records = ( + parsed = ( parse(html, source_url=url) if args.source in DETAIL_SOURCES or args.source == "rf4db-waterbodies" else parse(html) - )[:args.limit] + ) + records = parsed if args.source in DETAIL_SOURCES else parsed[:args.limit] except Exception as exc: print(f"community source failed: {exc}", file=sys.stderr) return 1 - print(json.dumps([asdict(item) for item in records], ensure_ascii=False, default=str)) + payload = asdict(records) if args.source in DETAIL_SOURCES else [asdict(item) for item in records] + print(json.dumps(payload, ensure_ascii=False, default=str)) return 0 diff --git a/tests/test_community_cli.py b/tests/test_community_cli.py index 735897b..9b47b6e 100644 --- a/tests/test_community_cli.py +++ b/tests/test_community_cli.py @@ -5,6 +5,7 @@ import pytest from rf4_research import community_cli from rf4_research.community_cli import enforce_fetch_interval, fetch_site_key, mark_fetch +from rf4_research.community_sources import RF4DBWaterbodyDetail def test_fetch_cooldown_is_persistent_per_source(tmp_path: Path) -> None: @@ -55,6 +56,39 @@ def test_failed_fetch_still_reserves_site_cooldown(tmp_path: Path, monkeypatch: assert "rf4db.com" in json.loads(state_file.read_text(encoding="utf-8")) +def test_detail_fetch_serializes_single_record( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + state_file = tmp_path / "fetch-state.json" + source_url = "https://download.rf4db.com/ru/maps/level_002_kirzah_river" + detail = RF4DBWaterbodyDetail( + source_system="rf4db", + source_external_id="level_002_kirzah_river", + source_url=source_url, + name="р. Вьюнок", + description=None, + aliases=(), + fish_species=("Щука обыкновенная",), + fish_external_ids=("n.pike",), + image_urls=(), + point_urls=(), + ) + + monkeypatch.setattr(community_cli, "fetch_html", lambda _url: "") + monkeypatch.setitem( + community_cli.DETAIL_SOURCES, + "rf4db-waterbody", + lambda _html, *, source_url: detail, + ) + + assert community_cli.main([ + "rf4db-waterbody", "--url", source_url, "--state-file", str(state_file), + ]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["source_external_id"] == "level_002_kirzah_river" + assert payload["fish_species"] == ["Щука обыкновенная"] + + def test_validate_url_host_rejects_disallowed_hosts() -> None: from rf4_research.community_cli import _validate_url_host