Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
787a5065bc | ||
|
|
f243807fc8 | ||
|
|
73feb75767 | ||
|
|
abe51b38d0 | ||
|
|
86ed3a966a | ||
|
|
1305cccfa5 |
@@ -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 и канал уведомлений.
|
||||
|
||||
@@ -42,16 +42,20 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
|
||||
|
||||
Публичные точки используют постоянные читаемые адреса вида `/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-сопоставления и отсутствие бесхозных оригиналов.
|
||||
|
||||
`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.
|
||||
|
||||
До сервера запросы проверяются командой `./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 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 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).
|
||||
|
||||
@@ -174,7 +178,7 @@ docker compose up --build
|
||||
## Что реализовано
|
||||
|
||||
- FastAPI и SQLAlchemy 2;
|
||||
- PostgreSQL 17 и миграции Alembic до `20260910_recovery`;
|
||||
- PostgreSQL 17 и линейные миграции Alembic до `0016`;
|
||||
- идемпотентный seed с двумя точками и свежими демо-уловами;
|
||||
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
|
||||
- `GET /api/v1/spots/{id}` и `/catches`;
|
||||
|
||||
@@ -14,6 +14,7 @@ from .config import settings
|
||||
from .database import SessionLocal
|
||||
from .logging_config import configure_logging
|
||||
from .models import CommunityImportRun, DataSource
|
||||
from .source_lifecycle import classify_source_failure, record_scheduled_source_check
|
||||
|
||||
logger = logging.getLogger("rf4.community_scheduler")
|
||||
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)
|
||||
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])
|
||||
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
|
||||
except Exception as exc:
|
||||
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]}"
|
||||
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()
|
||||
return True
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
from urllib.error import HTTPError
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import ExternalObservation, ModerationStatus
|
||||
@@ -11,6 +13,16 @@ from .models import ExternalObservation, ModerationStatus
|
||||
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(
|
||||
session: Session,
|
||||
observation: ExternalObservation,
|
||||
@@ -24,7 +36,18 @@ def record_source_check(
|
||||
data. Transient errors and access blocks remain diagnostic and never remove
|
||||
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_checked_at = current
|
||||
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.reviewed_at = current
|
||||
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()
|
||||
return observation
|
||||
return len(observations)
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.community_importer import CommunityImportError, stage_observations
|
||||
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.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=False)
|
||||
fish = Fish(slug="pike", name_ru="Щука")
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from urllib.error import HTTPError
|
||||
|
||||
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.config import Settings
|
||||
from app.source_lifecycle import classify_source_failure
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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:
|
||||
now = datetime.now(timezone.utc)
|
||||
all_keys = {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
|
||||
|
||||
@@ -39,6 +39,43 @@ const jsonLd = JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@graph": jsonLdGraph,
|
||||
}).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>
|
||||
<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:image" content={socialImage} />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -23,3 +23,20 @@ test("production bootstrap supports submission and moderation", async ({ page, r
|
||||
expect(response.ok()).toBeTruthy();
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -182,6 +182,8 @@ services:
|
||||
environment:
|
||||
PUBLIC_API_URL: https://${SITE_DOMAIN:?Set SITE_DOMAIN}
|
||||
API_INTERNAL_URL: http://api:8000
|
||||
FILES_DOMAIN: ${FILES_DOMAIN:?Set FILES_DOMAIN}
|
||||
FILES_ORIGIN: https://${FILES_DOMAIN:?Set FILES_DOMAIN}
|
||||
depends_on:
|
||||
api: {condition: service_healthy}
|
||||
healthcheck:
|
||||
|
||||
@@ -80,6 +80,7 @@ services:
|
||||
environment:
|
||||
PUBLIC_API_URL: http://localhost:8000
|
||||
API_INTERNAL_URL: http://api:8000
|
||||
FILES_ORIGIN: http://localhost:9000
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -2299,9 +2299,19 @@
|
||||
"source_pages": [
|
||||
"https://rf4map.ru/fishes"
|
||||
],
|
||||
"status": "queued",
|
||||
"status": "approved",
|
||||
"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",
|
||||
@@ -5921,12 +5931,14 @@
|
||||
"entity_type": "reference",
|
||||
"label": "Abbildung. 53: Rute mit Bonus Werten",
|
||||
"external_id": null,
|
||||
"status": "queued",
|
||||
"status": "invalid",
|
||||
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"last_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||
"source_pages": [
|
||||
"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/",
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@
|
||||
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||
X-Frame-Options "DENY"
|
||||
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
@@ -69,6 +69,12 @@ curl -fsS -H "Authorization: Bearer $ADMIN_TOKEN" -o rf4spotter-diagnostics.json
|
||||
./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 года.
|
||||
|
||||
Одноразовый `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
|
||||
```
|
||||
|
||||
Автоматический 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-запрос к источнику.
|
||||
|
||||
|
||||
@@ -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;
|
||||
Executable
+40
@@ -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
@@ -7,10 +7,10 @@
|
||||
Подтверждено:
|
||||
|
||||
- [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] 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] Astro + FastAPI + PostgreSQL остаются целевым стеком; Next.js и Vinext не используются.
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
- [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; это явно абстрактный отпечаток, а не карта или игровая география.
|
||||
- [ ] **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] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`.
|
||||
- [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
|
||||
@@ -50,23 +50,23 @@
|
||||
|
||||
- [ ] **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 подключит эту реакцию к разрешённому плановому запросу.
|
||||
- [ ] **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] **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] **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] **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
|
||||
|
||||
Аудит выполнен на старой базе `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 сохранены.
|
||||
- [ ] **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.
|
||||
- [ ] **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] **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 операций.
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
Проверено 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.
|
||||
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 удалены.
|
||||
4. Удалить последнее `unsafe-inline` из `script-src` после решения JSON-LD.
|
||||
5. Проверить report, spots, admin, JSON-LD и signed screenshots в production bootstrap. Не добавлять `blob:`, `*` или произвольные CDN для устранения ошибок.
|
||||
4. Caddy не перезаписывает CSP web-runtime и задаёт строгую политику без nonce только как fallback для API/служебных ответов.
|
||||
5. Production bootstrap сверяет policy и nonce на главной, report и admin, а также доступность OG. Signed screenshots разрешены только с валидированного `FILES_DOMAIN`; проверка реальной подписи остаётся в сценарии наполненного production-хранилища. Не добавлять `blob:`, `*` или произвольные CDN для устранения ошибок.
|
||||
|
||||
@@ -50,11 +50,11 @@
|
||||
|
||||
Проверка 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, без обхода защит.
|
||||
|
||||
Контрольная сверка 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, меняться независимо от названия и покрывать только приманки, попавшие в рекорды.
|
||||
|
||||
@@ -66,6 +66,8 @@ Fallback строится на собственных лёгких SVG: осмы
|
||||
|
||||
`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`: отчёт не выдаёт число найденных приманок за полноту всего каталога снастей.
|
||||
|
||||
Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Бюджет запросов для альфа-пилота
|
||||
|
||||
Дата фиксации: 7 сентября 2026 года.
|
||||
Дата фиксации: 13 сентября 2026 года.
|
||||
|
||||
## Контракт списочных API
|
||||
|
||||
@@ -31,8 +31,20 @@
|
||||
|
||||
Это стартовый эксплуатационный бюджет, а не результат синтетического бенчмарка. Планы на пустой 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 строк, а план остаётся последовательным и выходит за бюджет, сохранить план в журнал релиза и скорректировать индекс или форму запроса до открытия альфы.
|
||||
|
||||
Bootstrap-тест отдельно проверяет, что Alembic дошёл до `0011` и все восемь составных индексов созданы на чистой PostgreSQL.
|
||||
Bootstrap-тест отдельно проверяет, что Alembic прошёл миграцию `0011` и все восемь составных индексов созданы на чистой PostgreSQL. После запуска на сервере локальный gate не заменяет три полевых замера p95 из [load-testing.md](load-testing.md).
|
||||
|
||||
@@ -7,12 +7,63 @@ from pathlib import Path
|
||||
import urllib.error
|
||||
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
|
||||
|
||||
|
||||
DEFAULT_ROOT = Path("data/media")
|
||||
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:
|
||||
@@ -60,6 +111,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
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("--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("--state-file", type=Path, default=Path(".cache/community-fetch-state.json"))
|
||||
args = parser.parse_args(argv)
|
||||
@@ -71,6 +123,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.coverage:
|
||||
print(json.dumps(media_coverage(args.root), ensure_ascii=False, indent=2))
|
||||
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 not args.decision:
|
||||
parser.error("--decision is required with --review-url")
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user