Compare commits
3
Commits
6e21bb774d
...
3e70b0f387
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e70b0f387 | ||
|
|
486b4e9645 | ||
|
|
731eade7b3 |
@@ -18,9 +18,13 @@ Web Docker-образ устанавливает зависимости чере
|
|||||||
|
|
||||||
Идёт исправление аудита: актуальные изменения и ограничения перечислены в [AUDIT_FIXES.md](docs/AUDIT_FIXES.md). Production Compose включает community scheduler; страницы rules/privacy реализованы. Для запуска остаются сервер, DNS/TLS, секреты, внешний backup и контакты. Шкала 72 часов использует полную выборку по времени поступления; одинаковые поля разных источников больше не считаются доказательством одного события. Фоновая публикация обновляет кэш API в пределах TTL, не мгновенно.
|
Идёт исправление аудита: актуальные изменения и ограничения перечислены в [AUDIT_FIXES.md](docs/AUDIT_FIXES.md). Production Compose включает community scheduler; страницы rules/privacy реализованы. Для запуска остаются сервер, DNS/TLS, секреты, внешний backup и контакты. Шкала 72 часов использует полную выборку по времени поступления; одинаковые поля разных источников больше не считаются доказательством одного события. Фоновая публикация обновляет кэш API в пределах TTL, не мгновенно.
|
||||||
|
|
||||||
|
Полный аудит 8 сентября: [отчёт](docs/PROJECT_AUDIT_2026-09-08.md), [приоритетный план](docs/ROADMAP.md#ближайший-рабочий-пакет). Перед production нужно исправить Caddy-маршруты формы, конфликт Basic/Bearer admin-аутентификации и исходящую сеть community scheduler. Затем — клиентский rate limit, надёжность парсеров, согласованность фильтров/оценок и HTTP/SEO-контракт ошибок.
|
||||||
|
|
||||||
|
На ширинах 320, 390, 768 и 1280 px ранее проверено отсутствие горизонтального переполнения основных страниц. Это не полная визуальная приёмка: аудит обнаружил неверную desktop-компоновку фильтров; наполненные карточки, длинные названия, клавиатура и zoom остаются отдельной задачей.
|
||||||
|
|
||||||
Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. На всех страницах подключён компактный баннер открытой альфы со ссылками на статус, правила и отправку улова. В production Compose включён community scheduler; локально он запускается отдельным профилем. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений; публичный адрес обратной связи ещё не задан.
|
Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. На всех страницах подключён компактный баннер открытой альфы со ссылками на статус, правила и отправку улова. В production Compose включён community scheduler; локально он запускается отдельным профилем. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений; публичный адрес обратной связи ещё не задан.
|
||||||
|
|
||||||
RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Admin API предлагает точные ранее подтверждённые алиасы отдельно от mapping-действия и запрещает молча переназначать alias другой сущности. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации.
|
RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Admin API предлагает точные ранее подтверждённые алиасы отдельно от mapping-действия и запрещает молча переназначать alias другой сущности. Для разрешённых community-источников действует интервал не менее 30 минут на сайт, общий для всех его endpoint. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации.
|
||||||
|
|
||||||
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. Визуально объединяются только повторы одного ID источника; похожие записи разных площадок остаются самостоятельными наблюдениями. Sidebar лидера скрывается при единственном результате, чтобы не повторять ту же карточку.
|
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. Визуально объединяются только повторы одного ID источника; похожие записи разных площадок остаются самостоятельными наблюдениями. Sidebar лидера скрывается при единственном результате, чтобы не повторять ту же карточку.
|
||||||
|
|
||||||
@@ -28,7 +32,7 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
|
|||||||
|
|
||||||
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Каждый улов показывает источник, относительную свежесть и точное время UTC; время получения явно отделено от времени улова. Карточки активности и каталог дополнены лёгкими SVG-силуэтами рыб без внешних графических зависимостей. Пустые и аварийные состояния используют собственную CSS-иллюстрацию поплавка; анимация учитывает системное ограничение движения.
|
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Каждый улов показывает источник, относительную свежесть и точное время UTC; время получения явно отделено от времени улова. Карточки активности и каталог дополнены лёгкими SVG-силуэтами рыб без внешних графических зависимостей. Пустые и аварийные состояния используют собственную CSS-иллюстрацию поплавка; анимация учитывает системное ограничение движения.
|
||||||
|
|
||||||
Все пять community-парсеров подключены к отдельному scheduler-процессу. Состояние запусков и ошибок хранится в PostgreSQL, параллельный запуск одного источника блокируется, минимальный интервал жёстко ограничен 1800 секундами. Локально процесс включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
|
Все пять 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 задаются переменными окружения.
|
||||||
|
|
||||||
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
|
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
|
||||||
|
|
||||||
@@ -71,7 +75,7 @@ python -m rf4_research.community_cli rf4map-point --url https://rf4map.ru/points
|
|||||||
python -m rf4_research.community_cli rf4posts-spot --url https://rf4-posts.com/ru/spots/UUID --limit 25
|
python -m rf4_research.community_cli rf4posts-spot --url https://rf4-posts.com/ru/spots/UUID --limit 25
|
||||||
```
|
```
|
||||||
|
|
||||||
Команды печатают нормализованный JSON в stdout и ничего не записывают в базу. Detail-команды требуют явный публичный URL и не обходят запрещённые `/api/`. Для RF4-STAT действует пауза не менее пяти секунд между разными страницами; для RF4MAP/RF4 Posts CLI хранит состояние в `.cache/community-fetch-state.json` и блокирует повтор того же источника раньше 30 минут.
|
Команды печатают нормализованный JSON в stdout и ничего не записывают в базу. Detail-команды требуют явный публичный URL и не обходят запрещённые `/api/`. CLI резервирует домен в `.cache/community-fetch-state.json` до HTTP-запроса и блокирует любой его endpoint на 30 минут даже после ошибки. Это автономный исследовательский режим: не запускайте его одновременно с production scheduler; для ручного production-запуска используйте `app.cli fetch-community`, который разделяет PostgreSQL-cooldown с scheduler.
|
||||||
|
|
||||||
Проверенный JSON можно идемпотентно загрузить в изолированный staging, не влияющий на публичную статистику:
|
Проверенный JSON можно идемпотентно загрузить в изолированный staging, не влияющий на публичную статистику:
|
||||||
|
|
||||||
@@ -237,7 +241,7 @@ Production-логи структурированы в JSON и не содерж
|
|||||||
## Известные ограничения альфы
|
## Известные ограничения альфы
|
||||||
|
|
||||||
- нет пользовательских аккаунтов, OCR, Telegram-бота и уведомлений о клёве;
|
- нет пользовательских аккаунтов, OCR, Telegram-бота и уведомлений о клёве;
|
||||||
- community-источники автоматически публикуют только полные наблюдения с подтверждёнными алиасами; новые соответствия требуют ручной проверки, неполные наблюдения не публикуются;
|
- community-источники автоматически включают в активность только полные наблюдения с подтверждёнными external-ID алиасами; fallback alias по имени ещё не используется автопубликацией. Неполные наблюдения видны в «Полевых сигналах», но не влияют на индекс;
|
||||||
- offset pagination рассчитана на пилотные объёмы, не на бесконечную ленту;
|
- offset pagination рассчитана на пилотные объёмы, не на бесконечную ленту;
|
||||||
- прежний локальный Lighthouse показывал LCP 9,3 с; после него hero уменьшен с 1,6 МБ до 71 КБ и получил высокий приоритет загрузки, повторный production-замер выполняется после размещения;
|
- прежний локальный Lighthouse показывал LCP 9,3 с; после него hero уменьшен с 1,6 МБ до 71 КБ и получил высокий приоритет загрузки, повторный production-замер выполняется после размещения;
|
||||||
- один сервер остаётся точкой отказа, поэтому обязательны внешний backup и мониторинг;
|
- один сервер остаётся точкой отказа, поэтому обязательны внешний backup и мониторинг;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .community_importer import stage_observations
|
|||||||
from .retention import RetentionPolicy, apply_retention
|
from .retention import RetentionPolicy, apply_retention
|
||||||
from .storage import delete_screenshot
|
from .storage import delete_screenshot
|
||||||
from .catalog_audit import audit_catalog
|
from .catalog_audit import audit_catalog
|
||||||
|
from .community_scheduler import configured_sources, run_source
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -24,6 +25,8 @@ def main() -> int:
|
|||||||
community = sub.add_parser("stage-community-json")
|
community = sub.add_parser("stage-community-json")
|
||||||
community.add_argument("--input", default="-", help="JSON array path or - for stdin")
|
community.add_argument("--input", default="-", help="JSON array path or - for stdin")
|
||||||
community.add_argument("--limit", type=int, default=500)
|
community.add_argument("--limit", type=int, default=500)
|
||||||
|
fetch_community = sub.add_parser("fetch-community")
|
||||||
|
fetch_community.add_argument("source", choices=configured_sources())
|
||||||
cleanup = sub.add_parser("cleanup-retention")
|
cleanup = sub.add_parser("cleanup-retention")
|
||||||
cleanup.add_argument("--apply", action="store_true", help="apply changes; default is dry-run")
|
cleanup.add_argument("--apply", action="store_true", help="apply changes; default is dry-run")
|
||||||
sub.add_parser("audit-catalog")
|
sub.add_parser("audit-catalog")
|
||||||
@@ -45,6 +48,9 @@ def main() -> int:
|
|||||||
parser.error("input must be a JSON array")
|
parser.error("input must be a JSON array")
|
||||||
created, updated = stage_observations(session, payload[:args.limit])
|
created, updated = stage_observations(session, payload[:args.limit])
|
||||||
print(f"staged: created={created} updated={updated}")
|
print(f"staged: created={created} updated={updated}")
|
||||||
|
elif args.command == "fetch-community":
|
||||||
|
started = run_source(args.source)
|
||||||
|
print("community fetch started" if started else "community fetch skipped: disabled, locked, or cooling down")
|
||||||
elif args.command == "cleanup-retention":
|
elif args.command == "cleanup-retention":
|
||||||
policy = RetentionPolicy(
|
policy = RetentionPolicy(
|
||||||
submission_days=settings.retention_submission_days,
|
submission_days=settings.retention_submission_days,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
from rf4_research.community_cli import SOURCES, fetch_html
|
from rf4_research.community_cli import SOURCES, fetch_html, fetch_site_key
|
||||||
from rf4_research.community_sources import parse_rf4map_point, parse_rf4posts_spot
|
from rf4_research.community_sources import parse_rf4map_point, parse_rf4posts_spot
|
||||||
from .community_importer import stage_observations
|
from .community_importer import stage_observations
|
||||||
from .config import settings
|
from .config import settings
|
||||||
@@ -35,18 +35,32 @@ def configured_sources():
|
|||||||
"rf4posts-spot": (settings.rf4posts_spot_url, parse_rf4posts_spot),
|
"rf4posts-spot": (settings.rf4posts_spot_url, parse_rf4posts_spot),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def oldest_site_source(source_system: str, latest_by_source: dict[str, datetime]) -> str:
|
||||||
|
sources = configured_sources()
|
||||||
|
site = fetch_site_key(sources[source_system][0])
|
||||||
|
candidates = [key for key, (url, _) in sources.items() if fetch_site_key(url) == site]
|
||||||
|
order = {key: index for index, key in enumerate(candidates)}
|
||||||
|
return min(candidates, key=lambda key: (latest_by_source.get(key, datetime.min.replace(tzinfo=timezone.utc)), order[key]))
|
||||||
|
|
||||||
def run_source(source_system: str, *, now: datetime | None = None) -> bool:
|
def run_source(source_system: str, *, now: datetime | None = None) -> bool:
|
||||||
current = now or datetime.now(timezone.utc)
|
current = now or datetime.now(timezone.utc)
|
||||||
url, parser = configured_sources()[source_system]
|
url, parser = configured_sources()[source_system]
|
||||||
|
site_key = fetch_site_key(url)
|
||||||
|
site_sources = [key for key, (candidate_url, _) in configured_sources().items() if fetch_site_key(candidate_url) == site_key]
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
source = session.get(DataSource, source_system)
|
source = session.get(DataSource, source_system)
|
||||||
if source is None or not source.enabled:
|
if source is None or not source.enabled:
|
||||||
return False
|
return False
|
||||||
# Lock before reading cooldown: committing the reservation makes it visible
|
# Lock before reading cooldown: committing the reservation makes it visible
|
||||||
# to the next contender before releasing this transaction lock.
|
# to the next contender before releasing this transaction lock.
|
||||||
if session.bind and session.bind.dialect.name == "postgresql" and not session.scalar(text("select pg_try_advisory_xact_lock(hashtext(:key))"), {"key": f"community:{source_system}"}):
|
if session.bind and session.bind.dialect.name == "postgresql" and not session.scalar(text("select pg_try_advisory_xact_lock(hashtext(:key))"), {"key": f"community-site:{site_key}"}):
|
||||||
|
return False
|
||||||
|
recent = list(session.scalars(select(CommunityImportRun).where(CommunityImportRun.source_system.in_(site_sources)).order_by(CommunityImportRun.started_at.desc()).limit(32)))
|
||||||
|
latest_by_source: dict[str, datetime] = {}
|
||||||
|
for previous in recent:
|
||||||
|
latest_by_source.setdefault(previous.source_system, previous.started_at if previous.started_at.tzinfo else previous.started_at.replace(tzinfo=timezone.utc))
|
||||||
|
if oldest_site_source(source_system, latest_by_source) != source_system:
|
||||||
return False
|
return False
|
||||||
recent = list(session.scalars(select(CommunityImportRun).where(CommunityImportRun.source_system == source_system).order_by(CommunityImportRun.started_at.desc()).limit(8)))
|
|
||||||
latest = recent[0].started_at if recent else None
|
latest = recent[0].started_at if recent else None
|
||||||
delay = retry_delay([run.status for run in recent])
|
delay = retry_delay([run.status for run in recent])
|
||||||
if latest and (latest if latest.tzinfo else latest.replace(tzinfo=timezone.utc)) > current - timedelta(seconds=delay):
|
if latest and (latest if latest.tzinfo else latest.replace(tzinfo=timezone.utc)) > current - timedelta(seconds=delay):
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, retry_delay
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, oldest_site_source, retry_delay
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
|
|
||||||
|
|
||||||
@@ -19,3 +21,10 @@ def test_failed_runs_back_off_but_success_resets_delay() -> None:
|
|||||||
assert retry_delay(["failed", "failed", "failed"]) == 7200
|
assert retry_delay(["failed", "failed", "failed"]) == 7200
|
||||||
assert retry_delay(["failed"] * 20) == MAX_BACKOFF_SECONDS
|
assert retry_delay(["failed"] * 20) == MAX_BACKOFF_SECONDS
|
||||||
assert retry_delay(["success", "failed"]) == 1800
|
assert retry_delay(["success", "failed"]) == 1800
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_site_endpoints_rotate_by_oldest_attempt() -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
assert oldest_site_source("rf4stat-fishing", {}) == "rf4stat-fishing"
|
||||||
|
latest = {"rf4stat-fishing": now, "rf4stat-post": now - timedelta(hours=1)}
|
||||||
|
assert oldest_site_source("rf4stat-fishing", latest) == "rf4stat-post"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
:root{--paper:#f2f5ee;--deep:#082226;--teal:#12383b;--lime:#c9f45b;--ink:#092226;--muted:#647472;--line:#cbd6ce;--white:#fff;--orange:#ffb65c;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:var(--paper)}
|
:root{--paper:#f2f5ee;--deep:#082226;--teal:#12383b;--lime:#c9f45b;--ink:#092226;--muted:#647472;--line:#cbd6ce;--white:#fff;--orange:#ffb65c;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:var(--paper)}
|
||||||
|
/* Mobile horizontal rails: native scrolling without a second visual scrollbar. */
|
||||||
|
@media(max-width:720px){.topbar nav,.active-filters{overscroll-behavior-inline:contain;scroll-snap-type:x proximity;scrollbar-width:none}.topbar nav::-webkit-scrollbar,.active-filters::-webkit-scrollbar{display:none}.topbar nav a,.active-filters span{scroll-snap-align:start}}
|
||||||
|
@media(max-width:360px){body .records-hero h1,body .form-hero h1{font-size:46px}}
|
||||||
*{box-sizing:border-box}[hidden]{display:none!important}html{scroll-behavior:smooth}body{margin:0;min-width:320px;background:var(--paper);color:var(--ink)}a{color:inherit}button,input,select,textarea{font:inherit}button{cursor:pointer}.content-grid{width:min(1360px,calc(100% - 64px));margin-inline:auto}main{min-height:calc(100vh - 204px)}
|
*{box-sizing:border-box}[hidden]{display:none!important}html{scroll-behavior:smooth}body{margin:0;min-width:320px;background:var(--paper);color:var(--ink)}a{color:inherit}button,input,select,textarea{font:inherit}button{cursor:pointer}.content-grid{width:min(1360px,calc(100% - 64px));margin-inline:auto}main{min-height:calc(100vh - 204px)}
|
||||||
.skip-link{position:fixed;z-index:100;left:12px;top:12px;padding:12px 16px;background:var(--lime);color:var(--deep);font-weight:750;transform:translateY(-150%)}.skip-link:focus{transform:none}:focus-visible{outline:3px solid #7da529;outline-offset:3px}.topbar nav a[aria-current="page"]{font-weight:750}.notice:focus{outline:3px solid #9d3529;outline-offset:3px}
|
.skip-link{position:fixed;z-index:100;left:12px;top:12px;padding:12px 16px;background:var(--lime);color:var(--deep);font-weight:750;transform:translateY(-150%)}.skip-link:focus{transform:none}:focus-visible{outline:3px solid #7da529;outline-offset:3px}.topbar nav a[aria-current="page"]{font-weight:750}.notice:focus{outline:3px solid #9d3529;outline-offset:3px}
|
||||||
.topbar{height:86px;width:min(1480px,calc(100% - 48px));margin:auto;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:28px}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;min-width:max-content}.brand-mark{width:42px;height:42px;display:grid;place-items:center;border:1px solid #9eb0a7;border-radius:50%;background:var(--deep);color:var(--lime);font-size:28px}.brand-name{display:flex;flex-direction:column;font:17px/.95 Georgia,serif}.brand-name strong{font-size:20px;font-style:italic}.topbar nav{height:100%;display:flex;gap:23px;align-items:center}.topbar nav a{height:100%;display:flex;align-items:center;gap:7px;padding:0 8px;text-decoration:none;color:#526461;font-size:14px;border-bottom:3px solid transparent}.topbar nav a.active{color:var(--deep);border-color:var(--deep)}.live-badge{justify-self:end;display:flex;align-items:center;gap:9px;color:#60716e;font-size:13px}.live-badge>span{width:8px;height:8px;border-radius:50%;background:#83b83a;box-shadow:0 0 0 5px #83b83a20}
|
.topbar{height:86px;width:min(1480px,calc(100% - 48px));margin:auto;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:28px}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;min-width:max-content}.brand-mark{width:42px;height:42px;display:grid;place-items:center;border:1px solid #9eb0a7;border-radius:50%;background:var(--deep);color:var(--lime);font-size:28px}.brand-name{display:flex;flex-direction:column;font:17px/.95 Georgia,serif}.brand-name strong{font-size:20px;font-style:italic}.topbar nav{height:100%;display:flex;gap:23px;align-items:center}.topbar nav a{height:100%;display:flex;align-items:center;gap:7px;padding:0 8px;text-decoration:none;color:#526461;font-size:14px;border-bottom:3px solid transparent}.topbar nav a.active{color:var(--deep);border-color:var(--deep)}.live-badge{justify-self:end;display:flex;align-items:center;gap:9px;color:#60716e;font-size:13px}.live-badge>span{width:8px;height:8px;border-radius:50%;background:#83b83a;box-shadow:0 0 0 5px #83b83a20}
|
||||||
|
|||||||
+4
-2
@@ -1,5 +1,7 @@
|
|||||||
# Исправления аудита 7 сентября 2026
|
# Исправления аудита 7 сентября 2026
|
||||||
|
|
||||||
|
Дополнено [полным аудитом 8 сентября](PROJECT_AUDIT_2026-09-08.md): выявлены production-блокеры T01–T03. Исторические отметки ниже не означают закрытие новых пограничных случаев; актуальная очередь находится в ROADMAP.
|
||||||
|
|
||||||
## Исправлено в рабочей версии
|
## Исправлено в рабочей версии
|
||||||
|
|
||||||
- [x] Очередь внешних наблюдений фильтруется в SQL до limit/offset; UI показывает по 50 записей с навигацией, опубликованные записи не вытесняют ожидающие проверки.
|
- [x] Очередь внешних наблюдений фильтруется в SQL до limit/offset; UI показывает по 50 записей с навигацией, опубликованные записи не вытесняют ожидающие проверки.
|
||||||
@@ -19,7 +21,7 @@
|
|||||||
|
|
||||||
## Остаётся
|
## Остаётся
|
||||||
|
|
||||||
- [ ] Единый лимит по сайту для CLI и scheduler, включая неуспешные попытки.
|
- [ ] Довести общий cooldown до критериев D01–D03 нового аудита. В `486b4e9` добавлены доменная блокировка и резервирование до HTTP, но disabled-сосед блокирует ротацию, research CLI не атомарен и не разделяет PostgreSQL-state.
|
||||||
- [ ] Межпроцессная инвалидация: сейчас фоновая публикация видна после TTL.
|
- [ ] Межпроцессная инвалидация: сейчас фоновая публикация видна после TTL.
|
||||||
- [x] Изменённая публикация снимается с активности и отправляется на ручное сопоставление. Подтверждение обновляет прежний CatchReport без дубликата; старый снимок сохраняется до подтверждения. Изменения видны в API после TTL кэша.
|
- [x] Изменённая публикация снимается с активности и отправляется на ручное сопоставление. Подтверждение обновляет прежний CatchReport без дубликата; старый снимок сохраняется до подтверждения. Изменения видны в API после TTL кэша.
|
||||||
- [ ] Обнаружение удалённых оригиналов и долговременная история всех редакций источника.
|
- [ ] Обнаружение удалённых оригиналов и долговременная история всех редакций источника.
|
||||||
@@ -28,7 +30,7 @@
|
|||||||
- [x] Web Dockerfile использует npm ci; инструменты проверки/тестирования перенесены в devDependencies и удаляются перед копированием runtime. Обновлён lock-файл без сети. Сборка Docker-образа остаётся проверкой перед деплоем.
|
- [x] Web Dockerfile использует npm ci; инструменты проверки/тестирования перенесены в devDependencies и удаляются перед копированием runtime. Обновлён lock-файл без сети. Сборка Docker-образа остаётся проверкой перед деплоем.
|
||||||
- [x] Удалить pytest из Python production-зависимостей; локальная среда и CI используют отдельный requirements-dev.txt (8 сентября 2026).
|
- [x] Удалить pytest из Python production-зависимостей; локальная среда и CI используют отдельный requirements-dev.txt (8 сентября 2026).
|
||||||
- [x] Hero переведён в WebP (1,6 МБ → 71 КБ), PNG-иконка переупакована (368 КБ → 69 КБ), изображения получили явные размеры и приоритет LCP. Pillow нужен в runtime: API декодирует и повторно кодирует пользовательские изображения для проверки формата, удаления EXIF и безопасного хранения.
|
- [x] Hero переведён в WebP (1,6 МБ → 71 КБ), PNG-иконка переупакована (368 КБ → 69 КБ), изображения получили явные размеры и приоритет LCP. Pillow нужен в runtime: API декодирует и повторно кодирует пользовательские изображения для проверки формата, удаления EXIF и безопасного хранения.
|
||||||
- [ ] Визуальная проверка 320/390/768/1280 px, исправление карточек и пустых состояний.
|
- [ ] Завершить визуальную приёмку U01/U07 нового аудита. В `731eade` проверено отсутствие горизонтального переполнения и исправлен заголовок на 320 px, но desktop-фильтры и наполненные состояния требуют проверки/исправления.
|
||||||
- [ ] Согласовать остальные формулировки README/roadmap с фактическим поведением.
|
- [ ] Согласовать остальные формулировки README/roadmap с фактическим поведением.
|
||||||
|
|
||||||
Существовавшие перед этим пакетом незакоммиченные изменения кэша и карточек сохранены.
|
Существовавшие перед этим пакетом незакоммиченные изменения кэша и карточек сохранены.
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Аудит RF4 Spotter — 8 сентября 2026
|
||||||
|
|
||||||
|
База проверки: commit `486b4e9`. Аудит охватывает production-конфигурацию, API, импорт и публикацию данных, Astro, пользовательские сценарии, визуальную идентичность и SEO. Это отчёт о текущем состоянии, а не подтверждение готовности к запуску. Исправления приложения в этот пакет не входят.
|
||||||
|
|
||||||
|
## Вывод
|
||||||
|
|
||||||
|
Стек соответствует продукту: Astro SSR + FastAPI + PostgreSQL + MinIO + Caddy. Перенос на Next.js/Vinext, добавление SPA-фреймворка, Redis или микросервисов сейчас не обоснованы. Есть полезная основа: миграции, модерация, provenance, проверка изображений, Docker, backup/restore, SEO-разметка и выразительный рыболовный визуальный язык.
|
||||||
|
|
||||||
|
Открывать production пока рано. Подтверждены три блокирующих противоречия конфигурации: маршрутизация формы, несовместимые схемы admin-аутентификации и отсутствие внешней сети у community scheduler. Успешная локальная проверка напрямую через Astro/API эти дефекты не обнаруживает. Главный продуктовый риск — фактическое наполнение и достоверность времени/оценок, а не недостаток декоративных элементов.
|
||||||
|
|
||||||
|
## Метод и границы
|
||||||
|
|
||||||
|
- Прочитаны конфигурации Compose/Caddy/Docker/CI, ключевые обработчики API и Astro, код агрегации, scheduler, research CLI, staging/review/retention, seed, компоненты и стили, текущие планы и ограничения.
|
||||||
|
- Встроенный браузер: главная текущего локального стенда, desktop 1280×1000 и mobile 390×844; оценены композиция, меню и фильтры. Предыдущий прогон 320/390/768/1280 проверял прежде всего переполнение. Его нельзя считать полноценной визуальной приёмкой всех состояний.
|
||||||
|
- `astro check`: 40 файлов, 0 ошибок, 0 предупреждений. Целевые тесты CLI/scheduler/activity: 16 passed. Общий pytest был начат, но завершённый результат в этом прогоне не получен; прежние общие результаты не выдаются за новый полный прогон.
|
||||||
|
- Внешние парсеры не запускались, 30-минутный лимит не расходован. Актуальность HTML источников, CVE всех зависимостей, нагрузка, TLS/DNS, реальный backup на сервере и поисковая индексация повторно не проверялись.
|
||||||
|
- Проверены официальные документы Docker, Caddy и Google. Подтверждение по коду отделено ниже от дизайнерских предложений и будущих проверок.
|
||||||
|
|
||||||
|
Приоритеты: **P0** — блокирует production; **P1** — нужно до открытой альфы; **P2** — улучшение качества/масштабирования; **P3** — после обратной связи пилота.
|
||||||
|
|
||||||
|
## Техническая часть и эксплуатация
|
||||||
|
|
||||||
|
| ID | Приоритет | Подтверждение и последствие | Критерий исправления |
|
||||||
|
|---|---|---|---|
|
||||||
|
| T01 | P0 | `deploy/Caddyfile`: `/api/*` уходит в FastAPI, но `/api/report` и `/api/report-screenshot` реализованы в Astro. Production-форма попадает в отсутствующий маршрут. | Через Caddy отправка и повтор загрузки достигают Astro и возвращают ожидаемый 303; `/api/v1/*` достигает FastAPI. |
|
||||||
|
| T02 | P0 | Caddy `basic_auth` на `/api/v1/admin/*` требует Basic в `Authorization`; admin JS посылает Bearer, `_admin` требует именно Bearer. Два слоя используют один несовместимый контракт. | Выбрать согласованную схему; доказать успешные чтение/модерацию через proxy и отказ неавторизованному клиенту. |
|
||||||
|
| T03 | P0 | `compose.production.yaml`: community-scheduler только в `backend`, сеть `internal: true`. Нет внешней сети для HTTPS источников. | Разрешён исходящий доступ scheduler; БД/MinIO остаются без внешних портов. Проверить локальным контролируемым HTTP-источником. |
|
||||||
|
| T04 | P1 | Astro POST не передаёт идентичность клиента, FastAPI rate limit использует `request.client.host`. Заявки через web объединяются под его адресом. Доверенные proxy явно не настроены. | Два клиента имеют независимый лимит; поддельный forwarded header его не обходит; проверка через всю цепочку Caddy → Astro → API. |
|
||||||
|
| T05 | P1 | В Astro `request.formData()` выполняется до try, POST fetch без timeout; Caddy не задаёт лимит тела. Лимит 8 МБ в Python применяется после приёма multipart. | Ограничить входной body до буферизации, обработать повреждённый multipart, задать время ожидания и понятные 413/429/ошибки формы. |
|
||||||
|
| T06 | P1 | Bootstrap запускает db/minio/api/web, без proxy и community-scheduler; CI проверяет локальный Compose. `monitor.sh` не включает community-scheduler, `/ready` знает лишь официальный импорт. | Один production-contract прогон через proxy, проверка scheduler heartbeat/последнего успеха и уведомление о зависании/остановке. |
|
||||||
|
| T07 | P1 | `GET /api/v1/imports` публично возвращает `ImportRunOut`, включая `error_summary` и `source_url`. | Публичный DTO содержит только необходимые безопасные статусы; детали ошибок доступны администратору. |
|
||||||
|
| T08 | P2 | Python фиксирует прямые зависимости, но не транзитивные; Docker base tags изменяемы. В CI нет web unit job (`test:unit`) и отдельного dependency audit. | Воспроизводимый Python lock/constraints, поддерживаемые runtime-версии и регулярная проверка зависимостей; подключить существующие unit-тесты. CVE здесь не утверждаются. |
|
||||||
|
| T09 | P2 | `activity_rows` загружает все уловы окна и агрегирует Python-списками до пагинации; на карточке точки также читается история. Public cache локален процессу. | Измерить память/p95 на представительном объёме; оптимизировать запросы по результату. Сохранить документированный TTL 20 с до необходимости общей инвалидации. |
|
||||||
|
| T10 | P2 | API/web стартуют с миграциями/seed; MinIO app policy `readwrite` шире одного bucket, общие admin credentials. | Оформить отдельный release/migration шаг, bucket policy и план персонального доступа при росте команды; не добавлять identity provider до потребности. |
|
||||||
|
|
||||||
|
Семантика изоляции подтверждена [Docker networks](https://docs.docker.com/reference/compose-file/networks/#internal). Конфликт заголовков T02 следует из кода и [контракта Caddy Basic Auth](https://caddyserver.com/docs/caddyfile/directives/basic_auth).
|
||||||
|
|
||||||
|
## Парсеры и достоверность данных
|
||||||
|
|
||||||
|
| ID | Приоритет | Подтверждение и последствие | Критерий исправления |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D01 | P1 | `oldest_site_source` включает выключенные адаптеры. Если самым старым RF4-STAT endpoint окажется disabled, включённый сосед будет постоянно пропускаться. | Ротация только enabled-кандидатов; тест выключенного соседа, включения обратно, ошибки и двух конкурирующих запусков. |
|
||||||
|
| D02 | P1 | Research CLI делает check/write раздельно без межпроцессного lock, повреждённый state трактует как пустой; старые source-key записи после перехода к доменам не учитываются. Автономный state не связан с PostgreSQL. | Атомарное резервирование с fail-closed, миграция состояния; production сбор через единый механизм, offline parsing отделён. Нельзя заявлять общую гарантию только на основании предупреждения README. |
|
||||||
|
| D03 | P1 | `fetch_html` допускает произвольный override URL, автоматические redirects, неограниченный `read()`. `fetch_site_key` различает `download.rf4db.com` и `rf4db.com`. | Единый реестр разрешённых площадок/хостов, проверка URL и redirects до HTTP, лимит ответа, корректные 429/Retry-After в рамках ≥30 минут. |
|
||||||
|
| D04 | P1 | RF4-STAT fishing выдаёт `waterbody_external_id=None`. Mapping сохраняет fallback alias по имени, но `_auto_publish` требует оба external ID. Ранее подтверждённое имя не включает автопубликацию следующей полной записи. | Использовать единый безопасный контракт алиасов для suggestion/manual/auto; не разрешать неподтверждённое fuzzy-сопоставление. |
|
||||||
|
| D05 | P1 | Scheduler RF4MAP/RF4 Posts опрашивает по одному фиксированному detail URL. Seed даёт только 2 рыбы/2 водоёма; на локальной главной также только они и 0 активных точек. «Все парсеры включены» не означает полный охват. | Реестр задач/очередь разрешённых URL и пополнение канонического каталога; видимые счётчики fetched/staged/mapped/published/age; приёмочный набор из каждого адаптера. |
|
||||||
|
| D06 | P1 | Score зависит от количества сообщений одного ника; текст главной «Один игрок не может искусственно поднять уверенность» неверен. Имена без аккаунтов не доказывают независимость. | Исправить обещание, определить ограничения вклада одного игрока и неизвестных авторов; воспроизводимый пример спама не изображает независимые подтверждения. |
|
||||||
|
| D07 | P1 | В activity окно/затухание по `reported_at`, подпись свежести по `caught_at or reported_at`; community publisher записывает `published_at` также в `caught_at`. В feed «Получено» — `last_seen_at`, который обновляется каждым опросом. | Разделить время улова, публикации, первого получения и последнего обнаружения; старый повторно найденный материал не выглядит новым уловом. |
|
||||||
|
| D08 | P1 | На `/spots/[id]` score берётся из первых 100 глобальных activity-строк и только по spot_id, хотя индекс рассчитан на spot+fish. Возможны ложное «данных нет» и оценка одной рыбы под видом всей точки. | Прямой запрос оценки точки с явным контекстом рыбы; тест >100 комбинаций и нескольких рыб на точке. |
|
||||||
|
| D09 | P2 | Staging и autopublish коммитятся отдельными операциями; ошибка поздней записи может оставить часть публикаций при failed journal. Изменение сравнивает весь payload; полная история редакций отсутствует. | Явная семантика partial success, идемпотентный retry, сравнение значимых полей и история редакций/удалений; отсутствие записи в очередном списке само по себе не означает удаление. |
|
||||||
|
| D10 | P2 | `source_status` анализирует последние 20 запусков, running может считаться healthy; backoff смешан для домена, успешный сосед может сбросить ошибки endpoint. | Раздельные domain cooldown и endpoint backoff, stalled/running статус, последний успех вне ограничения 20 строк. |
|
||||||
|
|
||||||
|
Обнаруженная особенность sitemap **не является ошибкой пагинации**: API `public-spot-pages` выдаёт два пути на строку БД, поэтому `limit=500` и остановка при `<1000` путях согласованы. Нужен контрактный тест и явная документация, а не механическая замена на `<500`.
|
||||||
|
|
||||||
|
## UI/UX
|
||||||
|
|
||||||
|
| ID | Приоритет | Наблюдение | Критерий/решение |
|
||||||
|
|---|---|---|---|
|
||||||
|
| U01 | P1 | На screenshot 1280×1000 период и сортировка стоят вертикально; фильтры занимают около 196 px. Используется `details`/`display:contents`, результат не соответствует пятиколоночной задумке. | Устойчивая desktop-сетка и мобильное раскрытие, проверка реального layout браузеров; отсутствие overflow недостаточно. |
|
||||||
|
| U02 | P1 | Главная передаёт waterbody/fish/hours только в activity; feed получает лишь limit. После выбора водоёма снизу остаются чужие сигналы. | Фильтровать оба блока согласованно либо явно подписать независимый общий feed. URL и сброс сохраняют понятную область действия. |
|
||||||
|
| U03 | P1 | Главная показывает первые 20 activity, records — 50, каталоги — 100 activity; навигации по остальным нет. Счётчик `items.length` выглядит общим числом. | «Показано N из M», пагинация/следующая страница с сохранением фильтров; отдельно считать уникальные точки и комбинации точка+рыба. |
|
||||||
|
| U04 | P1 | Ошибки 422/429/5xx формы сводятся к «Проверьте поля»; нет ожидания/защиты от повторной отправки. Сохранение текста через sessionStorage не гарантировано при его недоступности; файл не восстанавливается. | Разные полезные сообщения, Retry-After для 429, блокировка повторного submit, честная подсказка о файле, server fallback. |
|
||||||
|
| U05 | P2 | В 390×844 результаты начинаются около y=700, mobile меню скрывает последний пункт; при 0 точек зелёный «пульс» всё ещё изображает живую активность. | Компактный первый экран, заметный доступ к скрытой навигации, отдельные empty/stale/error состояния, прямые действия «72 часа», «Сбросить», «Добавить улов». |
|
||||||
|
| U06 | P2 | Карточки многократно повторяют качество/доверие; «Не рассчитана», «сырые данные», «сюжеты» описывают реализацию. Текстовые badges часто 9–11 px. | Иерархия рыба → точка → приманка → возраст → источник; понятные статусы без выдуманных процентов, крупнее значимые подписи. |
|
||||||
|
| U07 | P2 | Предыдущая визуальная приёмка недостаточна: главная была пустой. | Матрица empty/1/many/long names/error; desktop/mobile, клавиатура, 200% zoom, раскрытые фильтры и форма; проверить также каталоги, detail и admin. Отдельно повторить axe, не приравнивая его к UX-аудиту. |
|
||||||
|
|
||||||
|
## Визуальная идентичность
|
||||||
|
|
||||||
|
Сильная основа: тёмный хвойный фон, лаймовый акцент, спокойная бумажная подложка, контрастная антиква, озеро, крючок, поплавок, рябь и координатный радар. Это стоит сохранить. Следующие пункты — дизайнерские предложения, не программные дефекты.
|
||||||
|
|
||||||
|
| ID | Приоритет | Предложение | Проверяемый результат |
|
||||||
|
|---|---|---|---|
|
||||||
|
| V01 | P2 | Утвердить RF4 Spotter как основное имя, «Ни хвоста, ни чешуи» как слоган либо явно выбрать обратную иерархию. | Одинаковое узнаваемое имя в шапке, favicon/manifest, title, OG и footer. |
|
||||||
|
| V02 | P2 | Свести размеры текста, отступы, радиусы, тени, focus и семантические цвета в небольшой набор CSS-токенов. | Компонентные состояния document/demo; источник и качество различаются текстом/формой, не только цветом. Разделить монолитный минифицированный global.css по компонентам. |
|
||||||
|
| V03 | P2 | Сделать паспорт точки компактным «полевым журналом»: координатная метка с копированием, линия времени, SVG рыбы, легенда шкалы. | Один основной визуальный акцент на карточку; индекс не выглядит вероятностью поимки, радар не выдаётся за настоящую карту. |
|
||||||
|
| V04 | P2 | Проверить маленькие знаки 16/32 px, sharpness app icons и maskable safe area; точность OG alt. | Чёткие отдельные SVG/PNG варианты; OG обещает реальный продукт и имеет осмысленную подпись изображения. |
|
||||||
|
| V05 | P3 | Отдельные обложки водоёмов и тематические OG для рыбы/точки. | Только после согласования прав и измерения веса; не препятствуют чтению карточек и загрузке. |
|
||||||
|
|
||||||
|
## SEO и производительность
|
||||||
|
|
||||||
|
SSR, русский `lang`, canonical, OG/Twitter, JSON-LD, sitemap, slug URL и 301 со старых UUID уже есть. Недостающие метатеги не являются главным препятствием: сейчас важнее корректные ответы, полнота каталога и полезный постоянный контент.
|
||||||
|
|
||||||
|
| ID | Приоритет | Подтверждение/риск | Критерий исправления |
|
||||||
|
|---|---|---|---|
|
||||||
|
| S01 | P1 | index/records/report/status при сбое API отображают ошибку с HTTP 200; в detail возможен частично заполненный spot и `noindex={!spot}`. | Единая таблица 200/404/422/503, no-store/Retry-After и политика индексации ошибок. Не выдавать пустую аварийную страницу за нормальный результат. |
|
||||||
|
| S02 | P1 | Sitemap собирает полный справочник, но detail ищет slug только в первых 500 элементах; формы загружают первые 200. При росте появятся sitemap URL с ложным 404. | Прямые slug endpoints и пагинация каталогов; все sitemap URL разрешаются независимо от позиции в справочнике. |
|
||||||
|
| S03 | P2 | canonical убирает все query; structured data содержит жёсткий домен, `PUBLIC_SITE_URL` не передаётся в Docker build. Отдельная политика trailing slash/пагинации не оформлена. | Единый origin, политика вариантов URL и страниц пагинации; внутренние ссылки используют конечные canonical URL (в sidebar ещё UUID redirect). |
|
||||||
|
| S04 | P2 | Постоянные страницы рыба+водоём живут в sitemap, но контент ограничен 72 часами и быстро превращается в шаблон «данных нет». | Полезный подтверждённый справочный контент, дата обновления/источники, архив последних известных наблюдений с явной давностью, видимые breadcrumbs. Решение об индексации пустых комбинаций. |
|
||||||
|
| S05 | P2 | Sitemap lastGood хранится в памяти без максимального срока fallback; >49000 путей вызывает ошибку вместо index. | Документированный срок fallback, sitemap index при необходимости, корректный lastmod только по реальным изменениям. |
|
||||||
|
| S06 | P2 | Hero уже WebP ~71 КБ, но не включён в Caddy matcher brandAssets; старый LCP 9,3 с не является текущим измерением. | Проверить фактический Cache-Control hero; свежий mobile/desktop performance baseline после P0/U01, затем production Core Web Vitals. Не обещать ускорение в секундах без измерения. |
|
||||||
|
| S07 | P2 | robots содержит обычные правила и не закрывает весь staging; поисковые сервисы/превью ещё не проверены на рабочем домене. | Закрытие тестового окружения, проверка Google Search Console и Яндекс Вебмастера после DNS/TLS, sitemap/JSON-LD/OG validation и наблюдение за индексацией. |
|
||||||
|
|
||||||
|
Политика HTTP основана на [Google: HTTP status codes](https://developers.google.com/crawling/docs/troubleshooting/http-status-codes), а согласование URL — на [Google: canonical URLs](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls). Наличие schema/сайткарты само по себе не обещает индексацию или позиции.
|
||||||
|
|
||||||
|
## Порядок реализации
|
||||||
|
|
||||||
|
1. T01–T06: production маршруты, auth, сеть, клиентский лимит и реальная приёмка через Caddy.
|
||||||
|
2. D01–D08, T07: лимиты/публикация, наполнение, честное время и оценка конкретной точки.
|
||||||
|
3. U01–U04, S01–S02: основные сценарии, пагинация, ошибки и доступность индексируемых страниц.
|
||||||
|
4. V01–V04, U05–U07, S03–S06: визуальная система, понятность и поисковое качество.
|
||||||
|
5. T08–T10, D09–D10 и внешний launch checklist; S07 на рабочем домене. V05 после пилота.
|
||||||
|
|
||||||
|
Тестировать по риску: адресные unit/contract проверки, один общий proxy/Compose-прогон для инфраструктурного пакета, браузер для визуального пакета. Документация и небольшие стилевые правки не требуют пересборки всех контейнеров. Статус выполнения ведётся в ROADMAP по ID этого отчёта.
|
||||||
+54
-14
@@ -1,10 +1,10 @@
|
|||||||
# План работ RF4 Spotter
|
# План работ RF4 Spotter
|
||||||
|
|
||||||
Приоритет после аудита: [пакет исправлений и оставшиеся задачи](AUDIT_FIXES.md). Ранее закрытые пункты не означают, что замечания аудита устранены.
|
Приоритет после полного аудита: [отчёт 8 сентября 2026](PROJECT_AUDIT_2026-09-08.md). Новый пакет ниже имеет приоритет над историческими чекбоксами; [AUDIT_FIXES.md](AUDIT_FIXES.md) сохраняет историю исправлений.
|
||||||
|
|
||||||
Этот файл — рабочий источник правды по развитию проекта. После завершения задачи её чекбокс меняется с `[ ]` на `[x]`, рядом добавляется ссылка на коммит или короткое подтверждение проверки. Новые задачи добавляются в соответствующий этап, а не хранятся только в переписке.
|
Этот файл — рабочий источник правды по развитию проекта. После завершения задачи её чекбокс меняется с `[ ]` на `[x]`, рядом добавляется ссылка на коммит или короткое подтверждение проверки. Новые задачи добавляются в соответствующий этап, а не хранятся только в переписке.
|
||||||
|
|
||||||
Последняя сверка плана со спецификацией, кодом и UI/UX-аудитом: 7 сентября 2026 года.
|
Последняя сверка кода, production-конфигурации, UI/UX, визуальной идентичности и SEO: 8 сентября 2026 года (база `486b4e9`).
|
||||||
|
|
||||||
Обозначения:
|
Обозначения:
|
||||||
|
|
||||||
@@ -180,18 +180,58 @@
|
|||||||
|
|
||||||
## Ближайший рабочий пакет
|
## Ближайший рабочий пакет
|
||||||
|
|
||||||
Технический production-контур, health/readiness, backup/restore и безопасные логи готовы. Ближайшие пункты выполняются небольшими проверяемыми пакетами:
|
Production-контур требует исправлений до запуска. Доказательства и критерии приёмки каждого ID находятся в [полном аудите](PROJECT_AUDIT_2026-09-08.md). Следующая задача — **T01**, затем T02 и T03. Инфраструктурные изменения можно объединить в один проверочный Compose-прогон.
|
||||||
|
|
||||||
1. базовый SEO-контракт, `robots.txt`, sitemap, structured data и 404;
|
### P0 — блокеры production
|
||||||
2. индексируемые страницы рыб и водоёмов;
|
|
||||||
3. community scheduler и наблюдаемость источников;
|
|
||||||
4. графический паспорт данных, OG-изображение и легенда;
|
|
||||||
5. внешние контакты, мониторинг, нагрузка, DNS/TLS и production-профиль на целевом сервере.
|
|
||||||
|
|
||||||
После каждого пункта необходимо:
|
- [ ] T01: исправить Caddy-маршруты формы и повторной загрузки скриншота.
|
||||||
|
- [ ] T02: согласовать Basic/Bearer admin-аутентификацию через production proxy.
|
||||||
|
- [ ] T03: обеспечить исходящую сеть community scheduler при изоляции БД/MinIO.
|
||||||
|
|
||||||
1. запустить затронутые unit/integration-тесты;
|
### P1 — до открытой альфы
|
||||||
2. выполнить `docker compose up --build -d` и проверить health;
|
|
||||||
3. для UI-изменений проверить desktop и ширину 390 px;
|
- [ ] T04: сохранить реальную идентичность клиента для rate limit с проверенной цепочкой доверия proxy.
|
||||||
4. обновить чекбокс в этом файле;
|
- [ ] T05: ограничить request body и время POST-запросов, обработать multipart/413/429.
|
||||||
5. зафиксировать результат отдельным небольшим коммитом.
|
- [ ] T06: расширить production acceptance на Caddy и scheduler; включить scheduler в мониторинг.
|
||||||
|
- [ ] T07: убрать диагностические подробности из публичного журнала импортов.
|
||||||
|
- [ ] D01: исключить disabled endpoint из ротации сайтов, проверить конкурирующие запуски.
|
||||||
|
- [ ] D02: атомарный cooldown research CLI, миграция старого state, единый production-путь запросов.
|
||||||
|
- [ ] D03: allowlist URL/redirects, общий ключ площадки и ограниченное чтение HTTP-ответов.
|
||||||
|
- [ ] D04: согласовать fallback alias по имени с автоматической публикацией.
|
||||||
|
- [ ] D05: расширить справочники и охват detail-источников через управляемую очередь с лимитом сайта.
|
||||||
|
- [ ] D06: устранить неверное обещание защиты от одного игрока и определить ограничения его вклада.
|
||||||
|
- [ ] D07: разделить время улова/публикации/получения/повторного обнаружения.
|
||||||
|
- [ ] D08: запрашивать оценку конкретной точки и рыбы без поиска в глобальном top-100.
|
||||||
|
- [ ] U01: исправить desktop-сетку фильтров, проверить раскрытие и мобильный layout.
|
||||||
|
- [ ] U02: согласовать область действия фильтров активности и полевых сигналов.
|
||||||
|
- [ ] U03: добавить публичную пагинацию и честные общие счётчики.
|
||||||
|
- [ ] U04: полезные ошибки формы, ожидание отправки и защита от двойного submit.
|
||||||
|
- [ ] S01: единый HTTP/SEO-контракт пустых, ошибочных и недоступных страниц.
|
||||||
|
- [ ] S02: получать сущности по slug, исключить ложные 404 после первых 500 записей каталога.
|
||||||
|
|
||||||
|
### P2 — качество интерфейса, SEO и сопровождения
|
||||||
|
|
||||||
|
- [ ] V01: согласовать основное имя RF4 Spotter и роль слогана «Ни хвоста, ни чешуи».
|
||||||
|
- [ ] V02: оформить CSS-токены и компонентные состояния, разделить глобальные стили.
|
||||||
|
- [ ] V03: компактный паспорт точки, копирование координат, ясная легенда шкалы.
|
||||||
|
- [ ] V04: проверить малые и maskable-иконки, качество OG и точность alt.
|
||||||
|
- [ ] U05: сократить первый экран, улучшить мобильную навигацию и empty/stale/error действия.
|
||||||
|
- [ ] U06: упростить карточки, подписи качества и терминологию.
|
||||||
|
- [ ] U07: визуальная матрица empty/1/many/long/error, клавиатура, zoom и повторный axe.
|
||||||
|
- [ ] S03: единый site origin, canonical/query/trailing-slash политика и конечные внутренние URL.
|
||||||
|
- [ ] S04: полезный постоянный контент рыб/водоёмов и архив с честной датой/атрибуцией.
|
||||||
|
- [ ] S05: ограничить срок sitemap fallback, документировать двойной URL-контракт и подготовить sitemap index.
|
||||||
|
- [ ] S06: проверить кэш hero и снять новый performance baseline после исправлений.
|
||||||
|
- [ ] T08: Python lock/constraints, CI web unit tests и регулярный аудит зависимостей.
|
||||||
|
- [ ] T09: нагрузочный бюджет агрегации/истории и решение об общей инвалидации по замерам.
|
||||||
|
- [ ] T10: release/migration шаг, bucket-scoped MinIO policy, план персонального admin-доступа.
|
||||||
|
- [ ] D09: частичные результаты импорта, история значимых редакций и процедура удаления оригиналов.
|
||||||
|
- [ ] D10: endpoint backoff, running/stalled состояния и достоверный последний успех.
|
||||||
|
|
||||||
|
### После появления сервера / после пилота
|
||||||
|
|
||||||
|
- [ ] S07 (P2): закрыть staging от индексации, подключить Search Console/Яндекс Вебмастер и проверить рабочие canonical/sitemap/OG/JSON-LD.
|
||||||
|
- [ ] V05 (P3): тематические обложки и OG рыб/водоёмов после обратной связи.
|
||||||
|
- [ ] Выполнить существующий внешний launch checklist: секреты, DNS/TLS, backup, мониторинг, контакты и реальные данные.
|
||||||
|
|
||||||
|
После завершения задачи: адресная проверка по риску, отметка `[x]` со ссылкой на коммит/результат, обновление README при изменении поведения и отдельный коммит. Docker запускается для инфраструктурных или общих интеграционных проверок, а не после каждой правки документации. UI проверяется на desktop/mobile с наполненными и пустыми состояниями.
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ Telegram, Discord и VK могут давать свежие координат
|
|||||||
|
|
||||||
Публичная detail-страница RF4 Posts содержит устойчивый UUID точки, координаты, slug водоёма, список slug рыб, способ ловли, оснастку, клипсу, дату и ссылки на доказательства. Русская локализация позволяет связать slug с отображаемым названием. Контрольный пост дал **6 записей видов рыб из одной точки**. Это инструкция по точке, а не шесть доказанных индивидуальных уловов, поэтому вес остаётся `null`, а происхождение сохраняет общий UUID поста.
|
Публичная detail-страница RF4 Posts содержит устойчивый UUID точки, координаты, slug водоёма, список slug рыб, способ ловли, оснастку, клипсу, дату и ссылки на доказательства. Русская локализация позволяет связать slug с отображаемым названием. Контрольный пост дал **6 записей видов рыб из одной точки**. Это инструкция по точке, а не шесть доказанных индивидуальных уловов, поэтому вес остаётся `null`, а происхождение сохраняет общий UUID поста.
|
||||||
|
|
||||||
Оба fail-closed парсера добавлены в read-only исследовательский CLI и разрешены владельцем проекта с интервалом не менее 30 минут на источник. Источники зарегистрированы для изолированного staging выключенными по умолчанию. CLI хранит время последнего успешного получения и отклоняет слишком ранний повтор. Автоматического расписания и публикации нет; RF4 Posts считается агрегированной точкой, а не набором взвешенных уловов.
|
Оба fail-closed парсера добавлены в read-only исследовательский CLI и разрешены владельцем проекта с интервалом не менее 30 минут на сайт. CLI резервирует домен до запроса и отклоняет слишком ранний повтор любого endpoint, включая повтор после ошибки. В production все разрешённые адаптеры подключены к scheduler и staging; полные записи с подтверждёнными алиасами публикуются автоматически, остальные требуют проверки. RF4 Posts считается агрегированной точкой, а не набором взвешенных уловов.
|
||||||
|
|
||||||
Также проверены два менее пригодных кандидата:
|
Также проверены два менее пригодных кандидата:
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import sys
|
|||||||
import time
|
import time
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
from .community_sources import (
|
from .community_sources import (
|
||||||
@@ -32,6 +33,16 @@ MIN_FETCH_INTERVAL_SECONDS = 30 * 60
|
|||||||
DEFAULT_STATE_FILE = Path(".cache/community-fetch-state.json")
|
DEFAULT_STATE_FILE = Path(".cache/community-fetch-state.json")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_site_key(url: str) -> str:
|
||||||
|
"""Return a stable cooldown key shared by all endpoints of one site."""
|
||||||
|
hostname = (urlsplit(url).hostname or "").lower()
|
||||||
|
if hostname.startswith("www."):
|
||||||
|
hostname = hostname[4:]
|
||||||
|
if not hostname:
|
||||||
|
raise ValueError("source URL must include a hostname")
|
||||||
|
return hostname
|
||||||
|
|
||||||
|
|
||||||
def enforce_fetch_interval(
|
def enforce_fetch_interval(
|
||||||
source: str, *, state_file: Path, now: float | None = None,
|
source: str, *, state_file: Path, now: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -83,9 +94,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
default_url, parse = SOURCES.get(args.source, (None, DETAIL_SOURCES.get(args.source)))
|
default_url, parse = SOURCES.get(args.source, (None, DETAIL_SOURCES.get(args.source)))
|
||||||
url = args.url or default_url
|
url = args.url or default_url
|
||||||
try:
|
try:
|
||||||
enforce_fetch_interval(args.source, state_file=args.state_file)
|
site_key = fetch_site_key(url)
|
||||||
|
enforce_fetch_interval(site_key, state_file=args.state_file)
|
||||||
|
# Reserve before network I/O: failed attempts count toward the limit too.
|
||||||
|
mark_fetch(site_key, state_file=args.state_file)
|
||||||
html = fetch_html(url)
|
html = fetch_html(url)
|
||||||
mark_fetch(args.source, state_file=args.state_file)
|
|
||||||
records = (parse(html, source_url=url) if args.source in DETAIL_SOURCES else parse(html))[:args.limit]
|
records = (parse(html, source_url=url) if args.source in DETAIL_SOURCES else parse(html))[:args.limit]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"community source failed: {exc}", file=sys.stderr)
|
print(f"community source failed: {exc}", file=sys.stderr)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from rf4_research.community_cli import enforce_fetch_interval, mark_fetch
|
from rf4_research import community_cli
|
||||||
|
from rf4_research.community_cli import enforce_fetch_interval, fetch_site_key, mark_fetch
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_cooldown_is_persistent_per_source(tmp_path: Path) -> None:
|
def test_fetch_cooldown_is_persistent_per_source(tmp_path: Path) -> None:
|
||||||
@@ -13,3 +15,19 @@ def test_fetch_cooldown_is_persistent_per_source(tmp_path: Path) -> None:
|
|||||||
enforce_fetch_interval("rf4map-point", state_file=state_file, now=1_000)
|
enforce_fetch_interval("rf4map-point", state_file=state_file, now=1_000)
|
||||||
enforce_fetch_interval("rf4posts-spot", state_file=state_file, now=1_000)
|
enforce_fetch_interval("rf4posts-spot", state_file=state_file, now=1_000)
|
||||||
enforce_fetch_interval("rf4map-point", state_file=state_file, now=2_800)
|
enforce_fetch_interval("rf4map-point", state_file=state_file, now=2_800)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_site_key_groups_endpoints_and_normalizes_www() -> None:
|
||||||
|
assert fetch_site_key("https://rf4-stat.ru/fishing/") == "rf4-stat.ru"
|
||||||
|
assert fetch_site_key("https://www.rf4-stat.ru/posts/") == "rf4-stat.ru"
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_fetch_still_reserves_site_cooldown(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
state_file = tmp_path / "fetch-state.json"
|
||||||
|
|
||||||
|
def fail(_url: str) -> str:
|
||||||
|
raise OSError("offline")
|
||||||
|
|
||||||
|
monkeypatch.setattr(community_cli, "fetch_html", fail)
|
||||||
|
assert community_cli.main(["rf4db", "--state-file", str(state_file)]) == 1
|
||||||
|
assert "download.rf4db.com" in json.loads(state_file.read_text(encoding="utf-8"))
|
||||||
|
|||||||
Reference in New Issue
Block a user