Compare commits

..
6 Commits
Author SHA1 Message Date
ik 787a5065bc data: review next prioritized media assets
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-13 16:53:19 +07:00
ik f243807fc8 feat: plan media queue without network access 2026-09-13 16:50:27 +07:00
ik 73feb75767 perf: add reproducible query plan gate 2026-09-13 16:46:41 +07:00
ik abe51b38d0 docs: reconcile roadmap with current project state 2026-09-13 16:40:47 +07:00
ik 86ed3a966a security: enforce nonce based content policy 2026-09-13 16:38:47 +07:00
ik 1305cccfa5 feat: check source links during scheduled fetches 2026-09-13 16:32:10 +07:00
20 changed files with 479 additions and 36 deletions
+10 -6
View File
@@ -6,7 +6,7 @@ RF4 Spotter — неофициальный сервис свежих точек
## Статус разработки ## Статус разработки
**Проверка 11 сентября 2026 (`907ad53`): локальный контур готов к развёртыванию открытой альфы, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **130 passed, 1 skipped**; Astro check/build и API-тесты проходят. Чистый production bootstrap подтвердил Caddy, scheduler validation, миграцию `20260910_recovery` и браузерный сценарий отправки/модерации. Реальные источники во время приёмки не опрашивались. **Проверка 13 сентября 2026 (`73feb75`): локальный контур готов к развёртыванию открытой альфы, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **155 passed, 1 skipped**; Astro check/build, web unit и API-тесты проходят. Граф миграций имеет единственную голову `0016`; последний полный production bootstrap подтвердил Caddy, scheduler и браузерный сценарий отправки/модерации на предыдущей голове, а актуальная голова проверяется CI на чистой PostgreSQL. Реальные источники во время приёмки не опрашивались.
Актуальные следующие задачи находятся только в [ROADMAP](docs/ROADMAP.md). Старые планы и аудиты сохранены как история и больше не задают порядок работ. До внешнего запуска нужны сервер, DNS/TLS, production-секреты, публичные контакты, внешний backup и канал уведомлений. Актуальные следующие задачи находятся только в [ROADMAP](docs/ROADMAP.md). Старые планы и аудиты сохранены как история и больше не задают порядок работ. До внешнего запуска нужны сервер, DNS/TLS, production-секреты, публичные контакты, внешний backup и канал уведомлений.
@@ -42,16 +42,20 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Каждый улов показывает источник, относительную свежесть и точное время UTC; время получения явно отделено от времени улова. Каталоги оформлены как полевой атлас: тёмный seal показывает объём справочника, карточки рыб используют смысловые SVG-силуэты, а каждый водоём — собственный детерминированный абстрактный отпечаток берега, волн, точки и индекса. На странице сочетания оба знака собираются в единую атласную эмблему, detail-иерархию связывает breadcrumb-леска с текстовыми узлами, а боковые переходы повторяют знаки связанных сущностей. Это не карта и не игровая география. Пустые состояния используют статичную CSS-иллюстрацию поплавка; смысловые анимации полностью учитывают системное ограничение движения. Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Каждый улов показывает источник, относительную свежесть и точное время UTC; время получения явно отделено от времени улова. Каталоги оформлены как полевой атлас: тёмный seal показывает объём справочника, карточки рыб используют смысловые SVG-силуэты, а каждый водоём — собственный детерминированный абстрактный отпечаток берега, волн, точки и индекса. На странице сочетания оба знака собираются в единую атласную эмблему, detail-иерархию связывает breadcrumb-леска с текстовыми узлами, а боковые переходы повторяют знаки связанных сущностей. Это не карта и не игровая география. Пустые состояния используют статичную CSS-иллюстрацию поплавка; смысловые анимации полностью учитывают системное ограничение движения.
Все пять community-парсеров подключены к отдельному scheduler-процессу. Попытка резервируется в PostgreSQL до HTTP-запроса, поэтому ошибки тоже расходуют cooldown. Блокировка и минимальный интервал 1800 секунд действуют на весь домен; endpoint одного сайта выбираются по самому давнему запуску и не голодают. Ручной production-запуск использует тот же журнал: `docker compose exec api python -m app.cli fetch-community rf4stat-fishing`. Локально scheduler включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения. Все пять community-парсеров подключены к отдельному scheduler-процессу. Попытка резервируется в PostgreSQL до HTTP-запроса, поэтому ошибки тоже расходуют cooldown. Блокировка и минимальный интервал 1800 секунд действуют на весь домен; endpoint одного сайта выбираются по самому давнему запуску и не голодают. Тот же запрос служит проверкой точной исходной ссылки: `404/410` означает `missing`, `401/403/429``blocked`, остальные сбои — `temporary_error`; отдельного link-checker и дополнительных обращений нет. Пропажа элемента из агрегатного списка сама по себе удалением не считается. Ручной production-запуск использует тот же журнал: `docker compose exec api python -m app.cli fetch-community rf4stat-fishing`. Локально scheduler включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов. Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает файлы, уникальные нормализованные подписи и кандидатов без подписи, поэтому дубли и общие учебные схемы не завышают покрытие. Сейчас не покрыты минимум 24 рыбы и все 19 водоёмов, а до ручного review не подтверждены 251 рыба и все 19 водоёмов. Общий target снастей остаётся `null`, пока разрешённый источник не отдаст проверяемый полный счётчик. `python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает файлы, уникальные нормализованные подписи и кандидатов без подписи, поэтому дубли и общие учебные схемы не завышают покрытие. Сейчас не покрыты минимум 24 рыбы и все 19 водоёмов, а до ручного review не подтверждены 250 рыб и все 19 водоёмов. Общий target снастей остаётся `null`, пока разрешённый источник не отдаст проверяемый полный счётчик.
Актуальный внешний ориентир — 19 водоёмов и 252 вида рыб; локальная альфа пока содержит 2+2 сущности. Media-manifest включает 452 кандидата: 228 изображений рыб, 149 приманок и 75 справочных изображений; подтверждённых entity-карт водоёмов пока нет. Вручную проверены 20 ассетов: 1 рыба, 9 приманок/наживок и 10 справочных схем; 425 записей остаются в очереди, 1 файл ожидает ревью, 6 URL признаны невалидными. Полное число «снастей» пока не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками. `python -m rf4_research.media_cli --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap.
Актуальный внешний ориентир — 19 водоёмов и 252 вида рыб; локальная альфа пока содержит 2+2 сущности. Media-manifest включает 452 кандидата: 228 изображений рыб, 149 приманок и 75 справочных изображений; подтверждённых entity-карт водоёмов пока нет. Локальный audit подтверждает 24 approved-ассета: 2 рыбы, 12 приманок/наживок и 10 справочных схем; 420 записей остаются в очереди, 8 URL признаны невалидными. Полное число «снастей» пока не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками.
Для измерений на собственном сервере подготовлен read-only `deploy/load-smoke.py`: он считает p50/p95/max и HTTP-коды для activity/records, а при наличии `ADMIN_TOKEN` — staging/moderation. Методика и безопасные ступени нагрузки описаны в [docs/load-testing.md](docs/load-testing.md); локальные цифры не выдаются за production baseline. Для измерений на собственном сервере подготовлен read-only `deploy/load-smoke.py`: он считает p50/p95/max и HTTP-коды для activity/records, а при наличии `ADMIN_TOKEN` — staging/moderation. Методика и безопасные ступени нагрузки описаны в [docs/load-testing.md](docs/load-testing.md); локальные цифры не выдаются за production baseline.
До сервера запросы проверяются командой `./deploy/test-query-plans.sh`: session-local TEMP-fixture на 100 000 уловов не меняет рабочую БД и требует индексные планы для activity, records и spot detail, а также выполнение пяти публичных планов быстрее 250 мс. Методика и последний локальный результат находятся в [docs/query-performance.md](docs/query-performance.md); новые индексы по текущему измерению не требуются.
В production MinIO root credentials доступны только одноразовому init-контейнеру. API использует отдельного пользователя с доступом исключительно к `S3_BUCKET`: просмотр bucket, чтение, запись и удаление его объектов без глобального списка bucket и без права создавать новые. В production MinIO root credentials доступны только одноразовому init-контейнеру. API использует отдельного пользователя с доступом исключительно к `S3_BUCKET`: просмотр bucket, чтение, запись и удаление его объектов без глобального списка bucket и без права создавать новые.
Production release отделяет Alembic от runtime: одноразовый `migrate` должен успешно завершиться до запуска новой версии API. Перед изменением схемы создаётся backup; совместимый rollback возвращает предыдущие images, несовместимый — восстанавливает предрелизную копию данных вместо непроверенного `alembic downgrade`. Production release отделяет Alembic от runtime: одноразовый `migrate` должен успешно завершиться до запуска новой версии API. Перед изменением схемы создаётся backup; совместимый rollback возвращает предыдущие images, несовместимый — восстанавливает предрелизную копию данных вместо непроверенного `alembic downgrade`.
@@ -93,7 +97,7 @@ FastAPI ─ PostgreSQL 17
Наружу production-профиль публикует только Caddy. PostgreSQL, API, Astro и MinIO находятся в Docker-сетях. Caddy завершает TLS и защищает административные страницы Basic Auth; административный API отдельно проверяет Bearer-токен в FastAPI. Basic не накладывается на API-запросы. Наружу production-профиль публикует только Caddy. PostgreSQL, API, Astro и MinIO находятся в Docker-сетях. Caddy завершает TLS и защищает административные страницы Basic Auth; административный API отдельно проверяет Bearer-токен в FastAPI. Basic не накладывается на API-запросы.
Production CSP ограничивает browser-запросы текущим доменом и отдельным files-доменом для изображений, запрещает plugins, frames, inline handlers и attributes, eval, wildcard и HTTP. Page scripts и scoped styles принудительно выпускаются отдельными same-origin `_astro`-ассетами; единственное временное inline-исключение остаётся для динамического JSON-LD и описано в [CSP inventory](docs/csp-inventory.md). Production CSP ограничивает browser-запросы текущим доменом и отдельным files-доменом для изображений, запрещает plugins, frames, inline handlers и attributes, `unsafe-inline`, eval, wildcard и HTTP. Page scripts и scoped styles выпускаются отдельными same-origin `_astro`-ассетами; динамический JSON-LD получает новый криптографический nonce на каждый SSR-ответ. Caddy сохраняет эту policy и задаёт строгий fallback для служебных ответов. Локальный Compose отдельно разрешает только loopback API/MinIO; детали и проверка описаны в [CSP inventory](docs/csp-inventory.md).
Тема по умолчанию следует системному `prefers-color-scheme`, а переключатель в header позволяет выбрать системную, светлую или тёмную палитру. Выбор сохраняется в cookie и применяется Astro при SSR без localStorage-only flash и ослабления CSP; browser chrome синхронизируется парными `theme-color`. Публичные и административные поверхности, формы, таблицы, provenance/status-плашки и фирменная SVG/CSS-графика используют семантические light/dark-токены и базовый forced-colors layer; ограничения и оставшаяся визуальная приёмка описаны в [dark-theme.md](docs/dark-theme.md). Тема по умолчанию следует системному `prefers-color-scheme`, а переключатель в header позволяет выбрать системную, светлую или тёмную палитру. Выбор сохраняется в cookie и применяется Astro при SSR без localStorage-only flash и ослабления CSP; browser chrome синхронизируется парными `theme-color`. Публичные и административные поверхности, формы, таблицы, provenance/status-плашки и фирменная SVG/CSS-графика используют семантические light/dark-токены и базовый forced-colors layer; ограничения и оставшаяся визуальная приёмка описаны в [dark-theme.md](docs/dark-theme.md).
@@ -174,7 +178,7 @@ docker compose up --build
## Что реализовано ## Что реализовано
- FastAPI и SQLAlchemy 2; - FastAPI и SQLAlchemy 2;
- PostgreSQL 17 и миграции Alembic до `20260910_recovery`; - PostgreSQL 17 и линейные миграции Alembic до `0016`;
- идемпотентный seed с двумя точками и свежими демо-уловами; - идемпотентный seed с двумя точками и свежими демо-уловами;
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки; - `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
- `GET /api/v1/spots/{id}` и `/catches`; - `GET /api/v1/spots/{id}` и `/catches`;
+18 -1
View File
@@ -14,6 +14,7 @@ from .config import settings
from .database import SessionLocal from .database import SessionLocal
from .logging_config import configure_logging from .logging_config import configure_logging
from .models import CommunityImportRun, DataSource from .models import CommunityImportRun, DataSource
from .source_lifecycle import classify_source_failure, record_scheduled_source_check
logger = logging.getLogger("rf4.community_scheduler") logger = logging.getLogger("rf4.community_scheduler")
MAX_BACKOFF_SECONDS = 24 * 60 * 60 MAX_BACKOFF_SECONDS = 24 * 60 * 60
@@ -102,11 +103,27 @@ def run_source(source_system: str, *, now: datetime | None = None) -> bool:
html = fetch_html(url) html = fetch_html(url)
records = parser(html, source_url=url) if source_system in {"rf4map", "rf4posts-spot"} else parser(html) records = parser(html, source_url=url) if source_system in {"rf4map", "rf4posts-spot"} else parser(html)
created, updated = stage_observations(session, [asdict(item) for item in records]) created, updated = stage_observations(session, [asdict(item) for item in records])
record_scheduled_source_check(
session, source_system=source_system, source_url=url,
status="available", checked_at=current,
)
run.status, run.rows_seen, run.rows_created, run.rows_updated = "success", len(records), created, updated run.status, run.rows_seen, run.rows_created, run.rows_updated = "success", len(records), created, updated
except Exception as exc: except Exception as exc:
session.rollback() session.rollback()
source_status = classify_source_failure(exc)
checked = datetime.now(timezone.utc)
affected = record_scheduled_source_check(
session,
source_system=source_system,
source_url=url,
status=source_status,
checked_at=checked,
)
run.status, run.error_summary = "failed", f"{type(exc).__name__}: {str(exc)[:500]}" run.status, run.error_summary = "failed", f"{type(exc).__name__}: {str(exc)[:500]}"
logger.exception("community import failed", extra={"event":"community_import_failed", "source_system":source_system}) logger.exception("community import failed", extra={
"event":"community_import_failed", "source_system":source_system,
"source_check_status": source_status, "affected_observations": affected,
})
run.finished_at = datetime.now(timezone.utc); session.commit() run.finished_at = datetime.now(timezone.utc); session.commit()
return True return True
+47 -2
View File
@@ -2,7 +2,9 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Literal from typing import Literal
from urllib.error import HTTPError
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import ExternalObservation, ModerationStatus from .models import ExternalObservation, ModerationStatus
@@ -11,6 +13,16 @@ from .models import ExternalObservation, ModerationStatus
SourceCheckStatus = Literal["available", "missing", "temporary_error", "blocked"] SourceCheckStatus = Literal["available", "missing", "temporary_error", "blocked"]
def classify_source_failure(exc: Exception) -> SourceCheckStatus:
"""Classify the result of the scheduled request without retrying it."""
if isinstance(exc, HTTPError):
if exc.code in {404, 410}:
return "missing"
if exc.code in {401, 403, 429}:
return "blocked"
return "temporary_error"
def record_source_check( def record_source_check(
session: Session, session: Session,
observation: ExternalObservation, observation: ExternalObservation,
@@ -24,7 +36,18 @@ def record_source_check(
data. Transient errors and access blocks remain diagnostic and never remove data. Transient errors and access blocks remain diagnostic and never remove
an observation from activity. an observation from activity.
""" """
current = checked_at or datetime.now(timezone.utc) _apply_source_check(observation, status, checked_at or datetime.now(timezone.utc))
session.commit()
return observation
def _apply_source_check(
observation: ExternalObservation,
status: SourceCheckStatus,
checked_at: datetime,
) -> None:
"""Mutate one observation; the caller owns the transaction boundary."""
current = checked_at
observation.source_check_status = status observation.source_check_status = status
observation.source_checked_at = current observation.source_checked_at = current
if status == "missing" and observation.status != "withdrawn": if status == "missing" and observation.status != "withdrawn":
@@ -34,5 +57,27 @@ def record_source_check(
observation.review_note = "Source record missing; withdrawn pending moderator review" observation.review_note = "Source record missing; withdrawn pending moderator review"
observation.reviewed_at = current observation.reviewed_at = current
observation.moderation_version += 1 observation.moderation_version += 1
def record_scheduled_source_check(
session: Session,
*,
source_system: str,
source_url: str,
status: SourceCheckStatus,
checked_at: datetime | None = None,
) -> int:
"""Apply one scheduled request result only to observations with that exact URL.
Aggregate pages cannot prove that an omitted record was deleted, so absence
from a parsed listing is deliberately ignored.
"""
observations = list(session.scalars(select(ExternalObservation).where(
ExternalObservation.source_system == source_system,
ExternalObservation.source_url == source_url,
)))
current = checked_at or datetime.now(timezone.utc)
for observation in observations:
_apply_source_check(observation, status, current)
session.commit() session.commit()
return observation return len(observations)
+30 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
from app.community_importer import CommunityImportError, stage_observations from app.community_importer import CommunityImportError, stage_observations
from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases
from app.source_lifecycle import record_source_check from app.source_lifecycle import record_scheduled_source_check, record_source_check
from app.database import Base from app.database import Base
from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot
@@ -186,6 +186,35 @@ def test_non_authoritative_source_failures_do_not_withdraw(db: Session, status:
assert item.source_check_status == status assert item.source_check_status == status
def test_scheduled_failure_only_affects_exact_source_url(db: Session) -> None:
stage_observations(db, [
record(external_id="matching"),
record(external_id="other") | {"source_url": "https://rf4db.com/catches/other"},
])
affected = record_scheduled_source_check(
db,
source_system="rf4db",
source_url="https://rf4db.com/ru/catches/matching",
status="missing",
)
items = {item.source_external_id: item for item in db.scalars(select(ExternalObservation))}
assert affected == 1
assert items["matching"].status == "withdrawn"
assert items["other"].status != "withdrawn"
assert items["other"].source_check_status == "available"
record_scheduled_source_check(
db,
source_system="rf4db",
source_url="https://rf4db.com/ru/catches/matching",
status="available",
)
assert items["matching"].source_check_status == "available"
assert items["matching"].status == "withdrawn"
def test_auto_publication_requires_enabled_source(db: Session) -> None: def test_auto_publication_requires_enabled_source(db: Session) -> None:
source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=False) source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=False)
fish = Fish(slug="pike", name_ru="Щука") fish = Fish(slug="pike", name_ru="Щука")
@@ -1,10 +1,12 @@
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from urllib.error import HTTPError
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, _static_registry, 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 from app.config import Settings
from app.source_lifecycle import classify_source_failure
def test_all_authorized_sources_are_scheduled() -> None: def test_all_authorized_sources_are_scheduled() -> None:
@@ -23,6 +25,16 @@ def test_failed_runs_back_off_but_success_resets_delay() -> None:
assert retry_delay(["success", "failed"]) == 1800 assert retry_delay(["success", "failed"]) == 1800
@pytest.mark.parametrize(("code", "expected"), [(404, "missing"), (410, "missing"), (403, "blocked"), (429, "blocked"), (500, "temporary_error")])
def test_source_http_failure_classification(code: int, expected: str) -> None:
error = HTTPError("https://rf4.example/source", code, "failure", {}, None)
assert classify_source_failure(error) == expected
def test_non_http_source_failure_is_temporary() -> None:
assert classify_source_failure(TimeoutError("timeout")) == "temporary_error"
def test_same_site_endpoints_rotate_by_oldest_attempt() -> None: def test_same_site_endpoints_rotate_by_oldest_attempt() -> None:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
all_keys = {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"} all_keys = {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
+38 -1
View File
@@ -39,6 +39,43 @@ const jsonLd = JSON.stringify({
"@context": "https://schema.org", "@context": "https://schema.org",
"@graph": jsonLdGraph, "@graph": jsonLdGraph,
}).replaceAll("<", "\\u003c"); }).replaceAll("<", "\\u003c");
const configuredFilesDomain = process.env.FILES_DOMAIN || "files.rf4spotter.ru";
const filesDomain = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i.test(configuredFilesDomain)
? configuredFilesDomain
: "files.rf4spotter.ru";
const isLoopback = (hostname: string) => hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
const safeOrigin = (value: string | undefined, fallback: string) => {
try {
const url = new URL(value || fallback);
return url.protocol === "https:" || (url.protocol === "http:" && isLoopback(url.hostname)) ? url.origin : fallback;
} catch {
return fallback;
}
};
const apiOrigin = safeOrigin(import.meta.env.PUBLIC_API_URL, siteUrl);
const filesFallback = `https://${filesDomain}`;
const filesOrigin = (() => {
try {
const url = new URL(process.env.FILES_ORIGIN || filesFallback);
if ((url.protocol === "https:" && url.hostname === filesDomain) || (url.protocol === "http:" && isLoopback(url.hostname))) {
return url.origin;
}
} catch {
// Invalid deployment input falls back to the validated production hostname.
}
return filesFallback;
})();
const localOrigins = [apiOrigin, filesOrigin].filter((origin) => origin.startsWith("http://"));
const cspNonce = crypto.randomUUID().replaceAll("-", "");
Astro.response.headers.set("Content-Security-Policy", [
"default-src 'self'", "base-uri 'self'", "object-src 'none'", "frame-ancestors 'none'",
"form-action 'self'", `connect-src 'self'${apiOrigin === siteUrl ? "" : ` ${apiOrigin}`}`,
`img-src 'self' data: ${filesOrigin}`,
"font-src 'self'", "media-src 'self'", "manifest-src 'self'",
`script-src 'self' 'nonce-${cspNonce}'`, "script-src-attr 'none'",
"style-src 'self'", "style-src-attr 'none'",
...(localOrigins.length ? [] : ["upgrade-insecure-requests"]),
].join("; "));
--- ---
<!doctype html> <!doctype html>
<html lang="ru" data-theme={theme === "system" ? undefined : theme}> <html lang="ru" data-theme={theme === "system" ? undefined : theme}>
@@ -70,7 +107,7 @@ const jsonLd = JSON.stringify({
<meta name="twitter:description" content={description} /> <meta name="twitter:description" content={description} />
<meta name="twitter:image" content={socialImage} /> <meta name="twitter:image" content={socialImage} />
<meta name="twitter:image:alt" content="Лаймовый поплавок на тёмном озере с координатной сеткой" /> <meta name="twitter:image:alt" content="Лаймовый поплавок на тёмном озере с координатной сеткой" />
<script type="application/ld+json" set:html={jsonLd} is:inline /> <script type="application/ld+json" nonce={cspNonce} set:html={jsonLd} is:inline />
<title>{title}</title> <title>{title}</title>
</head> </head>
<body> <body>
@@ -23,3 +23,20 @@ test("production bootstrap supports submission and moderation", async ({ page, r
expect(response.ok()).toBeTruthy(); expect(response.ok()).toBeTruthy();
expect(await response.text()).toContain(player); expect(await response.text()).toContain(player);
}); });
test("SSR pages use a per-response CSP nonce for JSON-LD", async ({ page, request }) => {
for (const path of ["/", "/report", "/admin/"]) {
const response = await page.goto(path);
const policy = response?.headers()["content-security-policy"] ?? "";
expect(policy).toContain("script-src 'self' 'nonce-");
expect(policy).not.toContain("'unsafe-inline'");
expect(policy).toContain("style-src-attr 'none'");
const nonce = await page.locator('script[type="application/ld+json"]').evaluate((node: HTMLScriptElement) => node.nonce);
expect(nonce).toMatch(/^[a-f0-9]{32}$/);
expect(policy).toContain(`'nonce-${nonce}'`);
}
const image = await request.get("/og-rf4spotter.png");
expect(image.ok()).toBeTruthy();
expect(image.headers()["content-type"]).toContain("image/png");
});
+2
View File
@@ -182,6 +182,8 @@ services:
environment: environment:
PUBLIC_API_URL: https://${SITE_DOMAIN:?Set SITE_DOMAIN} PUBLIC_API_URL: https://${SITE_DOMAIN:?Set SITE_DOMAIN}
API_INTERNAL_URL: http://api:8000 API_INTERNAL_URL: http://api:8000
FILES_DOMAIN: ${FILES_DOMAIN:?Set FILES_DOMAIN}
FILES_ORIGIN: https://${FILES_DOMAIN:?Set FILES_DOMAIN}
depends_on: depends_on:
api: {condition: service_healthy} api: {condition: service_healthy}
healthcheck: healthcheck:
+1
View File
@@ -80,6 +80,7 @@ services:
environment: environment:
PUBLIC_API_URL: http://localhost:8000 PUBLIC_API_URL: http://localhost:8000
API_INTERNAL_URL: http://api:8000 API_INTERNAL_URL: http://api:8000
FILES_ORIGIN: http://localhost:9000
depends_on: depends_on:
api: api:
condition: service_healthy condition: service_healthy
+16 -4
View File
@@ -2299,9 +2299,19 @@
"source_pages": [ "source_pages": [
"https://rf4map.ru/fishes" "https://rf4map.ru/fishes"
], ],
"status": "queued", "status": "approved",
"first_seen_at": "2026-09-12T14:59:53.013377+00:00", "first_seen_at": "2026-09-12T14:59:53.013377+00:00",
"last_seen_at": "2026-09-12T14:59:53.013377+00:00" "last_seen_at": "2026-09-12T14:59:53.013377+00:00",
"sha256": "0f0c119f7a8322b857510a521c9f3c81d373e608373bd7af5d586bee1fc65fca",
"local_path": "files/0f/0f0c119f7a8322b857510a521c9f3c81d373e608373bd7af5d586bee1fc65fca.png",
"content_type": "image/png",
"bytes": 3147,
"width": 48,
"height": 48,
"fetched_at": "2026-09-13T09:51:10.834752+00:00",
"entity_key": "rf4map-fish-1",
"reviewed_at": "2026-09-13T09:51:57.945682+00:00",
"review_note": "Visual review: large shark side profile compatible with Basking shark (Акула гигантская); transparent 48x48 PNG; RF4MAP fish catalog external ID 1"
}, },
{ {
"source_page": "https://rf4map.ru/fishes", "source_page": "https://rf4map.ru/fishes",
@@ -5921,12 +5931,14 @@
"entity_type": "reference", "entity_type": "reference",
"label": "Abbildung. 53: Rute mit Bonus Werten", "label": "Abbildung. 53: Rute mit Bonus Werten",
"external_id": null, "external_id": null,
"status": "queued", "status": "invalid",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00", "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [ "source_pages": [
"https://rf4game.de/userguide/" "https://rf4game.de/userguide/"
] ],
"last_attempt_at": "2026-09-13T09:51:18.821097+00:00",
"last_error": "expected image, got text/html"
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
+1 -1
View File
@@ -52,7 +52,7 @@
Permissions-Policy "camera=(), microphone=(), geolocation=()" Permissions-Policy "camera=(), microphone=(), geolocation=()"
X-Frame-Options "DENY" X-Frame-Options "DENY"
Cross-Origin-Opener-Policy "same-origin" Cross-Origin-Opener-Policy "same-origin"
Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; connect-src 'self'; img-src 'self' data: https://{$FILES_DOMAIN}; font-src 'self'; media-src 'self'; manifest-src 'self'; script-src 'self' 'unsafe-inline'; script-src-attr 'none'; style-src 'self'; style-src-attr 'none'; upgrade-insecure-requests" ?Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; connect-src 'self'; img-src 'self' data: https://{$FILES_DOMAIN}; font-src 'self'; media-src 'self'; manifest-src 'self'; script-src 'self'; script-src-attr 'none'; style-src 'self'; style-src-attr 'none'; upgrade-insecure-requests"
} }
} }
+7 -1
View File
@@ -69,6 +69,12 @@ curl -fsS -H "Authorization: Bearer $ADMIN_TOKEN" -o rf4spotter-diagnostics.json
./deploy/test-release-upgrade.sh ./deploy/test-release-upgrade.sh
``` ```
До полевого замера query plans можно проверить на session-local наборе из 100 000 уловов. Команда не изменяет рабочие таблицы и удаляет TEMP-fixture вместе с psql-сессией:
```bash
./deploy/test-query-plans.sh
```
По умолчанию временно используются только loopback-порты `14321` и `18000`; PostgreSQL и MinIO наружу не публикуются. Контур и volumes удаляются после проверки. Drill успешно пройден 6 сентября 2026 года. По умолчанию временно используются только loopback-порты `14321` и `18000`; PostgreSQL и MinIO наружу не публикуются. Контур и volumes удаляются после проверки. Drill успешно пройден 6 сентября 2026 года.
Одноразовый `migrate` применяет Alembic до rollout API; при ошибке новый runtime не запускается. API при старте выполняет только идемпотентный seed: в production он добавляет минимальные справочники и точки, а демонстрационные уловы жёстко отключены `SEED_DEMO_DATA=false`. Одноразовый `migrate` применяет Alembic до rollout API; при ошибке новый runtime не запускается. API при старте выполняет только идемпотентный seed: в production он добавляет минимальные справочники и точки, а демонстрационные уловы жёстко отключены `SEED_DEMO_DATA=false`.
@@ -90,7 +96,7 @@ MinIO health URL допустим для диагностики, но Console н
docker compose --env-file .env.production -f compose.production.yaml exec api python -m app.cli import-records docker compose --env-file .env.production -f compose.production.yaml exec api python -m app.cli import-records
``` ```
Автоматический scheduler не входит в production-файл. RF4MAP и RF4 Posts нельзя опрашивать чаще одного раза в 30 минут; до отдельной эксплуатационной задачи используйте только контролируемые ручные запуски и staging. Community scheduler входит в production-контур отдельным процессом и публикует только полные записи с ранее подтверждёнными алиасами; остальные данные остаются в staging. Все endpoint одной площадки разделяют PostgreSQL-cooldown не менее 30 минут, включая ошибки. Ручной запуск `python -m app.cli fetch-community SOURCE` использует тот же журнал и не обходит ограничение.
Официальный импорт защищён PostgreSQL advisory lock на комбинацию source/region/category. Параллельный admin-запрос получает `409`, а scheduler записывает безопасный skip и не делает второй HTTP-запрос к источнику. Официальный импорт защищён PostgreSQL advisory lock на комбинацию source/region/category. Параллельный admin-запрос получает `409`, а scheduler записывает безопасный skip и не делает второй HTTP-запрос к источнику.
+117
View File
@@ -0,0 +1,117 @@
\set ON_ERROR_STOP on
\timing off
-- Session-local alpha-sized fixture. No production row is inserted or changed.
SET search_path = pg_temp, public;
SET jit = off;
SET work_mem = '16MB';
CREATE TEMP TABLE fish (LIKE public.fish INCLUDING ALL);
CREATE TEMP TABLE waterbody (LIKE public.waterbody INCLUDING ALL);
CREATE TEMP TABLE bait (LIKE public.bait INCLUDING ALL);
CREATE TEMP TABLE spot (LIKE public.spot INCLUDING ALL);
CREATE TEMP TABLE catch_report (LIKE public.catch_report INCLUDING ALL);
INSERT INTO fish (id, slug, name_ru, trophy_weight_g)
SELECT md5('fish-' || g)::uuid, 'fish-' || g, 'Рыба ' || g, 5000 + g * 20
FROM generate_series(1, 252) AS g;
INSERT INTO waterbody (id, slug, name_ru, unlock_level)
SELECT md5('water-' || g)::uuid, 'water-' || g, 'Водоём ' || g, g
FROM generate_series(1, 19) AS g;
INSERT INTO bait (id, name, normalized_name, kind)
SELECT md5('bait-' || g)::uuid, 'Приманка ' || g, 'приманка-' || g,
CASE WHEN g % 3 = 0 THEN 'bait'::baitkind ELSE 'lure'::baitkind END
FROM generate_series(1, 500) AS g;
INSERT INTO spot (id, waterbody_id, x, y, description)
SELECT md5('spot-' || g)::uuid,
md5('water-' || ((g - 1) % 19 + 1))::uuid,
(g * 37) % 1000, (g * 61) % 1000, NULL
FROM generate_series(1, 5000) AS g;
INSERT INTO catch_report (
id, fish_id, spot_id, waterbody_id, bait_id, weight_g, fishing_method,
caught_at, reported_at, player_name, source_type, source_url,
source_external_id, source_confidence, moderation_status, deleted_at, raw_payload
)
SELECT md5('report-' || g)::uuid,
md5('fish-' || ((g - 1) % 252 + 1))::uuid,
md5('spot-' || ((g - 1) % 5000 + 1))::uuid,
md5('water-' || (((g - 1) % 5000) % 19 + 1))::uuid,
md5('bait-' || ((g - 1) % 500 + 1))::uuid,
100 + (g * 97) % 50000,
(ARRAY['float', 'bottom', 'spinning'])[(g - 1) % 3 + 1],
clock_timestamp() - (((g * 97) % 7776000) * interval '1 second'),
clock_timestamp() - (((g * 97) % 7776000) * interval '1 second'),
'player-' || (g % 2000),
CASE WHEN g % 5 = 0 THEN 'official_record'::sourcetype ELSE 'user'::sourcetype END,
'https://example.invalid/catches/' || g,
'query-plan-' || g,
50 + g % 51,
CASE WHEN g % 10 = 1 THEN 'pending'::moderationstatus ELSE 'approved'::moderationstatus END,
CASE WHEN g % 50 = 0 THEN clock_timestamp() ELSE NULL END,
json_build_object('category', CASE WHEN g % 2 = 0 THEN 'weekly' ELSE 'absolute' END, 'region', 'RU')
FROM generate_series(1, 100000) AS g;
ANALYZE fish;
ANALYZE waterbody;
ANALYZE bait;
ANALYZE spot;
ANALYZE catch_report;
\echo 'DATASET'
SELECT count(*) AS reports,
count(*) FILTER (WHERE moderation_status = 'approved') AS approved,
count(*) FILTER (WHERE source_type = 'official_record') AS official,
count(DISTINCT spot_id) AS spots
FROM catch_report;
\echo 'PLAN activity_72h'
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT cr.*
FROM catch_report AS cr
LEFT JOIN fish AS f ON f.id = cr.fish_id
LEFT JOIN waterbody AS w ON w.id = cr.waterbody_id
LEFT JOIN spot AS s ON s.id = cr.spot_id
LEFT JOIN bait AS b ON b.id = cr.bait_id
WHERE cr.moderation_status = 'approved'
AND cr.deleted_at IS NULL
AND cr.spot_id IS NOT NULL
AND cr.reported_at >= now() - interval '72 hours';
\echo 'PLAN records_count'
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT count(cr.id)
FROM catch_report AS cr
WHERE cr.source_type = 'official_record';
\echo 'PLAN records_page'
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT cr.id, cr.fish_id, cr.waterbody_id, cr.bait_id, cr.weight_g, cr.caught_at
FROM catch_report AS cr
WHERE cr.source_type = 'official_record'
ORDER BY cr.caught_at DESC, cr.weight_g DESC, cr.id DESC
LIMIT 50;
\echo 'PLAN spot_detail'
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT cr.*
FROM catch_report AS cr
LEFT JOIN bait AS b ON b.id = cr.bait_id
WHERE cr.spot_id = md5('spot-2499')::uuid
AND cr.moderation_status = 'approved'
AND cr.deleted_at IS NULL;
\echo 'PLAN public_spot_pages'
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT DISTINCT w.slug, s.x, s.y, f.slug
FROM catch_report AS cr
JOIN spot AS s ON cr.spot_id = s.id
JOIN waterbody AS w ON s.waterbody_id = w.id
JOIN fish AS f ON cr.fish_id = f.id
WHERE cr.moderation_status = 'approved'
AND cr.deleted_at IS NULL
ORDER BY w.slug, s.x, s.y, f.slug
LIMIT 500;
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
set -eu
repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
output=$(mktemp)
cleanup() { rm -f "$output"; }
trap cleanup EXIT INT TERM
docker compose exec -T db psql -X -U rf4 -d rf4_spotter \
< "$repo/deploy/query-plan-gate.sql" > "$output"
grep -Eq '100000[[:space:]]*\|[[:space:]]*90000[[:space:]]*\|[[:space:]]*20000[[:space:]]*\|[[:space:]]*5000' "$output"
for plan in activity_72h records_count records_page spot_detail public_spot_pages; do
grep -F "PLAN $plan" "$output" >/dev/null
done
# Public query budget from docs/query-performance.md. Five EXPLAIN statements
# must finish below it on the local alpha-sized fixture.
awk '
/Execution Time:/ { count += 1; if (($3 + 0) > 250) { print "Query plan exceeded 250 ms: " $0 > "/dev/stderr"; failed = 1 } }
END { if (count != 5 || failed) exit 1 }
' "$output"
# Selective paths must use an index. public_spot_pages may legitimately scan
# many approved rows while producing the distinct sitemap set.
awk '
/PLAN activity_72h/ { section = "activity"; next }
/PLAN records_count/ { section = "records_count"; next }
/PLAN records_page/ { section = "records_page"; next }
/PLAN spot_detail/ { section = "spot_detail"; next }
/PLAN public_spot_pages/ { section = "public_spot_pages"; next }
section == "activity" && /Index Scan/ { activity = 1 }
section == "records_page" && /Index Scan/ { records = 1 }
section == "spot_detail" && /Index Scan/ { spot = 1 }
END { if (!activity || !records || !spot) exit 1 }
' "$output"
grep 'Execution Time:' "$output"
echo "Query-plan gate passed: 100000 temporary reports, indexed selective paths, all plans under 250 ms"
+7 -7
View File
@@ -7,10 +7,10 @@
Подтверждено: Подтверждено:
- [x] пакет восстановления A01–A13 завершён; итог и доказательства собраны в [RECOVERY_FIXES_REPORT.md](RECOVERY_FIXES_REPORT.md); - [x] пакет восстановления A01–A13 завершён; итог и доказательства собраны в [RECOVERY_FIXES_REPORT.md](RECOVERY_FIXES_REPORT.md);
- [x] полный Python suite: **130 passed, 1 skipped**; skip относится к интеграционной проверке PostgreSQL и покрывается Docker-приёмкой; - [x] полный Python suite: **155 passed, 1 skipped**; skip относится к интеграционной проверке PostgreSQL и покрывается Docker-приёмкой;
- [x] Astro check: 40 файлов, **0 errors / 0 warnings / 0 hints**; production build проходит; - [x] Astro check: 40 файлов, **0 errors / 0 warnings / 0 hints**; production build проходит;
- [x] API после миграции healthy; `apps/api/tests/test_api.py`: **20 passed**; - [x] API после миграции healthy; `apps/api/tests/test_api.py`: **20 passed**;
- [x] локальная БД и чистый bootstrap достигают Alembic head `20260910_recovery`; - [x] граф Alembic линеен и имеет единственную голову `0016`; CI применяет её на чистой PostgreSQL, полный production bootstrap запускается отдельным еженедельным drill;
- [x] изолированный production bootstrap проходит Caddy adapt, scheduler validation и Playwright-сценарий отправки/модерации без обращения к внешним источникам; - [x] изолированный production bootstrap проходит Caddy adapt, scheduler validation и Playwright-сценарий отправки/модерации без обращения к внешним источникам;
- [x] Astro + FastAPI + PostgreSQL остаются целевым стеком; Next.js и Vinext не используются. - [x] Astro + FastAPI + PostgreSQL остаются целевым стеком; Next.js и Vinext не используются.
@@ -30,7 +30,7 @@
- [x] **B08 · Семейства силуэтов рыб.** Три случайных hash-варианта заменены классификатором и отдельными формами `pike`, `salmonid`, `cyprinid`, `perch`, `catfish`, `eel`, `flatfish`, `marine`, плюс честный `generic`. Компонент допускает ручное переопределение family; название остаётся главным идентификатором. - [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] **B09 · Глифы снастей и приманок.** Добавлены SVG-глифы `spinner`, `wobbler`, `soft`, `boilie`, `worm`, `rig`, `unknown` и стабильная палитра по normalized name. Классификация срабатывает только по явным словам; глиф сопровождает текст в activity, лидере, уловах, рекордах и списке лучших приманок, не выдавая категорию за точную модель.
- [x] **B10 · Визуальные отпечатки водоёмов.** Для каждого slug воспроизводимо выбираются один из восьми береговых контуров, число волн, положение точки и двухсимвольный индекс. Знак используется в каталоге и detail-hero; это явно абстрактный отпечаток, а не карта или игровая география. - [x] **B10 · Визуальные отпечатки водоёмов.** Для каждого slug воспроизводимо выбираются один из восьми береговых контуров, число волн, положение точки и двухсимвольный индекс. Знак используется в каталоге и detail-hero; это явно абстрактный отпечаток, а не карта или игровая география.
- [ ] **B11 · Разрешённый media pipeline — в работе.** Версионированный baseline на 12.09.2026 содержит 19 водоёмов и 252 рыбы; полное число снастей неизвестно и остаётся `null`, а не подменяется числом приманок. Воспроизводимый `media_cli --coverage` считает уникальные нормализованные подписи, а не файлы: 228/252 fish-кандидатов (1 approved), 0/19 waterbody, 149 tackle/bait (2 approved); ещё одна официальная Zig-Rig схема approved как reference. URL-классификация имеет приоритет над словами в названии, а две общие userguide-схемы карты больше не выдаются за водоёмы. Следующие gaps: минимум 24 рыбы, все 19 водоёмов и все категории снастей за пределами найденных приманок; получить канонические перечни и устойчивые ID, затем загружать не более одного asset с домена за окно и вручную сопоставлять. RF4DB вернул 403 — повторять только после cooldown. Официальные `/media/fische/`, `/media/levels/` и `.bait_icon` records исследовать только через публичные контракты без обхода защиты. - [ ] **B11 · Разрешённый media pipeline — в работе.** Версионированный baseline на 12.09.2026 содержит 19 водоёмов и 252 рыбы; полное число снастей неизвестно и остаётся `null`, а не подменяется числом приманок. Воспроизводимый `media_cli --coverage` считает уникальные нормализованные подписи, а не файлы: 228/252 fish-кандидатов (2 approved), 0/19 waterbody, 149 tackle/bait (12 approved); ещё 10 общих схем approved как reference. Общий audit: 24 approved, 420 queued, 8 invalid, ошибок целостности и бесхозных файлов нет. Визуально подтверждена «Акула гигантская» (`rf4map-fish-1`); официальный reference URL вернул HTML и корректно помечен invalid. Offline `--queue-plan` группирует очередь и cooldown по доменам: после текущего окна осталось 363 кандидата media-хранилища RF4MAP и 57 official reference. Точные названия минимум 24 отсутствующих рыб пока неизвестны, потому что baseline подтверждает число, но не содержит канонический перечень имён; выдавать разность только по двум несогласованным каталогам нельзя. Следующие gaps: получить канонические перечни и устойчивые ID, затем загружать assets с соблюдением общего cooldown и вручную сопоставлять. Официальные `/media/fische/`, `/media/levels/` и `.bait_icon` records исследовать только через публичные контракты без обхода защиты.
- [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений. - [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений.
- [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`. - [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`.
- [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания. - [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
@@ -50,23 +50,23 @@
- [ ] **Q01 · Документы источников — реестр готов, нужны первичные подтверждения.** Создан единый production-gate с атрибуцией, общим лимитом 30 минут, хранением и процедурой отзыва для RF4DB, RF4-STAT, RF4MAP, RF4 Posts и официального RF4. До открытой публикации приложить устойчивые ссылки/копии первичных разрешений, контакты, даты и отдельно подтвердить право на изображения; пустое поле блокирует соответствующий источник. - [ ] **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 подключит эту реакцию к разрешённому плановому запросу. - [x] **Q02 · Управляемое удаление источника.** Изменение ранее опубликованной записи возвращает её в staging, сбрасывает сопоставление и переводит связанный улов в pending с новой версией решения. Результат проверки хранится как `available`, `missing`, `temporary_error` или `blocked`: только подтверждённый `missing` отзывает публикацию, временная ошибка и блокировка остаются диагностикой. Повторное появление требует ручного подтверждения. Withdrawn-записи исключены из публичной активности, а статус и время проверки доступны в admin provenance и журнале решений. Отдельного сетевого обхода нет: Q03 подключит эту реакцию к разрешённому плановому запросу.
- [ ] **Q03 · Целостность ссылок.** Проверять исходные ссылки только во время разрешённого планового обращения к площадке, разделяя `missing`, `temporary_error` и `blocked`; не создавать дополнительный сетевой цикл. - [x] **Q03 · Целостность ссылок.** Результат уже разрешённого scheduler-запроса классифицируется без retry и дополнительного HTTP: `404/410``missing`, `401/403/429``blocked`, остальные сетевые/парсерные сбои — `temporary_error`. Результат применяется только к наблюдениям с точным совпадением source system и запрошенного URL. Отсутствие записи в агрегатном списке намеренно не считается удалением; появившиеся в успешном ответе записи отмечаются `available` обычным staging-проходом. Все попытки по-прежнему резервируются до запроса и расходуют общий cooldown домена.
- [x] **Q04 · Состояния ожидания.** Асинхронные admin-очереди получили каркасные карточки, `aria-busy`, очистку при ошибке и поддержку `prefers-reduced-motion`. Публичные страницы остаются SSR и не показывают искусственный skeleton; форма уже блокирует повторную отправку и сообщает «Отправка…». - [x] **Q04 · Состояния ожидания.** Асинхронные admin-очереди получили каркасные карточки, `aria-busy`, очистку при ошибке и поддержку `prefers-reduced-motion`. Публичные страницы остаются SSR и не показывают искусственный skeleton; форма уже блокирует повторную отправку и сообщает «Отправка…».
- [x] **Q05 · Базовая визуальная матрица.** Главная проверена в браузере на 320/390/768/1280 px, ключевые public-маршруты — на 320 px; удалён корневой `min-width`, создававший горизонтальный scroll. Добавлен E2E-контракт для `/`, records, report, waterbodies, status и видимого skip-link. Ширина 320 px также покрывает reflow, эквивалентный 200% zoom для окна 640 px. Расширенная матрица наполненных/длинных/error-состояний остаётся постоянной частью приёмки UI, а не отдельным блокером. - [x] **Q05 · Базовая визуальная матрица.** Главная проверена в браузере на 320/390/768/1280 px, ключевые public-маршруты — на 320 px; удалён корневой `min-width`, создававший горизонтальный scroll. Добавлен E2E-контракт для `/`, records, report, waterbodies, status и видимого skip-link. Ширина 320 px также покрывает reflow, эквивалентный 200% zoom для окна 640 px. Расширенная матрица наполненных/длинных/error-состояний остаётся постоянной частью приёмки UI, а не отдельным блокером.
- [x] **Q06 · Performance baseline.** На локальной production-сборке после оптимизации hero: performance 100, LCP 1,66 с, FCP 1,15 с, CLS 0,023, TBT 9 мс. Устранены найденные Lighthouse проблемы контраста и accessible name; методика и бюджеты записаны в [performance-baseline.md](performance-baseline.md). Полевой INP измеряется только после запуска. - [x] **Q06 · Performance baseline.** На локальной production-сборке после оптимизации hero: performance 100, LCP 1,66 с, FCP 1,15 с, CLS 0,023, TBT 9 мс. Устранены найденные Lighthouse проблемы контраста и accessible name; методика и бюджеты записаны в [performance-baseline.md](performance-baseline.md). Полевой INP измеряется только после запуска.
- [x] **Q07 · Нагрузочная методика.** Добавлен read-only runner для activity, records, staging и moderation с warm-up, p50/p95/max, распределением HTTP-кодов и ограниченной concurrency. Методика фиксирует контекст запуска, ступени нагрузки и бюджеты, но не объявляет результатов до трёх прогонов на целевом сервере. - [x] **Q07 · Нагрузочная методика.** Добавлен read-only runner для activity, records, staging и moderation с warm-up, p50/p95/max, распределением HTTP-кодов и ограниченной concurrency. Методика фиксирует контекст запуска, ступени нагрузки и бюджеты, но не объявляет результатов до трёх прогонов на целевом сервере.
- [x] **Q08 · Политика MinIO.** Production bootstrap создаёт bucket и отдельную policy только с bucket location/list и get/put/delete его объектов. App credentials проверяются через bucket stat и отрицательную проверку глобального list; API больше не требует `ListAllMyBuckets` и не пытается создавать bucket. Root credentials остаются только у init-задачи. - [x] **Q08 · Политика MinIO.** Production bootstrap создаёт bucket и отдельную policy только с bucket location/list и get/put/delete его объектов. App credentials проверяются через bucket stat и отрицательную проверку глобального list; API больше не требует `ListAllMyBuckets` и не пытается создавать bucket. Root credentials остаются только у init-задачи.
- [x] **Q09 · Release-процедура.** Миграции вынесены из API runtime в одноразовый `migrate` service; API запускается только после успешного Alembic upgrade. Документированы backup, rollout и два варианта отката. Изолированный drill поднимает предыдущую ревизию схемы, добавляет контрольные данные, обновляет до head и проверяет их сохранность и новые колонки. - [x] **Q09 · Release-процедура.** Миграции вынесены из API runtime в одноразовый `migrate` service; API запускается только после успешного Alembic upgrade. Документированы backup, rollout и два варианта отката. Изолированный drill поднимает предыдущую ревизию схемы, добавляет контрольные данные, обновляет до head и проверяет их сохранность и новые колонки.
- [ ] **Q10 · Документальная ревизия.** После каждого пакета обновлять этот файл и README, не возвращая закрытые R/A/T-задачи в активный backlog. - [x] **Q10 · Документальная ревизия.** README и активный ROADMAP сверены 13.09.2026 с тестами, Alembic head, CSP и локальными `media_cli --audit/--coverage`; устаревшие числа исправлены, исторические аудиты не возвращены в backlog. Дальнейшее обновление обоих файлов остаётся обязательным правилом каждого пакета.
### Дополнения после ревизии PROJECT_AUDIT_2026-09-10 ### Дополнения после ревизии PROJECT_AUDIT_2026-09-10
Аудит выполнен на старой базе `13e04e6`; рекомендации ниже повторно проверены по текущей ветке. Уже реализованные или неприменимые предложения не возвращаются в backlog. Аудит выполнен на старой базе `13e04e6`; рекомендации ниже повторно проверены по текущей ветке. Уже реализованные или неприменимые предложения не возвращаются в backlog.
- [x] **Q11 · Декомпозиция API.** Catalog, activity/spots, records/community/status/import-history, submissions и весь admin API вынесены в отдельные `APIRouter`. `main.py` оставляет composition root, middleware, health/readiness и временные совместимые экспорты rate-limit для тестового контракта; URL и OpenAPI сохранены. - [x] **Q11 · Декомпозиция API.** Catalog, activity/spots, records/community/status/import-history, submissions и весь admin API вынесены в отдельные `APIRouter`. `main.py` оставляет composition root, middleware, health/readiness и временные совместимые экспорты rate-limit для тестового контракта; URL и OpenAPI сохранены.
- [ ] **Q12 · Query-plan gate.** В рамках Q07 снять `EXPLAIN (ANALYZE, BUFFERS)` для activity, records, spot detail и public spot pages на реалистичном наборе данных. Существующие индексы миграции `0011_query_indexes` не дублировать; индекс с `fish_id`, SQL-агрегацию или materialized view добавлять только по измеренному плану и p95. - [x] **Q12 · Query-plan gate.** Воспроизводимый TEMP-only fixture создаёт 100 000 уловов, 5 000 точек, 252 рыбы и 19 водоёмов без изменения рабочей БД; gate снимает `EXPLAIN (ANALYZE, BUFFERS)` для activity, records count/page, spot detail и public spot pages, требует index scan у селективных путей и бюджет 250 мс. Повторный прогон: 1,58 / 13,66 / 0,55 / 0,14 / 29,61 мс. Планы подтвердили существующие индексы; новый `fish_id`-индекс, SQL-агрегация и materialized view не добавлялись без оснований. Production p95 остаётся задачей после сервера.
- [x] **Q13 · Production bootstrap в CI.** Отдельный workflow запускает `deploy/test-production-bootstrap.sh` вручную или раз в неделю, а не на каждом push. Вывод bootstrap всегда сохраняется 14 дней; при падении добавляются Compose status и Playwright diagnostics. - [x] **Q13 · Production bootstrap в CI.** Отдельный workflow запускает `deploy/test-production-bootstrap.sh` вручную или раз в неделю, а не на каждом push. Вывод bootstrap всегда сохраняется 14 дней; при падении добавляются Compose status и Playwright diagnostics.
- [ ] **Q14 · Полная CSP — в работе.** Production ограничивает default/connect/form/font/media/manifest текущим доменом, изображения — self/data/`FILES_DOMAIN`, запрещает inline handlers, eval, wildcard и HTTP. Page scripts и scoped styles гарантированно выпускаются внешними `_astro`-ассетами; динамические шкалы переведены на CSS-классы. `style-src` теперь только `'self'`, `style-src-attr` и `script-src-attr``'none'`. Остаётся внедрить nonce/hash для динамического JSON-LD, убрать последнее `unsafe-inline` из `script-src` и выполнить bootstrap-проверку report/admin/OG/screenshots. - [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] **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; CI проверяет его актуальность после backend suite. Изменение artifact обязательно рассматривается вместе с реализацией, а ручное редактирование не используется.
- [x] **Q17 · Эксплуатационные документы.** Зафиксированы ADR по Astro/FastAPI/PostgreSQL, локальному cache, scheduler/cooldown и разделению PostgreSQL/MinIO. Incident runbook покрывает заполнение диска, отказ PostgreSQL/MinIO, зависшие импорты, ошибки миграций, компрометацию секретов и критерии закрытия без опасных reset/recreate операций. - [x] **Q17 · Эксплуатационные документы.** Зафиксированы ADR по Astro/FastAPI/PostgreSQL, локальному cache, scheduler/cooldown и разделению PostgreSQL/MinIO. Incident runbook покрывает заполнение диска, отказ PostgreSQL/MinIO, зависшие импорты, ошибки миграций, компрометацию секретов и критерии закрытия без опасных reset/recreate операций.
+7 -7
View File
@@ -2,16 +2,16 @@
Проверено 13 сентября 2026 года. Production не требует произвольных внешних origin: browser API-запросы идут на текущий домен, изображения скриншотов — только на `FILES_DOMAIN`, шрифты и сборка Astro — локальные. Проверено 13 сентября 2026 года. Production не требует произвольных внешних origin: browser API-запросы идут на текущий домен, изображения скриншотов — только на `FILES_DOMAIN`, шрифты и сборка Astro — локальные.
## Временные inline-зависимости ## Inline-данные
- динамический JSON-LD в `Layout.astro` остаётся единственным намеренно inline script; - динамический JSON-LD в `Layout.astro` остаётся inline, но каждый SSR-ответ получает собственный криптографический nonce;
Vite собирает даже малые page scripts и scoped Astro styles в same-origin `_astro` assets (`assetsInlineLimit: 0`). Динамические шкалы используют дискретные CSS-классы вместо style attributes, поэтому `style-src` ограничен `'self'`, а `style-src-attr``'none'`. Временный `unsafe-inline` остаётся только для динамического JSON-LD в `script-src`. Inline event handlers запрещены `script-src-attr 'none'`; `unsafe-eval`, wildcard, HTTP и сторонние connect/script origin отсутствуют. Vite собирает даже малые page scripts и scoped Astro styles в same-origin `_astro` assets (`assetsInlineLimit: 0`). Динамические шкалы используют дискретные CSS-классы вместо style attributes, поэтому `style-src` ограничен `'self'`, а `style-src-attr``'none'`. `unsafe-inline` и `unsafe-eval` отсутствуют в основном policy. Inline event handlers запрещены `script-src-attr 'none'`; wildcard и сторонние script origin не разрешены. Production `FILES_DOMAIN` проходит hostname-валидацию и используется через HTTPS. Отдельные HTTP-origin API и MinIO разрешаются только для явных loopback-адресов локального Compose; тогда `upgrade-insecure-requests` не выставляется.
## Путь к nonce/hash ## Реализация и проверка
1. Page scripts перенесены в собираемые клиентские модули; конфигурация формы передаётся через безопасный `data-state`. Нулевой inline limit запрещает Vite встраивать малые модули обратно в HTML. 1. Page scripts перенесены в собираемые клиентские модули; конфигурация формы передаётся через безопасный `data-state`. Нулевой inline limit запрещает Vite встраивать малые модули обратно в HTML.
2. JSON-LD либо хэшировать на уровне SSR-заголовка, либо выдавать nonce из web-runtime; статический nonce запрещён. 2. Web-runtime создаёт новый nonce для каждого ответа, добавляет его только JSON-LD и выпускает соответствующий CSP-заголовок. Статического nonce нет.
3. Scoped styles находятся в скомпилированных `_astro` assets, вычисляемые шкалы используют классы с шагом 5%; inline-разрешения для styles и attributes удалены. 3. Scoped styles находятся в скомпилированных `_astro` assets, вычисляемые шкалы используют классы с шагом 5%; inline-разрешения для styles и attributes удалены.
4. Удалить последнее `unsafe-inline` из `script-src` после решения JSON-LD. 4. Caddy не перезаписывает CSP web-runtime и задаёт строгую политику без nonce только как fallback для API/служебных ответов.
5. Проверить report, spots, admin, JSON-LD и signed screenshots в production bootstrap. Не добавлять `blob:`, `*` или произвольные CDN для устранения ошибок. 5. Production bootstrap сверяет policy и nonce на главной, report и admin, а также доступность OG. Signed screenshots разрешены только с валидированного `FILES_DOMAIN`; проверка реальной подписи остаётся в сценарии наполненного production-хранилища. Не добавлять `blob:`, `*` или произвольные CDN для устранения ошибок.
+4 -2
View File
@@ -50,11 +50,11 @@
Проверка 12 сентября 2026 года не обнаружила стабильного публичного каталога вида `канонический slug → изображение` для рыб, водоёмов или снастей. Официальная [галерея водоёмов](https://rf4game.de/media/levels/) и категория [рыб](https://rf4game.de/media/fische/) содержат тематические публикации, а [руководство](https://rf4game.de/userguide/) — иллюстрации интерфейса, оснасток и карт. Это медиа-галереи, а не полный справочник с устойчивыми entity ID. Проверка 12 сентября 2026 года не обнаружила стабильного публичного каталога вида `канонический slug → изображение` для рыб, водоёмов или снастей. Официальная [галерея водоёмов](https://rf4game.de/media/levels/) и категория [рыб](https://rf4game.de/media/fische/) содержат тематические публикации, а [руководство](https://rf4game.de/userguide/) — иллюстрации интерфейса, оснасток и карт. Это медиа-галереи, а не полный справочник с устойчивыми entity ID.
Повторная сверка локальной альфы показала 2 рыбы, 2 водоёма и 3 приманки. Первый userguide-manifest содержал 69 кандидатов: 67 общих reference и 2 изображения карт. После расширения сбора вручную подтверждены первые четыре соответствия, включая Ерша и две приманки. Наличие картинки в очереди всё равно не считается покрытием каталога; coverage появляется только после сохранения, визуальной проверки и назначения canonical `entity_key`. Повторная сверка локальной альфы показала 2 рыбы, 2 водоёма и 3 приманки. Первый userguide-manifest содержал 69 кандидатов: 67 общих reference и 2 изображения карт. После расширения сбора локальный audit на 13 сентября показывает 24 approved-ассета: 2 рыбы, 12 приманок/наживок и 10 общих reference. Наличие картинки в очереди всё равно не считается покрытием каталога; coverage появляется только после сохранения, визуальной проверки и назначения canonical `entity_key`.
Прямой индекс `/media/fische/` вернул только общие изображения оформления (`flags/de.png`, `banner_de.png`): содержимое галереи загружается динамически. Эти файлы остаются `reference`, а не ошибочно маркируются как рыбы. Исследование допустимого gallery endpoint должно использовать обычный публичный контракт сайта и тот же общий cooldown, без обхода защит. Прямой индекс `/media/fische/` вернул только общие изображения оформления (`flags/de.png`, `banner_de.png`): содержимое галереи загружается динамически. Эти файлы остаются `reference`, а не ошибочно маркируются как рыбы. Исследование допустимого gallery endpoint должно использовать обычный публичный контракт сайта и тот же общий cooldown, без обхода защит.
Контрольная сверка 12–13 сентября 2026 года: RF4DB показывает 19 водоёмов и 252 вида рыб. Страница RF4MAP `/fishes` дала 228 уникальных кандидатов изображений рыб и 149 изображений приманок. Это означает минимум 24 отсутствующие рыбьи иконки; 149 нельзя считать числом всех снастей, поскольку в него не входят полные каталоги удилищ, катушек, лесок, крючков и прочих компонентов. Две картинки userguide с общей подписью «карта водоёма» являются reference, а не картами конкретных сущностей, поэтому текущее waterbody-покрытие равно 0/19. Вручную подтверждены Ерш, Nasty Worm 4.5-002 и Личинка веснянки; остальные entity-кандидаты остаются карантинными. Контрольная сверка 12–13 сентября 2026 года: RF4DB показывает 19 водоёмов и 252 вида рыб. Страница RF4MAP `/fishes` дала 228 уникальных кандидатов изображений рыб и 149 изображений приманок. Это означает минимум 24 отсутствующие рыбьи иконки; 149 нельзя считать числом всех снастей, поскольку в него не входят полные каталоги удилищ, катушек, лесок, крючков и прочих компонентов. Общие userguide-карты являются reference, а не картами конкретных сущностей, поэтому текущее waterbody-покрытие равно 0/19. Канонически подтверждены 2 из 252 fish-кандидатов и 12 tackle/bait-кандидатов; остальные entity-кандидаты остаются карантинными.
Страница рекордов визуально использует `.bait_icon`, но подтверждённый parser-контракт извлекает только `.bait_icon[title]`. Ни parser, ни `Fish`/`Waterbody`/`Bait` модели и API-схемы сейчас не сохраняют image URL. Даже если URL удастся извлечь из CSS или обновлённого DOM, он может быть presentation asset, меняться независимо от названия и покрывать только приманки, попавшие в рекорды. Страница рекордов визуально использует `.bait_icon`, но подтверждённый parser-контракт извлекает только `.bait_icon[title]`. Ни parser, ни `Fish`/`Waterbody`/`Bait` модели и API-схемы сейчас не сохраняют image URL. Даже если URL удастся извлечь из CSS или обновлённого DOM, он может быть presentation asset, меняться независимо от названия и покрывать только приманки, попавшие в рекорды.
@@ -66,6 +66,8 @@ Fallback строится на собственных лёгких SVG: осмы
`python -m rf4_research.media_cli --audit` не использует сеть: команда выводит сводку статусов и завершается с ошибкой при отсутствующем/изменённом файле, несовпадении SHA-256 или dimensions/MIME, некорректном approved-сопоставлении и наличии бесхозного файла. `python -m rf4_research.media_cli --audit` не использует сеть: команда выводит сводку статусов и завершается с ошибкой при отсутствующем/изменённом файле, несовпадении SHA-256 или dimensions/MIME, некорректном approved-сопоставлении и наличии бесхозного файла.
`python -m rf4_research.media_cli --queue-plan` также не использует сеть. Он читает manifest и общий cooldown-state, группирует queued-ассеты по фактическому домену, показывает готовность и оставшееся время, состав очереди по типам и один следующий кандидат с приоритетом waterbody → fish → tackle → reference. Команда намеренно сообщает, что точные catalog gaps неизвестны, пока baseline содержит только количества без канонических имён.
`python -m rf4_research.media_cli --coverage` также работает локально и сравнивает manifest с датированным `data/media/catalog-baseline.json`. Неизвестные контрольные значения хранятся как `null`: отчёт не выдаёт число найденных приманок за полноту всего каталога снастей. `python -m rf4_research.media_cli --coverage` также работает локально и сравнивает manifest с датированным `data/media/catalog-baseline.json`. Неизвестные контрольные значения хранятся как `null`: отчёт не выдаёт число найденных приманок за полноту всего каталога снастей.
Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU. Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU.
+14 -2
View File
@@ -1,6 +1,6 @@
# Бюджет запросов для альфа-пилота # Бюджет запросов для альфа-пилота
Дата фиксации: 7 сентября 2026 года. Дата фиксации: 13 сентября 2026 года.
## Контракт списочных API ## Контракт списочных API
@@ -31,8 +31,20 @@
Это стартовый эксплуатационный бюджет, а не результат синтетического бенчмарка. Планы на пустой bootstrap-БД не показательны: PostgreSQL обоснованно выбирает последовательное чтение маленьких таблиц. Это стартовый эксплуатационный бюджет, а не результат синтетического бенчмарка. Планы на пустой bootstrap-БД не показательны: PostgreSQL обоснованно выбирает последовательное чтение маленьких таблиц.
## Воспроизводимый локальный gate
`deploy/test-query-plans.sh` создаёт только session-local TEMP-копии production-таблиц и их индексов, загружает 100 000 уловов, 5 000 точек, 252 рыбы, 19 водоёмов и 500 приманок, выполняет `ANALYZE` и пять реальных форм запросов. После закрытия psql-сессии fixture исчезает; рабочая схема и данные не меняются.
```bash
./deploy/test-query-plans.sh
```
Контрольный повторный прогон 13 сентября 2026 года на локальном PostgreSQL 17: activity 72h — 1,58 мс и bitmap index scan по статусу/времени; records count — 13,66 мс; records page — 0,55 мс и backward index scan; spot detail — 0,14 мс и bitmap index scan; public spot pages — 29,61 мс с индексными join по водоёму, точке и рыбе. Все пять планов уложились в 250 мс. Это сравнительный локальный gate, а не production p95.
Измерение не подтвердило необходимость нового индекса с `fish_id`, SQL-агрегации или materialized view. Activity выбирает существующий временной индекс, точка — `ix_catch_report_spot_feed`, records — `ix_catch_report_official_records`; дублировать их нельзя. Gate проверяет бюджет и наличие index scan у селективных activity/records/spot путей, не фиксируя нестабильную полную строку плана.
## Проверка после загрузки пилотных данных ## Проверка после загрузки пилотных данных
После наполнения выполнить `EXPLAIN (ANALYZE, BUFFERS)` для activity, moderation queue, records, staging queue и удаления старых submission attempts. Проверять фактическое время, `Rows Removed by Filter`, объём buffers и соответствие выбранного индекса фильтрам. Если таблица превышает 10 000 строк, а план остаётся последовательным и выходит за бюджет, сохранить план в журнал релиза и скорректировать индекс или форму запроса до открытия альфы. После наполнения выполнить `EXPLAIN (ANALYZE, BUFFERS)` для activity, moderation queue, records, staging queue и удаления старых submission attempts. Проверять фактическое время, `Rows Removed by Filter`, объём buffers и соответствие выбранного индекса фильтрам. Если таблица превышает 10 000 строк, а план остаётся последовательным и выходит за бюджет, сохранить план в журнал релиза и скорректировать индекс или форму запроса до открытия альфы.
Bootstrap-тест отдельно проверяет, что Alembic дошёл до `0011` и все восемь составных индексов созданы на чистой PostgreSQL. Bootstrap-тест отдельно проверяет, что Alembic прошёл миграцию `0011` и все восемь составных индексов созданы на чистой PostgreSQL. После запуска на сервере локальный gate не заменяет три полевых замера p95 из [load-testing.md](load-testing.md).
+56 -1
View File
@@ -7,12 +7,63 @@ from pathlib import Path
import urllib.error import urllib.error
import urllib.request import urllib.request
from .community_cli import USER_AGENT, _StrictRedirectHandler, _validate_url_before_io, check_and_reserve, fetch_html, fetch_site_key from .community_cli import MIN_FETCH_INTERVAL_SECONDS, USER_AGENT, _StrictRedirectHandler, _read_state, _validate_url_before_io, check_and_reserve, fetch_html, fetch_site_key
from .media_assets import audit_media_catalog, extract_media_candidates, media_coverage, merge_manifest, reclassify_manifest, review_asset, store_asset from .media_assets import audit_media_catalog, extract_media_candidates, media_coverage, merge_manifest, reclassify_manifest, review_asset, store_asset
DEFAULT_ROOT = Path("data/media") DEFAULT_ROOT = Path("data/media")
MAX_ASSET_BYTES = 15 * 1024 * 1024 MAX_ASSET_BYTES = 15 * 1024 * 1024
MEDIA_PRIORITY = {"waterbody": 0, "fish": 1, "tackle": 2, "reference": 3}
def media_queue_plan(root: Path, state_file: Path, *, now: float | None = None) -> dict:
"""Plan the next useful download per domain without network access."""
manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
state = _read_state(state_file)
current = datetime.now(timezone.utc).timestamp() if now is None else now
grouped: dict[str, list[dict]] = {}
for item in manifest.get("assets", []):
if item.get("status") == "queued":
grouped.setdefault(fetch_site_key(item["asset_url"]), []).append(item)
domains: dict[str, dict] = {}
for site, items in sorted(grouped.items()):
items.sort(key=lambda item: (
MEDIA_PRIORITY.get(str(item.get("entity_type")), 99),
not bool(item.get("label")), str(item.get("label") or "").casefold(),
str(item.get("asset_url")),
))
last_attempt = state.get(site)
retry_in = max(0, round(float(last_attempt) + MIN_FETCH_INTERVAL_SECONDS - current)) if isinstance(last_attempt, (int, float)) else 0
next_item = items[0]
queued_by_type: dict[str, int] = {}
for item in items:
entity_type = str(item.get("entity_type") or "unknown")
queued_by_type[entity_type] = queued_by_type.get(entity_type, 0) + 1
domains[site] = {
"queued": len(items),
"queued_by_type": dict(sorted(queued_by_type.items())),
"ready": retry_in == 0,
"retry_in_seconds": retry_in,
"next_asset": {
"asset_url": next_item["asset_url"],
"entity_type": next_item.get("entity_type"),
"label": next_item.get("label"),
"external_id": next_item.get("external_id"),
"source_page": next_item.get("source_page"),
},
}
coverage = media_coverage(root)
return {
"generated_at": datetime.fromtimestamp(current, timezone.utc).isoformat(),
"network_requests": 0,
"cooldown_seconds": MIN_FETCH_INTERVAL_SECONDS,
"queued_total": sum(group["queued"] for group in domains.values()),
"domains": domains,
"coverage": coverage["entities"],
"exact_catalog_gaps_known": False,
"catalog_gap_note": "Baseline stores verified totals, not a canonical name list; exact missing entity names cannot be claimed yet.",
}
def _download_one(root: Path, state_file: Path, asset_url: str | None = None) -> str: def _download_one(root: Path, state_file: Path, asset_url: str | None = None) -> str:
@@ -60,6 +111,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--note") parser.add_argument("--note")
parser.add_argument("--audit", action="store_true", help="Verify manifest metadata, hashes and local files without network access") parser.add_argument("--audit", action="store_true", help="Verify manifest metadata, hashes and local files without network access")
parser.add_argument("--coverage", action="store_true", help="Compare candidates and approvals with the catalog baseline") parser.add_argument("--coverage", action="store_true", help="Compare candidates and approvals with the catalog baseline")
parser.add_argument("--queue-plan", action="store_true", help="Show the next useful queued asset per domain without network access")
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT) parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
parser.add_argument("--state-file", type=Path, default=Path(".cache/community-fetch-state.json")) parser.add_argument("--state-file", type=Path, default=Path(".cache/community-fetch-state.json"))
args = parser.parse_args(argv) args = parser.parse_args(argv)
@@ -71,6 +123,9 @@ def main(argv: list[str] | None = None) -> int:
if args.coverage: if args.coverage:
print(json.dumps(media_coverage(args.root), ensure_ascii=False, indent=2)) print(json.dumps(media_coverage(args.root), ensure_ascii=False, indent=2))
return 0 return 0
if args.queue_plan:
print(json.dumps(media_queue_plan(args.root, args.state_file), ensure_ascii=False, indent=2))
return 0
if args.review_url: if args.review_url:
if not args.decision: if not args.decision:
parser.error("--decision is required with --review-url") parser.error("--decision is required with --review-url")
+35
View File
@@ -0,0 +1,35 @@
import json
from pathlib import Path
from rf4_research.media_cli import media_queue_plan
def test_queue_plan_prioritizes_entity_gaps_and_respects_domain_cooldown(tmp_path: Path) -> None:
root = tmp_path / "media"
root.mkdir()
(root / "catalog-baseline.json").write_text(json.dumps({
"verified_at": "2026-09-12",
"entities": {
"fish": {"count": 2}, "waterbody": {"count": 1}, "tackle": {"count": None},
},
}), encoding="utf-8")
(root / "manifest.json").write_text(json.dumps({"version": 1, "assets": [
{"status": "queued", "asset_url": "https://rf4map.ru/tackle.png", "source_page": "https://rf4map.ru/fishes", "entity_type": "tackle", "label": "Катушка", "external_id": "3"},
{"status": "queued", "asset_url": "https://rf4map.ru/fish.png", "source_page": "https://rf4map.ru/fishes", "entity_type": "fish", "label": "Щука", "external_id": "2"},
{"status": "queued", "asset_url": "https://rf4map.ru/water.png", "source_page": "https://rf4map.ru/maps", "entity_type": "waterbody", "label": "Ладога", "external_id": "1"},
{"status": "queued", "asset_url": "https://rf4-posts.com/fish.png", "source_page": "https://rf4-posts.com/fishes", "entity_type": "fish", "label": "Ёрш", "external_id": "4"},
{"status": "approved", "asset_url": "https://rf4map.ru/approved.png", "source_page": "https://rf4map.ru/fishes", "entity_type": "fish", "label": "Окунь", "entity_key": "perch"},
]}), encoding="utf-8")
state = tmp_path / "state.json"
state.write_text(json.dumps({"rf4map.ru": 1000}), encoding="utf-8")
plan = media_queue_plan(root, state, now=2000)
assert plan["network_requests"] == 0
assert plan["queued_total"] == 4
assert plan["domains"]["rf4map.ru"]["ready"] is False
assert plan["domains"]["rf4map.ru"]["retry_in_seconds"] == 800
assert plan["domains"]["rf4map.ru"]["queued_by_type"] == {"fish": 1, "tackle": 1, "waterbody": 1}
assert plan["domains"]["rf4map.ru"]["next_asset"]["entity_type"] == "waterbody"
assert plan["domains"]["rf4-posts.com"]["ready"] is True
assert plan["exact_catalog_gaps_known"] is False