Compare commits

..
13 Commits
Author SHA1 Message Date
ik 46251c635f test: add tackle query acceptance gate
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-20 18:36:37 +07:00
ik a547d09fcd feat: extend media roles and acceptance coverage
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-20 18:30:16 +07:00
ik e94d247096 feat: add tackle catalog and recommendation analytics 2026-09-20 18:26:53 +07:00
ik 4f0c2d23de feat: preserve gear components through catch imports
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-20 18:10:41 +07:00
ik 4e9895fbf4 feat: add gear provenance models and browser fetcher 2026-09-20 15:50:43 +07:00
ik b0edae98c6 feat: continue roadmap acceptance work 2026-09-20 15:17:21 +07:00
ik d438c40542 sync
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-17 07:35:23 +07:00
ik 722c88d436 sync
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-17 07:35:04 +07:00
ik 5221442aeb docs: close admin media review milestone
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-16 20:29:10 +07:00
ik 85d81aa996 feat: add admin media upgrade decisions 2026-09-16 20:28:45 +07:00
ik 53e7b0e4ae docs: refresh media catalog snapshot 2026-09-16 20:26:30 +07:00
ik d63eedf41b test: gate admin cache headers in bootstrap 2026-09-16 20:23:37 +07:00
ik 28fffb3acb docs: record media visual review 2026-09-16 20:22:32 +07:00
98 changed files with 5355 additions and 116 deletions
@@ -0,0 +1,51 @@
# План работы RF4 Spotter — Регрессионный аудит
На основе: [REGRESSION_AUDIT_2026-09-09.md](../docs/REGRESSION_AUDIT_2026-09-09.md)
Дата: 2026-09-09
## Выполнено
### R01 ✅ — Caddy body_limit → request_body max_size
- **Файл**: `deploy/Caddyfile`
- **Проблема**: `body_limit 10M` не поддерживается в Caddy 2.10.2
- **Решение**: `request_body { max_size 10M }`
- **Верификация**: `caddy adapt` проходит без ошибок
### R02 ✅ — Activity API contract regression
- **API**: `main.py``/api/v1/activity` возвращает `PaginatedActivityOut`
- **Frontend**: все 4 потребителя обновлены:
- `index.astro` ✅ (из предыдущего коммита)
- `fish/[slug].astro`
- `waterbodies/[slug].astro`
- `waterbodies/[slug]/[fish].astro`
- `spots/[id].astro`
- **Тесты**: 4 теста обновлены под новый контракт
- **Результат**: 76 passed, 1 skipped, 0 failures
### R14 ✅ — Registry источников больше не зависит от БД при парсинге CLI
- **Файл**: `community_scheduler.py`
- **Решение**: `_static_registry()` — без БД; `configured_sources(enabled_keys)` — с опциональной БД
- **Тесты**: scheduler unit-тесты проходят без PostgreSQL
## Итоги тестов
- **Python**: 76 passed, 1 skipped ✅
- **Astro check**: 0 errors, 0 warnings, 0 hints ✅
- **Caddy adapt**: passes ✅
## Коммиты
1. `a37f9c4` — R01 Caddy body_limit → request_body
2. `d962ba2` — R02 activity API contract + R14 static registry
## Осталось из регрессий (R03-R15)
- R03: Research CLI cooldown broken (fcntl `r`/`r+` + encoding)
- R04: Disabled-фильтрация обходит site cooldown
- R05: Bootstrap небезопасен для источников
- R06: Фильтры сигналов сравнивают slug с названием
- R07: Ошибка главной остаётся HTTP 200
- R08: Фильтры UI не доведены до responsive-состояния
- R09: Пагинация (частично исправлено в U03)
- R10: Ошибки формы теряют черновик
- R11: Проверка URL после сетевого обращения
- R12: Мониторинг не сигнализирует о зависшем импорте
- R13: Доверие к IP клиента не ограничено proxy
- R15: D04/D06/D07/D08 выполнены не полностью
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Environment-dependent path to Maven home directory
/mavenHomeManager.xml
+266
View File
@@ -0,0 +1,266 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GigaCodeAgentSettings">
<option name="autoApproveEdits" value="true" />
<option name="autoApprovedCommands">
<list>
<Execute>
<option name="pattern" value="mkdir -p /home/ik/git/rf4-help/.gigacode/plans" />
</Execute>
<Execute>
<option name="pattern" value="cd *" />
</Execute>
<Execute>
<option name="pattern" value="ls *" />
</Execute>
<Execute>
<option name="pattern" value="pip install *" />
</Execute>
<Execute>
<option name="pattern" value="tail *" />
</Execute>
<Execute>
<option name="pattern" value="python pytest *" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;&#10;import ast&#10;import sys&#10;&#10;files = [&#10; 'apps/api/app/main.py',&#10; 'apps/api/app/readiness.py',&#10; 'apps/api/app/schemas.py',&#10; 'apps/api/app/activity.py',&#10; 'apps/api/app/community_scheduler.py',&#10; 'apps/api/app/community_importer.py',&#10; 'rf4_research/community_cli.py',&#10;]&#10;&#10;for f in files:&#10; try:&#10; with open(f) as fh:&#10; ast.parse(fh.read())&#10; print(f'OK: {f}')&#10; except SyntaxError as e:&#10; print(f'ERROR: {f}: {e}')&#10; sys.exit(1)&#10;&#10;print('All Python files syntax OK')&#10;&quot;" />
</Execute>
<Execute>
<option name="pattern" value="npm run *" />
</Execute>
<Execute>
<option name="pattern" value="head *" />
</Execute>
<Execute>
<option name="pattern" value="npm install *" />
</Execute>
<Execute>
<option name="pattern" value="git add *" />
</Execute>
<Execute>
<option name="pattern" value="git reset *" />
</Execute>
<Execute>
<option name="pattern" value="git diff *" />
</Execute>
<Execute>
<option name="pattern" value="git log *" />
</Execute>
<Execute>
<option name="pattern" value="git commit *" />
</Execute>
<Execute>
<option name="pattern" value="git status" />
</Execute>
<Execute>
<option name="pattern" value="echo *" />
</Execute>
<Execute>
<option name="pattern" value="printf 'localhost {\n body_limit 10M\n}\n' &gt; /tmp/test-caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="cp /home/ik/git/rf4-help/deploy/Caddyfile /tmp/Caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="printf 'example.com {\n body_limit 10M\n}\n' &gt; /tmp/Caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="printf 'example.com {\n request_body {\n max_size 10M\n }\n}\n' &gt; /tmp/Caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="docker run *" />
</Execute>
<Execute>
<option name="pattern" value="sed 's/email {$ACME_EMAIL}/email test@test.com/' /home/ik/git/rf4-help/deploy/Caddyfile &gt; /tmp/Caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="grep *" />
</Execute>
<Execute>
<option name="pattern" value="printf 'example.com {\n request_body {\n max_size 10M\n }\n handle {\n respond \&quot;ok\&quot;\n }\n}\n' &gt; /tmp/Caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;import ast; ast.parse(open('rf4_research/community_cli.py').read()); print('OK')&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;import ast; ast.parse(open('apps/api/app/community_scheduler.py').read()); print('OK')&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;import ast; ast.parse(open('apps/api/app/main.py').read()); print('OK')&quot;" />
</Execute>
<Execute>
<option name="pattern" value="node -e &quot;try { fetch('http://localhost:1', { signal: AbortSignal.timeout(100) }); } catch(e) { console.log(e.constructor.name, e.message); }&quot; 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="node -e &quot;fetch('http://httpbin.org/delay/10', { signal: AbortSignal.timeout(200) }).catch(e =&gt; console.log(e.constructor.name, e.message));&quot; 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="pip-compile requirements.txt --output-file requirements-lock.txt -q 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="pip-compile requirements-dev.txt --output-file requirements-dev-lock.txt -q 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="wc *" />
</Execute>
<Execute>
<option name="pattern" value="alembic revision *" />
</Execute>
<Execute>
<option name="pattern" value="python -m app.cli --help 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="python -m rf4_research.community_cli --help 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="alembic heads *" />
</Execute>
<Execute>
<option name="pattern" value="python3 -c &quot;&#10;from urllib.request import Request, urlopen&#10;from urllib.error import HTTPError&#10;# Test with a URL that redirects&#10;try:&#10; req = Request('https://httpbin.org/redirect/1', headers={'User-Agent': 'test'})&#10; resp = urlopen(req, timeout=5)&#10; print(f'Final URL: {resp.url}')&#10; print(f'Response URL: {resp.url}')&#10;except Exception as e:&#10; print(f'Error: {type(e).__name__}: {e}')&#10;&quot; 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="pwd" />
</Execute>
<Execute>
<option name="pattern" value="python3" />
</Execute>
<Execute>
<option name="pattern" value="python3 -c &quot;from rf4_research import community_cli; print('Syntax OK')&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/append_tests.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 pytest *" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_redirect_test.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a04.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a04_v2.py" />
</Execute>
<Execute>
<option name="pattern" value="git show *" />
</Execute>
<Execute>
<option name="pattern" value="git branch *" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/test_double_reservation_bug.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a02_double_reservation.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/test_debug.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/test_a02_final.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/test_a02_v2.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/test_a02_code.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a04_pagination.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a01_monitoring.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a01_tests.py" />
</Execute>
<Execute>
<option name="pattern" value="*" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a01_tests_v2.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a04_filters.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a05_sessionstorage.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a06_tests.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a07_review_note.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a08_errorpage.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a08_index.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a11_pip_audit.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a04_pagination_final.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a08_index_unavailable.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a08_spots.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a08_records.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a01_vars.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a01_test.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a07_auto_publish.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a07_review_note_auto.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a09_cli.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 -c &quot;from app.cli import main; import sys; sys.argv = ['cli', '--help']; main()&quot; 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="find *" />
</Execute>
<Execute>
<option name="pattern" value="git push *" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;&#10;from sqlalchemy import create_engine, select&#10;from sqlalchemy.orm import Session&#10;from sqlalchemy.pool import StaticPool&#10;from app.database import Base&#10;from app.models import SubmissionAttempt&#10;import hashlib, hmac&#10;&#10;engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)&#10;Base.metadata.create_all(engine)&#10;&#10;with Session(engine) as db:&#10; # Simulate first request&#10; key = 'idem-test-key-001'&#10; key_hash = hmac.new('change-rate-limit-secret'.encode(), key.encode(), hashlib.sha256).hexdigest()&#10; print(f'Key hash: {key_hash}')&#10; &#10; # Check before storing&#10; existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))&#10; print(f'Found before: {existing}')&#10; &#10; # Store&#10; from datetime import datetime, timezone&#10; db.add(SubmissionAttempt(client_hash='test', idempotency_key=key_hash, created_at=datetime.now(timezone.utc)))&#10; db.commit()&#10; &#10; # Check after storing&#10; existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))&#10; print(f'Found after: {existing}')&#10; if existing:&#10; print(f' idempotency_key: {existing.idempotency_key}')&#10;&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;&#10;from sqlalchemy import create_engine, select&#10;from sqlalchemy.orm import Session&#10;from sqlalchemy.pool import StaticPool&#10;from app.database import Base&#10;from app.models import SubmissionAttempt&#10;import hashlib, hmac&#10;&#10;engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)&#10;Base.metadata.create_all(engine)&#10;&#10;with Session(engine) as db:&#10; key = 'idem-test-key-001'&#10; key_hash = hmac.new('change-rate-limit-secret'.encode(), key.encode(), hashlib.sha256).hexdigest()&#10; print(f'Key hash: {key_hash}')&#10; &#10; existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))&#10; print(f'Found before: {existing}')&#10; &#10; from datetime import datetime, timezone&#10; db.add(SubmissionAttempt(client_hash='test', idempotency_key=key_hash, created_at=datetime.now(timezone.utc)))&#10; db.commit()&#10; &#10; existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))&#10; print(f'Found after: {existing}')&#10; if existing:&#10; print(f' idempotency_key: {existing.idempotency_key}')&#10;&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;&#10;from sqlalchemy import create_engine, inspect, text&#10;from sqlalchemy.pool import StaticPool&#10;from app.database import Base&#10;from app.models import SubmissionAttempt&#10;&#10;engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)&#10;Base.metadata.create_all(engine)&#10;&#10;inspector = inspect(engine)&#10;columns = inspector.get_columns('submission_attempt')&#10;print('Columns:', [c['name'] for c in columns])&#10;&quot;" />
</Execute>
</list>
</option>
<option name="autoApprovedReads">
<list>
<Read>
<option name="pattern" value="/path/to/apps/web/src/pages/index.astro" />
</Read>
<Read>
<option name="pattern" value="/path/to/apps/web/src/lib/api.ts" />
</Read>
<Read>
<option name="pattern" value="/path/to/rf4_spotter/rf4_research/community_cli.py" />
</Read>
</list>
</option>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/rf4-help.iml" filepath="$PROJECT_DIR$/.idea/rf4-help.iml" />
</modules>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+65
View File
@@ -0,0 +1,65 @@
# Repository Guidelines
## Project Structure & Module Organization
RF4 Spotter is an Astro/FastAPI/PostgreSQL application with an offline
research and media-ingestion toolkit. The main areas are:
- `apps/api/` — FastAPI application, routers, models, migrations, and API tests.
- `apps/web/` — Astro pages, components, styles, unit tests, and Playwright tests.
- `rf4_research/` — source parsers, media manifest tooling, and CLI commands.
- `tests/` — Python research/tooling tests and fixtures.
- `data/media/` — versioned manifest and content-addressed local media files.
- `docs/` — specification, roadmap, ADRs, runbooks, and acceptance procedures.
- `compose.yaml` — local PostgreSQL, API, web, and supporting services.
Keep generated reports and temporary downloads outside committed paths unless a
task explicitly requires versioning them.
## Build, Test, and Development Commands
From the repository root:
```bash
.venv/bin/pytest -q # Python suites
npm --prefix apps/web run check # Astro type/template checks
npm --prefix apps/web run build # Check and production build
npm --prefix apps/web run test:unit # Web unit tests
WEB_URL=http://127.0.0.1:4321 npm --prefix apps/web run test:e2e
docker compose up --build # Full local stack
```
For media work, use `.venv/bin/python -m rf4_research.media_cli --audit` and
respect the manifests cooldown and approval states. Do not hotlink or replace
approved media without explicit review.
## Coding Style & Naming Conventions
Use four-space indentation for Python and two spaces for Astro/TypeScript.
Prefer typed Python functions, `snake_case` for Python identifiers, and
`camelCase` for TypeScript variables/functions. Astro components use
`PascalCase.astro`; tests use descriptive `test_*.py` or `*.test.ts` names.
Keep UI text and data-source labels explicit and accessible; run `astro check`
before committing web changes.
## Testing Guidelines
Add focused regression tests beside the affected suite. Python tests use
pytest; web behavior uses Node unit tests and Playwright. Run the smallest
relevant test first, then the full suite before handoff. Never use live external
sources in tests; use fixtures or isolated Docker services.
## Commit & Pull Request Guidelines
Use imperative, concise commit subjects with the repositorys existing scope
style, such as `feat:`, `fix:`, `data:`, or `chore:`. Keep commits focused and
exclude unrelated user files. Pull requests should describe behavior changes,
verification commands, migration or configuration impact, and screenshots for
visual/UI work. Call out any media provenance, approval, or rollback decision.
## Security & Configuration Tips
Do not commit secrets, production `.env` files, tokens, or private payloads.
Use local fixtures and documented environment variables. Preserve the strict
CSP, admin authentication barriers, source attribution, and media provenance
when changing application code.
+7 -5
View File
@@ -6,7 +6,7 @@ RF4 Spotter — неофициальный сервис свежих точек
## Статус разработки ## Статус разработки
**Проверка 13 сентября 2026 (`787a506`): локальный контур готов к развёртыванию открытой альфы, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **157 passed, 1 skipped**; Astro check/build, web unit и API-тесты проходят. Граф миграций имеет единственную голову `0016`; последний полный production bootstrap подтвердил Caddy, scheduler и браузерный сценарий отправки/модерации на предыдущей голове, а актуальная голова проверяется CI на чистой PostgreSQL. Реальные источники во время приёмки не опрашивались. **Проверка 20 сентября 2026: локальный кодовый контур проходит сборочные и тестовые gates, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **191 passed, 1 skipped**; Astro check/build и web unit проходят. Граф миграций имеет единственную голову `0020`; OpenAPI artifact синхронизирован с FastAPI (`36 paths`). Изолированный production bootstrap с чистыми томами повторно пройден; живая БД, реальные секреты и импорт внешних источников по-прежнему требуют отдельной инфраструктурной/разрешённой приёмки.
Актуальные следующие задачи находятся только в [ROADMAP](docs/ROADMAP.md). Старые планы и аудиты сохранены как история и больше не задают порядок работ. До внешнего запуска нужны сервер, DNS/TLS, production-секреты, публичные контакты, внешний backup и канал уведомлений. Актуальные следующие задачи находятся только в [ROADMAP](docs/ROADMAP.md). Старые планы и аудиты сохранены как история и больше не задают порядок работ. До внешнего запуска нужны сервер, DNS/TLS, production-секреты, публичные контакты, внешний backup и канал уведомлений.
@@ -46,9 +46,11 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. После явного разрешения владельца от 14 сентября все скачанные и целостные материалы опубликованы в `/media`; публичный API отдаёт Git-копии по content-addressed URL, а каждая карточка показывает плашку и прямую ссылку на источник. Будущие загрузки по-прежнему не одобряются автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов. Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. После явного разрешения владельца от 14 сентября все скачанные и целостные материалы опубликованы в `/media`; публичный API отдаёт Git-копии по content-addressed URL, а каждая карточка показывает плашку и прямую ссылку на источник. Будущие загрузки по-прежнему не одобряются автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL не завышают покрытие. На 14.09.2026 manifest содержит 704 записи: 456 approved, 227 duplicate, 10 queued и 11 invalid. Опубликованы 243 уникально подписанные рыбы из 252, все 149 найденных изображений снастей/приманок и 64 справочных материала; 10 оставшихся RF4DB URL будут загружены после cooldown. Карты 19 водоёмов ещё предстоит проиндексировать, а общий target снастей остаётся `null` до проверяемого полного счётчика. Актуальный offline-срез от 20.09.2026: 704 записи manifest, 466 `approved`, 226 `superseded`, 1 `duplicate` и 11 `invalid`; audit проходит без ошибок и orphan-файлов. У 252 из 253 рыбных изображений есть 1024×1024 WebP, один 48×48 fallback сохранён из-за отсутствия проверенной альтернативы. Производные WebP/AVIF и provenance хранятся в Git; визуальная browser-приёмка остаётся отдельным пунктом B25.
`python -m rf4_research.media_cli --quality-report` выполняет offline-проверку разрешения опубликованных рыб. Текущий отчёт выявляет 227 PNG RF4MAP размером 48×48, которые увеличиваются в карточках до 180 px, и 16 WebP 1024×1024; для всех низких версий уже известны альтернативные RF4DB URL. План загрузки, сравнения, безопасного переключения и генерации производных размеров ведётся в B20–B25 ROADMAP. `python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL не завышают покрытие. Актуальный manifest содержит 704 записи: 466 approved, 226 superseded, 1 duplicate и 11 invalid; queue-представления больше нет. Опубликованы 253 fish-файла, все 149 найденных изображений снастей/приманок и 64 справочных материала; общий target снастей остаётся `null` до проверяемого полного счётчика. Водоёмы имеют отдельный canonical index из 19 карточек; detail-наполнение остаётся W02–W08.
`python -m rf4_research.media_cli --quality-report` выполняет offline-проверку разрешения опубликованных рыб. Контур quality-upgrade обработал 226 прямых RF4DB-альтернатив: 252 из 253 рыбных изображений теперь имеют 1024×1024 WebP, один 48×48 fallback сохранён из-за отсутствия проверенной альтернативы. `--queue-quality-upgrades` по-прежнему переводит только прямые альтернативы низкоразрешённых published-файлов в безопасную очередь, не снимая текущую версию с публикации; B21/B22 завершены, а browser-приёмка остаётся отдельным пунктом B25.
`python -m rf4_research.media_cli --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Разрешённое media-окно загружается командой `--download-batch --batch-limit 40`: до 40 assets на домен под одной резервацией, затем 30 минут до нового batch. Блокировка/rate limit/сетевая ошибка останавливает домен сразу, три последовательных невалидных ответа — досрочно. Все файлы остаются в карантине до ручного review. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap. `python -m rf4_research.media_cli --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Разрешённое media-окно загружается командой `--download-batch --batch-limit 40`: до 40 assets на домен под одной резервацией, затем 30 минут до нового batch. Блокировка/rate limit/сетевая ошибка останавливает домен сразу, три последовательных невалидных ответа — досрочно. Все файлы остаются в карантине до ручного review. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap.
@@ -146,7 +148,7 @@ docker compose up --build
- readiness PostgreSQL, MinIO и импорта с версией/revision сборки: <http://localhost:8000/ready>; - readiness PostgreSQL, MinIO и импорта с версией/revision сборки: <http://localhost:8000/ready>;
- консоль MinIO: <http://localhost:9001>. - консоль MinIO: <http://localhost:9001>.
Контейнер API сам выполняет `alembic upgrade head`, затем идемпотентный seed. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`. Compose ожидает readiness PostgreSQL и MinIO перед API, а API-контейнер проверяет `/ready`. Версия и commit SHA задаются через `APP_VERSION`/`APP_REVISION`; те же значения доступны администратору в `/api/v1/admin/diagnostics`. Официальный импорт по умолчанию необязателен; при включённом scheduler установите `OFFICIAL_IMPORT_REQUIRED=true`, тогда отсутствующий, неуспешный или просроченный запуск сделает readiness отрицательным. Одноразовые контейнеры `migrate` и `minio-init` перед запуском API соответственно применяют `alembic upgrade head` и идемпотентно создают локальный `S3_BUCKET`; приложение само не создаёт схему или bucket. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`. Compose ожидает readiness PostgreSQL, MinIO и успешное завершение обоих init-контейнеров перед API, а API-контейнер проверяет `/ready`. Версия и commit SHA задаются через `APP_VERSION`/`APP_REVISION`; те же значения доступны администратору в `/api/v1/admin/diagnostics`. Здоровье импортов диагностическое и не мешает API или scheduler восстановиться после сбоя.
API и scheduler пишут по одной JSON-записи на событие. HTTP-лог содержит только сгенерированный `request_id`, метод, путь без query string, статус и длительность; IP, заголовок авторизации и пользовательский payload не журналируются. `X-Request-ID` возвращается клиенту. Стандартный access-log Uvicorn отключён. Уровень управляется `LOG_LEVEL`. Публичный агрегат активности кэшируется в памяти процесса на 20 секунд (до 128 ключей) и очищается после публикации, модерации или удаления через этот процесс API; изменения scheduler видны после TTL; `X-Cache` показывает `HIT`/`MISS`. Защищённый `/api/v1/admin/diagnostics` скачивает JSON только с идентификатором сборки и агрегированными счётчиками, без имён игроков, исходных URL, payload и ошибок парсеров. API и scheduler пишут по одной JSON-записи на событие. HTTP-лог содержит только сгенерированный `request_id`, метод, путь без query string, статус и длительность; IP, заголовок авторизации и пользовательский payload не журналируются. `X-Request-ID` возвращается клиенту. Стандартный access-log Uvicorn отключён. Уровень управляется `LOG_LEVEL`. Публичный агрегат активности кэшируется в памяти процесса на 20 секунд (до 128 ключей) и очищается после публикации, модерации или удаления через этот процесс API; изменения scheduler видны после TTL; `X-Cache` показывает `HIT`/`MISS`. Защищённый `/api/v1/admin/diagnostics` скачивает JSON только с идентификатором сборки и агрегированными счётчиками, без имён игроков, исходных URL, payload и ошибок парсеров.
@@ -182,7 +184,7 @@ docker compose up --build
## Что реализовано ## Что реализовано
- FastAPI и SQLAlchemy 2; - FastAPI и SQLAlchemy 2;
- PostgreSQL 17 и линейные миграции Alembic до `0016`; - PostgreSQL 17 и линейные миграции Alembic до `0020`;
- идемпотентный seed с двумя точками и свежими демо-уловами; - идемпотентный seed с двумя точками и свежими демо-уловами;
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки; - `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
- `GET /api/v1/spots/{id}` и `/catches`; - `GET /api/v1/spots/{id}` и `/catches`;
@@ -0,0 +1,37 @@
"""add canonical waterbody provenance fields
Revision ID: 0017
Revises: 0016
"""
from alembic import op
import sqlalchemy as sa
revision = "0017"
down_revision = "0016"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("waterbody", sa.Column("source_system", sa.String(length=50), nullable=True))
op.add_column("waterbody", sa.Column("source_external_id", sa.String(length=200), nullable=True))
op.add_column("waterbody", sa.Column("source_url", sa.Text(), nullable=True))
op.add_column("waterbody", sa.Column("description", sa.Text(), nullable=True))
op.add_column("waterbody", sa.Column("source_checked_at", sa.DateTime(timezone=True), nullable=True))
op.create_index(
"uq_waterbody_source_identity",
"waterbody",
["source_system", "source_external_id"],
unique=True,
)
def downgrade() -> None:
op.drop_index("uq_waterbody_source_identity", table_name="waterbody")
op.drop_column("waterbody", "source_checked_at")
op.drop_column("waterbody", "description")
op.drop_column("waterbody", "source_url")
op.drop_column("waterbody", "source_external_id")
op.drop_column("waterbody", "source_system")
@@ -0,0 +1,26 @@
"""store source coordinate text and precision
Revision ID: 0018
Revises: 0017
"""
from alembic import op
import sqlalchemy as sa
revision = "0018"
down_revision = "0017"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("external_observation", sa.Column("coordinate_raw", sa.String(length=200), nullable=True))
op.add_column("external_observation", sa.Column("coordinate_precision", sa.String(length=20), nullable=True))
op.execute("UPDATE external_observation SET coordinate_precision = CASE WHEN x IS NOT NULL AND y IS NOT NULL THEN 'exact' ELSE 'missing' END")
op.alter_column("external_observation", "coordinate_precision", nullable=False, server_default="missing")
def downgrade() -> None:
op.drop_column("external_observation", "coordinate_precision")
op.drop_column("external_observation", "coordinate_raw")
@@ -0,0 +1,22 @@
"""store canonical waterbody fish count
Revision ID: 0019
Revises: 0018
"""
from alembic import op
import sqlalchemy as sa
revision = "0019"
down_revision = "0018"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("waterbody", sa.Column("fish_species_count", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("waterbody", "fish_species_count")
@@ -0,0 +1,24 @@
"""store canonical waterbody detail facts
Revision ID: 0020
Revises: 0019
"""
from alembic import op
import sqlalchemy as sa
revision = "0020"
down_revision = "0019"
branch_labels = None
depends_on = None
def upgrade() -> None:
for name in ("source_aliases", "source_fish_species", "source_image_urls", "source_point_urls"):
op.add_column("waterbody", sa.Column(name, sa.JSON(), nullable=True))
def downgrade() -> None:
for name in ("source_point_urls", "source_image_urls", "source_fish_species", "source_aliases"):
op.drop_column("waterbody", name)
@@ -0,0 +1,65 @@
"""add canonical tackle items and rig components
Revision ID: 0021
Revises: 0020
"""
from alembic import op
import sqlalchemy as sa
revision = "0021"
down_revision = "0020"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"tackle_item",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("normalized_name", sa.String(200), nullable=False, unique=True),
sa.Column("category", sa.String(20), nullable=False),
sa.Column("subcategory", sa.String(100)),
sa.Column("brand", sa.String(100)),
sa.Column("family", sa.String(100)),
sa.Column("unlock_level", sa.Integer()),
sa.Column("source_system", sa.String(50)),
sa.Column("source_external_id", sa.String(200)),
sa.Column("source_url", sa.Text()),
sa.Column("source_checked_at", sa.DateTime(timezone=True)),
sa.Column("raw_payload", sa.JSON()),
sa.UniqueConstraint("source_system", "source_external_id"),
sa.CheckConstraint(
"category IN ('bait', 'lure', 'rod', 'reel', 'line', 'hook', 'rig', 'float', 'sinker', 'other')",
name="ck_tackle_item_category",
),
)
op.create_table(
"rig",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("normalized_name", sa.String(200), nullable=False, unique=True),
sa.Column("source_system", sa.String(50)),
sa.Column("source_external_id", sa.String(200)),
sa.Column("source_url", sa.Text()),
sa.Column("source_checked_at", sa.DateTime(timezone=True)),
sa.Column("raw_payload", sa.JSON()),
)
op.create_table(
"rig_component",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("rig_id", sa.Uuid(), sa.ForeignKey("rig.id"), nullable=False),
sa.Column("tackle_item_id", sa.Uuid(), sa.ForeignKey("tackle_item.id")),
sa.Column("role", sa.String(50), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("raw_value", sa.String(200)),
sa.UniqueConstraint("rig_id", "position"),
)
def downgrade() -> None:
op.drop_table("rig_component")
op.drop_table("rig")
op.drop_table("tackle_item")
@@ -0,0 +1,40 @@
"""preserve ordered gear evidence on catches
Revision ID: 0022
Revises: 0021
"""
from alembic import op
import sqlalchemy as sa
revision = "0022"
down_revision = "0021"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"catch_tackle_component",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("catch_report_id", sa.Uuid(), sa.ForeignKey("catch_report.id"), nullable=False),
sa.Column("tackle_item_id", sa.Uuid(), sa.ForeignKey("tackle_item.id")),
sa.Column("rig_id", sa.Uuid(), sa.ForeignKey("rig.id")),
sa.Column("role", sa.String(50), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("raw_value", sa.String(200), nullable=False),
sa.Column("source_system", sa.String(50)),
sa.Column("source_external_id", sa.String(200)),
sa.Column("source_url", sa.Text()),
sa.Column("raw_payload", sa.JSON()),
sa.UniqueConstraint("catch_report_id", "position"),
sa.CheckConstraint(
"NOT (tackle_item_id IS NOT NULL AND rig_id IS NOT NULL)",
name="ck_catch_tackle_one_canonical_target",
),
)
def downgrade() -> None:
op.drop_table("catch_tackle_component")
+16 -1
View File
@@ -61,6 +61,9 @@ def activity_rows(
confidence = min(confidence, 50) confidence = min(confidence, 50)
elif len(players) == 2: elif len(players) == 2:
confidence = min(confidence, 65) confidence = min(confidence, 65)
coordinate_precisions = {_coordinate_precision(item) for item in items}
coordinate_precision = max(coordinate_precisions, key=_precision_rank)
coordinate_sources = sorted({_source_system(item) for item in items})
latest = max(_aware(r.reported_at) for r in items) latest = max(_aware(r.reported_at) for r in items)
baits = Counter(r.bait.name for r in items if r.bait) baits = Counter(r.bait.name for r in items if r.bait)
freshness_text = _freshness_text(now - latest) freshness_text = _freshness_text(now - latest)
@@ -74,7 +77,9 @@ def activity_rows(
max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest, max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest,
activity_score=activity, confidence_score=confidence, activity_score=activity, confidence_score=confidence,
explanation=_explanation(len(items), len(players), freshness_text, activity, confidence), explanation=_explanation(len(items), len(players), freshness_text, activity, confidence),
sources=sorted({_source_system(item) for item in items}), sources=coordinate_sources,
coordinate_precision=coordinate_precision,
coordinate_sources=coordinate_sources,
)) ))
return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True) return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True)
@@ -90,6 +95,16 @@ def _source_system(report: CatchReport) -> str:
return "manual-import" return "manual-import"
def _coordinate_precision(report: CatchReport) -> str:
provenance = (report.raw_payload or {}).get("provenance", {})
value = provenance.get("coordinate_precision") if isinstance(provenance, dict) else None
return value if value in {"exact", "approximate", "area", "missing"} else "exact"
def _precision_rank(value: str) -> int:
return {"exact": 0, "approximate": 1, "area": 2, "missing": 3}[value]
def _aware(value: datetime) -> datetime: def _aware(value: datetime) -> datetime:
return value if value.tzinfo else value.replace(tzinfo=timezone.utc) return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
+25
View File
@@ -15,3 +15,28 @@ def audit_catalog(db: Session) -> dict[str, int]:
"incomplete_published_staging": db.scalar(select(func.count()).select_from(ExternalObservation).where(ExternalObservation.status == "published", or_(ExternalObservation.fish_id.is_(None), ExternalObservation.waterbody_id.is_(None), ExternalObservation.x.is_(None), ExternalObservation.y.is_(None), ExternalObservation.weight_g.is_(None), ExternalObservation.catch_report_id.is_(None)))) or 0, "incomplete_published_staging": db.scalar(select(func.count()).select_from(ExternalObservation).where(ExternalObservation.status == "published", or_(ExternalObservation.fish_id.is_(None), ExternalObservation.waterbody_id.is_(None), ExternalObservation.x.is_(None), ExternalObservation.y.is_(None), ExternalObservation.weight_g.is_(None), ExternalObservation.catch_report_id.is_(None)))) or 0,
} }
return {"fishes": count(Fish), "waterbodies": count(Waterbody), "reports": count(CatchReport), "staging": count(ExternalObservation), **failures, "failures": sum(failures.values())} return {"fishes": count(Fish), "waterbodies": count(Waterbody), "reports": count(CatchReport), "staging": count(ExternalObservation), **failures, "failures": sum(failures.values())}
def audit_waterbody_catalog(db: Session, expected_ids: set[str]) -> dict:
"""Check a verified RF4DB snapshot without withdrawing legacy rows."""
rows = list(db.scalars(select(Waterbody).where(Waterbody.source_system == "rf4db")))
observed_ids = [str(row.source_external_id) for row in rows if row.source_external_id]
observed = set(observed_ids)
duplicate_ids = sorted({item for item in observed_ids if observed_ids.count(item) > 1})
missing = sorted(expected_ids - observed)
unexpected = sorted(observed - expected_ids)
provenance_issues = sorted(
str(row.source_external_id)
for row in rows
if not row.source_external_id or not row.source_url or not row.source_checked_at
)
failures = len(missing) + len(duplicate_ids) + len(provenance_issues)
return {
"expected": len(expected_ids),
"observed": len(observed),
"missing_source_external_ids": missing,
"unexpected_source_external_ids": unexpected,
"duplicate_source_external_ids": duplicate_ids,
"provenance_issues": provenance_issues,
"failures": failures,
}
+66 -2
View File
@@ -8,10 +8,10 @@ from dataclasses import asdict
from .config import settings from .config import settings
from .database import SessionLocal from .database import SessionLocal
from .importer import import_records from .importer import import_records
from .community_importer import stage_observations from .community_importer import stage_observations, update_waterbody_detail, update_waterbody_details, upsert_waterbody_catalog
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, audit_waterbody_catalog
from .community_scheduler import run_source, configured_sources from .community_scheduler import run_source, configured_sources
# Static registry for argparse choices — no DB required for --help # Static registry for argparse choices — no DB required for --help
@@ -34,11 +34,20 @@ 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)
waterbodies = sub.add_parser("import-waterbody-catalog")
waterbodies.add_argument("--input", required=True, help="JSON snapshot path or - for stdin")
waterbodies.add_argument("--limit", type=int, default=100)
detail = sub.add_parser("import-waterbody-detail")
detail.add_argument("--input", required=True, help="JSON detail snapshot path")
details = sub.add_parser("import-waterbody-details")
details.add_argument("--input", required=True, help="JSON array of detail snapshots")
fetch_community = sub.add_parser("fetch-community") fetch_community = sub.add_parser("fetch-community")
fetch_community.add_argument("source", choices=STATIC_SOURCE_CHOICES) fetch_community.add_argument("source", choices=STATIC_SOURCE_CHOICES)
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")
waterbody_audit = sub.add_parser("audit-waterbody-catalog")
waterbody_audit.add_argument("--input", required=True, help="JSON snapshot path")
args = parser.parse_args() args = parser.parse_args()
with SessionLocal() as session: with SessionLocal() as session:
if args.command == "import-records": if args.command == "import-records":
@@ -57,6 +66,43 @@ 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 == "import-waterbody-catalog":
if not 1 <= args.limit <= 500:
parser.error("--limit must be between 1 and 500")
stream = sys.stdin if args.input == "-" else open(args.input, encoding="utf-8")
try:
snapshot = json.load(stream)
finally:
if stream is not sys.stdin:
stream.close()
if isinstance(snapshot, dict):
payload = snapshot.get("items")
source_system = snapshot.get("source_system")
if isinstance(payload, list) and isinstance(source_system, str):
payload = [
{"source_system": source_system, **item}
for item in payload if isinstance(item, dict)
]
else:
payload = snapshot
if not isinstance(payload, list):
parser.error("input must be a JSON array or an object with an items array")
created, updated = upsert_waterbody_catalog(session, payload[:args.limit])
print(f"waterbodies: created={created} updated={updated}")
elif args.command == "import-waterbody-detail":
with open(args.input, encoding="utf-8") as stream:
payload = json.load(stream)
if not isinstance(payload, dict):
parser.error("input must be a JSON object")
update_waterbody_detail(session, payload)
print(f"waterbody detail: updated={payload.get('source_external_id', 'unknown')}")
elif args.command == "import-waterbody-details":
with open(args.input, encoding="utf-8") as stream:
payload = json.load(stream)
if not isinstance(payload, list):
parser.error("input must be a JSON array")
created, updated = update_waterbody_details(session, payload)
print(f"waterbody details: created={created} updated={updated}")
elif args.command == "fetch-community": elif args.command == "fetch-community":
# A09: Verify source is enabled at runtime (not just in static choices) # A09: Verify source is enabled at runtime (not just in static choices)
enabled = configured_sources() enabled = configured_sources()
@@ -76,6 +122,24 @@ def main() -> int:
) )
counts = apply_retention(session, policy=policy, dry_run=not args.apply, delete_object=delete_screenshot) counts = apply_retention(session, policy=policy, dry_run=not args.apply, delete_object=delete_screenshot)
print(json.dumps({"mode": "apply" if args.apply else "dry-run", "policy": asdict(policy), "counts": counts}, ensure_ascii=False)) print(json.dumps({"mode": "apply" if args.apply else "dry-run", "policy": asdict(policy), "counts": counts}, ensure_ascii=False))
elif args.command == "audit-waterbody-catalog":
stream = sys.stdin if args.input == "-" else open(args.input, encoding="utf-8")
try:
snapshot = json.load(stream)
finally:
if stream is not sys.stdin:
stream.close()
items = snapshot.get("items") if isinstance(snapshot, dict) else snapshot
if not isinstance(items, list):
parser.error("input must be a JSON array or an object with an items array")
expected_ids = {
str(item["source_external_id"])
for item in items
if isinstance(item, dict) and item.get("source_external_id")
}
result = audit_waterbody_catalog(session, expected_ids)
print(json.dumps(result, ensure_ascii=False))
return 1 if result["failures"] else 0
else: else:
result = audit_catalog(session) result = audit_catalog(session)
print(json.dumps(result, ensure_ascii=False)) print(json.dumps(result, ensure_ascii=False))
+167
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
import hashlib
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Iterable from typing import Any, Iterable
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -26,12 +28,153 @@ SOURCE_HOSTS = {
"rf4map": {"rf4map.ru"}, "rf4map": {"rf4map.ru"},
"rf4posts-spot": {"rf4-posts.com"}, "rf4posts-spot": {"rf4-posts.com"},
} }
COORDINATE_PRECISIONS = frozenset({"exact", "approximate", "area", "missing"})
class CommunityImportError(ValueError): class CommunityImportError(ValueError):
pass pass
def upsert_waterbody_catalog(
session: Session, rows: Iterable[dict[str, Any]], *, fetched_at: datetime | None = None,
) -> tuple[int, int]:
"""Apply a validated canonical waterbody snapshot without destructive sync.
Rows are matched by the RF4DB source identity first and by an exact existing
name second. Missing rows are deliberately left untouched: an incomplete
response must never withdraw a previously known waterbody.
"""
fetched_at = fetched_at or datetime.now(timezone.utc)
created = updated = 0
for raw in rows:
payload = _json_payload(raw)
if payload.get("source_system") != "rf4db":
raise CommunityImportError("waterbody catalog requires source_system=rf4db")
external_id = _required(payload, "source_external_id", 200)
name = _required(payload, "name", 200)
source_url = _required(payload, "source_url", 2000)
parsed_url = urlparse(source_url)
if parsed_url.scheme != "https" or parsed_url.hostname not in {"rf4db.com", "www.rf4db.com"}:
raise CommunityImportError("waterbody source_url does not match rf4db")
unlock_level = _integer(payload.get("unlock_level"), minimum=0, maximum=1_000)
unlock_label = _required(payload, "unlock_label", 50)
fish_species_count = _integer(payload.get("fish_species_count"), minimum=0, maximum=10_000)
if fish_species_count is None:
raise CommunityImportError("invalid fish_species_count")
item = session.scalar(select(Waterbody).where(
Waterbody.source_system == "rf4db",
Waterbody.source_external_id == external_id,
))
if item is None:
item = session.scalar(select(Waterbody).where(Waterbody.name_ru == name))
if item is None:
item = Waterbody(
slug=_catalog_slug(session, name, external_id),
name_ru=name,
unlock_level=unlock_level,
)
session.add(item)
created += 1
else:
updated += 1
item.name_ru = name
item.unlock_level = unlock_level
item.fish_species_count = fish_species_count
item.source_system = "rf4db"
item.source_external_id = external_id
item.source_url = source_url
item.source_checked_at = fetched_at
session.commit()
return created, updated
def update_waterbody_detail(
session: Session, detail: dict[str, Any], *, fetched_at: datetime | None = None,
) -> bool:
"""Persist one complete RF4DB detail snapshot without assigning media roles."""
fetched_at = fetched_at or datetime.now(timezone.utc)
payload = _validate_waterbody_detail(detail)
_apply_waterbody_detail(session, payload, fetched_at=fetched_at)
session.commit()
return True
def update_waterbody_details(
session: Session, details: Iterable[dict[str, Any]], *, fetched_at: datetime | None = None,
) -> tuple[int, int]:
"""Validate and apply a detail batch in one transaction."""
fetched_at = fetched_at or datetime.now(timezone.utc)
payloads = [_validate_waterbody_detail(detail) for detail in details]
external_ids = [str(payload["source_external_id"]) for payload in payloads]
if len(external_ids) != len(set(external_ids)):
raise CommunityImportError("waterbody detail batch contains duplicate source identities")
updated = 0
for payload in payloads:
_apply_waterbody_detail(session, payload, fetched_at=fetched_at)
updated += 1
session.commit()
return 0, updated
def _validate_waterbody_detail(detail: dict[str, Any]) -> dict[str, Any]:
payload = _json_payload(detail)
if payload.get("source_system") != "rf4db":
raise CommunityImportError("waterbody detail requires source_system=rf4db")
external_id = _required(payload, "source_external_id", 200)
source_url = _required(payload, "source_url", 2000)
parsed_url = urlparse(source_url)
if parsed_url.scheme != "https" or parsed_url.hostname not in {"rf4db.com", "www.rf4db.com", "download.rf4db.com"}:
raise CommunityImportError("waterbody detail source_url does not match rf4db")
_required(payload, "name", 200)
_optional(payload, "description", 20_000)
_string_list(payload, "aliases", 100, 200)
_string_list(payload, "fish_species", 10_000, 200)
_string_list(payload, "image_urls", 100, 2_000)
_string_list(payload, "point_urls", 10_000, 2_000)
return payload
def _apply_waterbody_detail(session: Session, payload: dict[str, Any], *, fetched_at: datetime) -> None:
external_id = str(payload["source_external_id"])
source_url = str(payload["source_url"])
item = session.scalar(select(Waterbody).where(
Waterbody.source_system == "rf4db", Waterbody.source_external_id == external_id,
))
if item is None:
raise CommunityImportError("waterbody detail has no imported catalog identity")
item.description = _optional(payload, "description", 20_000)
item.source_aliases = _string_list(payload, "aliases", 100, 200)
item.source_fish_species = _string_list(payload, "fish_species", 10_000, 200)
item.source_image_urls = _string_list(payload, "image_urls", 100, 2_000)
item.source_point_urls = _string_list(payload, "point_urls", 10_000, 2_000)
item.source_url = source_url
item.source_checked_at = fetched_at
def _catalog_slug(session: Session, name: str, external_id: str) -> str:
base = re.sub(r"[^a-z0-9а-яё]+", "-", name.casefold(), flags=re.IGNORECASE).strip("-")
base = base or "waterbody"
candidate = base[:100]
if session.scalar(select(Waterbody.id).where(Waterbody.slug == candidate)) is None:
return candidate
suffix = hashlib.sha256(external_id.encode()).hexdigest()[:10]
return f"{base[:89]}-{suffix}"
def _string_list(payload: dict[str, Any], key: str, max_items: int, max_length: int) -> list[str]:
value = payload.get(key)
if not isinstance(value, list) or len(value) > max_items:
raise CommunityImportError(f"invalid {key}")
result = []
for item in value:
text = str(item).strip()
if not text or len(text) > max_length:
raise CommunityImportError(f"invalid {key}")
result.append(text)
return list(dict.fromkeys(result))
def stage_observations( def stage_observations(
session: Session, records: Iterable[dict[str, Any]], *, fetched_at: datetime | None = None, session: Session, records: Iterable[dict[str, Any]], *, fetched_at: datetime | None = None,
) -> tuple[int, int]: ) -> tuple[int, int]:
@@ -65,6 +208,8 @@ def stage_observations(
"waterbody_external_id": _optional(payload, "waterbody_external_id", 200), "waterbody_external_id": _optional(payload, "waterbody_external_id", 200),
"x": _integer(payload.get("x"), maximum=10_000), "x": _integer(payload.get("x"), maximum=10_000),
"y": _integer(payload.get("y"), maximum=10_000), "y": _integer(payload.get("y"), maximum=10_000),
"coordinate_raw": _coordinate_raw(payload),
"coordinate_precision": _coordinate_precision(payload),
"weight_g": _integer(payload.get("weight_g"), minimum=1, maximum=3_000_000), "weight_g": _integer(payload.get("weight_g"), minimum=1, maximum=3_000_000),
"published_at": _datetime(payload.get("published_at")), "published_at": _datetime(payload.get("published_at")),
"last_seen_at": fetched_at, "payload": payload, "last_seen_at": fetched_at, "payload": payload,
@@ -83,6 +228,7 @@ def stage_observations(
changed = any(getattr(observation, key) != values[key] for key in ( changed = any(getattr(observation, key) != values[key] for key in (
"source_url", "fish_name", "fish_external_id", "waterbody_name", "source_url", "fish_name", "fish_external_id", "waterbody_name",
"waterbody_external_id", "x", "y", "weight_g", "waterbody_external_id", "x", "y", "weight_g",
"coordinate_raw", "coordinate_precision",
)) or observation.payload != payload )) or observation.payload != payload
if observation.status != "rejected" and changed and observation.catch_report is not None: if observation.status != "rejected" and changed and observation.catch_report is not None:
observation.catch_report.moderation_status = ModerationStatus.pending observation.catch_report.moderation_status = ModerationStatus.pending
@@ -186,6 +332,27 @@ def _optional(payload: dict[str, Any], key: str, limit: int) -> str | None:
return value or None return value or None
def _coordinate_raw(payload: dict[str, Any]) -> str | None:
value = str(payload.get("coordinate_raw") or "").strip()
if len(value) > 200:
raise CommunityImportError("invalid coordinate_raw")
if value:
return value
x, y = payload.get("x"), payload.get("y")
return f"{x}:{y}" if isinstance(x, int) and isinstance(y, int) else None
def _coordinate_precision(payload: dict[str, Any]) -> str:
value = str(payload.get("coordinate_precision") or "").strip().casefold()
if not value:
return "exact" if isinstance(payload.get("x"), int) and isinstance(payload.get("y"), int) else "missing"
if value not in COORDINATE_PRECISIONS:
raise CommunityImportError("invalid coordinate_precision")
if value == "exact" and (not isinstance(payload.get("x"), int) or not isinstance(payload.get("y"), int)):
raise CommunityImportError("exact coordinates require x and y")
return value
def _integer(value: Any, *, minimum: int = -10_000, maximum: int) -> int | None: def _integer(value: Any, *, minimum: int = -10_000, maximum: int) -> int | None:
if value is None: if value is None:
return None return None
+15
View File
@@ -11,6 +11,8 @@ from .models import (
Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation, Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation,
Fish, ModerationStatus, SourceType, Spot, Waterbody, Fish, ModerationStatus, SourceType, Spot, Waterbody,
) )
from .tackle_components import replace_tackle_components
from rf4_research.gear_components import from_catch_fields
class ExternalReviewError(ValueError): class ExternalReviewError(ValueError):
@@ -104,6 +106,8 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C
"external_observation_id": str(observation.id), "external_observation_id": str(observation.id),
"source_system": observation.source_system, "source_system": observation.source_system,
"source_external_id": observation.source_external_id, "source_external_id": observation.source_external_id,
"coordinate_raw": observation.coordinate_raw,
"coordinate_precision": observation.coordinate_precision,
}, },
"original": observation.payload, "original": observation.payload,
}, },
@@ -116,6 +120,17 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C
setattr(report, key, value) setattr(report, key, value)
session.add(report) session.add(report)
session.flush() session.flush()
replace_tackle_components(
session,
report,
from_catch_fields(
bait=observation.payload.get("bait"),
rig_type=observation.payload.get("rig_type"),
),
source_system=observation.source_system,
source_url=observation.source_url,
raw_payload={"origin": "community_observation", "observation_id": str(observation.id)},
)
observation.catch_report = report observation.catch_report = report
observation.status = "published" observation.status = "published"
observation.reviewed_at = now observation.reviewed_at = now
+10
View File
@@ -16,6 +16,8 @@ from .models import (
Bait, BaitKind, CatchReport, Fish, ImportRecordEvent, ImportStatus, ModerationStatus, Bait, BaitKind, CatchReport, Fish, ImportRecordEvent, ImportStatus, ModerationStatus,
OfficialRecordImport, SourceType, Waterbody, OfficialRecordImport, SourceType, Waterbody,
) )
from .tackle_components import replace_tackle_components
from rf4_research.gear_components import from_catch_fields
USER_AGENT = "RF4-Spotter/0.1 (public records importer)" USER_AGENT = "RF4-Spotter/0.1 (public records importer)"
@@ -222,6 +224,14 @@ def _import_records_locked(session: Session, *, url: str, region: str, category:
provenance={"source_system": "rf4-official", "source_url": url, "source_external_id": key}, provenance={"source_system": "rf4-official", "source_url": url, "source_external_id": key},
)) ))
run.rows_updated += 1 run.rows_updated += 1
replace_tackle_components(
session,
report,
from_catch_fields(bait=raw.bait, rig_type=None),
source_system="rf4-official",
source_url=url,
raw_payload={"origin": "official_record", "source_external_id": key},
)
run.status = ImportStatus.success run.status = ImportStatus.success
run.finished_at = datetime.now(timezone.utc) run.finished_at = datetime.now(timezone.utc)
session.commit() session.commit()
+2
View File
@@ -14,6 +14,7 @@ from .dependencies import Db
from .logging_config import configure_logging from .logging_config import configure_logging
from .readiness import readiness_report from .readiness import readiness_report
from .routers.activity import router as activity_router from .routers.activity import router as activity_router
from .routers.analytics import router as analytics_router
from .routers.admin import router as admin_router from .routers.admin import router as admin_router
from .routers.catalog import router as catalog_router from .routers.catalog import router as catalog_router
from .routers.media import router as media_router from .routers.media import router as media_router
@@ -90,6 +91,7 @@ def ready(db: Db) -> JSONResponse:
app.include_router(catalog_router) app.include_router(catalog_router)
app.include_router(media_router) app.include_router(media_router)
app.include_router(activity_router) app.include_router(activity_router)
app.include_router(analytics_router)
app.include_router(public_data_router) app.include_router(public_data_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(submissions_router) app.include_router(submissions_router)
+19 -3
View File
@@ -49,12 +49,26 @@ def published_file(digest: str) -> tuple[Path, str] | None:
return None return None
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8")) manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
item = next((row for row in manifest.get("assets", []) if row.get("status") == "approved" and row.get("sha256") == digest), None) item = next((row for row in manifest.get("assets", []) if row.get("status") == "approved" and row.get("sha256") == digest), None)
if not item: media_type = None
local_path = None
if item:
media_type = item.get("content_type")
local_path = item.get("local_path")
else:
for row in manifest.get("assets", []):
if row.get("status") != "approved":
continue
variant = next((candidate for candidate in row.get("derivatives", []) if candidate.get("sha256") == digest), None)
if variant:
media_type = variant.get("content_type")
local_path = variant.get("local_path")
break
if not local_path:
return None return None
target = (MEDIA_ROOT / item["local_path"]).resolve() target = (MEDIA_ROOT / local_path).resolve()
if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file(): if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file():
return None return None
return target, str(item["content_type"]) return target, str(media_type or "application/octet-stream")
def review_assets(entity_type: str | None = None, status: str | None = None) -> list[dict]: def review_assets(entity_type: str | None = None, status: str | None = None) -> list[dict]:
@@ -81,9 +95,11 @@ def review_assets(entity_type: str | None = None, status: str | None = None) ->
"height": item.get("height"), "height": item.get("height"),
"content_type": item.get("content_type"), "content_type": item.get("content_type"),
"image_url": f"/api/v1/admin/media/assets/{digest}", "image_url": f"/api/v1/admin/media/assets/{digest}",
"asset_url": item.get("asset_url", ""),
"source_system": source, "source_system": source,
"source_url": source_page, "source_url": source_page,
"duplicate_of": item.get("duplicate_of"), "duplicate_of": item.get("duplicate_of"),
"supersedes": item.get("supersedes"),
"derivatives": [{ "derivatives": [{
"role": variant.get("role"), "format": variant.get("format"), "role": variant.get("role"), "format": variant.get("format"),
"width": variant.get("width"), "height": variant.get("height"), "width": variant.get("width"), "height": variant.get("height"),
+95
View File
@@ -49,6 +49,16 @@ class Waterbody(Base):
slug: Mapped[str] = mapped_column(String(100), unique=True) slug: Mapped[str] = mapped_column(String(100), unique=True)
name_ru: Mapped[str] = mapped_column(String(200), unique=True) name_ru: Mapped[str] = mapped_column(String(200), unique=True)
unlock_level: Mapped[int | None] unlock_level: Mapped[int | None]
fish_species_count: Mapped[int | None]
source_system: Mapped[str | None] = mapped_column(String(50))
source_external_id: Mapped[str | None] = mapped_column(String(200))
source_url: Mapped[str | None] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text)
source_aliases: Mapped[list[str] | None] = mapped_column(JSON)
source_fish_species: Mapped[list[str] | None] = mapped_column(JSON)
source_image_urls: Mapped[list[str] | None] = mapped_column(JSON)
source_point_urls: Mapped[list[str] | None] = mapped_column(JSON)
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class Bait(Base): class Bait(Base):
@@ -59,6 +69,61 @@ class Bait(Base):
kind: Mapped[BaitKind] = mapped_column(Enum(BaitKind)) kind: Mapped[BaitKind] = mapped_column(Enum(BaitKind))
class TackleItem(Base):
"""Canonical gear item; legacy Bait rows remain source-compatible."""
__tablename__ = "tackle_item"
__table_args__ = (
UniqueConstraint("source_system", "source_external_id"),
CheckConstraint(
"category IN ('bait', 'lure', 'rod', 'reel', 'line', 'hook', 'rig', 'float', 'sinker', 'other')",
name="ck_tackle_item_category",
),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200))
normalized_name: Mapped[str] = mapped_column(String(200), unique=True)
category: Mapped[str] = mapped_column(String(20))
subcategory: Mapped[str | None] = mapped_column(String(100))
brand: Mapped[str | None] = mapped_column(String(100))
family: Mapped[str | None] = mapped_column(String(100))
unlock_level: Mapped[int | None]
source_system: Mapped[str | None] = mapped_column(String(50))
source_external_id: Mapped[str | None] = mapped_column(String(200))
source_url: Mapped[str | None] = mapped_column(Text)
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
raw_payload: Mapped[dict | None] = mapped_column(JSON)
rig_components: Mapped[list["RigComponent"]] = relationship(back_populates="tackle_item")
class Rig(Base):
"""A named rig/setup kept separate from individual tackle items."""
__tablename__ = "rig"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200))
normalized_name: Mapped[str] = mapped_column(String(200), unique=True)
source_system: Mapped[str | None] = mapped_column(String(50))
source_external_id: Mapped[str | None] = mapped_column(String(200))
source_url: Mapped[str | None] = mapped_column(Text)
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
raw_payload: Mapped[dict | None] = mapped_column(JSON)
components: Mapped[list["RigComponent"]] = relationship(back_populates="rig")
class RigComponent(Base):
__tablename__ = "rig_component"
__table_args__ = (UniqueConstraint("rig_id", "position"),)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
rig_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rig.id"))
tackle_item_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("tackle_item.id"))
role: Mapped[str] = mapped_column(String(50))
position: Mapped[int] = mapped_column(Integer)
raw_value: Mapped[str | None] = mapped_column(String(200))
rig: Mapped[Rig] = relationship(back_populates="components")
tackle_item: Mapped[TackleItem | None] = relationship(back_populates="rig_components")
class Spot(Base): class Spot(Base):
__tablename__ = "spot" __tablename__ = "spot"
__table_args__ = (UniqueConstraint("waterbody_id", "x", "y"),) __table_args__ = (UniqueConstraint("waterbody_id", "x", "y"),)
@@ -100,6 +165,34 @@ class CatchReport(Base):
spot: Mapped[Spot | None] = relationship() spot: Mapped[Spot | None] = relationship()
waterbody: Mapped[Waterbody] = relationship() waterbody: Mapped[Waterbody] = relationship()
bait: Mapped[Bait | None] = relationship() bait: Mapped[Bait | None] = relationship()
tackle_components: Mapped[list["CatchTackleComponent"]] = relationship(back_populates="catch_report")
class CatchTackleComponent(Base):
"""Ordered gear evidence; unresolved raw values are valid and preserved."""
__tablename__ = "catch_tackle_component"
__table_args__ = (
UniqueConstraint("catch_report_id", "position"),
CheckConstraint(
"NOT (tackle_item_id IS NOT NULL AND rig_id IS NOT NULL)",
name="ck_catch_tackle_one_canonical_target",
),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
catch_report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("catch_report.id"))
tackle_item_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("tackle_item.id"))
rig_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("rig.id"))
role: Mapped[str] = mapped_column(String(50))
position: Mapped[int] = mapped_column(Integer)
raw_value: Mapped[str] = mapped_column(String(200))
source_system: Mapped[str | None] = mapped_column(String(50))
source_external_id: Mapped[str | None] = mapped_column(String(200))
source_url: Mapped[str | None] = mapped_column(Text)
raw_payload: Mapped[dict | None] = mapped_column(JSON)
catch_report: Mapped[CatchReport] = relationship(back_populates="tackle_components")
tackle_item: Mapped[TackleItem | None] = relationship()
rig: Mapped[Rig | None] = relationship()
class OfficialRecordImport(Base): class OfficialRecordImport(Base):
@@ -187,6 +280,8 @@ class ExternalObservation(Base):
waterbody_external_id: Mapped[str | None] = mapped_column(String(200)) waterbody_external_id: Mapped[str | None] = mapped_column(String(200))
x: Mapped[int | None] x: Mapped[int | None]
y: Mapped[int | None] y: Mapped[int | None]
coordinate_raw: Mapped[str | None] = mapped_column(String(200))
coordinate_precision: Mapped[str] = mapped_column(String(20), default="missing")
weight_g: Mapped[int | None] weight_g: Mapped[int | None]
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
+17 -4
View File
@@ -5,7 +5,7 @@ from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, Response from fastapi import APIRouter, HTTPException, Query, Response
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload, selectinload
from ..activity import activity_rows from ..activity import activity_rows
from ..config import settings from ..config import settings
@@ -68,7 +68,15 @@ def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
def count_since(delta: timedelta) -> int: def count_since(delta: timedelta) -> int:
return sum(aware(report.reported_at) >= now - delta for report in reports) return sum(aware(report.reported_at) >= now - delta for report in reports)
return SpotOut(id=spot.id, waterbody_slug=spot.waterbody.slug, waterbody=spot.waterbody.name_ru, x=spot.x, y=spot.y, description=spot.description, catches_24h=count_since(timedelta(hours=24)), catches_3d=count_since(timedelta(days=3)), catches_7d=count_since(timedelta(days=7)), top_baits=[name for name, _ in bait_counts.most_common(5)]) provenance = [
(report.raw_payload or {}).get("provenance", {})
for report in reports
if isinstance((report.raw_payload or {}).get("provenance", {}), dict)
]
precisions = [item.get("coordinate_precision") for item in provenance]
precision = max((value for value in precisions if value in {"exact", "approximate", "area", "missing"}), key={"exact": 0, "approximate": 1, "area": 2, "missing": 3}.get, default="exact")
sources = sorted({str(item.get("source_system")) for item in provenance if item.get("source_system")}) or ["players"]
return SpotOut(id=spot.id, waterbody_slug=spot.waterbody.slug, waterbody=spot.waterbody.name_ru, x=spot.x, y=spot.y, description=spot.description, catches_24h=count_since(timedelta(hours=24)), catches_3d=count_since(timedelta(days=3)), catches_7d=count_since(timedelta(days=7)), top_baits=[name for name, _ in bait_counts.most_common(5)], coordinate_precision=precision, coordinate_sources=sources)
def _report_source(report: CatchReport) -> str: def _report_source(report: CatchReport) -> str:
@@ -83,8 +91,13 @@ def _report_source(report: CatchReport) -> str:
@router.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut]) @router.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut])
def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]: def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]:
_spot_or_404(db, spot_id) _spot_or_404(db, spot_id)
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit))) reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait), selectinload(CatchReport.tackle_components)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
return [CatchOut(id=report.id, fish=report.fish.name_ru, weight_g=report.weight_g, bait=report.bait.name if report.bait else None, player_name=report.player_name, caught_at=report.caught_at, reported_at=report.reported_at, retrieve_method=report.retrieve_method, retrieve_speed=report.retrieve_speed, source_system=_report_source(report), source_url=report.source_url) for report in reports] return [CatchOut(id=report.id, fish=report.fish.name_ru, weight_g=report.weight_g, bait=report.bait.name if report.bait else None, player_name=report.player_name, caught_at=report.caught_at, reported_at=report.reported_at, retrieve_method=report.retrieve_method, retrieve_speed=report.retrieve_speed, source_system=_report_source(report), source_url=report.source_url, tackle_components=[{
"id": component.id, "role": component.role, "position": component.position,
"raw_value": component.raw_value, "tackle_item_id": component.tackle_item_id,
"rig_id": component.rig_id, "source_system": component.source_system,
"source_url": component.source_url,
} for component in sorted(report.tackle_components, key=lambda value: value.position)]) for report in reports]
@router.get("/api/v1/spots/{spot_id}/timeline") @router.get("/api/v1/spots/{spot_id}/timeline")
+30 -2
View File
@@ -15,10 +15,12 @@ from ..community_review import ExternalReviewError, map_observation, publish_obs
from ..config import settings from ..config import settings
from ..dependencies import Db from ..dependencies import Db
from ..importer import ImportAlreadyRunning, ImportSourceError, import_records from ..importer import ImportAlreadyRunning, ImportSourceError, import_records
from ..media_catalog import review_assets, review_file from rf4_research.media_assets import publish_quality_upgrades, rollback_quality_upgrade
from ..media_catalog import MEDIA_ROOT, review_assets, review_file
from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody
from ..public_cache import public_cache from ..public_cache import public_cache
from ..schemas import AdminCatchReportOut, AdminMediaReviewOut, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate from ..schemas import AdminCatchReportOut, AdminMediaDecision, AdminMediaReviewOut, AdminMediaRollback, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
from ..storage import delete_screenshot, signed_screenshot_url from ..storage import delete_screenshot, signed_screenshot_url
from ..time_utils import aware from ..time_utils import aware
@@ -50,6 +52,32 @@ def admin_media_asset(digest: str, _: Annotated[str, Depends(_admin)]) -> FileRe
return FileResponse(path, media_type=media_type, headers={"Cache-Control": "private, no-store"}) return FileResponse(path, media_type=media_type, headers={"Cache-Control": "private, no-store"})
@router.post("/api/v1/admin/media/upgrades/publish")
def admin_publish_media_upgrades(
payload: AdminMediaDecision,
_: Annotated[str, Depends(_admin)],
) -> dict[str, int]:
"""Atomically publish all stored quality upgrades after an explicit decision."""
try:
return publish_quality_upgrades(MEDIA_ROOT / "manifest.json", note=payload.note)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/api/v1/admin/media/upgrades/rollback")
def admin_rollback_media_upgrade(
payload: AdminMediaRollback,
_: Annotated[str, Depends(_admin)],
) -> dict[str, str]:
"""Restore one superseded fallback while retaining the reviewed candidate."""
try:
return rollback_quality_upgrade(
MEDIA_ROOT / "manifest.json", asset_url=payload.asset_url, note=payload.note,
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.get("/api/v1/admin/diagnostics") @router.get("/api/v1/admin/diagnostics")
def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse: def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse:
report_counts = {status.value: count for status, count in db.execute( report_counts = {status.value: count for status, count in db.execute(
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Query
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload, selectinload
from ..dependencies import Db
from ..models import CatchReport, Fish, ModerationStatus, Spot, Waterbody
from ..schemas import TackleCombinationOut
router = APIRouter()
@router.get("/api/v1/analytics/tackle", response_model=list[TackleCombinationOut])
def tackle_combinations(
db: Db,
waterbody: str | None = None,
fish: str | None = None,
method: str | None = None,
hours: int = Query(72, ge=24, le=168),
min_samples: int = Query(3, ge=1, le=100),
min_players: int = Query(2, ge=1, le=100),
) -> list[TackleCombinationOut]:
now = datetime.now(timezone.utc)
query = select(CatchReport).options(
joinedload(CatchReport.fish), joinedload(CatchReport.waterbody),
selectinload(CatchReport.tackle_components),
).where(
CatchReport.moderation_status == ModerationStatus.approved,
CatchReport.deleted_at.is_(None),
CatchReport.reported_at >= now - timedelta(hours=hours),
)
if waterbody:
query = query.join(Waterbody, CatchReport.waterbody_id == Waterbody.id).where(Waterbody.slug == waterbody)
if fish:
query = query.join(Fish, CatchReport.fish_id == Fish.id).where(Fish.slug == fish)
if method:
query = query.where(CatchReport.fishing_method == method)
groups: dict[tuple[str, str], list[CatchReport]] = defaultdict(list)
for report in db.scalars(query):
for component in report.tackle_components:
if component.raw_value.strip():
groups[(component.role, component.raw_value.strip())].append(report)
result = []
for (role, value), reports in groups.items():
unique_reports = {report.id: report for report in reports}
players = {report.player_name.strip().casefold() for report in unique_reports.values() if report.player_name and report.player_name.strip()}
catches = len(unique_reports)
unique_players = len(players)
last_seen = max(report.reported_at for report in unique_reports.values())
enough = catches >= min_samples and unique_players >= min_players
result.append(TackleCombinationOut(
role=role, value=value, catches=catches, unique_players=unique_players,
last_seen_at=last_seen, status="recommendation" if enough else "insufficient_data",
explanation=(
"Достаточно независимых наблюдений для рекомендации."
if enough else
f"Данных мало: нужно минимум {min_samples} наблюдения и {min_players} независимых игрока."
),
))
return sorted(result, key=lambda item: (item.status != "recommendation", -item.catches, -item.unique_players, item.role, item.value))
+69 -4
View File
@@ -1,9 +1,12 @@
from fastapi import APIRouter, Query from uuid import UUID
from sqlalchemy import select
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
from ..dependencies import Db from ..dependencies import Db
from ..models import Bait, CatchReport, Fish, ModerationStatus, Spot, Waterbody from ..models import Bait, CatchReport, Fish, ModerationStatus, Rig, Spot, TackleItem, Waterbody
from ..schemas import BaitOut, FishOut, WaterbodyOut from ..schemas import BaitOut, FishOut, PaginatedTackleItemOut, RigOut, TackleItemOut, WaterbodyOut
router = APIRouter() router = APIRouter()
@@ -23,6 +26,68 @@ def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0,
return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit))) return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit)))
def _item_missing_fields(item: TackleItem) -> list[str]:
return [field for field, value in (
("subcategory", item.subcategory), ("brand", item.brand),
("family", item.family), ("unlock_level", item.unlock_level),
("source_url", item.source_url), ("source_checked_at", item.source_checked_at),
) if value is None]
@router.get("/api/v1/tackle/items", response_model=PaginatedTackleItemOut)
def tackle_items(
db: Db,
category: str | None = Query(None, pattern="^(bait|lure|rod|reel|line|hook|rig|float|sinker|other)$"),
brand: str | None = None,
family: str | None = None,
unlock_level: int | None = Query(None, ge=0),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> PaginatedTackleItemOut:
query = select(TackleItem)
if category:
query = query.where(TackleItem.category == category)
if brand:
query = query.where(TackleItem.brand == brand)
if family:
query = query.where(TackleItem.family == family)
if unlock_level is not None:
query = query.where(TackleItem.unlock_level == unlock_level)
total = db.scalar(query.with_only_columns(func.count(TackleItem.id), maintain_column_froms=True).order_by(None)) or 0
items = list(db.scalars(query.order_by(TackleItem.name, TackleItem.id).offset(offset).limit(limit)))
return PaginatedTackleItemOut(
items=[TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item)}) for item in items],
total=total, limit=limit, offset=offset,
)
@router.get("/api/v1/tackle/items/{item_id}", response_model=TackleItemOut)
def tackle_item(item_id: UUID, db: Db) -> TackleItemOut:
item = db.get(TackleItem, item_id)
if item is None:
raise HTTPException(status_code=404, detail="tackle item not found")
return TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item)})
@router.get("/api/v1/tackle/rigs/{rig_id}", response_model=RigOut)
def rig_detail(rig_id: UUID, db: Db) -> RigOut:
rig = db.scalar(select(Rig).options(selectinload(Rig.components)).where(Rig.id == rig_id))
if rig is None:
raise HTTPException(status_code=404, detail="rig not found")
missing = [field for field, value in (
("source_url", rig.source_url), ("source_checked_at", rig.source_checked_at),
) if value is None]
return RigOut(
id=rig.id, name=rig.name, source_system=rig.source_system,
source_external_id=rig.source_external_id, source_url=rig.source_url,
source_checked_at=rig.source_checked_at, missing_fields=missing,
components=[{
"id": component.id, "role": component.role, "position": component.position,
"raw_value": component.raw_value, "tackle_item_id": component.tackle_item_id,
} for component in sorted(rig.components, key=lambda value: value.position)],
)
@router.get("/api/v1/public-spot-pages") @router.get("/api/v1/public-spot-pages")
def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[str]: def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[str]:
rows = db.execute(select(Waterbody.slug, Spot.x, Spot.y, Fish.slug) rows = db.execute(select(Waterbody.slug, Spot.x, Spot.y, Fish.slug)
+10
View File
@@ -21,6 +21,8 @@ from ..models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, Source
from ..schemas import CatchReportAccepted, CatchReportCreate from ..schemas import CatchReportAccepted, CatchReportCreate
from ..storage import ScreenshotError, upload_screenshot from ..storage import ScreenshotError, upload_screenshot
from ..submission_security import check_rate_limit from ..submission_security import check_rate_limit
from ..tackle_components import replace_tackle_components
from rf4_research.gear_components import from_catch_fields
router = APIRouter() router = APIRouter()
logger = logging.getLogger("rf4.api.submissions") logger = logging.getLogger("rf4.api.submissions")
@@ -60,6 +62,14 @@ def create_catch_report(payload: CatchReportCreate, request: Request, db: Db, id
upload_token = _replay_token(key_hash) if key_hash else secrets.token_urlsafe(32) upload_token = _replay_token(key_hash) if key_hash else secrets.token_urlsafe(32)
report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None, screenshot_upload_token_hash=hashlib.sha256(upload_token.encode()).hexdigest()) report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None, screenshot_upload_token_hash=hashlib.sha256(upload_token.encode()).hexdigest())
db.add(report) db.add(report)
replace_tackle_components(
db,
report,
from_catch_fields(bait=payload.bait_name, rig_type=payload.rig_type),
source_system="user",
source_url=payload.source_url,
raw_payload={"origin": "user_submission"},
)
if key_hash: if key_hash:
db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, catch_report=report, payload_hash=payload_hash, created_at=datetime.now(timezone.utc))) db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, catch_report=report, payload_hash=payload_hash, created_at=datetime.now(timezone.utc)))
try: try:
+88
View File
@@ -20,6 +20,16 @@ class WaterbodyOut(BaseModel):
slug: str slug: str
name_ru: str name_ru: str
unlock_level: int | None unlock_level: int | None
fish_species_count: int | None
source_system: str | None
source_external_id: str | None
source_url: str | None
description: str | None
source_aliases: list[str] | None
source_fish_species: list[str] | None
source_image_urls: list[str] | None
source_point_urls: list[str] | None
source_checked_at: datetime | None
class BaitOut(BaseModel): class BaitOut(BaseModel):
@@ -30,6 +40,48 @@ class BaitOut(BaseModel):
kind: str kind: str
class TackleItemOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
category: str
subcategory: str | None
brand: str | None
family: str | None
unlock_level: int | None
source_system: str | None
source_external_id: str | None
source_url: str | None
source_checked_at: datetime | None
missing_fields: list[str] = Field(default_factory=list)
class PaginatedTackleItemOut(BaseModel):
items: list[TackleItemOut]
total: int
limit: int
offset: int
class RigComponentOut(BaseModel):
id: UUID
role: str
position: int
raw_value: str | None
tackle_item_id: UUID | None
class RigOut(BaseModel):
id: UUID
name: str
source_system: str | None
source_external_id: str | None
source_url: str | None
source_checked_at: datetime | None
missing_fields: list[str] = Field(default_factory=list)
components: list[RigComponentOut]
class ActivityOut(BaseModel): class ActivityOut(BaseModel):
spot_id: UUID spot_id: UUID
waterbody_slug: str waterbody_slug: str
@@ -48,6 +100,8 @@ class ActivityOut(BaseModel):
confidence_score: int confidence_score: int
explanation: str explanation: str
sources: list[str] sources: list[str]
coordinate_precision: str
coordinate_sources: list[str]
class PaginatedActivityOut(BaseModel): class PaginatedActivityOut(BaseModel):
@@ -57,6 +111,16 @@ class PaginatedActivityOut(BaseModel):
offset: int offset: int
class TackleCombinationOut(BaseModel):
role: str
value: str
catches: int
unique_players: int
last_seen_at: datetime
status: str
explanation: str
class CatchOut(BaseModel): class CatchOut(BaseModel):
id: UUID id: UUID
fish: str fish: str
@@ -69,6 +133,18 @@ class CatchOut(BaseModel):
retrieve_speed: int | None retrieve_speed: int | None
source_system: str source_system: str
source_url: str | None source_url: str | None
tackle_components: list["CatchTackleComponentOut"]
class CatchTackleComponentOut(BaseModel):
id: UUID
role: str
position: int
raw_value: str
tackle_item_id: UUID | None
rig_id: UUID | None
source_system: str | None
source_url: str | None
class SpotOut(BaseModel): class SpotOut(BaseModel):
@@ -82,6 +158,8 @@ class SpotOut(BaseModel):
catches_3d: int catches_3d: int
catches_7d: int catches_7d: int
top_baits: list[str] top_baits: list[str]
coordinate_precision: str
coordinate_sources: list[str]
class OfficialRecordOut(BaseModel): class OfficialRecordOut(BaseModel):
@@ -318,7 +396,17 @@ class AdminMediaReviewOut(BaseModel):
height: int | None height: int | None
content_type: str | None content_type: str | None
image_url: str image_url: str
asset_url: str
source_system: str source_system: str
source_url: str source_url: str
duplicate_of: str | None duplicate_of: str | None
supersedes: str | None
derivatives: list[AdminMediaDerivativeOut] derivatives: list[AdminMediaDerivativeOut]
class AdminMediaDecision(BaseModel):
note: str = Field(min_length=1, max_length=1000)
class AdminMediaRollback(AdminMediaDecision):
asset_url: str = Field(min_length=1, max_length=2000)
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from collections.abc import Iterable
from sqlalchemy import delete
from sqlalchemy.orm import Session
from rf4_research.gear_components import GearComponentIdentity
from .models import CatchReport, CatchTackleComponent
def replace_tackle_components(
session: Session,
report: CatchReport,
components: Iterable[GearComponentIdentity],
*,
source_system: str | None,
source_url: str | None = None,
raw_payload: dict | None = None,
) -> None:
"""Replace the ordered evidence for a report while keeping imports idempotent."""
session.flush()
session.execute(
delete(CatchTackleComponent).where(CatchTackleComponent.catch_report_id == report.id)
)
session.add_all(
CatchTackleComponent(
catch_report_id=report.id,
role=component.role,
position=component.position,
raw_value=component.raw_value,
source_system=source_system,
source_external_id=component.source_external_id,
source_url=source_url,
raw_payload=raw_payload,
)
for component in components
)
+176
View File
@@ -217,6 +217,21 @@
"title": "AdminCatchReportOut", "title": "AdminCatchReportOut",
"type": "object" "type": "object"
}, },
"AdminMediaDecision": {
"properties": {
"note": {
"maxLength": 1000,
"minLength": 1,
"title": "Note",
"type": "string"
}
},
"required": [
"note"
],
"title": "AdminMediaDecision",
"type": "object"
},
"AdminMediaDerivativeOut": { "AdminMediaDerivativeOut": {
"properties": { "properties": {
"format": { "format": {
@@ -275,6 +290,10 @@
}, },
"AdminMediaReviewOut": { "AdminMediaReviewOut": {
"properties": { "properties": {
"asset_url": {
"title": "Asset Url",
"type": "string"
},
"content_type": { "content_type": {
"anyOf": [ "anyOf": [
{ {
@@ -368,6 +387,17 @@
"title": "Status", "title": "Status",
"type": "string" "type": "string"
}, },
"supersedes": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Supersedes"
},
"width": { "width": {
"anyOf": [ "anyOf": [
{ {
@@ -390,14 +420,38 @@
"height", "height",
"content_type", "content_type",
"image_url", "image_url",
"asset_url",
"source_system", "source_system",
"source_url", "source_url",
"duplicate_of", "duplicate_of",
"supersedes",
"derivatives" "derivatives"
], ],
"title": "AdminMediaReviewOut", "title": "AdminMediaReviewOut",
"type": "object" "type": "object"
}, },
"AdminMediaRollback": {
"properties": {
"asset_url": {
"maxLength": 2000,
"minLength": 1,
"title": "Asset Url",
"type": "string"
},
"note": {
"maxLength": 1000,
"minLength": 1,
"title": "Note",
"type": "string"
}
},
"required": [
"note",
"asset_url"
],
"title": "AdminMediaRollback",
"type": "object"
},
"AdminModerationHistoryOut": { "AdminModerationHistoryOut": {
"properties": { "properties": {
"action": { "action": {
@@ -3223,6 +3277,128 @@
"summary": "Admin Media Catalog" "summary": "Admin Media Catalog"
} }
}, },
"/api/v1/admin/media/upgrades/publish": {
"post": {
"description": "Atomically publish all stored quality upgrades after an explicit decision.",
"operationId": "admin_publish_media_upgrades_api_v1_admin_media_upgrades_publish_post",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminMediaDecision"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "integer"
},
"title": "Response Admin Publish Media Upgrades Api V1 Admin Media Upgrades Publish Post",
"type": "object"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Publish Media Upgrades"
}
},
"/api/v1/admin/media/upgrades/rollback": {
"post": {
"description": "Restore one superseded fallback while retaining the reviewed candidate.",
"operationId": "admin_rollback_media_upgrade_api_v1_admin_media_upgrades_rollback_post",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminMediaRollback"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "string"
},
"title": "Response Admin Rollback Media Upgrade Api V1 Admin Media Upgrades Rollback Post",
"type": "object"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Rollback Media Upgrade"
}
},
"/api/v1/admin/moderation-history": { "/api/v1/admin/moderation-history": {
"get": { "get": {
"operationId": "admin_moderation_history_api_v1_admin_moderation_history_get", "operationId": "admin_moderation_history_api_v1_admin_moderation_history_get",
+71
View File
@@ -0,0 +1,71 @@
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.database import Base
from app.models import CatchReport, CatchTackleComponent, Fish, ModerationStatus, SourceType, Spot, Waterbody
from app.routers.analytics import tackle_combinations
def test_tackle_recommendation_requires_samples_and_independent_players() -> None:
now = datetime.now(timezone.utc)
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
waterbody = Waterbody(slug="lake", name_ru="Озеро", unlock_level=1)
fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000)
spot = Spot(waterbody=waterbody, x=10, y=20)
db.add_all([waterbody, fish, spot])
db.flush()
for index, player in enumerate(("One", "One", "Two")):
report = CatchReport(
fish=fish, waterbody=waterbody, spot=spot, weight_g=1000,
caught_at=now - timedelta(hours=1), reported_at=now - timedelta(hours=1),
player_name=player, source_type=SourceType.user, source_confidence=80,
moderation_status=ModerationStatus.approved,
)
report.tackle_components.append(CatchTackleComponent(role="lure", position=0, raw_value="Spinner #1"))
db.add(report)
db.commit()
rows = tackle_combinations(db, waterbody="lake", fish="pike", method=None, hours=72, min_samples=3, min_players=2)
assert len(rows) == 1
assert (rows[0].status, rows[0].catches, rows[0].unique_players) == ("recommendation", 3, 2)
rows = tackle_combinations(db, waterbody="lake", fish="pike", method=None, hours=72, min_samples=3, min_players=3)
assert rows[0].status == "insufficient_data"
engine.dispose()
def test_tackle_analytics_handles_empty_and_multicomponent_observations() -> None:
now = datetime.now(timezone.utc)
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
waterbody = Waterbody(slug="empty-check", name_ru="Проверка", unlock_level=1)
fish = Fish(slug="perch", name_ru="Окунь", trophy_weight_g=5_000)
spot = Spot(waterbody=waterbody, x=1, y=2)
report = CatchReport(
fish=fish, waterbody=waterbody, spot=spot, weight_g=500,
caught_at=now, reported_at=now, player_name="Player",
source_type=SourceType.user, source_confidence=80,
moderation_status=ModerationStatus.approved,
)
report.tackle_components.extend([
CatchTackleComponent(role="lure", position=0, raw_value="Spinner #1"),
CatchTackleComponent(role="rig", position=1, raw_value="Rig #1"),
CatchTackleComponent(role="lure", position=2, raw_value="Spinner #1"),
CatchTackleComponent(role="lure", position=3, raw_value=" "),
])
db.add(report)
db.commit()
rows = tackle_combinations(db, waterbody="empty-check", fish="perch", method=None, hours=72, min_samples=1, min_players=1)
assert {(row.role, row.value, row.catches) for row in rows} == {
("lure", "Spinner #1", 1), ("rig", "Rig #1", 1),
}
assert tackle_combinations(db, waterbody="missing", fish=None, method=None, hours=72) == []
engine.dispose()
+37 -2
View File
@@ -12,7 +12,8 @@ from app.database import Base, get_session
from app.community_importer import stage_observations from app.community_importer import stage_observations
from app.importer import ImportAlreadyRunning from app.importer import ImportAlreadyRunning
from app.main import app from app.main import app
from app.models import Bait, BaitKind, CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody from app.models import Bait, BaitKind, CatchReport, CatchTackleComponent, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
from app.routers import admin as admin_router
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
@@ -81,6 +82,9 @@ def test_published_media_catalog_and_content_addressed_file() -> None:
assert image.status_code == 200 assert image.status_code == 200
assert image.headers["content-type"].startswith("image/") assert image.headers["content-type"].startswith("image/")
assert image.headers["cache-control"] == "public, max-age=31536000, immutable" assert image.headers["cache-control"] == "public, max-age=31536000, immutable"
variant = client.get(item["variants"][0]["url"])
assert variant.status_code == 200
assert variant.headers["content-type"].startswith("image/")
assert client.get("/api/v1/media/assets/not-a-hash").status_code == 404 assert client.get("/api/v1/media/assets/not-a-hash").status_code == 404
@@ -182,6 +186,34 @@ def test_admin_media_review_requires_auth() -> None:
assert all({"role", "format", "width", "height"} <= set(derivative) for derivative in response.json()[0]["derivatives"]) assert all({"role", "format", "width", "height"} <= set(derivative) for derivative in response.json()[0]["derivatives"])
def test_admin_media_decisions_require_auth_and_note(monkeypatch) -> None:
assert client.post("/api/v1/admin/media/upgrades/publish", json={"note": "publish"}).status_code == 401
assert client.post("/api/v1/admin/media/upgrades/rollback", json={"asset_url": "https://example.test/a", "note": "rollback"}).status_code == 401
monkeypatch.setattr(admin_router, "publish_quality_upgrades", lambda path, note: {"published": 2, "retained_fallbacks": 2})
publish = client.post(
"/api/v1/admin/media/upgrades/publish",
json={"note": "visual review complete"},
headers={"Authorization": "Bearer change-me-in-production"},
)
assert publish.status_code == 200
assert publish.json() == {"published": 2, "retained_fallbacks": 2}
monkeypatch.setattr(admin_router, "rollback_quality_upgrade", lambda path, asset_url, note: {"rolled_back": asset_url, "restored": "https://example.test/fallback"})
rollback = client.post(
"/api/v1/admin/media/upgrades/rollback",
json={"asset_url": "https://example.test/a", "note": "fallback is preferred"},
headers={"Authorization": "Bearer change-me-in-production"},
)
assert rollback.status_code == 200
assert rollback.json()["rolled_back"] == "https://example.test/a"
assert client.post(
"/api/v1/admin/media/upgrades/publish",
json={"note": ""},
headers={"Authorization": "Bearer change-me-in-production"},
).status_code == 422
def test_liveness_does_not_probe_dependencies() -> None: def test_liveness_does_not_probe_dependencies() -> None:
response = client.get("/health?token=must-not-be-logged") response = client.get("/health?token=must-not-be-logged")
assert response.json() == {"status": "ok"} assert response.json() == {"status": "ok"}
@@ -319,11 +351,14 @@ def test_records_pagination_returns_correct_total_and_offset() -> None:
def test_user_report_requires_moderation_before_activity() -> None: def test_user_report_requires_moderation_before_activity() -> None:
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "player_name": "Reporter"}) created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "rig_type": "Спиннинг", "player_name": "Reporter"})
assert created.status_code == 201 assert created.status_code == 201
assert created.headers["Cache-Control"] == "no-store" assert created.headers["Cache-Control"] == "no-store"
assert created.json()["moderation_status"] == "pending" assert created.json()["moderation_status"] == "pending"
report_id = created.json()["id"] report_id = created.json()["id"]
with Session(engine) as db:
components = db.scalars(select(CatchTackleComponent).where(CatchTackleComponent.catch_report_id == UUID(report_id)).order_by(CatchTackleComponent.position)).all()
assert [(component.role, component.raw_value) for component in components] == [("lure", "Новая приманка"), ("rig", "Спиннинг")]
headers = {"Authorization": "Bearer change-me-in-production"} headers = {"Authorization": "Bearer change-me-in-production"}
pending = client.get("/api/v1/admin/catch-reports", headers=headers) pending = client.get("/api/v1/admin/catch-reports", headers=headers)
assert pending.status_code == 200 assert pending.status_code == 200
+22 -1
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.catalog_audit import audit_catalog from app.catalog_audit import audit_catalog, audit_waterbody_catalog
from app.database import Base from app.database import Base
from app.models import CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody from app.models import CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody
@@ -24,3 +24,24 @@ def test_catalog_audit_checks_the_whole_catalog() -> None:
assert result["reports"] == 1 assert result["reports"] == 1
assert result["invalid_coordinates"] == 1 assert result["invalid_coordinates"] == 1
assert result["failures"] == 1 assert result["failures"] == 1
def test_waterbody_catalog_audit_reports_snapshot_gaps_without_legacy_rows() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
now = datetime.now(timezone.utc)
db.add_all([
Waterbody(
slug="lake", name_ru="Озеро", source_system="rf4db",
source_external_id="level_001_lake", source_url="https://rf4db.com/ru/maps/level_001_lake",
source_checked_at=now,
),
Waterbody(slug="legacy", name_ru="Старое озеро"),
])
db.commit()
result = audit_waterbody_catalog(db, {"level_001_lake", "level_002_river"})
assert result["expected"] == 2
assert result["observed"] == 1
assert result["missing_source_external_ids"] == ["level_002_river"]
assert result["failures"] == 1
@@ -0,0 +1,31 @@
from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.database import Base
from app.models import CatchReport, CatchTackleComponent, Fish, ModerationStatus, SourceType, Waterbody
def test_catch_keeps_ordered_unresolved_gear_evidence() -> None:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as session:
fish = Fish(slug="pike", name_ru="Щука")
waterbody = Waterbody(slug="lake", name_ru="Озеро")
report = CatchReport(
fish=fish, waterbody=waterbody, weight_g=1000,
reported_at=datetime(2026, 9, 20, tzinfo=timezone.utc), source_type=SourceType.manual_import,
source_confidence=50, moderation_status=ModerationStatus.pending,
)
report.tackle_components.extend([
CatchTackleComponent(role="lure", position=0, raw_value="Spiker #2"),
CatchTackleComponent(role="rig", position=1, raw_value="Method Popup"),
])
session.add(report)
session.commit()
saved = session.get(CatchReport, report.id)
assert saved is not None
assert [(row.position, row.role, row.raw_value) for row in saved.tackle_components] == [
(0, "lure", "Spiker #2"), (1, "rig", "Method Popup"),
]
+105 -4
View File
@@ -7,11 +7,11 @@ import pytest
from sqlalchemy import create_engine, func, select from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.community_importer import CommunityImportError, stage_observations from app.community_importer import CommunityImportError, stage_observations, update_waterbody_detail, update_waterbody_details, upsert_waterbody_catalog
from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases
from app.source_lifecycle import record_scheduled_source_check, record_source_check from app.source_lifecycle import record_scheduled_source_check, record_source_check
from app.database import Base from app.database import Base
from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody from app.models import CatchReport, CatchTackleComponent, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot
@@ -42,6 +42,20 @@ def record(source: str = "rf4db", external_id: str = "catch-1") -> dict[str, obj
} }
def waterbody_row(**overrides: object) -> dict[str, object]:
row: dict[str, object] = {
"source_system": "rf4db",
"source_external_id": "level_001_mosquito",
"source_url": "https://rf4db.com/ru/maps/level_001_mosquito",
"name": "оз. Комариное",
"unlock_level": 1,
"unlock_label": "1",
"fish_species_count": 20,
}
row.update(overrides)
return row
@pytest.fixture @pytest.fixture
def db() -> Session: def db() -> Session:
engine = create_engine("sqlite://") engine = create_engine("sqlite://")
@@ -69,6 +83,77 @@ def test_staging_is_idempotent_and_preserves_first_seen(db: Session) -> None:
assert source is not None and source.enabled is True assert source is not None and source.enabled is True
def test_waterbody_catalog_upsert_is_idempotent_and_non_destructive(db: Session) -> None:
first = datetime(2026, 9, 16, 10, tzinfo=timezone.utc)
assert upsert_waterbody_catalog(db, [waterbody_row()], fetched_at=first) == (1, 0)
item = db.scalar(select(Waterbody).where(Waterbody.source_external_id == "level_001_mosquito"))
assert item is not None
assert item.slug == "оз-комариное"
assert item.source_checked_at.replace(tzinfo=timezone.utc) == first
assert item.fish_species_count == 20
assert upsert_waterbody_catalog(db, [waterbody_row(name="Озеро Комариное", unlock_level=2)], fetched_at=first) == (0, 1)
item = db.scalar(select(Waterbody).where(Waterbody.source_external_id == "level_001_mosquito"))
assert item is not None
assert (item.name_ru, item.unlock_level, item.fish_species_count) == ("Озеро Комариное", 2, 20)
assert db.scalar(select(Waterbody).where(Waterbody.name_ru == "оз. Комариное")) is None
def test_waterbody_catalog_rejects_untrusted_source(db: Session) -> None:
with pytest.raises(CommunityImportError, match="source_url"):
upsert_waterbody_catalog(db, [waterbody_row(source_url="https://example.test/map")])
def test_waterbody_detail_updates_only_imported_identity_without_media_roles(db: Session) -> None:
upsert_waterbody_catalog(db, [waterbody_row()])
assert update_waterbody_detail(db, {
"source_system": "rf4db",
"source_external_id": "level_001_mosquito",
"source_url": "https://rf4db.com/ru/maps/level_001_mosquito",
"name": "оз. Комариное",
"description": "Каменистые берега.",
"aliases": ["Комариное", "Комариное"],
"fish_species": ["Щука", "Окунь"],
"image_urls": ["https://oss.rf4db.com/map.webp"],
"point_urls": ["https://rf4db.com/ru/maps/level_001_mosquito/spots/12-34"],
}) is True
item = db.scalar(select(Waterbody).where(Waterbody.source_external_id == "level_001_mosquito"))
assert item is not None
assert item.source_aliases == ["Комариное"]
assert item.source_fish_species == ["Щука", "Окунь"]
assert item.source_image_urls == ["https://oss.rf4db.com/map.webp"]
def test_waterbody_detail_accepts_authorized_download_subdomain(db: Session) -> None:
upsert_waterbody_catalog(db, [waterbody_row()])
assert update_waterbody_detail(db, {
"source_system": "rf4db",
"source_external_id": "level_001_mosquito",
"source_url": "https://download.rf4db.com/ru/maps/level_001_mosquito",
"name": "оз. Комариное",
"description": None,
"aliases": [],
"fish_species": ["Щука"],
"image_urls": [],
"point_urls": [],
}) is True
def test_waterbody_detail_batch_validates_before_writing(db: Session) -> None:
upsert_waterbody_catalog(db, [waterbody_row()])
valid = {
"source_system": "rf4db", "source_external_id": "level_001_mosquito",
"source_url": "https://rf4db.com/ru/maps/level_001_mosquito", "name": "оз. Комариное",
"description": "Описание", "aliases": [], "fish_species": ["Щука"],
"image_urls": [], "point_urls": [],
}
invalid = valid | {"source_external_id": "unknown", "source_url": "https://example.test/map"}
with pytest.raises(CommunityImportError, match="source_url"):
update_waterbody_details(db, [valid, invalid])
item = db.scalar(select(Waterbody).where(Waterbody.source_external_id == "level_001_mosquito"))
assert item is not None and item.description is None
def test_external_ids_are_isolated_by_source(db: Session) -> None: def test_external_ids_are_isolated_by_source(db: Session) -> None:
created, updated = stage_observations(db, [record("rf4db"), record("rf4stat-fishing")]) created, updated = stage_observations(db, [record("rf4db"), record("rf4stat-fishing")])
@@ -104,14 +189,28 @@ def test_complete_observation_with_reviewed_aliases_is_published(db: Session) ->
assert item.catch_report is not None assert item.catch_report is not None
assert item.catch_report.fish_id == fish.id assert item.catch_report.fish_id == fish.id
assert item.catch_report.waterbody_id == waterbody.id assert item.catch_report.waterbody_id == waterbody.id
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
assert stage_observations(db, [record() | {"weight_g": 5_000}]) == (0, 1) assert stage_observations(db, [record() | {"weight_g": 5_000}]) == (0, 1)
db.refresh(item) db.refresh(item)
assert item.status == "published" assert item.status == "published"
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1 assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
def test_observation_preserves_coordinate_text_and_precision(db: Session) -> None:
stage_observations(db, [record() | {
"source_external_id": "coordinate-area",
"x": None, "y": None, "coordinate_raw": "северная бухта",
"coordinate_precision": "area", "weight_g": None,
}])
item = db.scalar(select(ExternalObservation).where(ExternalObservation.source_external_id == "coordinate-area"))
assert item is not None
assert (item.coordinate_raw, item.coordinate_precision, item.x, item.y) == ("северная бухта", "area", None, None)
def test_coordinate_precision_rejects_unknown_value(db: Session) -> None:
with pytest.raises(CommunityImportError, match="invalid coordinate_precision"):
stage_observations(db, [record() | {"coordinate_precision": "guess"}])
def test_changed_published_record_requires_review_and_reuses_report(db: Session) -> None: def test_changed_published_record_requires_review_and_reuses_report(db: Session) -> None:
fish = Fish(slug="pike", name_ru="Щука") fish = Fish(slug="pike", name_ru="Щука")
water = Waterbody(slug="test-lake", name_ru="Тестовое озеро") water = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
@@ -140,6 +239,8 @@ def test_changed_published_record_requires_review_and_reuses_report(db: Session)
assert updated.id == report_id assert updated.id == report_id
assert updated.weight_g == 6000 assert updated.weight_g == 6000
assert updated.moderation_status.value == "approved" assert updated.moderation_status.value == "approved"
components = db.scalars(select(CatchTackleComponent).order_by(CatchTackleComponent.position)).all()
assert [(component.position, component.raw_value) for component in components] == [(0, "Приманка")]
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1 assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
+2 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.database import Base from app.database import Base
from app.importer import FetchResult, ImportAlreadyRunning, ImportSourceError, _lock_key, _official_import_lock, import_records, parse_html from app.importer import FetchResult, ImportAlreadyRunning, ImportSourceError, _lock_key, _official_import_lock, import_records, parse_html
from app.models import CatchReport, ImportStatus, OfficialRecordImport, SourceType from app.models import CatchReport, CatchTackleComponent, ImportStatus, OfficialRecordImport, SourceType
FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.html" FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.html"
@@ -50,6 +50,7 @@ def test_parser_and_import_are_idempotent() -> None:
# A12: Second import of identical data creates no events (no fields changed) # A12: Second import of identical data creates no events (no fields changed)
assert (second.rows_created, second.rows_updated) == (0, 0) assert (second.rows_created, second.rows_updated) == (0, 0)
assert db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record)) == 2 assert db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record)) == 2
assert db.scalar(select(func.count()).select_from(CatchTackleComponent)) == 2
assert db.scalar(select(func.count()).select_from(OfficialRecordImport)) == 2 assert db.scalar(select(func.count()).select_from(OfficialRecordImport)) == 2
+32
View File
@@ -0,0 +1,32 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.database import Base
from app.models import Rig, RigComponent, TackleItem
from app.routers.catalog import rig_detail, tackle_item, tackle_items
def test_tackle_catalog_filters_details_and_missing_fields() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
item = TackleItem(
name="API тестовая блесна", normalized_name="api тестовая блесна",
category="lure", subcategory="spinner", brand="RF4", family=None,
unlock_level=0, source_system="fixture", source_external_id="api-lure-1",
source_url="https://example.test/lure/api-lure-1",
)
rig = Rig(name="API тестовый монтаж", normalized_name="api тестовый монтаж", source_system="fixture")
rig.components.append(RigComponent(role="lure", position=0, tackle_item=item, raw_value=item.name))
db.add(rig)
db.commit()
page = tackle_items(db, category="lure", brand="RF4", family=None, unlock_level=None, limit=10, offset=0)
assert page.total == 1
assert page.items[0].missing_fields == ["family", "source_checked_at"]
assert tackle_item(item.id, db).name == item.name
details = rig_detail(rig.id, db)
assert details.components[0].raw_value == item.name
assert details.missing_fields == ["source_url", "source_checked_at"]
engine.dispose()
+35
View File
@@ -0,0 +1,35 @@
import uuid
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.database import Base
from app.models import Rig, RigComponent, TackleItem
def test_tackle_item_and_rig_components_keep_provenance_and_legacy_independence() -> None:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
item_id = uuid.uuid4()
rig_id = uuid.uuid4()
with Session(engine) as session:
item = TackleItem(
id=item_id, name="Spiker #2", normalized_name="spiker #2",
category="lure", subcategory="spinner", brand="RF4", family="spoon",
unlock_level=0, source_system="rf4db", source_external_id="spiker-2",
source_url="https://rf4db.com/ru/wiki/lures/spiker-2",
raw_payload={"weight": {"state": "value", "value": 0}},
)
rig = Rig(
id=rig_id, name="Method Popup", normalized_name="method popup",
source_system="rf4db", source_external_id="method-popup",
)
rig.components.append(RigComponent(role="lure", position=0, tackle_item=item, raw_value="Spiker #2"))
session.add(rig)
session.commit()
saved = session.get(TackleItem, item_id)
assert saved is not None
assert saved.unlock_level == 0
assert saved.raw_payload == {"weight": {"state": "value", "value": 0}}
assert saved.rig_components[0].rig_id == rig_id
+2 -1
View File
@@ -8,6 +8,7 @@ import TackleGlyph from "./TackleGlyph.astro";
const { item } = Astro.props as { item: Activity }; const { item } = Astro.props as { item: Activity };
const level = activityLevel(item.activity_score); const level = activityLevel(item.activity_score);
const limited = item.catches < 3; const limited = item.catches < 3;
const precision = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" }[item.coordinate_precision];
--- ---
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={spotPath(item)}> <a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={spotPath(item)}>
<span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span> <span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
@@ -15,7 +16,7 @@ const limited = item.catches < 3;
<div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill" data-activity-level={level.short}><i></i>{level.short}</span>{limited && <span class="data-quality">Данных мало</span>}</div> <div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill" data-activity-level={level.short}><i></i>{level.short}</span>{limited && <span class="data-quality">Данных мало</span>}</div>
<h3>{item.fish}</h3> <h3>{item.fish}</h3>
<FishSilhouette name={item.fish}/> <FishSilhouette name={item.fish}/>
<div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div> <div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y} · {precision}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div>
<p class="data-note">{item.explanation}</p> <p class="data-note">{item.explanation}</p>
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} confidence={item.confidence_score}/> <DataPassport sources={item.sources} observedAt={item.last_confirmed_at} confidence={item.confidence_score}/>
<div class="bait-line"><TackleGlyph name={item.best_bait}/><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div> <div class="bait-line"><TackleGlyph name={item.best_bait}/><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div>
+7 -1
View File
@@ -4,6 +4,12 @@ import type { MediaAsset } from "../lib/api";
const { asset, compact = false, sourceLink = false } = Astro.props as { asset: MediaAsset; compact?: boolean; sourceLink?: boolean }; const { asset, compact = false, sourceLink = false } = Astro.props as { asset: MediaAsset; compact?: boolean; sourceLink?: boolean };
--- ---
<figure class:list={["entity-media", { "entity-media--compact": compact }]}> <figure class:list={["entity-media", { "entity-media--compact": compact }]}>
<span class="entity-media__frame"><img src={asset.image_url} alt={asset.label ?? "Иллюстрация RF4"} width={asset.width} height={asset.height} loading="lazy" decoding="async" /></span> <span class="entity-media__frame"><picture>
{(["avif", "webp"] as const).map(format => {
const variants = (asset.variants ?? []).filter(item => item.format === format);
return variants.length ? <source type={`image/${format}`} srcset={variants.map(item => `${item.url} ${item.width}w`).join(", ")} sizes={compact ? "180px" : "(max-width: 720px) 100vw, 720px"} /> : null;
})}
<img src={asset.image_url} alt={asset.label ?? "Иллюстрация RF4"} width={asset.width} height={asset.height} loading="lazy" decoding="async" />
</picture></span>
<figcaption><SourceBadge source={asset.source_system} href={sourceLink ? asset.source_url : undefined} /><span>{asset.label ?? "Справочный материал"}</span></figcaption> <figcaption><SourceBadge source={asset.source_system} href={sourceLink ? asset.source_url : undefined} /><span>{asset.label ?? "Справочный материал"}</span></figcaption>
</figure> </figure>
+27
View File
@@ -0,0 +1,27 @@
---
import { pageHref, pageWindow } from "../lib/pagination";
interface Props {
path: string;
params: URLSearchParams;
total: number;
limit: number;
offset: number;
anchor?: string;
itemLabel?: string;
}
const { path, params, total, limit, offset, anchor = "", itemLabel = "записей" } = Astro.props;
const state = pageWindow(total, limit, offset);
---
{total > limit && <nav class="pagination" aria-label="Пагинация">
<span>{state.start}{state.end} из {total} {itemLabel} · страница {state.page} из {state.pages}</span>
<div>
{state.previousOffset !== null
? <a data-action="secondary" rel="prev" href={pageHref(path, params, state.previousOffset, anchor)}>← Предыдущая</a>
: <span class="pagination__disabled" aria-disabled="true">← Предыдущая</span>}
{state.nextOffset !== null
? <a data-action="secondary" rel="next" href={pageHref(path, params, state.nextOffset, anchor)}>Следующая →</a>
: <span class="pagination__disabled" aria-disabled="true">Следующая →</span>}
</div>
</nav>}
+1 -1
View File
@@ -121,7 +121,7 @@ Astro.response.headers.set("Content-Security-Policy", [
<a class="skip-link" href="#main-content">Перейти к содержимому</a> <a class="skip-link" href="#main-content">Перейти к содержимому</a>
<header class="topbar"> <header class="topbar">
<a href="/" class="brand"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><strong>RF4 Spotter</strong><span>Ни хвоста, ни чешуи</span></span></a> <a href="/" class="brand"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><strong>RF4 Spotter</strong><span>Ни хвоста, ни чешуи</span></span></a>
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/waterbodies") || path.startsWith("/fish")}} aria-current={path.startsWith("/waterbodies") || path.startsWith("/fish") ? "page" : undefined} href="/waterbodies"><FishingIcon name="ripple"/> <span>Каталог</span></a><a class:list={{active:path.startsWith("/media")}} aria-current={path.startsWith("/media") ? "page" : undefined} href="/media"><FishingIcon name="lure"/> <span>Медиатека</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav> <nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/waterbodies") || path.startsWith("/fish")}} aria-current={path.startsWith("/waterbodies") || path.startsWith("/fish") ? "page" : undefined} href="/waterbodies"><FishingIcon name="ripple"/> <span>Каталог</span></a><a class:list={{active:path.startsWith("/media") || path.startsWith("/admin/media")}} aria-current={path.startsWith("/media") || path.startsWith("/admin/media") ? "page" : undefined} href="/media"><FishingIcon name="lure"/> <span>Медиатека</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
<div class="header-tools"> <div class="header-tools">
<p class="live-badge"><span></span> Свежие данные и честная оценка</p> <p class="live-badge"><span></span> Свежие данные и честная оценка</p>
<div class="theme-switcher" role="group" aria-label="Цветовая тема"> <div class="theme-switcher" role="group" aria-label="Цветовая тема">
+7 -3
View File
@@ -4,6 +4,7 @@ export type Activity = {
unique_players: number; average_weight_g: number; max_weight_g: number; unique_players: number; average_weight_g: number; max_weight_g: number;
last_confirmed_at: string; activity_score: number; confidence_score: number; last_confirmed_at: string; activity_score: number; confidence_score: number;
explanation: string; sources: string[]; explanation: string; sources: string[];
coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[];
}; };
export type PaginatedActivity = { export type PaginatedActivity = {
@@ -13,9 +14,11 @@ export type PaginatedActivity = {
offset: number; offset: number;
}; };
export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: number; y: number; description: string | null; catches_24h: number; catches_3d: number; catches_7d: number; top_baits: string[] }; export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: number; y: number; description: string | null; catches_24h: number; catches_3d: number; catches_7d: number; top_baits: string[]; coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[] };
export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null }; export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null };
export type DictionaryItem = { id: string; slug: string; name_ru: string }; export type DictionaryItem = { id: string; slug: string; name_ru: string; unlock_level?: number | null; fish_species_count?: number | null; source_system?: string | null; source_external_id?: string | null; source_url?: string | null; description?: string | null; source_aliases?: string[] | null; source_fish_species?: string[] | null; source_image_urls?: string[] | null; source_point_urls?: string[] | null; source_checked_at?: string | null };
export type TackleItem = { id: string; name: string; category: string; subcategory: string | null; brand: string | null; family: string | null; unlock_level: number | null; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[] };
export type PaginatedTackleItems = { items: TackleItem[]; total: number; limit: number; offset: number };
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null; source_system: string }; export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null; source_system: string };
export type PaginatedOfficialRecord = { export type PaginatedOfficialRecord = {
items: OfficialRecord[]; items: OfficialRecord[];
@@ -26,7 +29,8 @@ export type PaginatedOfficialRecord = {
export type PublicObservation = { id: string; source_system: string; source_name: string; source_url: string; fish_name: string; waterbody_name: string; x: number | null; y: number | null; weight_g: number | null; last_seen_at: string; missing_fields: string[]; quality: "incomplete" | "unverified" }; export type PublicObservation = { id: string; source_system: string; source_name: string; source_url: string; fish_name: string; waterbody_name: string; x: number | null; y: number | null; weight_g: number | null; last_seen_at: string; missing_fields: string[]; quality: "incomplete" | "unverified" };
export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null }; export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null };
export type SourceStatus = { source_system: string; name: string; status: "healthy" | "stale" | "temporarily_limited" | "source_changed" | "waiting" | "disabled"; last_started_at: string | null; last_success_at: string | null; observations: number }; export type SourceStatus = { source_system: string; name: string; status: "healthy" | "stale" | "temporarily_limited" | "source_changed" | "waiting" | "disabled"; last_started_at: string | null; last_success_at: string | null; observations: number };
export type MediaAsset = { id: string; entity_type: "fish" | "waterbody" | "tackle" | "reference"; entity_key: string; label: string | null; width: number; height: number; content_type: string; image_url: string; source_system: string; source_url: string }; export type MediaVariant = { role: "card" | "detail"; format: "webp" | "avif"; width: number; height: number; url: string };
export type MediaAsset = { id: string; entity_type: "fish" | "waterbody" | "tackle" | "reference"; entity_key: string; label: string | null; width: number; height: number; content_type: string; image_url: string; source_system: string; source_url: string; variants?: MediaVariant[] };
export const spotPath = (item: Pick<Activity, "waterbody_slug" | "x" | "y">) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`; export const spotPath = (item: Pick<Activity, "waterbody_slug" | "x" | "y">) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`;
+31
View File
@@ -0,0 +1,31 @@
export type PageWindow = {
start: number;
end: number;
page: number;
pages: number;
previousOffset: number | null;
nextOffset: number | null;
};
export const pageWindow = (total: number, limit: number, offset: number): PageWindow => {
const safeTotal = Math.max(0, Math.trunc(total));
const safeLimit = Math.max(1, Math.trunc(limit));
const safeOffset = Math.max(0, Math.trunc(offset));
const end = Math.min(safeOffset + safeLimit, safeTotal);
return {
start: safeTotal > 0 && safeOffset < safeTotal ? safeOffset + 1 : 0,
end,
page: Math.floor(safeOffset / safeLimit) + 1,
pages: Math.max(1, Math.ceil(safeTotal / safeLimit)),
previousOffset: safeOffset > 0 ? Math.max(0, safeOffset - safeLimit) : null,
nextOffset: end < safeTotal ? safeOffset + safeLimit : null,
};
};
export const pageHref = (path: string, search: URLSearchParams, offset: number, anchor = "") => {
const params = new URLSearchParams(search);
if (offset > 0) params.set("offset", String(offset));
else params.delete("offset");
const query = params.toString();
return `${path}${query ? `?${query}` : ""}${anchor}`;
};
+4 -2
View File
@@ -8,7 +8,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<section class="moderation-app" data-api-url={apiUrl}> <section class="moderation-app" data-api-url={apiUrl}>
<AdminNav /> <AdminNav />
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть медиатеку</button></form> <form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть медиатеку</button></form>
<p class="privacy">Публичные файлы не переключаются из этого экрана. Токен хранится только в памяти страницы.</p> <p class="privacy">Публикация требует явной причины и атомарно сохраняет fallback для rollback. Токен хранится только в памяти страницы.</p>
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div> <div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
<form class="admin-queue-filters" hidden><label>Тип<select name="entity_type"><option value="">Все типы</option><option value="fish">Рыбы</option><option value="waterbody">Водоёмы</option><option value="tackle">Снасти</option><option value="reference">Справка</option></select></label><label>Состояние<select name="status"><option value="">Все состояния</option><option value="approved">Approved</option><option value="upgrade_queued">Upgrade queued</option><option value="upgrade_stored">Upgrade stored</option></select></label><button data-action="primary" type="submit">Применить</button></form> <form class="admin-queue-filters" hidden><label>Тип<select name="entity_type"><option value="">Все типы</option><option value="fish">Рыбы</option><option value="waterbody">Водоёмы</option><option value="tackle">Снасти</option><option value="reference">Справка</option></select></label><label>Состояние<select name="status"><option value="">Все состояния</option><option value="approved">Approved</option><option value="upgrade_queued">Upgrade queued</option><option value="upgrade_stored">Upgrade stored</option></select></label><button data-action="primary" type="submit">Применить</button></form>
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><section class="media-library__grid" data-media-list aria-live="polite"></section> <div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><section class="media-library__grid" data-media-list aria-live="polite"></section>
@@ -47,7 +47,9 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
if (!rows.length && offset > 0) { offset = 0; return load(); } if (!rows.length && offset > 0) { offset = 0; return load(); }
const assets = rows.slice(0, 50); if (pages) pages.hidden = !assets.length; if (previous) previous.disabled = offset === 0; if (next) next.disabled = rows.length <= 50; if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`; const assets = rows.slice(0, 50); if (pages) pages.hidden = !assets.length; if (previous) previous.disabled = offset === 0; if (next) next.disabled = rows.length <= 50; if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
if (!assets.length) { list.innerHTML = '<div class="state"><h2>Кандидатов нет</h2><p>Для выбранных фильтров нет approved или upgrade_queued файлов.</p></div>'; return; } if (!assets.length) { list.innerHTML = '<div class="state"><h2>Кандидатов нет</h2><p>Для выбранных фильтров нет approved или upgrade_queued файлов.</p></div>'; return; }
list.innerHTML = assets.map(asset => { const image = url(asset.image_url); const source = url(asset.source_url); const variants = (asset.derivatives as Record<string, unknown>[] ?? []).map(item => `${esc(item.format)} ${esc(item.width)}×${esc(item.height)}`).join(", "); return `<article class="media-library__card"><a href="${image}" target="_blank" rel="noreferrer">${image ? `<img src="${image}" alt="${esc(asset.label)}" loading="lazy" />` : ""}</a><h2>${esc(asset.label)}</h2><span>${esc(asset.status)} · ${esc(asset.entity_type)} · ${esc(asset.width)}×${esc(asset.height)}</span><p>${esc(asset.source_system)}${asset.duplicate_of ? ` · duplicate_of ${esc(asset.duplicate_of)}` : ""}</p>${variants ? `<small>Производные: ${variants}</small>` : "<small>Производных нет</small>"}${source ? `<a href="${source}" target="_blank" rel="noreferrer">Первоисточник →</a>` : ""}</article>`; }).join(""); list.innerHTML = assets.map(asset => { const image = url(asset.image_url); const source = url(asset.source_url); const variants = (asset.derivatives as Record<string, unknown>[] ?? []).map(item => `${esc(item.format)} ${esc(item.width)}×${esc(item.height)}`).join(", "); const rollback = asset.status === "approved" && asset.supersedes ? `<button data-media-rollback="${esc(asset.asset_url)}" type="button" data-action="secondary">Откатить замену</button>` : ""; return `<article class="media-library__card"><a href="${image}" target="_blank" rel="noreferrer">${image ? `<img src="${image}" alt="${esc(asset.label)}" loading="lazy" />` : ""}</a><h2>${esc(asset.label)}</h2><span>${esc(asset.status)} · ${esc(asset.entity_type)} · ${esc(asset.width)}×${esc(asset.height)}</span><p>${esc(asset.source_system)}${asset.duplicate_of ? ` · duplicate_of ${esc(asset.duplicate_of)}` : ""}</p>${variants ? `<small>Производные: ${variants}</small>` : "<small>Производных нет</small>"}${source ? `<a href="${source}" target="_blank" rel="noreferrer">Первоисточник →</a>` : ""}${rollback}</article>`; }).join("");
list.querySelectorAll<HTMLButtonElement>("[data-media-rollback]").forEach(button => button.addEventListener("click", async () => { const note = window.prompt("Причина отката:"); if (!note?.trim()) return; button.disabled = true; try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/media/upgrades/rollback`, {method:"POST", headers:{Authorization:`Bearer ${token}`, "Content-Type":"application/json"}, body:JSON.stringify({asset_url:button.dataset.mediaRollback, note:note.trim()})}); if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось выполнить откат.")); succeed("Fallback восстановлен."); await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось выполнить откат."); } finally { button.disabled = false; } }));
if (assets.some(asset => asset.status === "upgrade_stored")) { const publish = document.createElement("button"); publish.type = "button"; publish.dataset.action = "primary"; publish.textContent = "Опубликовать сохранённые замены"; publish.addEventListener("click", async () => { const note = window.prompt("Причина публикации замен:"); if (!note?.trim()) return; publish.disabled = true; try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/media/upgrades/publish`, {method:"POST", headers:{Authorization:`Bearer ${token}`, "Content-Type":"application/json"}, body:JSON.stringify({note:note.trim()})}); if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось опубликовать замены.")); succeed("Замены опубликованы атомарно, fallback сохранены."); await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось опубликовать замены."); } finally { publish.disabled = false; } }); list.prepend(publish); }
} }
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); offset = 0; try { await load(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { list && (list.innerHTML = ""); fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } }); login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); offset = 0; try { await load(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { list && (list.innerHTML = ""); fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } }); filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });
+14 -4
View File
@@ -4,10 +4,19 @@ import FishSilhouette from "../../components/FishSilhouette.astro";
import EntityMedia from "../../components/EntityMedia.astro"; import EntityMedia from "../../components/EntityMedia.astro";
import PageHero from "../../components/PageHero.astro"; import PageHero from "../../components/PageHero.astro";
import StatePanel from "../../components/StatePanel.astro"; import StatePanel from "../../components/StatePanel.astro";
import Pagination from "../../components/Pagination.astro";
import { api, type DictionaryItem, type MediaAsset } from "../../lib/api"; import { api, type DictionaryItem, type MediaAsset } from "../../lib/api";
import { findMediaByLabel } from "../../lib/media"; import { findMediaByLabel } from "../../lib/media";
let fishes: DictionaryItem[] = [], media: MediaAsset[] = [], unavailable = false; const params = Astro.url.searchParams;
try { [fishes, media] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=fish")]); } catch { unavailable = true; } const pageLimit = 48;
const requestedOffset = Number(params.get("offset") ?? 0);
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
let fishes: DictionaryItem[] = [], allFishes: DictionaryItem[] = [], media: MediaAsset[] = [], unavailable = false;
try {
[allFishes, media] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=fish")]);
if (allFishes.length > 0 && offset >= allFishes.length) offset = Math.floor((allFishes.length - 1) / pageLimit) * pageLimit;
fishes = allFishes.slice(offset, offset + pageLimit);
} catch { unavailable = true; }
if (unavailable) { if (unavailable) {
Astro.response.status = 503; Astro.response.status = 503;
Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Retry-After", "60");
@@ -15,6 +24,7 @@ if (unavailable) {
} }
--- ---
<Layout title="Все виды рыб Russian Fishing 4 — RF4 Spotter" description="Каталог рыб RF4 со свежими точками, уловами, приманками и прозрачными источниками данных."> <Layout title="Все виды рыб Russian Fishing 4 — RF4 Spotter" description="Каталог рыб RF4 со свежими точками, уловами, приманками и прозрачными источниками данных.">
<PageHero eyebrow="Справочник RF4" title="Рыбы" description="Выберите вид, чтобы увидеть свежие подтверждённые точки и полевые сигналы." variant="fish" count={fishes.length} /> <PageHero eyebrow="Справочник RF4" title="Рыбы" description="Выберите вид, чтобы увидеть свежие подтверждённые точки и полевые сигналы." variant="fish" count={allFishes.length} />
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid content-grid" aria-label="Виды рыб">{fishes.map((fish,index) => { const asset = findMediaByLabel(media, fish.name_ru); return <a href={`/fish/${fish.slug}`}><span>Вид рыбы · {String(index + 1).padStart(2,"0")}</span><strong>{fish.name_ru}</strong><i>Открыть <b>→</b></i>{asset ? <EntityMedia asset={asset} compact /> : <FishSilhouette name={fish.name_ru} size={92}/>}</a>; })}</nav>} {unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid content-grid" aria-label="Виды рыб">{fishes.map((fish,index) => { const asset = findMediaByLabel(media, fish.name_ru); return <a href={`/fish/${fish.slug}`}><span>Вид рыбы · {String(offset + index + 1).padStart(2,"0")}</span><strong>{fish.name_ru}</strong><i>Открыть <b>→</b></i>{asset ? <EntityMedia asset={asset} compact /> : <FishSilhouette name={fish.name_ru} size={92}/>}</a>; })}</nav>}
{!unavailable && <Pagination path="/fish" params={params} total={allFishes.length} limit={pageLimit} offset={offset} itemLabel="видов" />}
</Layout> </Layout>
+12 -4
View File
@@ -6,6 +6,7 @@ import SourceBadge from "../components/SourceBadge.astro";
import TackleGlyph from "../components/TackleGlyph.astro"; import TackleGlyph from "../components/TackleGlyph.astro";
import SignalFeed from "../components/SignalFeed.astro"; import SignalFeed from "../components/SignalFeed.astro";
import StatePanel from "../components/StatePanel.astro"; import StatePanel from "../components/StatePanel.astro";
import Pagination from "../components/Pagination.astro";
import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PaginatedActivity, type PublicObservation } from "../lib/api"; import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PaginatedActivity, type PublicObservation } from "../lib/api";
const params = Astro.url.searchParams; const params = Astro.url.searchParams;
@@ -13,8 +14,9 @@ const hours = params.get("hours") ?? "24";
const waterbody = params.get("waterbody") ?? ""; const waterbody = params.get("waterbody") ?? "";
const fish = params.get("fish") ?? ""; const fish = params.get("fish") ?? "";
const sort = params.get("sort") ?? "activity"; const sort = params.get("sort") ?? "activity";
const activityLimit = 20;
const requestedOffset = Number(params.get("offset") ?? 0); const requestedOffset = Number(params.get("offset") ?? 0);
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0; let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
const requestedSignalLimit = Number(params.get("signals") ?? 12); const requestedSignalLimit = Number(params.get("signals") ?? 12);
const signalLimit = Number.isInteger(requestedSignalLimit) ? Math.min(48, Math.max(12, requestedSignalLimit)) : 12; const signalLimit = Number.isInteger(requestedSignalLimit) ? Math.min(48, Math.max(12, requestedSignalLimit)) : 12;
let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = []; let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
@@ -38,11 +40,16 @@ if (!signalsUnavailable) {
signals = signalRows.slice(0, signalLimit); signals = signalRows.slice(0, signalLimit);
} }
if (!filterError) { if (!filterError) {
const query = new URLSearchParams({ hours, waterbody, fish, sort, limit: "20", offset: String(offset) }); const query = new URLSearchParams({ hours, waterbody, fish, sort, limit: String(activityLimit), offset: String(offset) });
try { try {
const paginated = await api<PaginatedActivity>(`/api/v1/activity?${query}`); const paginated = await api<PaginatedActivity>(`/api/v1/activity?${query}`);
items = offset > 0 ? [...items, ...paginated.items] : paginated.items; items = paginated.items;
totalItems = paginated.total; totalItems = paginated.total;
if (totalItems > 0 && offset >= totalItems) {
offset = Math.floor((totalItems - 1) / activityLimit) * activityLimit;
query.set("offset", String(offset));
items = (await api<PaginatedActivity>(`/api/v1/activity?${query}`)).items;
}
} catch { activityUnavailable = true; } } catch { activityUnavailable = true; }
} }
const catalogUnavailable = fishCatalogUnavailable || waterCatalogUnavailable; const catalogUnavailable = fishCatalogUnavailable || waterCatalogUnavailable;
@@ -86,9 +93,10 @@ const datasetJsonLd = {
<button data-action="primary">⌕ Найти клёв</button> <button data-action="primary">⌕ Найти клёв</button>
</form></section> </form></section>
<div class="active-filters content-grid" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить</a>}</div> <div class="active-filters content-grid" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить</a>}</div>
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : activityUnavailable ? <StatePanel contained={false} tone="unavailable" title="Горячие точки временно недоступны" description="Полевые сигналы и справочники продолжают работать независимо." /> : items.length ? <><div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div>{offset + items.length < totalItems && <a class="load-more" data-action="secondary" href={`/?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}#results`}>Показать ещё <span>{offset + items.length} из {totalItems}</span> ↓</a>}</> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div> <section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} на странице · всего {totalItems}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : activityUnavailable ? <StatePanel contained={false} tone="unavailable" title="Горячие точки временно недоступны" description="Полевые сигналы и справочники продолжают работать независимо." /> : items.length ? <div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
{items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class:list={["float-gauge", `meter-level-${leaderMeter}`]} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><TackleGlyph name={items[0].best_bait}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>} {items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class:list={["float-gauge", `meter-level-${leaderMeter}`]} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><TackleGlyph name={items[0].best_bait}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
</section> </section>
{!filterError && !activityUnavailable && <Pagination path="/" params={params} total={totalItems} limit={activityLimit} offset={offset} anchor="#results" itemLabel="точек" />}
{signals.length > 0 && <SignalFeed signals={signals}/>} {signals.length > 0 && <SignalFeed signals={signals}/>}
{signalsUnavailable && <div class="content-grid"><StatePanel tone="unavailable" title="Полевые сигналы временно недоступны" description="Горячие точки и справочники продолжают работать независимо." /></div>} {signalsUnavailable && <div class="content-grid"><StatePanel tone="unavailable" title="Полевые сигналы временно недоступны" description="Горячие точки и справочники продолжают работать независимо." /></div>}
{hasMoreSignals && signalLimit < 48 && <a class="signal-more" data-action="secondary" href={moreSignalsHref}>Показать ещё <span>{signalLimit} из доступных</span> ↓</a>} {hasMoreSignals && signalLimit < 48 && <a class="signal-more" data-action="secondary" href={moreSignalsHref}>Показать ещё <span>{signalLimit} из доступных</span> ↓</a>}
+12 -5
View File
@@ -4,19 +4,26 @@ import SourceBadge from "../components/SourceBadge.astro";
import SectionHeading from "../components/SectionHeading.astro"; import SectionHeading from "../components/SectionHeading.astro";
import StatePanel from "../components/StatePanel.astro"; import StatePanel from "../components/StatePanel.astro";
import TackleGlyph from "../components/TackleGlyph.astro"; import TackleGlyph from "../components/TackleGlyph.astro";
import Pagination from "../components/Pagination.astro";
import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord, type PaginatedOfficialRecord } from "../lib/api"; import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord, type PaginatedOfficialRecord } from "../lib/api";
const params = Astro.url.searchParams; const params = Astro.url.searchParams;
const fish = params.get("fish") ?? ""; const fish = params.get("fish") ?? "";
const waterbody = params.get("waterbody") ?? ""; const waterbody = params.get("waterbody") ?? "";
const recordsLimit = 50;
const requestedOffset = Number(params.get("offset") ?? 0); const requestedOffset = Number(params.get("offset") ?? 0);
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0; let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
let items: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = []; let items: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
let unavailable = false, showNoIndex = false, totalRecords = 0; let unavailable = false, showNoIndex = false, totalRecords = 0;
try { try {
const query = new URLSearchParams({ fish, waterbody, limit: "50", offset: String(offset) }); const query = new URLSearchParams({ fish, waterbody, limit: String(recordsLimit), offset: String(offset) });
const paginated = await api<PaginatedOfficialRecord>(`/api/v1/records?${query}`); const paginated = await api<PaginatedOfficialRecord>(`/api/v1/records?${query}`);
items = offset > 0 ? [...items, ...paginated.items] : paginated.items; items = paginated.items;
totalRecords = paginated.total; totalRecords = paginated.total;
if (totalRecords > 0 && offset >= totalRecords) {
offset = Math.floor((totalRecords - 1) / recordsLimit) * recordsLimit;
query.set("offset", String(offset));
items = (await api<PaginatedOfficialRecord>(`/api/v1/records?${query}`)).items;
}
[runs, fishes, waterbodies] = await Promise.all([api<ImportRun[]>("/api/v1/imports?limit=1"), api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); [runs, fishes, waterbodies] = await Promise.all([api<ImportRun[]>("/api/v1/imports?limit=1"), api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]);
} catch { unavailable = true; showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); } } catch { unavailable = true; showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); }
const last = runs[0]; const last = runs[0];
@@ -24,10 +31,10 @@ const last = runs[0];
<Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник." noindex={showNoIndex} errorPage={unavailable}> <Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник." noindex={showNoIndex} errorPage={unavailable}>
<section class="records-hero"><div><span class="eyebrow">Публичные данные RF4</span><h1>Официальные<br/><em>рекорды</em></h1></div><div class="source-status"><span class:list={["status-dot", last?.status]}></span><strong>{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}</strong>{last?.finished_at && <small>{new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк</small>}</div></section> <section class="records-hero"><div><span class="eyebrow">Публичные данные RF4</span><h1>Официальные<br/><em>рекорды</em></h1></div><div class="source-status"><span class:list={["status-dot", last?.status]}></span><strong>{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}</strong>{last?.finished_at && <small>{new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк</small>}</div></section>
<form class="record-filters" method="get"><label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><button data-action="primary">Фильтровать</button>{(fish || waterbody) && <a data-action="quiet" href="/records">Сбросить</a>}</form> <form class="record-filters" method="get"><label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><button data-action="primary">Фильтровать</button>{(fish || waterbody) && <a data-action="quiet" href="/records">Сбросить</a>}</form>
<div class="content-grid"><SectionHeading eyebrow="Официальный источник" title="Последние записи" count={`${items.length} из ${totalRecords} записей`} /></div> <div class="content-grid"><SectionHeading eyebrow="Официальный источник" title="Последние записи" count={`${items.length} на странице · всего ${totalRecords}`} /></div>
{unavailable && <StatePanel tone="unavailable" title="Источник временно недоступен" description="Сохраняем ранее импортированные данные, но не показываем их как свежее обновление." />} {unavailable && <StatePanel tone="unavailable" title="Источник временно недоступен" description="Сохраняем ранее импортированные данные, но не показываем их как свежее обновление." />}
{!unavailable && items.length && <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата и источник</span></div>{items.map(record => <article class="record-row"><strong data-label="Рыба">{record.fish}</strong><strong data-label="Вес">{kg(record.weight_g)}</strong><span data-label="Водоём">{record.waterbody}</span><span class="tackle-label" data-label="Приманка"><TackleGlyph name={record.bait} size={24}/><span>{record.bait ?? "—"}</span></span><span data-label="Игрок">{record.player_name ?? "—"}</span><span data-label="Дата и источник" class="record-provenance"><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time><SourceBadge source={record.source_system} href={record.source_url}/></span></article>)}</div>} {!unavailable && items.length && <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата и источник</span></div>{items.map(record => <article class="record-row"><strong data-label="Рыба">{record.fish}</strong><strong data-label="Вес">{kg(record.weight_g)}</strong><span data-label="Водоём">{record.waterbody}</span><span class="tackle-label" data-label="Приманка"><TackleGlyph name={record.bait} size={24}/><span>{record.bait ?? "—"}</span></span><span data-label="Игрок">{record.player_name ?? "—"}</span><span data-label="Дата и источник" class="record-provenance"><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time><SourceBadge source={record.source_system} href={record.source_url}/></span></article>)}</div>}
{!unavailable && items.length && offset + items.length < totalRecords && <a class="load-more" data-action="secondary" href={`/records?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}`}>Показать ещё <span>{offset + items.length} из {totalRecords}</span> ↓</a>} {!unavailable && <Pagination path="/records" params={params} total={totalRecords} limit={recordsLimit} offset={offset} itemLabel="записей" />}
{!unavailable && !items.length && <StatePanel title="Рекорды ещё не импортированы" description="Для выбранных условий записей пока нет." actionHref="/records" actionLabel="Сбросить фильтры" />} {!unavailable && !items.length && <StatePanel title="Рекорды ещё не импортированы" description="Для выбранных условий записей пока нет." actionHref="/records" actionLabel="Сбросить фильтры" />}
<p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p> <p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p>
</Layout> </Layout>
+2 -1
View File
@@ -25,6 +25,7 @@ try {
if (Astro.response.status === 503) Astro.response.headers.set("Retry-After", "60"); if (Astro.response.status === 503) Astro.response.headers.set("Retry-After", "60");
} }
const level = activity ? activityLevel(activity.activity_score) : null; const level = activity ? activityLevel(activity.activity_score) : null;
const coordinatePrecision = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" } as const;
const spotDescription = spot ? `Свежие уловы и активность на точке ${spot.x}:${spot.y}, ${spot.waterbody}: рыба, вес, приманки и источники данных.` : "Данные точки ловли Russian Fishing 4."; const spotDescription = spot ? `Свежие уловы и активность на точке ${spot.x}:${spot.y}, ${spot.waterbody}: рыба, вес, приманки и источники данных.` : "Данные точки ловли Russian Fishing 4.";
const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [
{ "@type": "ListItem", position: 1, name: "Сейчас клюёт", item: "https://rf4spotter.ru/" }, { "@type": "ListItem", position: 1, name: "Сейчас клюёт", item: "https://rf4spotter.ru/" },
@@ -34,7 +35,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}> <Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}>
<AtlasBreadcrumbs items={[{ label: "Сейчас клюёт", href: "/" }, ...(spot ? [{ label: spot.waterbody, href: `/waterbodies/${spot.waterbody_slug}` }, { label: `Точка ${spot.x}:${spot.y}` }] : [{ label: "Точка недоступна" }])]} /> <AtlasBreadcrumbs items={[{ label: "Сейчас клюёт", href: "/" }, ...(spot ? [{ label: spot.waterbody, href: `/waterbodies/${spot.waterbody_slug}` }, { label: `Точка ${spot.x}:${spot.y}` }] : [{ label: "Точка недоступна" }])]} />
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <> {unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><button class="coordinate-copy" data-action="inverse" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><small class="copy-status" aria-live="polite"></small></div><CoordinateRadar x={spot.x} y={spot.y}/></section> <section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><p class="coordinate-precision">Точность координат: <strong>{coordinatePrecision[spot.coordinate_precision as keyof typeof coordinatePrecision] ?? "не указаны"}</strong></p><button class="coordinate-copy" data-action="inverse" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><small class="copy-status" aria-live="polite"></small></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div> <div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
<ActivityTimeline buckets={timeline}/> <ActivityTimeline buckets={timeline}/>
<div class="activity-legend" aria-label="Уровни активности"><span>Тихо</span><span>Есть сигналы</span><span>Горячо</span></div> <div class="activity-legend" aria-label="Уровни активности"><span>Тихо</span><span>Есть сигналы</span><span>Горячо</span></div>
+40
View File
@@ -0,0 +1,40 @@
---
import Layout from "../../layouts/Layout.astro";
import PageHero from "../../components/PageHero.astro";
import StatePanel from "../../components/StatePanel.astro";
import Pagination from "../../components/Pagination.astro";
import TackleGlyph from "../../components/TackleGlyph.astro";
import { api, type PaginatedTackleItems } from "../../lib/api";
const params = Astro.url.searchParams;
const category = params.get("category") ?? "";
const brand = params.get("brand") ?? "";
const family = params.get("family") ?? "";
const requestedOffset = Number(params.get("offset") ?? 0);
const limit = 48;
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
const query = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (category) query.set("category", category);
if (brand) query.set("brand", brand);
if (family) query.set("family", family);
let result: PaginatedTackleItems = { items: [], total: 0, limit, offset }, unavailable = false;
try { result = await api<PaginatedTackleItems>(`/api/v1/tackle/items?${query}`); } catch { unavailable = true; }
if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); }
const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Монтаж", float: "Поплавок", sinker: "Груз", other: "Другое" };
const filterParams = new URLSearchParams();
if (category) filterParams.set("category", category);
if (brand) filterParams.set("brand", brand);
if (family) filterParams.set("family", family);
---
<Layout title="Снасти и приманки RF4 — RF4 Spotter" description="Канонический каталог снастей, приманок и монтажей Russian Fishing 4 с источниками и отметками неполноты.">
<PageHero eyebrow="Канонический справочник" title="Снасти и приманки" description="Показываем только подтверждённые карточки. Пустые поля явно отмечены и не заменяются догадками." variant="fish" count={result.total} />
<form class="record-filters" method="get" aria-label="Фильтры каталога снастей">
<label>Категория<select name="category"><option value="">Все категории</option>{Object.entries(categoryLabels).map(([value, label]) => <option value={value} selected={category === value}>{label}</option>)}</select></label>
<label>Бренд<input name="brand" value={brand} maxlength="100" placeholder="Например, RF4" /></label>
<label>Семейство<input name="family" value={family} maxlength="100" placeholder="Название семейства" /></label>
<button data-action="primary">Фильтровать</button>
{(category || brand || family) && <a data-action="quiet" href="/tackle">Сбросить</a>}
</form>
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенные карточки. Попробуйте обновить страницу позже." /> : result.items.length ? <section class="catalog-grid content-grid" aria-label="Карточки снастей">{result.items.map(item => <a href={`/tackle/items/${item.id}`}><TackleGlyph name={item.name} size={32} /><span>{categoryLabels[item.category] ?? item.category}</span><strong>{item.name}</strong><small>{[item.brand, item.family].filter(Boolean).join(" · ") || "Характеристики уточняются"}</small></a>)}</section> : <StatePanel title="Подтверждённых карточек пока нет" description="Каталог заполнится после разрешённой загрузки и ручной проверки источников." />}
{!unavailable && <Pagination path="/tackle" params={filterParams} total={result.total} limit={limit} offset={offset} itemLabel="карточек" />}
</Layout>
@@ -30,5 +30,6 @@ const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPag
<AtlasBreadcrumbs items={[{ label: "Водоёмы", href: "/waterbodies" }, { label: water?.name_ru ?? "Не найдено" }]} /> <AtlasBreadcrumbs items={[{ label: "Водоёмы", href: "/waterbodies" }, { label: water?.name_ru ?? "Не найдено" }]} />
<PageHero eyebrow="Свежие данные за 72 часа" title={water?.name_ru ?? "Водоём не найден"} description={water ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} для ${fishes.length} ${plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.` : undefined} variant={water ? "water" : undefined} identity={water?.slug} /> <PageHero eyebrow="Свежие данные за 72 часа" title={water?.name_ru ?? "Водоём не найден"} description={water ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} для ${fishes.length} ${plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.` : undefined} variant={water ? "water" : undefined} identity={water?.slug} />
{image && <section class="entity-feature content-grid" aria-label={`Изображение: ${water!.name_ru}`}><EntityMedia asset={image} sourceLink /><div><span class="overline">Карта и образ водоёма</span><h2>{water!.name_ru}</h2><p>Материал показан с прямой атрибуцией. Координаты активных точек ниже относятся к данным наблюдений, а не к геометрии изображения.</p></div></section>} {image && <section class="entity-feature content-grid" aria-label={`Изображение: ${water!.name_ru}`}><EntityMedia asset={image} sourceLink /><div><span class="overline">Карта и образ водоёма</span><h2>{water!.name_ru}</h2><p>Материал показан с прямой атрибуцией. Координаты активных точек ниже относятся к данным наблюдений, а не к геометрии изображения.</p></div></section>}
{water && (water.description || water.unlock_level !== undefined || water.source_url || water.source_fish_species) && <section class="data-passport content-grid" aria-label="Паспорт водоёма"><div><span class="overline">Канонические сведения</span><h2>{water.name_ru}</h2>{water.description && <p>{water.description}</p>}{water.unlock_level !== undefined && water.unlock_level !== null && <p>Открывается с уровня: <strong>{water.unlock_level}</strong></p>}{water.source_fish_species?.length && <p>В карточке источника указано видов рыб: <strong>{water.source_fish_species.length}</strong>.</p>}{water.source_aliases?.length && <p>Алиасы источника: {water.source_aliases.join(", ")}.</p>}</div><div><span class="overline">Источник</span>{water.source_url ? <p><a href={water.source_url} rel="noreferrer">{water.source_system ?? "Внешний источник"} · исходная карточка</a></p> : <p>Источник для описания ещё не подтверждён.</p>}{water.source_point_urls?.length && <p>Сохранено ссылок на точки: <strong>{water.source_point_urls.length}</strong>.</p>}{water.source_image_urls?.length && <p>Изображений-кандидатов: <strong>{water.source_image_urls.length}</strong>; публикация требует отдельной проверки.</p>}{water.source_checked_at && <p>Проверено: {new Date(water.source_checked_at).toLocaleDateString("ru-RU")}</p>}</div></section>}
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !water ? <StatePanel tone="error" title="Такого водоёма нет в справочнике" actionHref="/waterbodies" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <AtlasEntityLink href={`/waterbodies/${water!.slug}/${fishSlug}`} label={name} kind="fish" identity={name} />)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>} {unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !water ? <StatePanel tone="error" title="Такого водоёма нет в справочнике" actionHref="/waterbodies" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <AtlasEntityLink href={`/waterbodies/${water!.slug}/${fishSlug}`} label={name} kind="fish" identity={name} />)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
</Layout> </Layout>
+14 -4
View File
@@ -3,9 +3,18 @@ import Layout from "../../layouts/Layout.astro";
import PageHero from "../../components/PageHero.astro"; import PageHero from "../../components/PageHero.astro";
import StatePanel from "../../components/StatePanel.astro"; import StatePanel from "../../components/StatePanel.astro";
import WaterbodyMark from "../../components/WaterbodyMark.astro"; import WaterbodyMark from "../../components/WaterbodyMark.astro";
import Pagination from "../../components/Pagination.astro";
import { api, type DictionaryItem } from "../../lib/api"; import { api, type DictionaryItem } from "../../lib/api";
let waters: DictionaryItem[] = [], unavailable = false; const params = Astro.url.searchParams;
try { waters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"); } catch { unavailable = true; } const pageLimit = 48;
const requestedOffset = Number(params.get("offset") ?? 0);
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
let waters: DictionaryItem[] = [], allWaters: DictionaryItem[] = [], unavailable = false;
try {
allWaters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500");
if (allWaters.length > 0 && offset >= allWaters.length) offset = Math.floor((allWaters.length - 1) / pageLimit) * pageLimit;
waters = allWaters.slice(offset, offset + pageLimit);
} catch { unavailable = true; }
if (unavailable) { if (unavailable) {
Astro.response.status = 503; Astro.response.status = 503;
Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Retry-After", "60");
@@ -13,6 +22,7 @@ if (unavailable) {
} }
--- ---
<Layout title="Все водоёмы Russian Fishing 4 — RF4 Spotter" description="Каталог водоёмов RF4 со свежими точками, рыбами, приманками и прозрачными источниками данных."> <Layout title="Все водоёмы Russian Fishing 4 — RF4 Spotter" description="Каталог водоёмов RF4 со свежими точками, рыбами, приманками и прозрачными источниками данных.">
<PageHero eyebrow="Карта водоёмов RF4" title="Водоёмы" description="Откройте водоём, чтобы увидеть активные виды рыб и последние подтверждённые точки." variant="water" count={waters.length} /> <PageHero eyebrow="Карта водоёмов RF4" title="Водоёмы" description="Откройте водоём, чтобы увидеть активные виды рыб и последние подтверждённые точки." variant="water" count={allWaters.length} />
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid catalog-grid--waters content-grid" aria-label="Водоёмы">{waters.map((water,index) => <a href={`/waterbodies/${water.slug}`}><span>Водоём · {String(index + 1).padStart(2,"0")}</span><strong>{water.name_ru}</strong><i>Открыть <b>→</b></i><WaterbodyMark identity={water.slug}/></a>)}</nav>} {unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid catalog-grid--waters content-grid" aria-label="Водоёмы">{waters.map((water,index) => <a href={`/waterbodies/${water.slug}`}><span>Водоём · {String(offset + index + 1).padStart(2,"0")}</span><strong>{water.name_ru}</strong><i>Открыть <b>→</b></i><WaterbodyMark identity={water.slug}/></a>)}</nav>}
{!unavailable && <Pagination path="/waterbodies" params={params} total={allWaters.length} limit={pageLimit} offset={offset} itemLabel="водоёмов" />}
</Layout> </Layout>
+9
View File
@@ -23,6 +23,15 @@
footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32px,calc((100vw - 1360px)/2));display:grid;grid-template-columns:1fr 1fr auto;align-items:center;gap:28px}footer .brand-mark{border-color:#ffffff32}footer .brand-name span{color:#9dafaa}footer p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)} footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32px,calc((100vw - 1360px)/2));display:grid;grid-template-columns:1fr 1fr auto;align-items:center;gap:28px}footer .brand-mark{border-color:#ffffff32}footer .brand-name span{color:#9dafaa}footer p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)}
@media(max-width:1040px){.content-grid{width:min(100% - 36px,900px)}.topbar{width:calc(100% - 36px);grid-template-columns:1fr auto}.live-badge{display:none}.intro{grid-template-columns:1fr;gap:34px;padding-top:54px}.lake-card{height:280px}.dashboard{grid-template-columns:1fr}.detail-card{position:relative;top:auto}.how-it-works{grid-template-columns:1fr}.records-hero,.form-hero{display:block}.records-hero>div:last-child,.form-hero>p{margin-top:25px}} @media(max-width:1040px){.content-grid{width:min(100% - 36px,900px)}.topbar{width:calc(100% - 36px);grid-template-columns:1fr auto}.live-badge{display:none}.intro{grid-template-columns:1fr;gap:34px;padding-top:54px}.lake-card{height:280px}.dashboard{grid-template-columns:1fr}.detail-card{position:relative;top:auto}.how-it-works{grid-template-columns:1fr}.records-hero,.form-hero{display:block}.records-hero>div:last-child,.form-hero>p{margin-top:25px}}
@media(max-width:720px){.content-grid,.records-hero,.form-hero,.record-filters,.record-table,.official-note,.report-form,.spot-hero,.periods,.detail-grid{width:calc(100% - 28px)}.topbar{width:100%;padding:13px 14px 0;display:flex;flex-wrap:wrap;height:auto}.topbar .brand{flex:1}.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}.topbar nav a{flex:0 0 auto;font-size:13px}.intro h1,.records-hero h1,.form-hero h1{font-size:55px}.intro{padding:30px 0 25px}.intro h1{font-size:47px;margin:13px 0 12px}.intro-copy>p{font-size:15px;margin:0}.lake-card{display:none}.filters-wrap{position:relative;padding:17px 0}.filters{grid-template-columns:1fr 1fr}.filter-advanced-field{display:none}.filter-compact-hidden{display:none!important}.filter-advanced-fallback{display:block;grid-column:1/-1;border-top:1px solid #ffffff1d;padding-top:10px}.filter-advanced-fallback summary{display:flex;justify-content:space-between;color:#d5dfdc;font-size:12px;cursor:pointer;list-style:none}.filter-advanced-fallback summary::-webkit-details-marker{display:none}.filter-advanced-fallback summary:before{content:"";margin-right:7px;color:var(--lime)}.filter-advanced-fallback[open] summary:before{content:""}.filter-advanced-fallback summary span{margin-left:auto;color:#9fb0ad}.filter-advanced-fallback .advanced-fields{display:grid!important;grid-template-columns:1fr 1fr;gap:12px;padding-top:12px}.filter-advanced-fallback:not([open]) .advanced-fields{display:none!important}.filters button{grid-column:1/-1}.active-filters{min-height:0;overflow-x:auto;padding:10px 14px 0;width:100%;scrollbar-width:none}.active-filters span{flex:0 0 auto}.active-filters a{position:sticky;right:0;padding:6px 10px;background:var(--paper)}.dashboard{padding:28px 0 74px;scroll-margin-top:10px}.spot-card{grid-template-columns:34px 1fr;padding:18px 18px 18px 14px;gap:10px}.spot-stats{grid-column:2;border:0;border-top:1px solid #e2e8e2;padding:13px 0 0;grid-template-columns:repeat(4,1fr)}.card-arrow{display:none}.detail-score{grid-template-columns:105px 1fr}.score-ring{width:100px;height:100px}.principles{grid-template-columns:1fr}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}.form-grid,.detail-grid{grid-template-columns:1fr}.spot-hero{padding:28px}.spot-hero h1{font-size:48px}.pin{display:none}footer{grid-template-columns:1fr auto;padding:30px 20px}footer p{grid-column:1/-1;order:3}} @media(max-width:720px){.content-grid,.records-hero,.form-hero,.record-filters,.record-table,.official-note,.report-form,.spot-hero,.periods,.detail-grid{width:calc(100% - 28px)}.topbar{width:100%;padding:13px 14px 0;display:flex;flex-wrap:wrap;height:auto}.topbar .brand{flex:1}.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}.topbar nav a{flex:0 0 auto;font-size:13px}.intro h1,.records-hero h1,.form-hero h1{font-size:55px}.intro{padding:30px 0 25px}.intro h1{font-size:47px;margin:13px 0 12px}.intro-copy>p{font-size:15px;margin:0}.lake-card{display:none}.filters-wrap{position:relative;padding:17px 0}.filters{grid-template-columns:1fr 1fr}.filter-advanced-field{display:none}.filter-compact-hidden{display:none!important}.filter-advanced-fallback{display:block;grid-column:1/-1;border-top:1px solid #ffffff1d;padding-top:10px}.filter-advanced-fallback summary{display:flex;justify-content:space-between;color:#d5dfdc;font-size:12px;cursor:pointer;list-style:none}.filter-advanced-fallback summary::-webkit-details-marker{display:none}.filter-advanced-fallback summary:before{content:"";margin-right:7px;color:var(--lime)}.filter-advanced-fallback[open] summary:before{content:""}.filter-advanced-fallback summary span{margin-left:auto;color:#9fb0ad}.filter-advanced-fallback .advanced-fields{display:grid!important;grid-template-columns:1fr 1fr;gap:12px;padding-top:12px}.filter-advanced-fallback:not([open]) .advanced-fields{display:none!important}.filters button{grid-column:1/-1}.active-filters{min-height:0;overflow-x:auto;padding:10px 14px 0;width:100%;scrollbar-width:none}.active-filters span{flex:0 0 auto}.active-filters a{position:sticky;right:0;padding:6px 10px;background:var(--paper)}.dashboard{padding:28px 0 74px;scroll-margin-top:10px}.spot-card{grid-template-columns:34px 1fr;padding:18px 18px 18px 14px;gap:10px}.spot-stats{grid-column:2;border:0;border-top:1px solid #e2e8e2;padding:13px 0 0;grid-template-columns:repeat(4,1fr)}.card-arrow{display:none}.detail-score{grid-template-columns:105px 1fr}.score-ring{width:100px;height:100px}.principles{grid-template-columns:1fr}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}.form-grid,.detail-grid{grid-template-columns:1fr}.spot-hero{padding:28px}.spot-hero h1{font-size:48px}.pin{display:none}footer{grid-template-columns:1fr auto;padding:30px 20px}footer p{grid-column:1/-1;order:3}}
/* The tablet header needs the same two-row layout as mobile: the full theme
switcher and horizontal navigation cannot share one 720px row. */
@media (min-width:721px) and (max-width:900px){
.topbar{width:100%;padding:13px 24px 0;display:flex;flex-wrap:wrap;height:auto}
.topbar .brand{flex:1;min-width:0}
.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}
.topbar nav a{flex:0 0 auto;font-size:13px}
}
@media(max-width:720px){.moderation-app{width:calc(100% - 28px)}.moderation-card{grid-template-columns:1fr}.moderation-proof{grid-row:2}.moderation-actions{grid-column:1;display:block}.moderation-actions>div{margin-top:12px}.admin-login{display:block}.admin-login button{width:100%;margin-top:12px}} @media(max-width:720px){.moderation-app{width:calc(100% - 28px)}.moderation-card{grid-template-columns:1fr}.moderation-proof{grid-row:2}.moderation-actions{grid-column:1;display:block}.moderation-actions>div{margin-top:12px}.admin-login{display:block}.admin-login button{width:100%;margin-top:12px}}
@media(max-width:480px){.filters{grid-template-columns:1fr 1fr}.spot-stats strong{font-size:16px}.topbar nav{gap:4px}.topbar nav a{padding:0 5px}.brand-name strong{font-size:14px}.brand-name span{font-size:11px}.moderation-actions>div{display:grid}.moderation-actions button{width:100%}} @media(max-width:480px){.filters{grid-template-columns:1fr 1fr}.spot-stats strong{font-size:16px}.topbar nav{gap:4px}.topbar nav a{padding:0 5px}.brand-name strong{font-size:14px}.brand-name span{font-size:11px}.moderation-actions>div{display:grid}.moderation-actions button{width:100%}}
@media(max-width:720px){.filter-advanced-fallback .filter-advanced-field{display:flex!important;align-items:center;gap:6px}.filter-advanced-fallback .filter-compact-hidden{display:flex!important}} @media(max-width:720px){.filter-advanced-fallback .filter-advanced-field{display:flex!important;align-items:center;gap:6px}.filter-advanced-fallback .filter-compact-hidden{display:flex!important}}
+2
View File
@@ -1,2 +1,4 @@
.entity-media{margin:0;min-width:0}.entity-media__frame{position:relative;display:grid;place-items:center;overflow:hidden;min-height:190px;padding:18px;border:1px solid var(--border-soft);border-radius:16px;background:radial-gradient(circle at 50% 46%,color-mix(in srgb,var(--lime) 15%,var(--surface)) 0 18%,var(--surface-soft) 62%)}.entity-media__frame:after{content:"";position:absolute;inset:12px;border:1px solid color-mix(in srgb,var(--border) 55%,transparent);border-radius:11px;pointer-events:none}.entity-media img{position:relative;z-index:1;display:block;width:100%;height:180px;object-fit:contain;filter:drop-shadow(0 12px 18px #08222624);image-rendering:auto}.entity-media figcaption{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:9px;color:var(--text-muted);font-size:11px}.entity-media figcaption>span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.entity-media--compact{grid-column:2;grid-row:1/4;width:118px}.entity-media--compact .entity-media__frame{min-height:88px;height:88px;padding:10px;border:0;background:color-mix(in srgb,var(--lime) 8%,var(--surface-soft))}.entity-media--compact img{height:72px}.entity-media--compact figcaption{justify-content:flex-end}.entity-media--compact figcaption>span:last-child{display:none}.entity-media--compact .source-chip{transform:scale(.9);transform-origin:right center}.media-library{padding:34px 0 100px}.media-library__intro{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:24px;color:var(--text-muted)}.media-library__grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.media-library__card{padding:12px;border:1px solid var(--border-soft);border-radius:18px;background:var(--surface)}.media-library__card .entity-media__frame{min-height:170px}.media-library__card h2{margin:12px 3px 3px;font:400 19px Georgia,serif}.media-library__card>span{margin-left:3px;color:var(--text-subtle);font-size:10px;text-transform:uppercase;letter-spacing:.09em}@media(max-width:1050px){.media-library__grid{grid-template-columns:repeat(3,1fr)}}@media(max-width:720px){.media-library__grid{grid-template-columns:repeat(2,1fr)}.entity-media--compact{width:90px}.entity-media--compact .entity-media__frame{height:76px}.media-library__intro{display:block}}@media(max-width:440px){.media-library__grid{grid-template-columns:1fr}} .entity-media{margin:0;min-width:0}.entity-media__frame{position:relative;display:grid;place-items:center;overflow:hidden;min-height:190px;padding:18px;border:1px solid var(--border-soft);border-radius:16px;background:radial-gradient(circle at 50% 46%,color-mix(in srgb,var(--lime) 15%,var(--surface)) 0 18%,var(--surface-soft) 62%)}.entity-media__frame:after{content:"";position:absolute;inset:12px;border:1px solid color-mix(in srgb,var(--border) 55%,transparent);border-radius:11px;pointer-events:none}.entity-media img{position:relative;z-index:1;display:block;width:100%;height:180px;object-fit:contain;filter:drop-shadow(0 12px 18px #08222624);image-rendering:auto}.entity-media figcaption{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:9px;color:var(--text-muted);font-size:11px}.entity-media figcaption>span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.entity-media--compact{grid-column:2;grid-row:1/4;width:118px}.entity-media--compact .entity-media__frame{min-height:88px;height:88px;padding:10px;border:0;background:color-mix(in srgb,var(--lime) 8%,var(--surface-soft))}.entity-media--compact img{height:72px}.entity-media--compact figcaption{justify-content:flex-end}.entity-media--compact figcaption>span:last-child{display:none}.entity-media--compact .source-chip{transform:scale(.9);transform-origin:right center}.media-library{padding:34px 0 100px}.media-library__intro{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:24px;color:var(--text-muted)}.media-library__grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.media-library__card{padding:12px;border:1px solid var(--border-soft);border-radius:18px;background:var(--surface)}.media-library__card .entity-media__frame{min-height:170px}.media-library__card h2{margin:12px 3px 3px;font:400 19px Georgia,serif}.media-library__card>span{margin-left:3px;color:var(--text-subtle);font-size:10px;text-transform:uppercase;letter-spacing:.09em}@media(max-width:1050px){.media-library__grid{grid-template-columns:repeat(3,1fr)}}@media(max-width:720px){.media-library__grid{grid-template-columns:repeat(2,1fr)}.entity-media--compact{width:90px}.entity-media--compact .entity-media__frame{height:76px}.media-library__intro{display:block}}@media(max-width:440px){.media-library__grid{grid-template-columns:1fr}}
.entity-feature{display:grid;grid-template-columns:minmax(280px,420px) 1fr;align-items:center;gap:48px;padding-block:36px;border-bottom:1px solid var(--border)}.entity-feature .entity-media__frame{min-height:260px}.entity-feature .entity-media img{height:230px}.entity-feature h2{margin:10px 0 12px;font:400 clamp(34px,4vw,58px)/.95 Georgia,serif}.entity-feature p{max-width:620px;color:var(--text-muted);line-height:1.6}@media(max-width:720px){.entity-feature{grid-template-columns:1fr;gap:24px;padding-block:26px}} .entity-feature{display:grid;grid-template-columns:minmax(280px,420px) 1fr;align-items:center;gap:48px;padding-block:36px;border-bottom:1px solid var(--border)}.entity-feature .entity-media__frame{min-height:260px}.entity-feature .entity-media img{height:230px}.entity-feature h2{margin:10px 0 12px;font:400 clamp(34px,4vw,58px)/.95 Georgia,serif}.entity-feature p{max-width:620px;color:var(--text-muted);line-height:1.6}@media(max-width:720px){.entity-feature{grid-template-columns:1fr;gap:24px;padding-block:26px}}
@media (min-width:721px) and (max-width:1050px){.media-library__grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
+1
View File
@@ -0,0 +1 @@
.pagination{width:min(1360px,calc(100% - 64px));margin:22px auto 48px;display:flex;align-items:center;justify-content:space-between;gap:16px;color:var(--text-secondary);font-size:13px}.pagination>div{display:flex;gap:10px}.pagination a,.pagination__disabled{display:inline-flex;align-items:center;min-height:42px;padding:10px 15px;border:1px solid var(--border-strong);border-radius:10px;background:var(--surface-elevated);color:var(--text-secondary);text-decoration:none}.pagination a:hover{background:var(--surface-raised);color:var(--text-primary)}.pagination__disabled{opacity:.5}@media(max-width:720px){.pagination{width:calc(100% - 28px);align-items:stretch;flex-direction:column}.pagination>div{display:grid;grid-template-columns:1fr 1fr}.pagination a,.pagination__disabled{justify-content:center;text-align:center}}
+2 -2
View File
@@ -39,11 +39,11 @@
--status-warning: light-dark(#9a741d, #e8c768); --status-warning: light-dark(#9a741d, #e8c768);
--status-changed: light-dark(#a44236, #ff9587); --status-changed: light-dark(#a44236, #ff9587);
--source-official: light-dark(#815713, #e3b65b); --source-official: light-dark(#815713, #e3b65b);
--source-rf4db: light-dark(#286581, #74bee3); --source-rf4db: light-dark(#245d78, #74bee3);
--source-rf4stat: light-dark(#60499a, #b7a2ed); --source-rf4stat: light-dark(#60499a, #b7a2ed);
--source-rf4map: light-dark(#2f704f, #79c79e); --source-rf4map: light-dark(#2f704f, #79c79e);
--source-rf4posts: light-dark(#984a33, #ee9a7e); --source-rf4posts: light-dark(#984a33, #ee9a7e);
--source-players: light-dark(#526b1c, #add066); --source-players: light-dark(#4b6419, #add066);
--graphic-line: light-dark(#3f7779, #79b5b8); --graphic-line: light-dark(#3f7779, #79b5b8);
--graphic-grid: light-dark(#dce5de, #294244); --graphic-grid: light-dark(#dce5de, #294244);
--tackle-orange: light-dark(#b96b27, #f0a55f); --tackle-orange: light-dark(#b96b27, #f0a55f);
+50
View File
@@ -11,3 +11,53 @@ for (const path of [
expect(results.violations.filter(item => ["critical", "serious"].includes(item.impact ?? ""))).toEqual([]); expect(results.violations.filter(item => ["critical", "serious"].includes(item.impact ?? ""))).toEqual([]);
}); });
} }
test("admin keyboard navigation keeps focus out of the document body", async ({ page }) => {
await page.goto("/admin");
await page.keyboard.press("Tab");
await expect(page.locator(".skip-link")).toBeFocused();
const focusedLabels: string[] = [];
for (let index = 0; index < 20; index += 1) {
await page.keyboard.press("Tab");
focusedLabels.push(await page.evaluate(() => {
const active = document.activeElement;
if (active?.tagName === "BODY") return "BODY";
if (active?.matches(".admin-login input")) return "ADMIN_TOKEN_INPUT";
if (active?.matches(".admin-login button")) return "ADMIN_PANEL_BUTTON";
return (active?.getAttribute("aria-label") || active?.textContent || active?.tagName || "").trim().slice(0, 80);
}));
}
expect(focusedLabels).not.toContain("BODY");
expect(focusedLabels).toContain("ADMIN_TOKEN_INPUT");
expect(focusedLabels).toContain("ADMIN_PANEL_BUTTON");
});
test("print media keeps a light color scheme and removes hero image filters", async ({ page }) => {
await page.emulateMedia({ media: "print" });
await page.goto("/");
const printState = await page.evaluate(() => ({
colorScheme: getComputedStyle(document.documentElement).colorScheme,
heroFilter: document.querySelector(".lake-card img") ? getComputedStyle(document.querySelector(".lake-card img")!).filter : "none",
}));
expect(printState.colorScheme).toBe("light");
expect(printState.heroFilter).toBe("none");
});
test("forced colors keeps theme controls visibly bordered", async ({ page }) => {
await page.emulateMedia({ forcedColors: "active" });
await page.goto("/admin");
const forcedColorsState = await page.evaluate(() => {
const control = document.querySelector(".theme-switcher button");
const pressed = document.querySelector(".theme-switcher button[aria-pressed='true']");
return {
borderStyle: control ? getComputedStyle(control).borderStyle : "",
borderWidth: control ? getComputedStyle(control).borderWidth : "",
activeBackground: pressed ? getComputedStyle(pressed).backgroundColor : "",
};
});
expect(forcedColorsState.borderStyle).toBe("solid");
expect(forcedColorsState.borderWidth).toBe("1px");
expect(forcedColorsState.activeBackground).not.toBe("transparent");
});
+2 -11
View File
@@ -1,19 +1,10 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
test("player can open an active spot", async ({ page }) => { test("player can open an active spot", async ({ page }) => {
await page.goto("/"); await page.goto("/spots/vyunok-321x654");
await expect(page.getByRole("heading", { name: "Выбирай место, пока клюёт." })).toBeVisible();
const activeSpots = page.locator(".spot-card[data-testid]");
await expect(activeSpots).not.toHaveCount(0);
const activeSpot = activeSpots.first();
await expect(activeSpot.locator(".source-chip")).not.toHaveCount(0);
const level = await activeSpot.locator("[data-activity-level]").getAttribute("data-activity-level");
await expect(page.locator(".detail-score [data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
await activeSpot.click();
await expect(page.getByRole("heading", { name: /^Точка / })).toBeVisible(); await expect(page.getByRole("heading", { name: /^Точка / })).toBeVisible();
await expect(page.getByText("Точность координат:", { exact: false })).toBeVisible();
await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible();
await expect(page.locator(".catch-list .source-chip")).not.toHaveCount(0);
await expect(page.locator("[data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
}); });
test("submitted catch appears publicly only after moderation", async ({ page }) => { test("submitted catch appears publicly only after moderation", async ({ page }) => {
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from "@playwright/test";
const routes = [
"/", "/waterbodies", "/tackle", "/records", "/report", "/status", "/media", "/rules",
"/waterbodies/р-вьюнок", "/fish/pike", "/admin", "/admin/moderation",
"/admin/external-sources", "/admin/media",
];
const viewports = [
{ width: 320, height: 800 },
{ width: 390, height: 844 },
{ width: 768, height: 900 },
{ width: 1280, height: 900 },
];
const themes = ["system", "light", "dark"] as const;
test("ROADMAP visual matrix keeps routes within the viewport", async ({ page, context }) => {
for (const theme of themes) {
await context.clearCookies();
if (theme !== "system") {
await context.addCookies([{ name: "rf4-theme", value: theme, url: "http://127.0.0.1:4321" }]);
}
await page.emulateMedia({ colorScheme: theme === "system" ? "light" : theme });
for (const viewport of viewports) {
await page.setViewportSize(viewport);
for (const route of routes) {
await page.goto(route);
const state = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
hasMain: Boolean(document.querySelector("main")),
hasHeading: Boolean(document.querySelector("h1")),
theme: document.documentElement.dataset.theme || "system",
}));
expect(state.scrollWidth, `${theme} ${viewport.width}px ${route} overflows`).toBeLessThanOrEqual(state.clientWidth);
expect(state.hasMain, `${route} has no main landmark`).toBe(true);
expect(state.hasHeading, `${route} has no h1`).toBe(true);
expect(state.theme, `${theme} SSR theme mismatch on ${route}`).toBe(theme);
}
}
}
});
+2 -2
View File
@@ -84,7 +84,7 @@ services:
- >- - >-
test "$$MINIO_ROOT_USER" != "$$S3_ACCESS_KEY" && test "$$MINIO_ROOT_USER" != "$$S3_ACCESS_KEY" &&
test "$$MINIO_ROOT_PASSWORD" != "$$S3_SECRET_KEY" && test "$$MINIO_ROOT_PASSWORD" != "$$S3_SECRET_KEY" &&
mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" >/dev/null && attempt=0; until mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" >/dev/null 2>&1; do attempt=$$((attempt + 1)); test "$$attempt" -lt 30 || exit 1; sleep 1; done &&
mc mb --ignore-existing "local/$$S3_BUCKET" >/dev/null && mc mb --ignore-existing "local/$$S3_BUCKET" >/dev/null &&
printf '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetBucketLocation","s3:ListBucket"],"Resource":["arn:aws:s3:::%s"]},{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject"],"Resource":["arn:aws:s3:::%s/*"]}]}' "$$S3_BUCKET" "$$S3_BUCKET" > /tmp/rf4-app-policy.json && printf '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetBucketLocation","s3:ListBucket"],"Resource":["arn:aws:s3:::%s"]},{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject"],"Resource":["arn:aws:s3:::%s/*"]}]}' "$$S3_BUCKET" "$$S3_BUCKET" > /tmp/rf4-app-policy.json &&
mc admin policy create local rf4-app-bucket /tmp/rf4-app-policy.json >/dev/null && mc admin policy create local rf4-app-bucket /tmp/rf4-app-policy.json >/dev/null &&
@@ -93,7 +93,7 @@ services:
mc admin policy attach local rf4-app-bucket --user "$$S3_ACCESS_KEY" >/dev/null && mc admin policy attach local rf4-app-bucket --user "$$S3_ACCESS_KEY" >/dev/null &&
mc alias set app http://minio:9000 "$$S3_ACCESS_KEY" "$$S3_SECRET_KEY" >/dev/null && mc alias set app http://minio:9000 "$$S3_ACCESS_KEY" "$$S3_SECRET_KEY" >/dev/null &&
mc stat "app/$$S3_BUCKET" >/dev/null && mc stat "app/$$S3_BUCKET" >/dev/null &&
! mc ls app >/dev/null 2>&1 ! mc admin info app >/dev/null 2>&1
environment: environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER} MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD}
+19
View File
@@ -30,6 +30,23 @@ services:
volumes: volumes:
- minio_data:/data - minio_data:/data
minio-init:
image: minio/mc:RELEASE.2025-07-21T05-28-08Z
restart: "no"
entrypoint: ["/bin/sh", "-c"]
command:
- >-
mc alias set local http://minio:9000 "$$S3_ACCESS_KEY" "$$S3_SECRET_KEY" >/dev/null &&
mc mb --ignore-existing "local/$$S3_BUCKET" >/dev/null &&
mc stat "local/$$S3_BUCKET" >/dev/null
environment:
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-rf4-local}
S3_SECRET_KEY: ${S3_SECRET_KEY:-rf4-local-secret}
S3_BUCKET: ${S3_BUCKET:-catch-screenshots}
depends_on:
minio:
condition: service_healthy
api: api:
build: build:
context: . context: .
@@ -55,6 +72,8 @@ services:
condition: service_completed_successfully condition: service_completed_successfully
minio: minio:
condition: service_healthy condition: service_healthy
minio-init:
condition: service_completed_successfully
ports: ports:
- "8000:8000" - "8000:8000"
healthcheck: healthcheck:
@@ -0,0 +1,25 @@
{
"source_system": "rf4db",
"source_external_id": "level_000_home",
"source_url": "https://rf4db.com/ru/maps/level_000_home",
"checked_at": "2026-09-16",
"name": "Дачный пруд",
"description": null,
"aliases": [],
"fish_species": ["Лягушка", "Ротан", "Окунь", "Карась золотой", "Карась серебряный", "Линь", "Карп чешуйчатый"],
"image_urls": [
"https://oss.rf4db.com/game/maps/level_000_home.png",
"https://oss.rf4db.com/game/paper_maps_webp/map_level_000_home.webp"
],
"point_urls": [
"https://rf4db.com/ru/positions/b5bd02d2-997c-466f-ad8d-b9b3be726c18",
"https://rf4db.com/ru/positions/60770f78-815d-4681-9079-d8ece4efc20b",
"https://rf4db.com/ru/positions/5a4e6bb4-b2f7-4085-84ce-b567301c1d80",
"https://rf4db.com/ru/positions/ddf76541-6de2-472f-ad1f-636a232a0f4b",
"https://rf4db.com/ru/positions/c98d9718-2f06-4069-a227-4d1753d4e14a",
"https://rf4db.com/ru/positions/4ac21a3c-0c50-4ca8-b7a5-29481eb034b7",
"https://rf4db.com/ru/positions/efada3fc-9f35-4eb1-aee0-55eeb52c1c90",
"https://rf4db.com/ru/positions/0cb5d601-5c54-40dc-84d9-05c9c51052ce",
"https://rf4db.com/ru/positions/bb4e2977-f4be-4edc-ac7f-476991eb034b7"
]
}
@@ -0,0 +1,12 @@
{
"source_system": "rf4db",
"source_external_id": "level_001_lake",
"source_url": "https://download.rf4db.com/ru/maps/level_001_lake",
"name": "оз. Комариное",
"description": null,
"aliases": [],
"fish_species": ["Уклейка", "Ёрш", "Лягушка", "Вьюн", "Ротан", "Густера", "Плотва обыкновенная", "Окунь", "Карась золотой", "Карась серебряный", "Линь", "Линь золотистый", "Язь", "Голавль", "Лещ", "Щука обыкновенная", "Карп чешуйчатый", "Карп зеркальный", "Сом", "Карп Кои Мидори-гои"],
"fish_external_ids": ["c_bleak", "ruffe", "frog", "vjun", "a.sleeper", "s.bream", "c.roach", "perch", "c.carp", "crucian", "tench", "g_tench", "s.orfe", "e.chub", "bream", "n.pike", "carp", "m_carp", "w_catfish", "midori_g"],
"image_urls": ["https://oss.rf4db.com/game/maps/level_001_lake.png", "https://oss.rf4db.com/game/paper_maps_preview/map_level_001_lake.webp"],
"point_urls": []
}
@@ -0,0 +1,75 @@
{
"source_system": "rf4db",
"source_external_id": "level_002_kirzah_river",
"source_url": "https://download.rf4db.com/ru/maps/level_002_kirzah_river",
"name": "р. Вьюнок",
"description": null,
"aliases": [],
"fish_species": [
"Пескарь обыкновенный",
"Уклейка",
"Ёрш",
"Дрейссена речная",
"Лягушка",
"Перловица",
"Рак речной",
"Елец",
"Ёрш-носарь",
"Ротан",
"Белоглазка",
"Синец",
"Подуст",
"Густера",
"Плотва обыкновенная",
"Берш",
"Окунь",
"Карась золотой",
"Карась серебряный",
"Линь",
"Язь",
"Голавль",
"Лещ",
"Жерех",
"Судак",
"Налим",
"Щука обыкновенная",
"Карп чешуйчатый",
"Сом"
],
"fish_external_ids": [
"gudgeon",
"c_bleak",
"ruffe",
"dreissena",
"frog",
"freshwater_mussel",
"crawfish",
"dace",
"ruffe_n",
"a.sleeper",
"wa_bream",
"b_bream",
"c_nase",
"s.bream",
"c.roach",
"barsch",
"perch",
"c.carp",
"crucian",
"tench",
"s.orfe",
"e.chub",
"bream",
"asp",
"e.pikeperch",
"burbot",
"n.pike",
"carp",
"w_catfish"
],
"image_urls": [
"https://oss.rf4db.com/game/maps/level_002_kirzah_river.png",
"https://oss.rf4db.com/game/paper_maps_preview/map_level_002_kirzah.webp"
],
"point_urls": []
}
@@ -0,0 +1,27 @@
{
"source_system": "rf4db",
"source_url": "https://rf4db.com/ru/maps",
"checked_at": "2026-09-16",
"expected_count": 19,
"items": [
{"source_external_id":"level_000_home","source_url":"https://rf4db.com/ru/maps/level_000_home","name":"Дачный пруд","unlock_level":null,"unlock_label":"Старт","fish_species_count":7,"image_url":"https://oss.rf4db.com/game/maps/level_000_home.png"},
{"source_external_id":"level_001_lake","source_url":"https://rf4db.com/ru/maps/level_001_lake","name":"оз. Комариное","unlock_level":1,"unlock_label":"Lv.1","fish_species_count":20,"image_url":"https://oss.rf4db.com/game/maps/level_001_lake.png"},
{"source_external_id":"level_002_kirzah_river","source_url":"https://rf4db.com/ru/maps/level_002_kirzah_river","name":"р. Вьюнок","unlock_level":1,"unlock_label":"Lv.1","fish_species_count":29,"image_url":"https://oss.rf4db.com/game/maps/level_002_kirzah_river.png"},
{"source_external_id":"level_019_american_pond","source_url":"https://rf4db.com/ru/maps/level_019_american_pond","name":"оз. Лосиное","unlock_level":1,"unlock_label":"Lv.1","fish_species_count":19,"image_url":"https://oss.rf4db.com/game/maps/level_019_american_pond.png"},
{"source_external_id":"level_004_torfyanoe_ozero","source_url":"https://rf4db.com/ru/maps/level_004_torfyanoe_ozero","name":"оз. Старый Острог","unlock_level":12,"unlock_label":"Lv.12","fish_species_count":21,"image_url":"https://oss.rf4db.com/game/maps/level_004_torfyanoe_ozero.png"},
{"source_external_id":"level_010_belaya","source_url":"https://rf4db.com/ru/maps/level_010_belaya","name":"р. Белая","unlock_level":12,"unlock_label":"Lv.12","fish_species_count":29,"image_url":"https://oss.rf4db.com/game/maps/level_010_belaya.png"},
{"source_external_id":"level_003_viiveri_lake","source_url":"https://rf4db.com/ru/maps/level_003_viiveri_lake","name":"оз. Куори","unlock_level":16,"unlock_label":"Lv.16","fish_species_count":24,"image_url":"https://oss.rf4db.com/game/maps/level_003_viiveri_lake.png"},
{"source_external_id":"level_007_grass_lake","source_url":"https://rf4db.com/ru/maps/level_007_grass_lake","name":"оз. Медвежье","unlock_level":18,"unlock_label":"Lv.18","fish_species_count":26,"image_url":"https://oss.rf4db.com/game/maps/level_007_grass_lake.png"},
{"source_external_id":"level_005_volhov","source_url":"https://rf4db.com/ru/maps/level_005_volhov","name":"р. Волхов","unlock_level":20,"unlock_label":"Lv.20","fish_species_count":32,"image_url":"https://oss.rf4db.com/game/maps/level_005_volhov.png"},
{"source_external_id":"level_013_sev_don","source_url":"https://rf4db.com/ru/maps/level_013_sev_don","name":"р. Северский Донец","unlock_level":22,"unlock_label":"Lv.22","fish_species_count":40,"image_url":"https://oss.rf4db.com/game/maps/level_013_sev_don.png"},
{"source_external_id":"level_008_sura_river","source_url":"https://rf4db.com/ru/maps/level_008_sura_river","name":"р. Сура","unlock_level":24,"unlock_label":"Lv.24","fish_species_count":37,"image_url":"https://oss.rf4db.com/game/maps/level_008_sura_river.png"},
{"source_external_id":"level_006_ladoga","source_url":"https://rf4db.com/ru/maps/level_006_ladoga","name":"Ладожское оз.","unlock_level":26,"unlock_label":"Lv.26","fish_species_count":40,"image_url":"https://oss.rf4db.com/game/maps/level_006_ladoga.png"},
{"source_external_id":"level_014_carp_lake","source_url":"https://rf4db.com/ru/maps/level_014_carp_lake","name":"оз. Янтарное","unlock_level":26,"unlock_label":"Lv.26","fish_species_count":37,"image_url":"https://oss.rf4db.com/game/maps/level_014_carp_lake.png"},
{"source_external_id":"level_011_ladoga_02","source_url":"https://rf4db.com/ru/maps/level_011_ladoga_02","name":"Ладожский архипелаг","unlock_level":27,"unlock_label":"Lv.27","fish_species_count":43,"image_url":"https://oss.rf4db.com/game/maps/level_011_ladoga_02.png"},
{"source_external_id":"level_009_ahtuba","source_url":"https://rf4db.com/ru/maps/level_009_ahtuba","name":"р. Ахтуба","unlock_level":28,"unlock_label":"Lv.28","fish_species_count":54,"image_url":"https://oss.rf4db.com/game/maps/level_009_ahtuba.png"},
{"source_external_id":"level_018_moskitnoe","source_url":"https://rf4db.com/ru/maps/level_018_moskitnoe","name":"оз. Медное","unlock_level":29,"unlock_label":"Lv.29","fish_species_count":30,"image_url":"https://oss.rf4db.com/game/maps/level_018_moskitnoe.png"},
{"source_external_id":"level_015_tunguska","source_url":"https://rf4db.com/ru/maps/level_015_tunguska","name":"р. Нижняя Тунгуска","unlock_level":30,"unlock_label":"Lv.30","fish_species_count":41,"image_url":"https://oss.rf4db.com/game/maps/level_015_tunguska.png"},
{"source_external_id":"level_016_yama","source_url":"https://rf4db.com/ru/maps/level_016_yama","name":"р. Яма","unlock_level":32,"unlock_label":"Lv.32","fish_species_count":28,"image_url":"https://oss.rf4db.com/game/maps/level_016_yama.png"},
{"source_external_id":"level_012_north_sea","source_url":"https://rf4db.com/ru/maps/level_012_north_sea","name":"Норвежское море","unlock_level":34,"unlock_label":"Lv.34","fish_species_count":57,"image_url":"https://oss.rf4db.com/game/maps/level_012_north_sea.png"}
]
}
+77
View File
@@ -0,0 +1,77 @@
\set ON_ERROR_STOP on
\timing off
-- Session-local fixture for the G06/G07 public tackle paths.
-- No production table or row is modified.
SET search_path = pg_temp, public;
SET jit = off;
SET work_mem = '16MB';
CREATE TEMP TABLE tackle_item (
id uuid PRIMARY KEY, name varchar(200) NOT NULL, category varchar(20) NOT NULL,
brand varchar(100), family varchar(100), unlock_level integer
);
CREATE TEMP TABLE catch_report (
id uuid PRIMARY KEY, moderation_status moderationstatus NOT NULL,
deleted_at timestamptz, reported_at timestamptz NOT NULL
);
CREATE TEMP TABLE catch_tackle_component (
id uuid PRIMARY KEY, catch_report_id uuid NOT NULL, role varchar(50) NOT NULL,
raw_value varchar(200) NOT NULL, position integer NOT NULL
);
INSERT INTO tackle_item (id, name, category, brand, family, unlock_level)
SELECT md5('tackle-' || g)::uuid, 'Предмет ' || g,
CASE WHEN g % 4 = 0 THEN 'bait' ELSE 'lure' END,
'Brand-' || (g % 25), 'Family-' || (g % 100), g % 30
FROM generate_series(1, 5000) AS g;
INSERT INTO catch_report (id, moderation_status, deleted_at, reported_at)
SELECT md5('tackle-report-' || g)::uuid,
CASE WHEN g % 10 = 0 THEN 'pending'::moderationstatus ELSE 'approved'::moderationstatus END,
CASE WHEN g % 50 = 0 THEN clock_timestamp() ELSE NULL END,
clock_timestamp() - (((g * 97) % 7776000) * interval '1 second')
FROM generate_series(1, 100000) AS g;
INSERT INTO catch_tackle_component (id, catch_report_id, role, raw_value, position)
SELECT md5('component-' || g)::uuid,
md5('tackle-report-' || g)::uuid,
CASE WHEN g % 3 = 0 THEN 'rig' ELSE 'lure' END,
'Предмет ' || ((g - 1) % 5000 + 1), 0
FROM generate_series(1, 100000) AS g;
CREATE INDEX tackle_item_catalog_filter_idx
ON tackle_item (category, brand, family, unlock_level, name, id);
CREATE INDEX catch_report_tackle_feed_idx
ON catch_report (moderation_status, deleted_at, reported_at, id);
CREATE INDEX catch_tackle_component_report_idx
ON catch_tackle_component (catch_report_id, role, raw_value);
ANALYZE tackle_item;
ANALYZE catch_report;
ANALYZE catch_tackle_component;
\echo 'DATASET'
SELECT count(*) AS tackle_items, (SELECT count(*) FROM catch_report) AS reports,
(SELECT count(*) FROM catch_tackle_component) AS components
FROM tackle_item;
\echo 'PLAN tackle_catalog_filter'
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT id, name, category, brand, family, unlock_level
FROM tackle_item
WHERE category = 'lure' AND brand = 'Brand-7' AND family = 'Family-7'
ORDER BY name, id
LIMIT 50;
\echo 'PLAN tackle_combinations'
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT c.role, c.raw_value, count(DISTINCT r.id)
FROM catch_report AS r
JOIN catch_tackle_component AS c ON c.catch_report_id = r.id
WHERE r.moderation_status = 'approved'
AND r.deleted_at IS NULL
AND r.reported_at >= now() - interval '72 hours'
GROUP BY c.role, c.raw_value
ORDER BY count(DISTINCT r.id) DESC, c.role, c.raw_value
LIMIT 50;
+3 -1
View File
@@ -15,7 +15,7 @@ cleanup() {
status=$? status=$?
if [ "$status" -ne 0 ]; then if [ "$status" -ne 0 ]; then
$compose ps >&2 || true $compose ps >&2 || true
$compose logs --no-color api web db minio >&2 || true $compose logs --no-color api web db minio minio-init >&2 || true
fi fi
$compose down --volumes --remove-orphans >/dev/null 2>&1 || true $compose down --volumes --remove-orphans >/dev/null 2>&1 || true
} }
@@ -84,4 +84,6 @@ test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) f
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from catch_report')" = "0" test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from catch_report')" = "0"
WEB_URL="http://127.0.0.1:$BOOTSTRAP_WEB_PORT" BOOTSTRAP_API_URL="http://127.0.0.1:$BOOTSTRAP_API_PORT" BOOTSTRAP_ADMIN_TOKEN=replace-with-at-least-32-random-characters npm --prefix apps/web run test:bootstrap WEB_URL="http://127.0.0.1:$BOOTSTRAP_WEB_PORT" BOOTSTRAP_API_URL="http://127.0.0.1:$BOOTSTRAP_API_PORT" BOOTSTRAP_ADMIN_TOKEN=replace-with-at-least-32-random-characters npm --prefix apps/web run test:bootstrap
curl -fsS -D - -o /dev/null -H 'Authorization: Bearer replace-with-at-least-32-random-characters' "http://127.0.0.1:$BOOTSTRAP_API_PORT/api/v1/admin/catch-reports" | grep -qi '^cache-control: no-store' curl -fsS -D - -o /dev/null -H 'Authorization: Bearer replace-with-at-least-32-random-characters' "http://127.0.0.1:$BOOTSTRAP_API_PORT/api/v1/admin/catch-reports" | grep -qi '^cache-control: no-store'
curl -fsS -D - -o /dev/null -H 'Authorization: Bearer replace-with-at-least-32-random-characters' "http://127.0.0.1:$BOOTSTRAP_API_PORT/api/v1/admin/media/catalog" | grep -qi '^cache-control: no-store'
curl -fsS -D - -o /dev/null -H 'Authorization: Bearer replace-with-at-least-32-random-characters' "http://127.0.0.1:$BOOTSTRAP_API_PORT/api/v1/admin/source-status" | grep -qi '^cache-control: no-store'
echo "Production bootstrap passed from empty volumes" echo "Production bootstrap passed from empty volumes"
+24
View File
@@ -0,0 +1,24 @@
#!/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/tackle-query-plan-gate.sql" > "$output"
for plan in tackle_catalog_filter tackle_combinations; do
grep -F "PLAN $plan" "$output" >/dev/null
done
awk '
/Execution Time:/ { count += 1; if (($3 + 0) > 250) { print "Tackle query plan exceeded 250 ms: " $0 > "/dev/stderr"; failed = 1 } }
END { if (count != 2 || failed) exit 1 }
' "$output"
grep -A40 'PLAN tackle_catalog_filter' "$output" | grep -Eq 'Index Scan|Index Only Scan|Bitmap Index Scan'
grep -A60 'PLAN tackle_combinations' "$output" | grep -Eq 'Index Scan|Bitmap Index Scan'
grep 'Execution Time:' "$output"
echo "Tackle query-plan gate passed: indexed catalog and combination paths under 250 ms"
+170
View File
@@ -0,0 +1,170 @@
# Актуальный технический аудит RF4 Spotter — 20 сентября 2026
База аудита: ветка `main`, commit `d438c40`, рабочая копия проверена 20.09.2026.
Изменения до аудита уже существовали в рабочем дереве только в `README.md` и
`docs/ROADMAP.md`; они сохранены и не смешаны с результатами аудита.
Цель — отделить фактически проверенное локально от исторических результатов,
которые требуют повторного Docker/browser/production-прогона.
## Итог
Проект представляет собой функциональный single-owner alpha MVP на Astro SSR,
FastAPI, PostgreSQL, MinIO и Caddy. Кодовая база сейчас проходит локальные
quality gates: Python — `190 passed, 1 skipped`, Astro check — `0 errors / 0
warnings / 0 hints`, production build — успешен, web unit — `1 passed`, media
audit — без issues и orphan-файлов. Production Compose файл корректно
интерполируется с безопасными временными значениями, а Alembic graph имеет
единственную голову `0020`.
Открытая альфа ещё не готова к объявлению production-ready. Не подтверждены в
этой сессии: запуск чистого полного Compose stack, фактический `alembic current`
против PostgreSQL, внешний DNS/TLS/backup/restore, browser acceptance тем,
административных сценариев и B25 media. Это инфраструктурные и приёмочные
ограничения, а не обнаруженная ошибка сборки.
## Архитектура и границы ответственности
| Слой | Текущее состояние | Оценка |
|---|---|---|
| Caddy | Production proxy, TLS/Basic Auth для `/admin`, API routing, headers, CSP | готово локально; внешний TLS не проверен |
| Astro | SSR Node adapter, public/admin pages, server-side form/API bridges | сборка и check проходят |
| FastAPI | роутеры activity/catalog/media/public/admin/submissions, health/readiness | API contract и тесты проходят |
| PostgreSQL | SQLAlchemy 2, Alembic, persistent rate-limit/import history | миграционный graph проверен, живая БД не запущена |
| MinIO/S3 | отдельный app user/policy, Git media proxy и provenance | offline media audit проходит |
| Scheduler/import | opt-in Compose profiles, общий cooldown/backoff контур | запуск источников намеренно не включён по умолчанию |
Архитектурное решение Astro + FastAPI + PostgreSQL сохраняется. API уже
разделён на роутеры, поэтому старое замечание о монолитном `main.py` из аудита
10 сентября больше не актуально. `main.py` оставляет composition root,
middleware, health/readiness и регистрацию роутеров.
## Проверки и доказательства
| Проверка | Результат | Комментарий |
|---|---:|---|
| `.venv/bin/pytest -q` | `190 passed, 1 skipped` | 72 deprecation warnings FastAPI/`asyncio.iscoroutinefunction`; skip — PostgreSQL integration |
| `npm --prefix apps/web run check` | 0/0/0 | 61 Astro files inspected |
| `npm --prefix apps/web run build` | passed | SSR Node production build |
| `npm --prefix apps/web run test:unit` | `1 passed` | Node unit suite |
| `media_cli --audit` | 704 entries, 0 issues | 466 approved, 226 superseded, 1 duplicate, 11 invalid; no orphans |
| `media_cli --quality-report` | 253 fish published | 252 are 1024×1024 WebP; one 48×48 fallback remains |
| `docker compose -f compose.production.yaml config --quiet` | passed | checked with temporary non-secret values only |
| dev `docker compose up --build --wait` | passed | PostgreSQL, MinIO, API and web healthy; migration service exited 0 |
| live `/health`, `/ready`, public routes | passed | health/readiness and public `/`, `/waterbodies/`, `/records/` returned expected responses from container network |
| Alembic `heads` / live `current` | `0020 (head)` | both repository graph and running PostgreSQL verified |
| waterbody catalog import/audit | `19/19`, 0 failures | canonical snapshot applied; no missing, duplicate or provenance issues |
| OpenAPI | passed after Q16 fix | `apps/api/export_openapi.py --check` passes with `36 paths`; artifact regenerated from FastAPI, not manually edited |
| Browser acceptance | not confirmed | in-app browser backend unavailable in the current session |
The dev stack was stopped after the checks without removing its named volumes.
This is a local integration gate, not a production deployment or external
browser acceptance.
## Backend and data layer
### Strengths
- API endpoints are separated into focused routers and have a tracked OpenAPI
generator/artifact; Q16 was closed by regenerating the artifact and passing
the deterministic check.
- Alembic migrations are linear through `0020`; latest migrations cover
waterbody provenance, coordinate precision, fish counts and detail fields.
- Official/community import paths preserve old data on incomplete or failed
responses, use provenance, and keep network fetching opt-in.
- Admin media publish/rollback keeps fallback assets and provenance; public
media is content-addressed and served locally rather than hotlinked.
- Rate limiting uses a persistent, privacy-preserving HMAC client hash and
trusted-proxy handling.
### Remaining risks
- The single-owner admin model still uses a shared Bearer token and proxy Basic
Auth. A05 (personal accounts, roles, revocation and operator IDs) is correctly
deferred until the pilot has more than one operator.
- Public cache is process-local; shared invalidation/Redis is explicitly a
post-pilot decision and should not be added before measured need.
- Production query p95 and real data growth are not measured in this workspace.
The existing query-plan gate is a local fixture gate, not a production SLO.
- The waterbody canonical index has 19 verified entries, but detail snapshots
currently cover only the first waterbody. W02W08 remain open.
## Frontend, accessibility and visual system
Astro SSR pages include public catalogs, fish/waterbody/spot detail, official
records, reports, status, media and admin screens. The current implementation
has semantic light/dark tokens, system/manual theme selection, forced-colors
rules, nonce-based JSON-LD CSP, noindex/no-store handling for admin/error
surfaces, responsive pagination and reduced-motion styles.
Automated structural and browser checks pass, but visual acceptance is not
equivalent to `astro check`. The local visual matrix now covers 13 routes × 4
viewports × 3 theme modes (156 combinations) without document overflow, with
matching SSR theme, `main` and `h1`; accessibility E2E covers axe 9/9,
keyboard/focus, print and forced-colors. B25, D05/D07 and A06 remain open only
for retained reference screenshots and manual review of long data,
empty/error states, shadows and gradients.
## Security and operations
### Confirmed in code/config
- Production settings reject demo/short admin, rate-limit and S3 credentials.
- Production CORS requires explicit HTTPS origins; demo seeding is forbidden.
- Caddy and API set HSTS, `nosniff`, restrictive referrer/permissions policy,
anti-framing headers and CSP; Astro uses a per-response nonce for JSON-LD.
- Production MinIO app credentials are separated from root credentials and
limited to one bucket's list/location/get/put/delete operations.
- CI contains locked dependency installation, pip-audit, clean migration,
Compose E2E and scheduled production-bootstrap drill definitions.
### Not yet proven for release
- No production server, DNS, TLS certificate, real secrets or external backup
restore were exercised here.
- `compose.yaml` intentionally contains local demo defaults; only
`compose.production.yaml` has fail-fast production secret requirements.
- The scheduled bootstrap workflow exists, but its latest successful run is a
repository history fact, not a fresh verification in this workspace.
- The Docker operator account remains a high-privilege operational boundary;
SSH/firewall/VPN restrictions belong to deployment hardening.
## Media and provenance snapshot
The manifest contains 704 records: 466 approved, 226 superseded, one
provenance-only duplicate and 11 invalid records. Fish quality-upgrade processing
completed for 226 alternatives; 253 fish assets are published, 252 with
1024×1024 WebP and one explicit low-resolution fallback. Offline media audit
reports no checksum/dimension/MIME/provenance/orphan problems.
Browser checks now cover broken images, responsive overflow, light/dark
presentation and source links on the media/admin paths; the visual matrix also
confirms route-level overflow and SSR theme consistency. This does not close
B25 because retained reference screenshots and manual visual review are still
required. It also does not grant new source permissions; Q01 remains the
source-rights gate.
## Prioritized action plan reflected in ROADMAP
1. **Release blockers:** repeat the clean production Compose/bootstrap gate;
verify readiness, migration current, proxy routing, backup/restore and
production secret/preflight checks (A07 and open-alpha gate).
2. **Browser gates:** retain reference screenshots and finish the remaining
manual visual review for B25, A06 and D07; the Chromium matrix and
accessibility regression gates are already green locally.
3. **Waterbody data:** process the remaining 18 detail snapshots under the
shared cooldown, then complete W03W08 without assigning unreviewed media
roles.
4. **Source governance:** attach durable primary permission evidence and image
rights before expanding public catalog coverage (Q01).
5. **After pilot:** personal admin identities (A05/M06), shared cache only after
measurement, production p95 and external search/analytics integrations.
## Scope decisions
- No Next.js/Vinext migration, Redis addition, broad CSS rewrite or unrelated
refactor is justified by this audit.
- No external source was fetched during this audit.
- No media was downloaded, replaced or automatically promoted.
- Historical audits remain historical; this document and `ROADMAP.md` are the
current decision context.
+8 -1
View File
@@ -8,7 +8,14 @@
Проверено: 107 Python-тестов проходят, 1 пропущен; Astro check/build, web unit и Caddy adapt проходят. Это не полная приёмка: проверки не покрывают обнаруженные ниже сценарии. Несовместимость activity envelope исправлена у четырёх потребителей по коду; история site cooldown теперь включает disabled endpoint. Не повторять эти изменения без нового воспроизведения. Проверено: 107 Python-тестов проходят, 1 пропущен; Astro check/build, web unit и Caddy adapt проходят. Это не полная приёмка: проверки не покрывают обнаруженные ниже сценарии. Несовместимость activity envelope исправлена у четырёх потребителей по коду; история site cooldown теперь включает disabled endpoint. Не повторять эти изменения без нового воспроизведения.
`REGRESSION_FIXES_REPORT.md` устарел, содержит противоречивые статусы. Новые задачи ниже пока не выполнены. Источники в сеть для этой проверки не опрашивались. Ниже сохранён исходный снимок критериев до исправлений; формулировки «не выполнено» и секция «основание» описывают состояние базы `4f68d6b`, а не текущую ветку. По повторной приёмке 11 сентября A01–A13 закрыты в [RECOVERY_FIXES_REPORT.md](RECOVERY_FIXES_REPORT.md). 15 сентября пагинация A04 дополнительно переведена с вводящей в заблуждение кнопки «Показать ещё» на серверные страницы previous/next для activity, records и каталогов; фильтры сохраняются, избыточный offset нормализуется на последнюю страницу, сценарий 45 элементов закреплён unit-тестом. Источники в сеть для этой проверки не опрашивались.
| Исторический пакет | Текущий статус | Актуальное подтверждение |
|---|---|---|
| A01A03 | закрыт | `RECOVERY_FIXES_REPORT.md`, тесты readiness/cooldown/redirect |
| A04 | закрыт, повторно усилен 15.09 | серверные previous/next, диапазон и unit-тест 45 элементов |
| A05A12 | закрыт | `RECOVERY_FIXES_REPORT.md`, адресные regression-тесты |
| A13 | закрыт | итоговый отчёт и единственная активная очередь в `ROADMAP.md` |
## Порядок и критерии приёмки ## Порядок и критерии приёмки
+2 -2
View File
@@ -1,6 +1,6 @@
# Отчёт по регрессионному аудиту — 9 сентября 2026 # Отчёт по регрессионному аудиту — 9 сентября 2026
> **Архивный промежуточный отчёт.** Его раздел «оставшиеся регрессии» закрыт последующим пакетом восстановления. Актуальный итог находится в [RECOVERY_FIXES_REPORT.md](RECOVERY_FIXES_REPORT.md), текущие задачи — в [ROADMAP.md](ROADMAP.md). > **Архивный промежуточный отчёт.** Его статусы и раздел «Следующие шаги» относятся только к базе `9ae05ef` и не являются текущим backlog. Регрессии закрыты последующим пакетом восстановления; актуальный итог находится в [RECOVERY_FIXES_REPORT.md](RECOVERY_FIXES_REPORT.md), текущие задачи — только в [ROADMAP.md](ROADMAP.md). Пагинация R09 дополнительно усилена 15 сентября полноценными серверными previous/next-страницами.
База: `9ae05ef` (после трёх коммитов исправлений предыдущего аудита). База: `9ae05ef` (после трёх коммитов исправлений предыдущего аудита).
Исходный отчёт: [REGRESSION_AUDIT_2026-09-09.md](REGRESSION_AUDIT_2026-09-09.md). Исходный отчёт: [REGRESSION_AUDIT_2026-09-09.md](REGRESSION_AUDIT_2026-09-09.md).
@@ -169,7 +169,7 @@
--- ---
## Следующие шаги ## Исторические следующие шаги (закрыты последующим recovery-пакетом)
1. **R03** — Research CLI cooldown: исправить `fcntl` locking и `encoding`. 1. **R03** — Research CLI cooldown: исправить `fcntl` locking и `encoding`.
2. **R04** — Разделить `configured_sources()` и `site_cooldown_sources()`. 2. **R04** — Разделить `configured_sources()` и `site_cooldown_sources()`.
+33 -32
View File
@@ -1,17 +1,18 @@
# План работ RF4 Spotter # План работ RF4 Spotter
Этот файл — единственный актуальный список задач. Завершённые аудиты сохранены как история в [PROJECT_AUDIT_2026-09-08.md](PROJECT_AUDIT_2026-09-08.md), [REGRESSION_AUDIT_2026-09-09.md](REGRESSION_AUDIT_2026-09-09.md) и [RECOVERY_PLAN_2026-09-10.md](RECOVERY_PLAN_2026-09-10.md); их старые чекбоксы не являются текущей очередью. Этот файл — единственный актуальный список задач. Актуальный технический срез и границы доказательств зафиксированы в [PROJECT_AUDIT_2026-09-20.md](PROJECT_AUDIT_2026-09-20.md). Завершённые аудиты сохранены как история в [PROJECT_AUDIT_2026-09-08.md](PROJECT_AUDIT_2026-09-08.md), [REGRESSION_AUDIT_2026-09-09.md](REGRESSION_AUDIT_2026-09-09.md) и [RECOVERY_PLAN_2026-09-10.md](RECOVERY_PLAN_2026-09-10.md); их старые чекбоксы не являются текущей очередью.
Последняя сверка: **15 сентября 2026**. Последняя сверка: **20 сентября 2026**.
Подтверждено: Подтверждено:
- [x] пакет восстановления A01–A13 завершён; итог и доказательства собраны в [RECOVERY_FIXES_REPORT.md](RECOVERY_FIXES_REPORT.md); - [x] пакет восстановления A01–A13 завершён; итог и доказательства собраны в [RECOVERY_FIXES_REPORT.md](RECOVERY_FIXES_REPORT.md);
- [x] полный Python suite: **178 passed, 1 skipped**; skip относится к интеграционной проверке PostgreSQL и покрывается Docker-приёмкой; - [x] полный Python suite: **196 passed, 1 skipped**; skip относится к интеграционной проверке PostgreSQL и требует отдельного Docker-прогона;
- [x] Astro check: 40 файлов, **0 errors / 0 warnings / 0 hints**; production build проходит; - [x] Astro check: 62 файла, **0 errors / 0 warnings / 0 hints**; production build проходит;
- [x] API после миграции healthy; `apps/api/tests/test_api.py`: **20 passed**; - [x] API contract suite: `apps/api/tests/test_api.py` содержит **20 passed**; live `/health` и `/ready` после миграции требуют отдельного Docker-прогона;
- [x] граф Alembic линеен и имеет единственную голову `0018`; CI применяет её на чистой PostgreSQL, полный production bootstrap запускается отдельным еженедельным drill; - [x] граф Alembic линеен и имеет единственную голову `0020`; CI применяет её на чистой PostgreSQL, полный production bootstrap запускается отдельным еженедельным drill;
- [x] изолированный production bootstrap проходит Caddy adapt, scheduler validation и Playwright-сценарий отправки/модерации без обращения к внешним источникам; - [x] локальный dev Compose gate 20.09: PostgreSQL, MinIO, API и web healthy; `/health`/`/ready` успешны, migration current — `0020 (head)`, public `/`, `/waterbodies/` и `/records/` отвечают `200` из контейнерной сети;
- [x] изолированный production bootstrap повторно прошёл 20.09: чистые PostgreSQL/MinIO volumes, MinIO init с отдельными app credentials, Alembic `0020`, Caddy adapt, scheduler validation и оба Playwright-сценария; drill не обращается к внешним источникам;
- [x] Astro + FastAPI + PostgreSQL остаются целевым стеком; Next.js и Vinext не используются. - [x] Astro + FastAPI + PostgreSQL остаются целевым стеком; Next.js и Vinext не используются.
## Ближайший пакет — без сервера ## Ближайший пакет — без сервера
@@ -30,32 +31,32 @@
- [x] **B08 · Семейства силуэтов рыб.** Три случайных hash-варианта заменены классификатором и отдельными формами `pike`, `salmonid`, `cyprinid`, `perch`, `catfish`, `eel`, `flatfish`, `marine`, плюс честный `generic`. Компонент допускает ручное переопределение family; название остаётся главным идентификатором. - [x] **B08 · Семейства силуэтов рыб.** Три случайных hash-варианта заменены классификатором и отдельными формами `pike`, `salmonid`, `cyprinid`, `perch`, `catfish`, `eel`, `flatfish`, `marine`, плюс честный `generic`. Компонент допускает ручное переопределение family; название остаётся главным идентификатором.
- [x] **B09 · Глифы снастей и приманок.** Добавлены SVG-глифы `spinner`, `wobbler`, `soft`, `boilie`, `worm`, `rig`, `unknown` и стабильная палитра по normalized name. Классификация срабатывает только по явным словам; глиф сопровождает текст в activity, лидере, уловах, рекордах и списке лучших приманок, не выдавая категорию за точную модель. - [x] **B09 · Глифы снастей и приманок.** Добавлены SVG-глифы `spinner`, `wobbler`, `soft`, `boilie`, `worm`, `rig`, `unknown` и стабильная палитра по normalized name. Классификация срабатывает только по явным словам; глиф сопровождает текст в activity, лидере, уловах, рекордах и списке лучших приманок, не выдавая категорию за точную модель.
- [x] **B10 · Визуальные отпечатки водоёмов.** Для каждого slug воспроизводимо выбираются один из восьми береговых контуров, число волн, положение точки и двухсимвольный индекс. Знак используется в каталоге и detail-hero; это явно абстрактный отпечаток, а не карта или игровая география. - [x] **B10 · Визуальные отпечатки водоёмов.** Для каждого slug воспроизводимо выбираются один из восьми береговых контуров, число волн, положение точки и двухсимвольный индекс. Знак используется в каталоге и detail-hero; это явно абстрактный отпечаток, а не карта или игровая география.
- [x] **B11 · Разрешённый media pipeline и публичная медиатека.** Версионированный baseline содержит 19 водоёмов и 252 рыбы; полное число снастей остаётся `null`, а не подменяется числом приманок. После явного разрешения владельца от 14.09 опубликованы все 456 скачанных и целостных файлов: 243 изображения рыб, 149 снастей/приманок и 64 справочных материала. Публичные `/media` и `/api/v1/media/*` отдают Git-копии по SHA-256 без hotlink; у каждой карточки есть плашка и прямая ссылка на источник. 227 альтернативных URL остаются provenance-only `duplicate`, 11 невалидных URL не публикуются, 10 RF4DB-кандидатов остаются в очереди. Audit не находит ошибок и бесхозных файлов. Batch-контур по-прежнему соблюдает единый 30-минутный cooldown домена и не одобряет будущие загрузки автоматически. - [x] **B11 · Разрешённый media pipeline и публичная медиатека.** Версионированный baseline содержит 19 водоёмов и 252 канонические рыбы; полное число снастей остаётся `null`, а не подменяется числом приманок. Актуальный manifest содержит 704 записи: 466 `approved`, 226 `superseded`, 1 provenance-only `duplicate` и 11 `invalid`; опубликованы 253 fish-файла, 149 снастей/приманок и 64 справочных материала. Публичные `/media` и `/api/v1/media/*` отдают Git-копии по SHA-256 без hotlink; у каждой карточки есть плашка и прямая ссылка на источник. Audit не находит ошибок и бесхозных файлов. Batch-контур по-прежнему соблюдает единый 30-минутный cooldown домена и не одобряет будущие загрузки автоматически.
- [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений. - [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений.
- [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`. - [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`.
- [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания. - [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
- [x] **B15 · Целостность медиакаталога.** Локальный audit сводит статусы очереди и проверяет наличие файлов, SHA-256, фактические dimensions/MIME, каноническое соответствие approved-записей и бесхозные файлы. Проверка не обращается в сеть и может использоваться как pre-publication gate. - [x] **B15 · Целостность медиакаталога.** Локальный audit сводит статусы очереди и проверяет наличие файлов, SHA-256, фактические dimensions/MIME, каноническое соответствие approved-записей и бесхозные файлы. Проверка не обращается в сеть и может использоваться как pre-publication gate.
- [x] **B16 · Самодостаточное хранение media dataset.** Все управляемые оригиналы из `data/media/files/` версионируются в Git вместе с manifest; локальный клон содержит полный исследовательский набор. Пользовательские скриншоты остаются в MinIO/S3, а наличие файла в Git не означает разрешение на публикацию без статуса `approved`. - [x] **B16 · Самодостаточное хранение media dataset.** Все управляемые оригиналы из `data/media/files/` версионируются в Git вместе с manifest; локальный клон содержит полный исследовательский набор. Пользовательские скриншоты остаются в MinIO/S3, а наличие файла в Git не означает разрешение на публикацию без статуса `approved`.
- [ ] **B17 · Полный каталог рыб RF4DB — финальная загрузка.** Разрешённый RF4DB дал все 252 подписанных кандидата; 227 совпадений с RF4MAP сохранены как provenance-only `duplicate`, 15 уникальных файлов скачаны, а 10 URL (9 уникальных названий) остаются в очереди после корректно остановленного TLS-сбоя. Все поддомены RF4DB объединены одним 30-минутным cooldown. Завершить следующим разрешённым batch-окном и отдельно утвердить новые файлы. - [x] **B17 · Полный каталог рыб RF4DB — финальная загрузка.** Разрешённый RF4DB дал все 252 подписанных кандидата; прямые альтернативы обработаны с общим 30-минутным cooldown, а утверждённые версии опубликованы только после отдельного review. Очередь fish-кандидатов пуста: 226 старых версий сохранены как `superseded`, один provenance-only `duplicate` не связан с низкоразрешённым published-файлом. Один `48×48` fallback (`Ёрш`) сохранён честно из-за отсутствия проверенной альтернативы.
- [ ] **B18 · Каталог водоёмов RF4DB.** После cooldown проиндексировать 19 русских карточек водоёмов, проверить полноразмерные карты и поставить только уникальные изображения в media queue. - [ ] **B18 · Каталог водоёмов RF4DB.** После cooldown проиндексировать 19 русских карточек водоёмов, проверить полноразмерные карты и поставить только уникальные изображения в media queue.
- [ ] **B19 · Каталог снастей RF4DB.** Подготовительный media-контур; полный справочник, crosswalk, связи с уловами и публичные страницы выполняются по пакету **G01G09** ниже. Не считать каталог полным до получения проверяемого общего счётчика по категориям. - [ ] **B19 · Каталог снастей RF4DB.** Подготовительный media-контур; полный справочник, crosswalk, связи с уловами и публичные страницы выполняются по пакету **G01G09** ниже. Не считать каталог полным до получения проверяемого общего счётчика по категориям.
- [x] **B20 · Аудит качества рыбных изображений.** Offline `media_cli --quality-report` проверяет фактические dimensions опубликованных файлов, отдельно считает неизбежный upscale в карточках и известные альтернативы. На 14.09: 227 из 243 опубликованных рыб имеют только 48×48 PNG RF4MAP и растягиваются до 180 px; 16 имеют 1024×1024 WebP. Для всех 227 низких файлов уже известны альтернативные URL RF4DB. Совпадение сущности больше нельзя считать достаточным основанием пропустить потенциально более качественный файл. - [x] **B20 · Аудит качества рыбных изображений.** Offline `media_cli --quality-report` проверяет фактические dimensions опубликованных файлов, отдельно считает неизбежный upscale в карточках и известные альтернативы. После quality-upgrade: 253 опубликованных fish-файла, 252 имеют 1024×1024 WebP, один fallback (`Ёрш`) остаётся 48×48 и увеличивается до 180 px; новых известных альтернатив для него нет. Совпадение сущности больше нельзя считать достаточным основанием пропустить потенциально более качественный файл.
- [ ] **B21 · Очередь quality-upgrade.** Текущие approved-файлы не меняются до готовности замены. 15.09 отдельная очередь создана для 226 прямых RF4DB-альтернатив с сохранением `duplicate_of`; первая партия из 40 файлов загружена без ошибок в `upgrade_stored`, ещё 186 замен и 10 кандидатов недостающих рыб ожидают следующих разрешённых 30-минутных окон. Один provenance-only `duplicate`, не связанный с низкоразрешённым published-файлом, намеренно не переведён. Ошибка останавливает домен по существующим правилам; публикация старой версии при этом не прерывается. - [x] **B21 · Очередь quality-upgrade.** Текущие approved-файлы не менялись до готовности замены. Очередь из 226 прямых RF4DB-альтернатив обработана с сохранением `duplicate_of`: все 226 кандидатов прошли `upgrade_stored` и затем были атомарно продвинуты в `approved`, старые версии сохранены как `superseded`. Один provenance-only `duplicate`, не связанный с низкоразрешённым published-файлом, намеренно не переводился. Ошибка останавливает домен по существующим правилам; публикация старой версии при этом не прерывается.
- [ ] **B22 · Сравнение вариантов разных источников.** Offline-команда `--compare-quality-upgrades` сопоставляет сохранённые кандидаты с опубликованными fallback по `duplicate_of` и проверяет dimensions, размер файла, MIME, прозрачность и aspect ratio. Первая партия: 40/40 кандидатов — прозрачные WebP 1024×1024, все прошли порог 256 px и сохранили пропорции; технических ошибок нет. До переключения остаётся визуально подтвердить соответствие подписи. Маленький файл сохраняется как fallback; источник RF4DB/RF4MAP/официальный RF4 не получает автоматического приоритета. - [x] **B22 · Сравнение вариантов разных источников.** Offline-команда `--compare-quality-upgrades` сопоставила сохранённые кандидаты с опубликованными fallback по `duplicate_of` и проверила dimensions, размер файла, MIME, прозрачность и aspect ratio. Все 226 кандидатов прошли технические пороги и offline contact-sheet review; источник RF4DB/RF4MAP/официальный RF4 не получал автоматического приоритета. После публикации в manifest не осталось `upgrade_stored`, поэтому текущий повторный запуск команды корректно сообщает `compared: 0`.
- [x] **B23 · Безопасное продвижение и provenance.** Связи `supersedes`/`replaced_by`, отдельное решение review, атомарное переключение публичного `entity_key` и явный CLI rollback сохраняют обе исходные ссылки и возможность отката. Публичная карточка показывает источник выбранного изображения, а manifest сохраняет проверенные варианты. Старый Git-файл не удаляется при включении нового. - [x] **B23 · Безопасное продвижение и provenance.** Связи `supersedes`/`replaced_by`, отдельное решение review, атомарное переключение публичного `entity_key` и явный CLI rollback сохраняют обе исходные ссылки и возможность отката. Публичная карточка показывает источник выбранного изображения, а manifest сохраняет проверенные варианты. Старый Git-файл не удаляется при включении нового.
- [x] **B24 · Производные размеры.** После выбора оригиналов генерируются детерминированные WebP/AVIF thumbnails для каталога и отдельный крупный вариант для detail, хэши производных фиксируются в manifest и отдаются через каталог. Маленькие карточки больше не обязаны загружать 1024×1024 оригинал. - [x] **B24 · Производные размеры.** После выбора оригиналов генерируются детерминированные WebP/AVIF thumbnails для каталога и отдельный крупный вариант для detail, хэши производных фиксируются в manifest и отдаются через каталог. Маленькие карточки больше не обязаны загружать 1024×1024 оригинал.
- [ ] **B25 · Визуальная приёмка media.** Собирать контактный лист «старое / кандидат / выбранное» с названием и источником; вручную проверить минимум все замены и репрезентативные desktop/mobile страницы в light/dark. Gate: нет битых файлов, искажённых пропорций, ложных соответствий, обрезанного объекта и изображений ниже 256 px без явной пометки «низкое разрешение». - [ ] **B25 · Визуальная приёмка media.** Contact sheets для всех 226 замен собраны и просмотрены offline: явных ложных соответствий, обрезки и искажённых пропорций не обнаружено; одно отличие подписи (`Ерш-носарь`/`Ёрш-носарь`) орфографическое. In-app Chromium проверил 12 комбинаций `/media` (320/390/768/1280 px × light/dark/system): горизонтального overflow нет, у 253 изображений есть `alt`, broken loaded images — `0`, переключение темы корректно меняет `data-theme`; keyboard/focus и print-regression проходят, standalone axe — `9/9` маршрутов. Launcher `scripts/capture-visual-matrix.mjs` успешно создал полную матрицу `156` PNG и `manifest.json` во внешней директории; остаются ручной review и решение о сохранении reference-артефактов. Gate: нет битых файлов, искажённых пропорций, ложных соответствий, обрезанного объекта и изображений ниже 256 px без явной пометки «низкое разрешение».
### Каталог водоёмов, карты и координаты ### Каталог водоёмов, карты и координаты
- [x] **W01 · Canonical-каталог RF4DB.** 16.09 через браузерный контекст получен и проверен индекс `/ru/maps`: 19/19 карточек, source slug/ID, русское название, уровень открытия, число видов рыб и URL изображения сохранены в [`data/waterbodies/rf4db-catalog-2026-09-16.json`](../data/waterbodies/rf4db-catalog-2026-09-16.json). Добавлена команда `python -m app.cli import-waterbody-catalog --input ...` для применения снимка в БД. Изображения остаются кандидатами без автоматической роли `map` или `cover`; detail-данные выполняются отдельно по W02. - [x] **W01 · Canonical-каталог RF4DB.** 16.09 через браузерный контекст получен и проверен индекс `/ru/maps`: 19/19 карточек, source slug/ID, русское название, уровень открытия, число видов рыб и URL изображения сохранены в [`data/waterbodies/rf4db-catalog-2026-09-16.json`](../data/waterbodies/rf4db-catalog-2026-09-16.json). Добавлена команда `python -m app.cli import-waterbody-catalog --input ...` для применения снимка в БД. Изображения остаются кандидатами без автоматической роли `map` или `cover`; detail-данные выполняются отдельно по W02.
- [ ] **W02 · Карточки водоёмов RF4DB.** Строгий fixture-based parser `rf4db-waterbody` расширен под реальный DOM `/fishes/` и `/positions/`, regression-тесты, безопасный `update_waterbody_detail` и атомарный batch `update_waterbody_details`/`import-waterbody-details` готовы; первая detail-карточка (`level_000_home`) сохранена браузером как snapshot. Осталось последовательно разобрать 18 detail-страниц с общим 30-минутным cooldown домена. - [ ] **W02 · Карточки водоёмов RF4DB.** Строгий fixture-based parser `rf4db-waterbody` расширен под реальный DOM `/fishes/` и `/positions/`, regression-тесты, безопасный `update_waterbody_detail` и атомарный batch `update_waterbody_details`/`import-waterbody-details` готовы. Snapshots `level_000_home`, `level_001_lake` и `level_002_kirzah_river` сохранены. Добавлены guarded launcher `scripts/fetch-waterbodies-chromium.mjs` для обычной Chromium-сессии и offline-режим `scripts/fetch-waterbodies.py --html`; оба сохраняют snapshots атомарно, не импортируют их автоматически и используют общий cooldown, а Chromium-launcher сначала делает отдельную reserve-only операцию. Повторный разрешённый запрос `level_002_kirzah_river` 20.09 вернул совпадающие с canonical-каталогом имя, `29/29` видов и два URL изображений; snapshot импортирован в Compose PostgreSQL, `/health` и `/ready` успешны, публичная `/waterbodies/р-вьюнок` отвечает `200`. Описание и позиции источник не вернул, поэтому они оставлены `null`/пустыми без домыслов; изображения остаются только provenance-кандидатами и не публиковались. Три разрешённых запроса `level_019_american_pond` 20.09 завершились `403 Forbidden`, а текущий Chromium показывает защитную страницу Cloudflare: это блокировка HTTP-клиента источником, не ошибка атомарного сохранения launcher-а; snapshot и импорт не создавались, прежние подтверждённые данные и media не менялись. Одно промежуточное разрешённое окно 20.09 было зарезервировано CLI, но завершилось локальной DNS-ошибкой до ответа источника; повтор с сетевым доступом был остановлен общим cooldown до запроса. Следующий сетевой запуск допускается только после общего cooldown; полнота ответа остаётся неподтверждённой. CAPTCHA/challenge не автоматизируются. Осталось последовательно разобрать 16 detail-страниц.
- [ ] **W03 · Модель и provenance.** В модель `Waterbody`, API-каталог и миграции `0017`/`0019`/`0020` добавлены nullable-поля provenance, счётчик видов и detail-факты; идемпотентные upsert-функции обновляют только подтверждённые source identity и не удаляют исчезнувшие строки. Осталось применить их к проверенному canonical-каталогу и отдельно разделить игровой и редакционный тексты при подключении detail-данных. - [ ] **W03 · Модель и provenance.** В модель `Waterbody`, API-каталог и миграции `0017`/`0019`/`0020` добавлены nullable-поля provenance, счётчик видов и detail-факты; идемпотентный upsert применён к canonical snapshot: `19/19`, без missing/duplicate/provenance issues. Осталось применить подтверждённые detail snapshots и отдельно разделить игровые и редакционные тексты при подключении detail-данных.
- [ ] **W04 · Классификация изображений.** Добавлены допустимые роли `waterbody_cover`, `waterbody_map`, `waterbody_depth_map`, `waterbody_screenshot` и проверка их назначения только через review для canonical waterbody. Кандидаты по-прежнему не получают роль автоматически. Осталось наполнить очередь detail-изображениями и провести contact-sheet review с проверкой dimensions, MIME, SHA-256, соответствия названию и источника. - [ ] **W04 · Классификация изображений.** Добавлены допустимые роли `waterbody_cover`, `waterbody_map`, `waterbody_depth_map`, `waterbody_screenshot` и проверка их назначения только через review для canonical waterbody. Кандидаты по-прежнему не получают роль автоматически. Осталось наполнить очередь detail-изображениями и провести contact-sheet review с проверкой dimensions, MIME, SHA-256, соответствия названию и источника.
- [ ] **W05 · Crosswalk источников.** Добавлен offline-конструктор консервативных предложений: нормализуются только точные имена/алиасы, неоднозначные и unmatched строки не получают canonical key; отсутствие ID выдаётся лишь диагностикой и не считается удалением. Осталось подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты, включая три ранее отмеченных отсутствующих RF4MAP объекта. - [ ] **W05 · Crosswalk источников.** Добавлен offline-конструктор консервативных предложений: нормализуются только точные имена/алиасы, неоднозначные и unmatched строки не получают canonical key; отсутствие ID выдаётся лишь диагностикой и не считается удалением. Осталось подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты, включая три ранее отмеченных отсутствующих RF4MAP объекта.
- [ ] **W06 · Координаты и точность.** В `ExternalObservation`, staging, provenance опубликованного улова и публичных activity/spot-ответах добавлены `coordinate_raw`, `coordinate_precision = exact | approximate | area | missing` и список источников; RF4DB/RF4-STAT/RF4MAP/RF4 Posts parsers теперь протягивают исходную строку, включая строки без доступных числовых координат. Карточка точки показывает точность рядом с координатами. Осталось провести browser QA. - [ ] **W06 · Координаты и точность.** В `ExternalObservation`, staging, provenance опубликованного улова и публичных activity/spot-ответах добавлены `coordinate_raw`, `coordinate_precision = exact | approximate | area | missing` и список источников; RF4DB/RF4-STAT/RF4MAP/RF4 Posts parsers теперь протягивают исходную строку, включая строки без доступных числовых координат. Исправлена spot detail: точность (`точные`/`приблизительные`/`район`/`не указаны`) теперь видна рядом с координатами и покрыта smoke regression. In-app Chromium и focused smoke подтвердили `/spots/vyunok-321x654` на desktop/mobile без overflow. Осталась полная browser QA матрица для всех precision/error-состояний.
- [ ] **W07 · Публичный API и страницы.** API и detail-страница теперь выводят подтверждённые detail-факты водоёма: описание, уровень, количество видов, алиасы, число ссылок на точки и отдельный счётчик изображений-кандидатов; источники и непроверенные media не смешиваются. Осталось подключить только проверенные waterbody media roles и завершить browser QA, включая различение карты, заставки и абстрактного отпечатка. - [ ] **W07 · Публичный API и страницы.** API и detail-страница теперь выводят подтверждённые detail-факты водоёма: описание, уровень, количество видов, алиасы, число ссылок на точки и отдельный счётчик изображений-кандидатов; источники и непроверенные media не смешиваются. Осталось подключить только проверенные waterbody media roles и завершить browser QA, включая различение карты, заставки и абстрактного отпечатка.
- [ ] **W08 · Приёмка и эксплуатация.** Добавить fixture-based parser tests, offline catalog/media audit, проверку 19 canonical entities, отсутствие битых файлов и browser QA desktop/mobile. Сетевые тесты не выполнять; регулярный импорт оставить opt-in и под общим cooldown/backoff. - [ ] **W08 · Приёмка и эксплуатация.** Fixture-based parser tests, offline catalog/media audit и повторный idempotent import подтверждены: `created=0 updated=19`, в PostgreSQL ровно `19` RF4DB waterbodies плюс `2` legacy-записи, media audit сообщает `issues=[]` и `orphaned_files=[]`. In-app Chromium и focused smoke покрывают desktop/mobile основные страницы; остаются полная browser QA error/empty matrix и закрытие внешних detail-данных. Сетевые тесты не выполнять; регулярный импорт оставить opt-in и под общим cooldown/backoff.
### Каталог снастей, наживок и прочей оснастки ### Каталог снастей, наживок и прочей оснастки
@@ -65,15 +66,15 @@
собранная схема или монтаж (например, донная, поплавочная, method). Число собранная схема или монтаж (например, донная, поплавочная, method). Число
найденных изображений приманок не считается размером полного каталога. найденных изображений приманок не считается размером полного каталога.
- [ ] **G01 · Canonical-каталог RF4DB.** Получить разрешённый индекс категорий gear и проверить полный набор доступных страниц по типам: `bait`, `lure`, `rod`, `reel`, `line`, `hook`, `rig`, `float`, `sinker`, `other`. Сохранить исходный slug/ID, русское название, категорию, подкатегорию, бренд, семейство, игровые ограничения/уровень и source URL. Отсутствие общего счётчика или закрытая категория должны оставаться явно `unknown`, а не превращаться в оценку полноты. - [ ] **G01 · Canonical-каталог RF4DB.** Offline-контракт и строгий parser готовы для категорий `bait`, `lure`, `rod`, `reel`, `line`, `hook`, `rig`, `float`, `sinker`, `other`: сохраняются исходный slug/ID, русское название, категория, подкатегория, бренд, семейство, игровой уровень и source URL; duplicate ID и неизвестная категория отклоняются. Общий count остаётся `unknown`, пока не получен разрешённый реальный индекс; fixture не считается каталогом.
- [ ] **G02 · Карточки предметов и оснасток.** Подготовить строгие fixture-based parsers для списка и detail-страницы: характеристики, варианты, совместимость, изображения, связанные типы монтажа и исходные значения. Парсер должен различать отсутствующее поле, «не применимо» и фактическое нулевое значение; при неполном или изменившемся ответе сохранять предыдущие подтверждённые данные. Detail-запросы выполнять только для выбранных карточек и с общим 30-минутным cooldown домена. - [ ] **G02 · Карточки предметов и оснасток.** Fixture-based parser списка и detail-страницы готов: характеристики, варианты, совместимость, изображения и связанные типы монтажа сохраняются; атрибуты различают фактический `0`, `not_applicable` и `missing`. Parser fail-closed при неполном/изменившемся DOM; реальные detail-запросы выполнять только для выбранных карточек и с общим 30-минутным cooldown домена.
- [ ] **G03 · Модель и provenance.** Расширить `bait` до канонического `tackle_item` либо выполнить безопасную миграцию с обратной совместимостью API: `kind`, `category`, `subcategory`, `brand`, `family`, `source_system`, `source_external_id`, `source_url`, `source_checked_at`, `raw_payload`. Отдельно моделировать `rig`/монтаж и его компоненты; не хранить удилище, катушку и монтаж в одном свободном `rig_type`. Для каждой характеристики сохранить источник и статус проверки. - [ ] **G03 · Модель и provenance.** Добавлены отдельные `tackle_item`, `rig` и `rig_component` с миграцией `0021`; legacy `bait` и `catch_report.bait_id` не изменялись. `tackle_item` хранит `category`, `subcategory`, `brand`, `family`, `unlock_level`, source identity, timestamp и `raw_payload`; `rig_component` хранит роль, порядок, исходное значение и optional canonical item. Связи с catch-потоком добавлены в G05; безопасный backfill остаётся только после реального crosswalk.
- [ ] **G04 · Crosswalk и нормализация.** Построить offline-crosswalk между RF4DB, официальными рекордами, RF4MAP, RF4 Posts и локальным справочником. Нормализовать регистр, пробелы, дефисы, единицы и локализацию; предлагать совпадение только при точном имени/алиасе плюс совместимой категории. Неоднозначные, брендовые варианты и unmatched-строки отправлять на review без автоматического canonical key; исходное значение всегда сохранять. - [ ] **G04 · Crosswalk и нормализация.** Добавлен offline `gear_crosswalk`: нормализация регистра/пробелов/`е/ё`, точное имя или alias плюс совместимая категория, консервативная проверка brand/family. Неоднозначные, несовместимые, брендовые и unmatched-строки получают review-статус без canonical key; исходное значение сохраняется. Остаётся подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты.
- [ ] **G05 · Связи с уловами и источниками.** Протянуть канонические предметы и монтажи через community import, официальные записи и форму улова, сохранив `raw_payload` и список missing fields. Поддержать несколько предметов в одном комплекте, порядок/роль компонента и источник каждой связи; старый `bait_id` и текстовые значения не терять при миграции. Публикация полного наблюдения по-прежнему требует подтверждённых соответствий, а не простого совпадения строки. - [ ] **G05 · Связи с уловами и источниками.** Добавлены `catch_tackle_component` и offline `gear_components`: можно сохранять несколько unresolved/canonical компонентов с ролью, порядком, исходным значением, source identity и `raw_payload`; legacy `bait_id` не меняется. Parser сохраняет порядок оборудования из detail и разделяет bait/rig в catch-полях. Запись компонентов подключена к community import, официальному импорту и пользовательской форме; повторная обработка идемпотентна. Canonical-привязка и безопасный backfill остаются только после подтверждённого crosswalk.
- [ ] **G06 · API и публичный каталог.** Добавить пагинированные каталоги и detail endpoints с фильтрами по категории, бренду, семейству и уровню, а также безопасные ссылки из улова/точки на использованную приманку, снасть и монтаж. Показывать только подтверждённые характеристики, источник, свежесть и неполноту; не выдавать рейтинг эффективности, если его нельзя объяснить числом наблюдений, игроками, периодом и качеством источников. - [ ] **G06 · API и публичный каталог.** Добавлены пагинированный `/api/v1/tackle/items` с фильтрами по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Остаётся подключить публичные Astro-карточки и ссылки из всех нужных представлений улова.
- [ ] **G07 · Аналитика сочетаний и рекомендации.** После появления достаточных данных считать отдельно «водоём + рыба + предмет», «точка + рыба + предмет» и «способ ловли + монтаж». Зафиксировать минимальный объём выборки, защиту от одного игрока/дубликатов и decay по свежести; разделить факт использования, частоту и рекомендацию. Пустая или малая выборка должна показывать «данных мало», а не советовать конкретную снасть. - [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Пустая или малая выборка получает `insufficient_data`; decay по свежести и отдельные UI-состояния остаются следующим шагом.
- [ ] **G08 · Медиа и качество.** Разнести media roles для `tackle_item`, `bait`, `rig` и общего reference; связать варианты через `entity_key`, `duplicate_of`, `supersedes`/`replaced_by`. Проверять dimensions, MIME, SHA-256, прозрачность, aspect ratio, подпись и категорию; не переключать approved-файл автоматически, не считать userguide-скриншот карточкой предмета и не публиковать media-кандидатов без review и разрешённого provenance. - [ ] **G08 · Медиа и качество.** Добавлены отдельные reviewed-роли `tackle_card`, `tackle_detail`, `rig_diagram`, `tackle_screenshot` для `tackle`, а также CLI-параметр `--media-role`; offline audit отклоняет неизвестную роль и несовпадение роли с entity type. Существующие dimensions, MIME, SHA-256, прозрачность, aspect ratio, provenance и атомарное продвижение сохраняются. Остаётся провести реальный contact-sheet review для будущих tackle-кандидатов без автоматической публикации.
- [ ] **G09 · Приёмка и эксплуатация.** Добавить fixture/regression tests, offline catalog/crosswalk/media audits, проверку идемпотентности и сохранения старых данных при сбое, API/UI acceptance для пустых, неоднозначных и многокомпонентных комплектов, browser QA desktop/mobile и query-plan gate для фильтров/сочетаний. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff. Закрывать пакет только после проверяемого счётчика по каждой категории либо явной фиксации `unknown`. - [ ] **G09 · Приёмка и эксплуатация.** Добавлены fixture/regression tests для crosswalk, media roles, идемпотентных компонентов, пустых результатов и многокомпонентных наблюдений; каталог `/tackle` включён в visual-matrix, narrow smoke и accessibility routes. Offline catalog/media audits и сохранение старых данных при сбое проходят. Добавлен TEMP-only query-plan gate для фильтра каталога и группировки сочетаний: production-фильтры должны использовать индексы и укладываться в 250 мс. Остаются HTTP/browser acceptance для пустых, неоднозначных и многокомпонентных комплектов. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff. Закрывать пакет только после проверяемого счётчика по каждой категории либо явной фиксации `unknown`.
### Тёмная тема ### Тёмная тема
@@ -83,9 +84,9 @@
- [x] **D02 · Системный режим без вспышки.** Первый визит следует `prefers-color-scheme` полностью через CSS: серверный HTML сразу совместим с системной темой, состояние не хранится, inline bootstrap не добавлен и CSP не ослаблена. Явное переопределение появится только вместе с D03–D04. - [x] **D02 · Системный режим без вспышки.** Первый визит следует `prefers-color-scheme` полностью через CSS: серверный HTML сразу совместим с системной темой, состояние не хранится, inline bootstrap не добавлен и CSP не ослаблена. Явное переопределение появится только вместе с D03–D04.
- [x] **D03 · Переключатель темы.** В header добавлен доступный трёхпозиционный выбор «Системная / Светлая / Тёмная»; на узких экранах он сохраняет три понятные иконки и доступные названия. Кнопки работают с клавиатуры, имеют видимый focus и синхронизируют `aria-pressed`. - [x] **D03 · Переключатель темы.** В header добавлен доступный трёхпозиционный выбор «Системная / Светлая / Тёмная»; на узких экранах он сохраняет три понятные иконки и доступные названия. Кнопки работают с клавиатуры, имеют видимый focus и синхронизируют `aria-pressed`.
- [x] **D04 · Сохранение и SSR-согласование.** Выбор хранится год в allowlist-cookie `rf4-theme` с `SameSite=Lax` и `Secure` на HTTPS; Astro SSR выставляет `data-theme` до отрисовки, а системный режим оставляет выбор браузеру. Собранный Astro client script переключает тему без inline-кода, localStorage и ослабления CSP. - [x] **D04 · Сохранение и SSR-согласование.** Выбор хранится год в allowlist-cookie `rf4-theme` с `SameSite=Lax` и `Secure` на HTTPS; Astro SSR выставляет `data-theme` до отрисовки, а системный режим оставляет выбор браузеру. Собранный Astro client script переключает тему без inline-кода, localStorage и ослабления CSP.
- [ ] **D05 · Темизация компонентов и графики.** На семантические light/dark-токены переведены базовые и admin-поверхности, header, каталог, breadcrumb-леска, ссылки сущностей, таблицы, формы, moderation-карточки, provenance/quality/status badges, паспорта данных, pagination, empty/loading-состояния, радар, timeline, силуэты и глифы; добавлен forced-colors layer. Инвентаризированы публичные растры и alpha-каналы PWA-иконок, hero получил dark/print treatment, screenshots — семантическую подложку. Осталась браузерная приёмка теней и градиентов в обеих темах. Источники и статусы сохраняют текстовые подписи и не различаются только цветом. - [ ] **D05 · Темизация компонентов и графики.** На семантические light/dark-токены переведены базовые и admin-поверхности, header, каталог, breadcrumb-леска, ссылки сущностей, таблицы, формы, moderation-карточки, provenance/quality/status badges, паспорта данных, pagination, empty/loading-состояния, радар, timeline, силуэты и глифы; добавлен forced-colors layer. In-app Chromium подтвердил переключение light/dark/system на медиатеке во всех четырёх ширинах; standalone axe проходит `9/9` маршрутов, forced-colors и print regression подтверждены. Полная screenshot-матрица `156` PNG захватывается launcher-ом без document overflow; остаётся вручную проверить тени/градиенты и выбрать сохраняемый reference-набор. Источники и статусы сохраняют текстовые подписи и не различаются только цветом.
- [x] **D06 · Метаданные браузера и CSP.** Добавлены парные `theme-color` для системной light/dark схемы; явный cookie-выбор согласуется с SSR и мгновенно переключает активный meta-тег. PWA manifest использует устойчивый тёмный brand chrome и splash background. Production build сохраняет `inlinedScripts: []`; новые inline script/style/attributes не появились, ослабление CSP не потребовалось. - [x] **D06 · Метаданные браузера и CSP.** Добавлены парные `theme-color` для системной light/dark схемы; явный cookie-выбор согласуется с SSR и мгновенно переключает активный meta-тег. PWA manifest использует устойчивый тёмный brand chrome и splash background. Production build сохраняет `inlinedScripts: []`; новые inline script/style/attributes не появились, ослабление CSP не потребовалось.
- [ ] **D07 · Визуальная и accessibility-приёмка.** Проверить light/dark/system на 320/390/768/1280 px для главной, каталогов, detail, records, report, status и всех admin-экранов; покрыть normal/hover/focus/disabled/error/loading/empty и длинные данные. Для обеих тем обеспечить WCAG AA, отсутствие горизонтального scroll и CLS, корректную печать, reduced motion и переключение без потери введённых данных; сохранить эталонные screenshots и краткий отчёт. - [ ] **D07 · Визуальная и accessibility-приёмка.** In-app Chromium подтвердил отсутствие горизонтального scroll на публичных `/`, `/waterbodies`, `/records`, `/report`, `/status`, `/media`, `/rules`, waterbody detail `/waterbodies/р-вьюнок` и fish detail `/fish/pike`, а также admin `/admin`, `/admin/moderation`, `/admin/external-sources`, `/admin/media` в 320/390/768/1280 px; исправлены tablet header и media-grid overflow. Автоматизированный visual-matrix regression и `scripts/capture-visual-matrix.mjs` подтверждают 13 маршрутов × 4 ширины × 3 режима (`156` комбинаций): document overflow отсутствует, везде есть `main`/`h1`, SSR-тема совпадает; полная screenshot-матрица захвачена во внешнюю директорию. Для media и двух detail-страниц light/dark/system корректно переключаются, broken images — `0`; axe `9/9`, keyboard/focus, forced-colors и print-regression проходят. Остались ручная проверка shadows/gradients и выбор сохраняемого reference-набора. Для обеих тем обеспечить WCAG AA, отсутствие горизонтального scroll и CLS, reduced motion и переключение без потери введённых данных; сохранить эталонные screenshots и краткий отчёт.
- [ ] **Q01 · Документы источников — реестр готов, нужны первичные подтверждения.** Создан единый production-gate с атрибуцией, общим лимитом 30 минут, хранением и процедурой отзыва для RF4DB, RF4-STAT, RF4MAP, RF4 Posts и официального RF4. До открытой публикации приложить устойчивые ссылки/копии первичных разрешений, контакты, даты и отдельно подтвердить право на изображения; пустое поле блокирует соответствующий источник. - [ ] **Q01 · Документы источников — реестр готов, нужны первичные подтверждения.** Создан единый production-gate с атрибуцией, общим лимитом 30 минут, хранением и процедурой отзыва для RF4DB, RF4-STAT, RF4MAP, RF4 Posts и официального RF4. До открытой публикации приложить устойчивые ссылки/копии первичных разрешений, контакты, даты и отдельно подтвердить право на изображения; пустое поле блокирует соответствующий источник.
- [x] **Q02 · Управляемое удаление источника.** Изменение ранее опубликованной записи возвращает её в staging, сбрасывает сопоставление и переводит связанный улов в pending с новой версией решения. Результат проверки хранится как `available`, `missing`, `temporary_error` или `blocked`: только подтверждённый `missing` отзывает публикацию, временная ошибка и блокировка остаются диагностикой. Повторное появление требует ручного подтверждения. Withdrawn-записи исключены из публичной активности, а статус и время проверки доступны в admin provenance и журнале решений. Отдельного сетевого обхода нет: Q03 подключит эту реакцию к разрешённому плановому запросу. - [x] **Q02 · Управляемое удаление источника.** Изменение ранее опубликованной записи возвращает её в staging, сбрасывает сопоставление и переводит связанный улов в pending с новой версией решения. Результат проверки хранится как `available`, `missing`, `temporary_error` или `blocked`: только подтверждённый `missing` отзывает публикацию, временная ошибка и блокировка остаются диагностикой. Повторное появление требует ручного подтверждения. Withdrawn-записи исключены из публичной активности, а статус и время проверки доступны в admin provenance и журнале решений. Отдельного сетевого обхода нет: Q03 подключит эту реакцию к разрешённому плановому запросу.
@@ -107,7 +108,7 @@
- [x] **Q13 · Production bootstrap в CI.** Отдельный workflow запускает `deploy/test-production-bootstrap.sh` вручную или раз в неделю, а не на каждом push. Вывод bootstrap всегда сохраняется 14 дней; при падении добавляются Compose status и Playwright diagnostics. - [x] **Q13 · Production bootstrap в CI.** Отдельный workflow запускает `deploy/test-production-bootstrap.sh` вручную или раз в неделю, а не на каждом push. Вывод bootstrap всегда сохраняется 14 дней; при падении добавляются Compose status и Playwright diagnostics.
- [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] **Q14 · Полная CSP.** Каждый Astro SSR-ответ получает отдельный nonce для динамического JSON-LD и собственный строгий CSP; `FILES_DOMAIN` валидируется как hostname. Production запрещает inline handlers, `unsafe-inline`, eval, wildcard и HTTP; page scripts/styles остаются same-origin `_astro`-ассетами, style/script attributes запрещены. Caddy сохраняет upstream policy и использует строгий fallback для API. Bootstrap проверяет совпадение nonce на главной, report и admin и доступность OG; реальный signed screenshot проверяется после наполнения production MinIO.
- [x] **Q15 · Частичная деградация главной.** SSR независимо получает activity, community signals и оба справочника через settled-результаты. Отказ секции показывает собственный `StatePanel`, сохраняет остальные данные и HTTP 200 с `X-RF4-Partial`/`Cache-Control: no-store`; только отказ всех четырёх частей возвращает 503, `Retry-After` и noindex. Client-side loading и optimistic UI не добавлялись. - [x] **Q15 · Частичная деградация главной.** SSR независимо получает activity, community signals и оба справочника через settled-результаты. Отказ секции показывает собственный `StatePanel`, сохраняет остальные данные и HTTP 200 с `X-RF4-Partial`/`Cache-Control: no-store`; только отказ всех четырёх частей возвращает 503, `Retry-After` и noindex. Client-side loading и optimistic UI не добавлялись.
- [x] **Q16 · Контракт OpenAPI.** `apps/api/openapi.json` детерминированно генерируется из FastAPI; CI проверяет его актуальность после backend suite. Изменение artifact обязательно рассматривается вместе с реализацией, а ручное редактирование не используется. - [x] **Q16 · Контракт OpenAPI.** `apps/api/openapi.json` перегенерирован из текущего FastAPI-контракта; `apps/api/export_openapi.py --check` проходит (`36 paths`). В artifact добавлены media decision/rollback schemas и quality-upgrade routes; ручное редактирование не использовалось.
- [x] **Q17 · Эксплуатационные документы.** Зафиксированы ADR по Astro/FastAPI/PostgreSQL, локальному cache, scheduler/cooldown и разделению PostgreSQL/MinIO. Incident runbook покрывает заполнение диска, отказ PostgreSQL/MinIO, зависшие импорты, ошибки миграций, компрометацию секретов и критерии закрытия без опасных reset/recreate операций. - [x] **Q17 · Эксплуатационные документы.** Зафиксированы ADR по Astro/FastAPI/PostgreSQL, локальному cache, scheduler/cooldown и разделению PostgreSQL/MinIO. Incident runbook покрывает заполнение диска, отказ PostgreSQL/MinIO, зависшие импорты, ошибки миграций, компрометацию секретов и критерии закрытия без опасных reset/recreate операций.
- [x] **Q18 · Минимальная observability.** Зафиксированы дешёвые SLI, стартовые пороги и источники для request count/latency/5xx, moderation/staging depth, возраста импортов и инфраструктуры. Начальный контур использует JSON-логи, readiness, diagnostics и host monitor; exporter выбирается после покупки сервера, tracing и Prometheus не вводятся без измеренной потребности. Перед альфой остаётся подключить реальный канал и проверить critical/recovery alert. - [x] **Q18 · Минимальная observability.** Зафиксированы дешёвые SLI, стартовые пороги и источники для request count/latency/5xx, moderation/staging depth, возраста импортов и инфраструктуры. Начальный контур использует JSON-логи, readiness, diagnostics и host monitor; exporter выбирается после покупки сервера, tracing и Prometheus не вводятся без измеренной потребности. Перед альфой остаётся подключить реальный канал и проверить critical/recovery alert.
@@ -127,10 +128,10 @@
- [x] **A01 · Защита маршрутов и границы сессии.** Покрыть точный `/admin` и `/admin/*` единым Caddy Basic Auth, выставлять `noindex` и `no-store` для всех административных ответов, проверить отсутствие обхода через API и корректные `401/429`. Критерий: автоматический proxy-smoke для `/admin`, страниц и `/api/v1/admin/*` с отсутствующим, неверным и валидным доступом. - [x] **A01 · Защита маршрутов и границы сессии.** Покрыть точный `/admin` и `/admin/*` единым Caddy Basic Auth, выставлять `noindex` и `no-store` для всех административных ответов, проверить отсутствие обхода через API и корректные `401/429`. Критерий: автоматический proxy-smoke для `/admin`, страниц и `/api/v1/admin/*` с отсутствующим, неверным и валидным доступом.
- [x] **A02 · Единая auth/error UX.** Привести dashboard, moderation и external sources к одинаковому поведению при `401`, `409`, `429`, `5xx`, loading/empty-состояниях: понятное сообщение, блокировка повторной отправки, возврат к входу только при истёкшей авторизации. Критерий: regression-тесты на каждый ответ и сохранение введённой причины. - [x] **A02 · Единая auth/error UX.** Привести dashboard, moderation и external sources к одинаковому поведению при `401`, `409`, `429`, `5xx`, loading/empty-состояниях: понятное сообщение, блокировка повторной отправки, возврат к входу только при истёкшей авторизации. Критерий: regression-тесты на каждый ответ и сохранение введённой причины.
- [x] **A03 · Полный single-owner workflow.** Добавить пагинацию очереди уловов, кнопку запуска официального импорта и отображение результата/истории, безопасные статусы источников с возрастом данных, cooldown/backoff и ручное обновление очередей. Критерий: владелец может пройти путь «импорт → проверка → решение → история» без API/CLI; старые данные сохраняются при сбое импорта. - [x] **A03 · Полный single-owner workflow.** Добавить пагинацию очереди уловов, кнопку запуска официального импорта и отображение результата/истории, безопасные статусы источников с возрастом данных, cooldown/backoff и ручное обновление очередей. Критерий: владелец может пройти путь «импорт → проверка → решение → история» без API/CLI; старые данные сохраняются при сбое импорта.
- [ ] **A04 · Контур медиа-проверки.** Сделать admin-экран для просмотра approved/upgrade_queued медиа, исходника, размеров, производных и provenance; добавить approve/rollback только через существующие безопасные состояния. Критерий: ни одна публичная замена не происходит без явного решения и проверяемого manifest-а. - [x] **A04 · Контур медиа-проверки.** Admin-экран показывает approved/upgrade_queued/upgrade_stored медиа, источник, размеры, производные и provenance. Защищённые действия требуют непустую причину: publish атомарно продвигает только проверенные `upgrade_stored` и сохраняет fallback, rollback принимает только связанную пару `approved`/`superseded`; небезопасные состояния возвращают `409`. API-тесты проверяют auth, валидацию причины и вызов безопасных операций; browser acceptance остаётся в A06.
- [ ] **A05 · Многопользовательский доступ.** После пилота заменить общий Bearer-токен персональными аккаунтами и короткими серверными сессиями с отзывом, ролями read-only/moderator/importer/owner, operator ID в аудите и журналом входов. Критерий: минимальные права реально ограничивают действия, logout/revoke инвалидируют сессию на сервере. - [ ] **A05 · Многопользовательский доступ.** После пилота заменить общий Bearer-токен персональными аккаунтами и короткими серверными сессиями с отзывом, ролями read-only/moderator/importer/owner, operator ID в аудите и журналом входов. Критерий: минимальные права реально ограничивают действия, logout/revoke инвалидируют сессию на сервере.
- [ ] **A06 · Browser/accessibility acceptance.** Проверить `/admin`, moderation и external sources в 320/390/768/1280 px: клавиатура, focus order, screen reader labels, reduced motion, forced colors, темы, ошибки/пустые очереди и конфликт `409`. Критерий: Playwright + axe без блокирующих дефектов и ручная визуальная проверка. - [ ] **A06 · Browser/accessibility acceptance.** In-app Chromium подтвердил `/admin`, moderation, external sources и media в 320/390/768/1280 px без горизонтального overflow; четыре admin-маршрута имеют landmarks, подписанные поля, именованные кнопки и `alt` у изображений. Visual-matrix regression покрывает 13 маршрутов × 4 ширины × 3 режима (`156` комбинаций) без document overflow и с совпадающей SSR-темой. Standalone axe проходит `9/9` маршрутов без serious/critical violations, keyboard/focus regression не оставляет фокус на `BODY`, forced-colors сохраняет видимую границу theme controls, print-regression сохраняет светлую схему и убирает фильтр hero image. Остаются screenshots и ручная визуальная проверка shadows/gradients; headless gate больше не блокируется sandbox при escalated запуске.
- [ ] **A07 · Production gate.** Выполнить preflight с реальными секретами, проверить Caddy Basic + API Bearer, закрытые внутренние порты, backup/restore PostgreSQL и MinIO, readiness, no-store и внешний smoke после деплоя. Критерий: acceptance-runbook пройден, rollback и процедура отзыва доступа документированы. - [ ] **A07 · Production gate.** Выполнить preflight с реальными секретами, проверить Caddy Basic + API Bearer, закрытые внутренние порты, backup/restore PostgreSQL и MinIO, readiness, no-store и внешний smoke после деплоя. Локальный чистый-volume bootstrap и оба Playwright-сценария подтверждены; критерий A07 остаётся открытым до acceptance-runbook с реальными секретами, backup/restore, rollback и процедурой отзыва доступа.
## Готовность открытой альфы — требуется сервер или внешний сервис ## Готовность открытой альфы — требуется сервер или внешний сервис
+31
View File
@@ -76,6 +76,37 @@ Fallback строится на собственных лёгких SVG: осмы
Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU. Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU.
## Ручное получение snapshots водоёмов
Для ручного запуска используется [`scripts/fetch-waterbodies.py`](../scripts/fetch-waterbodies.py). Скрипт вызывает тот же guarded CLI, что и остальные исследовательские источники: общий `rf4db.com` cooldown резервируется до HTTP-запроса, поэтому повтор после `403` или сетевой ошибки не выполняется раньше разрешённого окна.
Для страницы, которая требует обычного браузерного JavaScript, используется Chromium-launcher с отдельным persistent profile:
```bash
node scripts/fetch-waterbodies-chromium.mjs catalog
node scripts/fetch-waterbodies-chromium.mjs detail \
--url https://download.rf4db.com/ru/maps/level_019_american_pond
```
Он сначала резервирует тот же cooldown, затем открывает Chromium, сохраняет DOM и передаёт его локальному parser-у. Первый запуск можно оставить headed и вручную завершить обычную проверку сайта; `--headless` предназначен только для страниц, которые уже открываются без пользовательского действия. CAPTCHA и защитные challenge не автоматизируются.
```bash
./scripts/fetch-waterbodies.py catalog
./scripts/fetch-waterbodies.py detail \
--url https://download.rf4db.com/ru/maps/level_019_american_pond
```
Результат сохраняется атомарно в `.cache/waterbodies/` с timestamp-именем; `403`, неполный ответ и cooldown оставляют прежние snapshots нетронутыми. Если RF4DB показывает защитную страницу в разрешённой Chromium-сессии, сохраните полученный HTML и разберите его без повторного HTTP:
```bash
./scripts/fetch-waterbodies.py catalog --html /path/to/maps.html
./scripts/fetch-waterbodies.py detail \
--url https://download.rf4db.com/ru/maps/level_019_american_pond \
--html /path/to/detail.html
```
`--html` не резервирует cooldown и не обходит защиту: challenge/неполный документ будет отклонён строгим parser-ом. Скрипт намеренно не импортирует данные в PostgreSQL автоматически. После ручной проверки snapshot импортируется отдельно через `python -m app.cli import-waterbody-catalog` или `import-waterbody-detail`; для запуска из Docker файл передаётся в контейнер через stdin.
## Рекомендуемый режим получения ## Рекомендуемый режим получения
- Один понятный `User-Agent`, таймаут 20 секунд. - Один понятный `User-Agent`, таймаут 20 секунд.
+8
View File
@@ -47,4 +47,12 @@
После наполнения выполнить `EXPLAIN (ANALYZE, BUFFERS)` для activity, moderation queue, records, staging queue и удаления старых submission attempts. Проверять фактическое время, `Rows Removed by Filter`, объём buffers и соответствие выбранного индекса фильтрам. Если таблица превышает 10 000 строк, а план остаётся последовательным и выходит за бюджет, сохранить план в журнал релиза и скорректировать индекс или форму запроса до открытия альфы. После наполнения выполнить `EXPLAIN (ANALYZE, BUFFERS)` для activity, moderation queue, records, staging queue и удаления старых submission attempts. Проверять фактическое время, `Rows Removed by Filter`, объём buffers и соответствие выбранного индекса фильтрам. Если таблица превышает 10 000 строк, а план остаётся последовательным и выходит за бюджет, сохранить план в журнал релиза и скорректировать индекс или форму запроса до открытия альфы.
Для новых каталогов снастей и аналитики сочетаний используется отдельный TEMP-only gate:
```bash
./deploy/test-tackle-query-plans.sh
```
Он создаёт 5 000 предметов, 100 000 уловов и 100 000 ordered-компонентов только в текущей psql-сессии, проверяет фильтр каталога и группировку approved-компонентов за 72 часа. Фильтр каталога должен использовать индексный путь, аналитика — селективный индексный путь по временной ленте и уложиться вместе с каталогом в 250 мс; широкий scan компонентов допустим после ограничения уловов. Production-таблицы не изменяются.
Bootstrap-тест отдельно проверяет, что Alembic прошёл миграцию `0011` и все восемь составных индексов созданы на чистой PostgreSQL. После запуска на сервере локальный gate не заменяет три полевых замера p95 из [load-testing.md](load-testing.md). Bootstrap-тест отдельно проверяет, что Alembic прошёл миграцию `0011` и все восемь составных индексов созданы на чистой PostgreSQL. После запуска на сервере локальный gate не заменяет три полевых замера p95 из [load-testing.md](load-testing.md).
+14 -3
View File
@@ -16,6 +16,7 @@ from urllib.request import Request, urlopen, HTTPRedirectHandler, build_opener
from .community_sources import ( from .community_sources import (
parse_rf4db_catches, parse_rf4db_catches,
parse_rf4db_waterbodies, parse_rf4db_waterbodies,
parse_rf4db_waterbody_detail,
parse_rf4map_point, parse_rf4map_point,
parse_rf4posts_spot, parse_rf4posts_spot,
parse_rf4stat_fishing, parse_rf4stat_fishing,
@@ -30,6 +31,7 @@ SOURCES = {
"rf4stat-posts": ("https://rf4-stat.ru/posts/", parse_rf4stat_posts), "rf4stat-posts": ("https://rf4-stat.ru/posts/", parse_rf4stat_posts),
} }
DETAIL_SOURCES = { DETAIL_SOURCES = {
"rf4db-waterbody": parse_rf4db_waterbody_detail,
"rf4map-point": parse_rf4map_point, "rf4map-point": parse_rf4map_point,
"rf4posts-spot": parse_rf4posts_spot, "rf4posts-spot": parse_rf4posts_spot,
} }
@@ -308,6 +310,10 @@ def main(argv: list[str] | None = None) -> int:
default=Path(os.environ.get("RF4_COMMUNITY_FETCH_STATE", DEFAULT_STATE_FILE)), default=Path(os.environ.get("RF4_COMMUNITY_FETCH_STATE", DEFAULT_STATE_FILE)),
help="Persistent per-source cooldown state", help="Persistent per-source cooldown state",
) )
parser.add_argument(
"--reserve-only", action="store_true",
help="Reserve the shared site cooldown and stop before making an HTTP request",
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.source in DETAIL_SOURCES and not args.url: if args.source in DETAIL_SOURCES and not args.url:
parser.error(f"--url is required for {args.source}") parser.error(f"--url is required for {args.source}")
@@ -317,16 +323,21 @@ def main(argv: list[str] | None = None) -> int:
site_key = fetch_site_key(url) site_key = fetch_site_key(url)
# Single atomic check-and-reserve before network I/O: failed attempts count toward the limit too. # Single atomic check-and-reserve before network I/O: failed attempts count toward the limit too.
check_and_reserve(site_key, state_file=args.state_file) check_and_reserve(site_key, state_file=args.state_file)
if args.reserve_only:
print(json.dumps({"reserved": True, "source": args.source, "site_key": site_key}, ensure_ascii=False))
return 0
html = fetch_html(url) html = fetch_html(url)
records = ( parsed = (
parse(html, source_url=url) parse(html, source_url=url)
if args.source in DETAIL_SOURCES or args.source == "rf4db-waterbodies" if args.source in DETAIL_SOURCES or args.source == "rf4db-waterbodies"
else parse(html) else parse(html)
)[:args.limit] )
records = parsed if args.source in DETAIL_SOURCES else parsed[: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)
return 1 return 1
print(json.dumps([asdict(item) for item in records], ensure_ascii=False, default=str)) payload = asdict(records) if args.source in DETAIL_SOURCES else [asdict(item) for item in records]
print(json.dumps(payload, ensure_ascii=False, default=str))
return 0 return 0
+235 -4
View File
@@ -35,6 +35,7 @@ class ExternalCatch:
clip: str | None clip: str | None
fishing_style: str | None fishing_style: str | None
evidence_urls: tuple[str, ...] evidence_urls: tuple[str, ...]
coordinate_raw: str | None = None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -44,6 +45,51 @@ class EquipmentItem:
external_id: str | None external_id: str | None
GEAR_CATEGORIES = frozenset({
"bait", "lure", "rod", "reel", "line", "hook", "rig", "float", "sinker", "other",
})
@dataclass(frozen=True, slots=True)
class RF4DBGearItem:
source_system: str
source_external_id: str
source_url: str
name: str
category: str
subcategory: str | None
brand: str | None
family: str | None
unlock_level: int | None
image_url: str | None
@dataclass(frozen=True, slots=True)
class RF4DBGearAttribute:
key: str
state: str
value: str | int | float | None
source_text: str | None
@dataclass(frozen=True, slots=True)
class RF4DBGearDetail:
source_system: str
source_external_id: str
source_url: str
name: str
category: str
subcategory: str | None
brand: str | None
family: str | None
unlock_level: int | None
attributes: tuple[RF4DBGearAttribute, ...]
variants: tuple[str, ...]
compatible_with: tuple[str, ...]
rig_types: tuple[str, ...]
image_urls: tuple[str, ...]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class RF4DBCatchDetail: class RF4DBCatchDetail:
source_external_id: str source_external_id: str
@@ -67,10 +113,29 @@ class RF4DBWaterbody:
image_url: str | None image_url: str | None
@dataclass(frozen=True, slots=True)
class RF4DBWaterbodyDetail:
source_system: str
source_external_id: str
source_url: str
name: str
description: str | None
aliases: tuple[str, ...]
fish_species: tuple[str, ...]
fish_external_ids: tuple[str | None, ...]
image_urls: tuple[str, ...]
point_urls: tuple[str, ...]
def _text(node: Tag | None) -> str: def _text(node: Tag | None) -> str:
return " ".join(node.get_text(" ", strip=True).split()) if node else "" return " ".join(node.get_text(" ", strip=True).split()) if node else ""
def _fish_name(node: Tag) -> str:
"""Read a fish label while dropping the optional trophy weight suffix."""
return re.sub(r"\s*\d+(?:[.,]\d+)?\s*(?:кг|kg|г|g)\s*$", "", _text(node), flags=re.I).strip()
def _key(href: str | None) -> str | None: def _key(href: str | None) -> str | None:
if not href: if not href:
return None return None
@@ -189,6 +254,167 @@ def parse_rf4db_waterbodies(
return result return result
def _gear_level(value: str | None) -> int | None:
text = (value or "").strip()
if not text:
return None
if not re.fullmatch(r"\d+", text):
raise CommunityParseError(f"invalid gear unlock level: {text!r}")
return int(text)
def _gear_category(value: str | None) -> str:
category = (value or "").strip().casefold()
if category not in GEAR_CATEGORIES:
raise CommunityParseError(f"invalid or missing gear category: {value!r}")
return category
def _gear_value(node: Tag) -> str | int | float | None:
text = _text(node) or None
if text is None:
return None
value_type = str(node.get("data-type") or "text").casefold()
if value_type == "number":
try:
return float(text) if "." in text else int(text)
except ValueError as exc:
raise CommunityParseError(f"invalid numeric gear attribute: {text!r}") from exc
return text
def parse_rf4db_gear_catalog(
html: str, *, source_url: str = "https://rf4db.com/ru/wiki/gear", expected_count: int | None = None,
) -> list[RF4DBGearItem]:
"""Parse a gear index while keeping an unknown total explicitly unknown."""
soup = BeautifulSoup(html, "html.parser")
cards = soup.select("article.gear-card, [data-gear-card]")
result: list[RF4DBGearItem] = []
seen: set[str] = set()
for card in cards:
link = card.select_one("a[href]")
external_id = str(card.get("data-gear-id") or _key(link.get("href") if link else None) or "")
name = _text(card.select_one("[data-name], h2, h3, .gear-name"))
category = _gear_category(card.get("data-category"))
if not external_id or not name:
raise CommunityParseError("RF4DB gear card is missing id or name")
if external_id in seen:
raise CommunityParseError(f"duplicate RF4DB gear id: {external_id}")
seen.add(external_id)
item_url = urljoin(source_url, str(link.get("href"))) if link and link.get("href") else source_url
image = card.select_one("img[src], img[data-src]")
image_url = urljoin(item_url, str(image.get("src") or image.get("data-src"))) if image else None
result.append(RF4DBGearItem(
source_system="rf4db", source_external_id=external_id, source_url=item_url,
name=name, category=category,
subcategory=_text(card.select_one("[data-subcategory], .gear-subcategory")) or None,
brand=_text(card.select_one("[data-brand], .gear-brand")) or None,
family=_text(card.select_one("[data-family], .gear-family")) or None,
unlock_level=_gear_level(card.get("data-unlock-level")), image_url=image_url,
))
if not result:
raise CommunityParseError("RF4DB gear cards not found")
if expected_count is not None and len(result) != expected_count:
raise CommunityParseError(f"RF4DB gear catalog count mismatch: expected {expected_count}, got {len(result)}")
return result
def parse_rf4db_gear_detail(
html: str, *, source_url: str,
) -> RF4DBGearDetail:
"""Parse one gear detail page with explicit missing/not-applicable/value states."""
soup = BeautifulSoup(html, "html.parser")
root = soup.select_one("main[data-gear-detail], article.gear-detail") or soup
external_id = _key(source_url)
name = _text(root.select_one("h1, [data-name]"))
category = _gear_category(root.get("data-category"))
if not external_id or not name:
raise CommunityParseError("RF4DB gear detail is missing id or name")
attributes: list[RF4DBGearAttribute] = []
for node in root.select("[data-gear-attribute]"):
key = str(node.get("data-gear-attribute") or "").strip()
state = str(node.get("data-state") or "value").strip().casefold()
if not key or state not in {"value", "not_applicable", "missing"}:
raise CommunityParseError("invalid RF4DB gear attribute state")
value = None if state != "value" else _gear_value(node)
attributes.append(RF4DBGearAttribute(key=key, state=state, value=value, source_text=_text(node) or None))
images = tuple(dict.fromkeys(
urljoin(source_url, str(node.get("src") or node.get("data-src")))
for node in root.select("img[src], img[data-src]")
if node.get("src") or node.get("data-src")
))
return RF4DBGearDetail(
source_system="rf4db", source_external_id=external_id, source_url=source_url,
name=name, category=category,
subcategory=_text(root.select_one("[data-subcategory], .gear-subcategory")) or None,
brand=_text(root.select_one("[data-brand], .gear-brand")) or None,
family=_text(root.select_one("[data-family], .gear-family")) or None,
unlock_level=_gear_level(root.get("data-unlock-level")),
attributes=tuple(attributes),
variants=tuple(dict.fromkeys(_text(node) for node in root.select("[data-gear-variant]") if _text(node))),
compatible_with=tuple(dict.fromkeys(_text(node) for node in root.select("[data-compatible-with]") if _text(node))),
rig_types=tuple(dict.fromkeys(_text(node) for node in root.select("[data-rig-type]") if _text(node))),
image_urls=images,
)
def parse_rf4db_waterbody_detail(
html: str, *, source_url: str,
) -> RF4DBWaterbodyDetail:
"""Parse one RF4DB waterbody page without assigning media or coordinates.
The detail page is accepted only when its localized heading and fish list
are present. Images and point links remain source candidates; review and
canonical crosswalks happen in later pipeline stages.
"""
soup = BeautifulSoup(html, "html.parser")
root = soup.select_one("article.waterbody-detail, main[data-waterbody-detail]") or soup
external_id = _key(source_url)
name = _text(root.select_one("h1"))
fish_nodes = root.select(".waterbody-fish a[href], [data-fish-list] a[href], a[href*='/fishes/']")
if not fish_nodes:
fish_nodes = root.select(".fish-list a[href], ul.fish a[href]")
fish_species: list[str] = []
fish_external_ids: list[str | None] = []
seen_fish: set[str] = set()
for node in fish_nodes:
fish_name = _fish_name(node)
fish_id = _key(node.get("href"))
identity = fish_id or fish_name.casefold()
if not fish_name or identity in seen_fish:
continue
fish_species.append(fish_name)
fish_external_ids.append(fish_id)
seen_fish.add(identity)
if not external_id or not name or not fish_species:
raise CommunityParseError("RF4DB waterbody detail not found or incomplete")
description_node = root.select_one("[data-description], .waterbody-description, .description")
description = _text(description_node) or None
aliases = tuple(dict.fromkeys(
_text(node) for node in root.select("[data-alias], .waterbody-aliases li, .aliases li")
if _text(node) and _text(node) != name
))
image_urls = tuple(dict.fromkeys(
urljoin(source_url, str(node.get("src") or node.get("data-src")))
for node in root.select("img[src], img[data-src]")
if (node.get("src") or node.get("data-src"))
and "/fish/" not in str(node.get("src") or node.get("data-src"))
))
point_urls = tuple(dict.fromkeys(
urljoin(source_url, str(node.get("href")))
for node in root.select('a[href*="/spots/"], a[href*="/points/"]')
if node.get("href")
))
return RF4DBWaterbodyDetail(
source_system="rf4db", source_external_id=external_id,
source_url=source_url, name=name, description=description,
aliases=aliases, fish_species=tuple(fish_species),
fish_external_ids=tuple(fish_external_ids), image_urls=image_urls,
point_urls=point_urls,
)
def parse_rf4db_catches(html: str, *, base_url: str = "https://rf4db.com") -> list[ExternalCatch]: def parse_rf4db_catches(html: str, *, base_url: str = "https://rf4db.com") -> list[ExternalCatch]:
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
result: list[ExternalCatch] = [] result: list[ExternalCatch] = []
@@ -201,7 +427,8 @@ def parse_rf4db_catches(html: str, *, base_url: str = "https://rf4db.com") -> li
external_id = _key(detail.get("href")) external_id = _key(detail.get("href"))
if not external_id: if not external_id:
continue continue
x, y = _coordinates(_text(card.select_one(".catch-card__place b"))) coordinate_raw = _text(card.select_one(".catch-card__place b")) or None
x, y = _coordinates(coordinate_raw or "")
bait_link = card.select_one('.catch-card__place a[href*="/wiki/baits/"]') bait_link = card.select_one('.catch-card__place a[href*="/wiki/baits/"]')
badges = card.select(".catch-badge") badges = card.select(".catch-badge")
weather = next((_text(b.select_one("b")) for b in badges if _text(b).startswith("Погода")), None) weather = next((_text(b.select_one("b")) for b in badges if _text(b).startswith("Погода")), None)
@@ -218,7 +445,7 @@ def parse_rf4db_catches(html: str, *, base_url: str = "https://rf4db.com") -> li
game_time=_game_time(_text(card.select_one(".catch-card__place"))), game_time=_game_time(_text(card.select_one(".catch-card__place"))),
published_at=None, player_name=None, weather=weather or None, published_at=None, player_name=None, weather=weather or None,
water_temperature_c=float(temperature_match.group().replace(",", ".")) if temperature_match else None, water_temperature_c=float(temperature_match.group().replace(",", ".")) if temperature_match else None,
clip=None, fishing_style=None, evidence_urls=(), clip=None, fishing_style=None, evidence_urls=(), coordinate_raw=coordinate_raw,
)) ))
if not result: if not result:
raise CommunityParseError("RF4DB catch cards not found") raise CommunityParseError("RF4DB catch cards not found")
@@ -288,6 +515,7 @@ def parse_rf4stat_fishing(
if not external_id: if not external_id:
continue continue
position = row.select_one(".list-position .position:not(.position-locked)") position = row.select_one(".list-position .position:not(.position-locked)")
coordinate_raw = _text(row.select_one(".list-position .position")) or None
x, y = _coordinates(_text(position)) x, y = _coordinates(_text(position))
style = row.select_one(".post-style-icon[title]") style = row.select_one(".post-style-icon[title]")
style_text = str(style.get("title", "")) if style else "" style_text = str(style.get("title", "")) if style else ""
@@ -305,6 +533,7 @@ def parse_rf4stat_fishing(
clip=_text(row.select_one(".clip")) or None, clip=_text(row.select_one(".clip")) or None,
fishing_style=style_text.removeprefix("Вид ловли:").strip() or None, fishing_style=style_text.removeprefix("Вид ловли:").strip() or None,
evidence_urls=(urljoin(base_url, str(row.select_one("a.share.hide-print").get("href"))),) if row.select_one("a.share.hide-print") else (), evidence_urls=(urljoin(base_url, str(row.select_one("a.share.hide-print").get("href"))),) if row.select_one("a.share.hide-print") else (),
coordinate_raw=coordinate_raw,
)) ))
if not result: if not result:
raise CommunityParseError("RF4-STAT fishing rows not found") raise CommunityParseError("RF4-STAT fishing rows not found")
@@ -323,6 +552,7 @@ def parse_rf4stat_posts(
published_raw = str(post.get("data-published-at", "")) published_raw = str(post.get("data-published-at", ""))
published_at = datetime.fromtimestamp(int(published_raw), tz=timezone.utc) if published_raw.isdigit() else None published_at = datetime.fromtimestamp(int(published_raw), tz=timezone.utc) if published_raw.isdigit() else None
position = post.select_one(".spot-col .position:not(.position-locked)") position = post.select_one(".spot-col .position:not(.position-locked)")
coordinate_raw = _text(post.select_one(".spot-col .position")) or None
x, y = _coordinates(_text(position)) x, y = _coordinates(_text(position))
style = post.select_one(".post-style-icon[title]") style = post.select_one(".post-style-icon[title]")
style_text = str(style.get("title", "")) if style else "" style_text = str(style.get("title", "")) if style else ""
@@ -344,7 +574,7 @@ def parse_rf4stat_posts(
weather=None, water_temperature_c=None, weather=None, water_temperature_c=None,
clip=_text(post.select_one(".clip")) or None, clip=_text(post.select_one(".clip")) or None,
fishing_style=style_text.removeprefix("Вид ловли:").strip() or None, fishing_style=style_text.removeprefix("Вид ловли:").strip() or None,
evidence_urls=evidence, evidence_urls=evidence, coordinate_raw=coordinate_raw,
)) ))
if not result: if not result:
raise CommunityParseError("RF4-STAT posts not found") raise CommunityParseError("RF4-STAT posts not found")
@@ -386,6 +616,7 @@ def parse_rf4map_point(html: str, *, source_url: str) -> list[ExternalCatch]:
clip=str(item["clip"]) if isinstance(item.get("clip"), (int, float)) else None, clip=str(item["clip"]) if isinstance(item.get("clip"), (int, float)) else None,
fishing_style=None, fishing_style=None,
evidence_urls=tuple(url for url in evidence or [] if isinstance(url, str)), evidence_urls=tuple(url for url in evidence or [] if isinstance(url, str)),
coordinate_raw=f"{item['positionX']}:{item['positionY']}" if isinstance(item.get("positionX"), int) and isinstance(item.get("positionY"), int) else None,
)) ))
if not result: if not result:
raise CommunityParseError("RF4MAP point observations not found") raise CommunityParseError("RF4MAP point observations not found")
@@ -428,7 +659,7 @@ def parse_rf4posts_spot(html: str, *, source_url: str) -> list[ExternalCatch]:
weather=None, water_temperature_c=None, weather=None, water_temperature_c=None,
clip=str(spot["clip"]) if isinstance(spot.get("clip"), (int, float)) else None, clip=str(spot["clip"]) if isinstance(spot.get("clip"), (int, float)) else None,
fishing_style=str(spot["tackleType"]) if isinstance(spot.get("tackleType"), str) else None, fishing_style=str(spot["tackleType"]) if isinstance(spot.get("tackleType"), str) else None,
evidence_urls=evidence, evidence_urls=evidence, coordinate_raw=str(spot["coordinates"]) if isinstance(spot.get("coordinates"), str) else None,
)) ))
if not result: if not result:
raise CommunityParseError("RF4 Posts fish species not found") raise CommunityParseError("RF4 Posts fish species not found")
+46
View File
@@ -0,0 +1,46 @@
"""Ordered gear evidence extracted from source records without auto-mapping."""
from __future__ import annotations
from dataclasses import dataclass
from .community_sources import EquipmentItem
@dataclass(frozen=True, slots=True)
class GearComponentIdentity:
role: str
position: int
raw_value: str
source_external_id: str | None
def _role_from_kind(kind: str) -> str:
value = kind.casefold()
terms = {
"удилищ": "rod", "катуш": "reel", "леск": "line", "крюч": "hook",
"поплав": "float", "груз": "sinker", "монтаж": "rig", "rig": "rig",
"приманк": "lure", "нажив": "bait", "bait": "bait", "lure": "lure",
}
return next((role for term, role in terms.items() if term in value), "other")
def from_equipment(items: tuple[EquipmentItem, ...]) -> tuple[GearComponentIdentity, ...]:
"""Keep source order and raw text; canonical mapping happens only after review."""
return tuple(
GearComponentIdentity(
role=_role_from_kind(item.kind), position=index,
raw_value=item.name, source_external_id=item.external_id,
)
for index, item in enumerate(items)
if item.name.strip()
)
def from_catch_fields(*, bait: str | None, rig_type: str | None) -> tuple[GearComponentIdentity, ...]:
values: list[GearComponentIdentity] = []
if bait and bait.strip():
values.append(GearComponentIdentity("lure", len(values), bait.strip(), None))
if rig_type and rig_type.strip():
values.append(GearComponentIdentity("rig", len(values), rig_type.strip(), None))
return tuple(values)
+86
View File
@@ -0,0 +1,86 @@
"""Conservative offline crosswalk suggestions for gear identities."""
from __future__ import annotations
from dataclasses import dataclass
from .community_sources import GEAR_CATEGORIES
from .media_assets import normalize_entity_label
@dataclass(frozen=True, slots=True)
class CanonicalGear:
key: str
name: str
category: str
aliases: tuple[str, ...] = ()
brand: str | None = None
family: str | None = None
@dataclass(frozen=True, slots=True)
class GearIdentity:
source_system: str
external_id: str
name: str
category: str
brand: str | None = None
family: str | None = None
@dataclass(frozen=True, slots=True)
class GearCrosswalkSuggestion:
source_system: str
external_id: str
external_name: str
category: str
status: str
canonical_keys: tuple[str, ...]
def _compatible_category(value: str) -> bool:
return value.casefold().strip() in GEAR_CATEGORIES
def suggest_gear_crosswalk(
canonical: list[CanonicalGear], identities: list[GearIdentity],
) -> list[GearCrosswalkSuggestion]:
"""Suggest only unique exact normalized-name matches in the same category.
Brand/family differences never create an automatic match. Ambiguous,
category-mismatched and unmatched identities retain no canonical key.
"""
by_name: dict[str, set[str]] = {}
by_key = {item.key: item for item in canonical}
for item in canonical:
if not _compatible_category(item.category):
raise ValueError(f"invalid canonical gear category: {item.category!r}")
for name in (item.name, *item.aliases):
by_name.setdefault(normalize_entity_label(name), set()).add(item.key)
result: list[GearCrosswalkSuggestion] = []
for identity in identities:
keys = tuple(sorted(by_name.get(normalize_entity_label(identity.name), set())))
compatible = tuple(
key for key in keys
if by_key[key].category.casefold() == identity.category.casefold()
and not (by_key[key].brand and identity.brand and normalize_entity_label(by_key[key].brand) != normalize_entity_label(identity.brand))
and not (by_key[key].family and identity.family and normalize_entity_label(by_key[key].family) != normalize_entity_label(identity.family))
)
if not _compatible_category(identity.category):
status = "category_mismatch"
selected: tuple[str, ...] = ()
elif len(compatible) == 1:
status = "exact"
selected = compatible
elif len(compatible) > 1:
status = "ambiguous"
selected = ()
else:
status = "unmatched"
selected = ()
result.append(GearCrosswalkSuggestion(
source_system=identity.source_system, external_id=identity.external_id,
external_name=identity.name, category=identity.category,
status=status, canonical_keys=selected,
))
return result
+13 -2
View File
@@ -18,6 +18,13 @@ DERIVATIVE_TARGETS = {"card": 256, "detail": 1024}
WATERBODY_MEDIA_ROLES = frozenset({ WATERBODY_MEDIA_ROLES = frozenset({
"waterbody_cover", "waterbody_map", "waterbody_depth_map", "waterbody_screenshot", "waterbody_cover", "waterbody_map", "waterbody_depth_map", "waterbody_screenshot",
}) })
TACKLE_MEDIA_ROLES = frozenset({
"tackle_card", "tackle_detail", "rig_diagram", "tackle_screenshot",
})
MEDIA_ROLES_BY_ENTITY = {
"waterbody": WATERBODY_MEDIA_ROLES,
"tackle": TACKLE_MEDIA_ROLES,
}
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -276,8 +283,8 @@ def review_asset(
item = next((asset for asset in manifest.get("assets", []) if asset["asset_url"] == asset_url), None) item = next((asset for asset in manifest.get("assets", []) if asset["asset_url"] == asset_url), None)
if item is None: if item is None:
raise ValueError("asset URL is not present in manifest") raise ValueError("asset URL is not present in manifest")
if media_role is not None and (entity_type != "waterbody" or media_role not in WATERBODY_MEDIA_ROLES): if media_role is not None and media_role not in MEDIA_ROLES_BY_ENTITY.get(entity_type or "", set()):
raise ValueError("media role is only valid for a waterbody and must be a known waterbody role") raise ValueError(f"media role is only valid for {entity_type}; use a known {entity_type} role")
if decision == "approved": if decision == "approved":
if item.get("status") != "stored": if item.get("status") != "stored":
raise ValueError("only a stored asset can be approved") raise ValueError("only a stored asset can be approved")
@@ -345,6 +352,10 @@ def audit_media_catalog(root: Path) -> dict:
issues.append(f"{item['asset_url']}: derivative {exc}") issues.append(f"{item['asset_url']}: derivative {exc}")
if status == "approved" and (not item.get("entity_key") or item.get("entity_type") not in {"fish", "waterbody", "tackle", "reference"}): if status == "approved" and (not item.get("entity_key") or item.get("entity_type") not in {"fish", "waterbody", "tackle", "reference"}):
issues.append(f"{item['asset_url']}: approved asset has no valid canonical mapping") issues.append(f"{item['asset_url']}: approved asset has no valid canonical mapping")
if item.get("media_role") not in {None, *WATERBODY_MEDIA_ROLES, *TACKLE_MEDIA_ROLES}:
issues.append(f"{item['asset_url']}: unknown media role")
elif item.get("media_role") is not None and item.get("media_role") not in MEDIA_ROLES_BY_ENTITY.get(item.get("entity_type"), set()):
issues.append(f"{item['asset_url']}: media role does not match entity type")
files_root = root / "files" files_root = root / "files"
orphaned = sorted(str(path.relative_to(root)) for path in files_root.rglob("*") if path.is_file() and str(path.relative_to(root)) not in referenced) if files_root.exists() else [] orphaned = sorted(str(path.relative_to(root)) for path in files_root.rglob("*") if path.is_file() and str(path.relative_to(root)) not in referenced) if files_root.exists() else []
return {"total": sum(statuses.values()), "statuses": statuses, "issues": issues, "orphaned_files": orphaned} return {"total": sum(statuses.values()), "statuses": statuses, "issues": issues, "orphaned_files": orphaned}
+2 -1
View File
@@ -192,6 +192,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--decision", choices=("approved", "rejected")) parser.add_argument("--decision", choices=("approved", "rejected"))
parser.add_argument("--entity-type", choices=("fish", "waterbody", "tackle", "reference")) parser.add_argument("--entity-type", choices=("fish", "waterbody", "tackle", "reference"))
parser.add_argument("--entity-key") parser.add_argument("--entity-key")
parser.add_argument("--media-role", help="Explicit reviewed role for waterbody/tackle media")
parser.add_argument("--note") parser.add_argument("--note")
parser.add_argument("--audit", action="store_true", help="Verify manifest metadata, hashes and local files without network access") parser.add_argument("--audit", action="store_true", help="Verify manifest metadata, hashes and local files without network access")
parser.add_argument("--coverage", action="store_true", help="Compare candidates and approvals with the catalog baseline") parser.add_argument("--coverage", action="store_true", help="Compare candidates and approvals with the catalog baseline")
@@ -232,7 +233,7 @@ def main(argv: list[str] | None = None) -> int:
if args.review_url: if args.review_url:
if not args.decision: if not args.decision:
parser.error("--decision is required with --review-url") parser.error("--decision is required with --review-url")
item = review_asset(args.root / "manifest.json", asset_url=args.review_url, decision=args.decision, entity_type=args.entity_type, entity_key=args.entity_key, note=args.note) item = review_asset(args.root / "manifest.json", asset_url=args.review_url, decision=args.decision, entity_type=args.entity_type, entity_key=args.entity_key, note=args.note, media_role=args.media_role)
print(f"{item['status']} {item['asset_url']}") print(f"{item['status']} {item['asset_url']}")
return 0 return 0
if args.reclassify: if args.reclassify:
+59
View File
@@ -0,0 +1,59 @@
"""Conservative, offline crosswalk suggestions for waterbody identities."""
from __future__ import annotations
from dataclasses import dataclass
from .media_assets import normalize_entity_label
@dataclass(frozen=True, slots=True)
class CanonicalWaterbody:
key: str
name: str
aliases: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class WaterbodyIdentity:
source_system: str
external_id: str
name: str
@dataclass(frozen=True, slots=True)
class CrosswalkSuggestion:
source_system: str
external_id: str
external_name: str
status: str
canonical_keys: tuple[str, ...]
def suggest_waterbody_crosswalk(
canonical: list[CanonicalWaterbody], identities: list[WaterbodyIdentity],
) -> list[CrosswalkSuggestion]:
"""Suggest only unique exact normalized-name matches.
``ambiguous`` and ``unmatched`` rows intentionally have no selected key;
callers must not turn this report into aliases without human review.
"""
by_name: dict[str, set[str]] = {}
for item in canonical:
for name in (item.name, *item.aliases):
by_name.setdefault(normalize_entity_label(name), set()).add(item.key)
result: list[CrosswalkSuggestion] = []
for identity in identities:
keys = tuple(sorted(by_name.get(normalize_entity_label(identity.name), set())))
status = "exact" if len(keys) == 1 else "ambiguous" if keys else "unmatched"
result.append(CrosswalkSuggestion(
source_system=identity.source_system, external_id=identity.external_id,
external_name=identity.name, status=status,
canonical_keys=keys if status == "exact" else (),
))
return result
def missing_external_ids(expected: set[str], observed: set[str]) -> tuple[str, ...]:
"""Return stable missing IDs without interpreting absence as deletion."""
return tuple(sorted(expected - observed))
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { chromium } from "../apps/web/node_modules/playwright/index.mjs";
const routes = [
"/", "/waterbodies", "/tackle", "/records", "/report", "/status", "/media", "/rules",
"/waterbodies/р-вьюнок", "/fish/pike", "/admin", "/admin/moderation",
"/admin/external-sources", "/admin/media",
];
const viewports = [
[320, 800], [390, 844], [768, 900], [1280, 900],
];
const themes = ["system", "light", "dark"];
function usage() {
console.log("Usage: node scripts/capture-visual-matrix.mjs [--base-url URL] [--output DIR]");
}
function parseArgs(argv) {
const args = { baseUrl: process.env.WEB_URL || "http://127.0.0.1:4321", output: "/tmp/rf4-visual-matrix" };
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === "--help" || argv[index] === "-h") {
usage();
process.exit(0);
}
if (argv[index] === "--base-url") args.baseUrl = argv[++index];
else if (argv[index] === "--output") args.output = argv[++index];
else throw new Error(`unknown argument: ${argv[index]}`);
}
return args;
}
function safeName(value) {
return value.replace(/^\//, "home").replaceAll("/", "-").replace(/[^\p{L}\p{N}._-]+/gu, "-");
}
const { baseUrl, output } = parseArgs(process.argv.slice(2));
const outputDir = path.resolve(output);
await fs.mkdir(outputDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
const manifest = [];
try {
for (const theme of themes) {
await page.context().clearCookies();
if (theme !== "system") {
await page.context().addCookies([{ name: "rf4-theme", value: theme, url: baseUrl }]);
}
await page.emulateMedia({ colorScheme: theme === "system" ? "light" : theme });
for (const [width, height] of viewports) {
await page.setViewportSize({ width, height });
for (const route of routes) {
await page.goto(new URL(route, baseUrl).toString(), { waitUntil: "networkidle" });
const state = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
theme: document.documentElement.dataset.theme || "system",
}));
const filename = `${theme}-${width}x${height}-${safeName(route)}.png`;
await page.screenshot({ path: path.join(outputDir, filename), fullPage: true });
manifest.push({ theme, width, height, route, filename, ...state });
}
}
}
} finally {
await browser.close();
}
await fs.writeFile(
path.join(outputDir, "manifest.json"),
`${JSON.stringify({ baseUrl, count: manifest.length, items: manifest }, null, 2)}\n`,
"utf8",
);
const overflow = manifest.filter((item) => item.scrollWidth > item.clientWidth);
console.log(`captured ${manifest.length} screenshots in ${outputDir}`);
console.log(`document overflow: ${overflow.length}`);
if (overflow.length) process.exitCode = 1;
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs, { access } from "node:fs/promises";
import readline from "node:readline/promises";
import process from "node:process";
import path from "node:path";
import { chromium } from "../apps/web/node_modules/playwright/index.mjs";
const ROOT = path.resolve(new URL("..", import.meta.url).pathname);
const PYTHON = process.env.RF4_PYTHON || path.join(ROOT, ".venv/bin/python");
const DEFAULT_STATE = path.join(ROOT, ".cache/community-fetch-state.json");
const DEFAULT_PROFILE = path.join(ROOT, ".cache/rf4db-chromium-profile");
function usage() {
console.log(`Usage:
node scripts/fetch-waterbodies-chromium.mjs catalog [options]
node scripts/fetch-waterbodies-chromium.mjs detail --url URL [options]
Options:
--output PATH JSON snapshot path (default: .cache/waterbodies/)
--html-output PATH retain browser DOM HTML (default: temporary .cache file)
--state-file PATH shared cooldown state file
--profile PATH persistent Chromium profile directory
--executable PATH Chromium/Chrome executable (default: RF4_CHROMIUM_EXECUTABLE or system Chrome)
--headless run Chromium headless; cannot handle manual challenges
--wait-seconds N headed wait after load (default: 30)
`);
}
function parseArgs(argv) {
if (!argv.length || argv.includes("--help")) {
usage();
process.exit(0);
}
const mode = argv.shift();
if (!new Set(["catalog", "detail"]).has(mode)) throw new Error(`unknown mode: ${mode}`);
const args = {
mode, url: mode === "catalog" ? "https://rf4db.com/ru/maps" : null,
output: null, htmlOutput: null, stateFile: DEFAULT_STATE, profile: DEFAULT_PROFILE,
executable: process.env.RF4_CHROMIUM_EXECUTABLE || null,
headless: false, waitSeconds: 30,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--url") args.url = argv[++index];
else if (arg === "--output") args.output = argv[++index];
else if (arg === "--html-output") args.htmlOutput = argv[++index];
else if (arg === "--state-file") args.stateFile = argv[++index];
else if (arg === "--profile") args.profile = argv[++index];
else if (arg === "--executable") args.executable = argv[++index];
else if (arg === "--headless") args.headless = true;
else if (arg === "--wait-seconds") args.waitSeconds = Number(argv[++index]);
else throw new Error(`unknown argument: ${arg}`);
}
if (args.mode === "detail" && !args.url) throw new Error("detail requires --url");
if (!Number.isInteger(args.waitSeconds) || args.waitSeconds < 0 || args.waitSeconds > 600) {
throw new Error("--wait-seconds must be an integer from 0 to 600");
}
const parsed = new URL(args.url);
if (parsed.protocol !== "https:" || !["rf4db.com", "download.rf4db.com"].includes(parsed.hostname)) {
throw new Error("Chromium launcher accepts only HTTPS rf4db.com URLs");
}
return args;
}
async function resolveExecutable(requested) {
if (requested) {
await access(requested);
return requested;
}
for (const candidate of ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/usr/bin/chromium"]) {
try {
await access(candidate);
return candidate;
} catch {
// Try the next locally installed browser.
}
}
return undefined;
}
function reserveCooldown(args) {
const source = args.mode === "catalog" ? "rf4db-waterbodies" : "rf4db-waterbody";
const command = ["-m", "rf4_research.community_cli", source, "--url", args.url, "--state-file", args.stateFile, "--reserve-only"];
const result = spawnSync(PYTHON, command, { cwd: ROOT, encoding: "utf8" });
if (result.status !== 0) {
if (result.stderr) process.stderr.write(result.stderr);
throw new Error(`cooldown reservation failed with exit code ${result.status}`);
}
process.stdout.write(`${result.stdout.trim()}\n`);
}
function outputPath(args) {
if (args.output) return path.resolve(args.output);
const stamp = new Date().toISOString().replaceAll(/[-:]/g, "").replace(".000", "");
const stem = args.mode === "catalog" ? "rf4db-catalog" : `rf4db-${path.basename(new URL(args.url).pathname)}`;
return path.join(ROOT, ".cache/waterbodies", `${stem}-${stamp}.json`);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
reserveCooldown(args);
const output = outputPath(args);
const htmlOutput = path.resolve(args.htmlOutput || `${output}.html`);
await fs.mkdir(path.dirname(htmlOutput), { recursive: true });
await fs.mkdir(path.dirname(output), { recursive: true });
const executablePath = await resolveExecutable(args.executable);
if (executablePath) console.log(`Using browser executable: ${executablePath}`);
const context = await chromium.launchPersistentContext(path.resolve(args.profile), {
headless: args.headless,
...(executablePath ? { executablePath } : {}),
});
try {
const page = context.pages()[0] || await context.newPage();
await page.goto(args.url, { waitUntil: "domcontentloaded", timeout: 60_000 });
if (!args.headless && args.waitSeconds > 0) {
console.log(`Chromium opened ${args.url}. Complete only an ordinary site challenge if shown, then press Enter (or wait ${args.waitSeconds}s).`);
const input = readline.createInterface({ input: process.stdin, output: process.stdout });
await Promise.race([
input.question("Ready to capture DOM? "),
new Promise(resolve => setTimeout(resolve, args.waitSeconds * 1000)),
]);
input.close();
} else if (args.waitSeconds > 0) {
await page.waitForTimeout(args.waitSeconds * 1000);
}
const html = await page.locator("html").evaluate(element => element.outerHTML);
await fs.writeFile(htmlOutput, `<!doctype html>\n${html}\n`, "utf8");
} finally {
await context.close();
}
const parser = path.join(ROOT, "scripts/fetch-waterbodies.py");
const result = spawnSync(PYTHON, [parser, args.mode, "--html", htmlOutput, "--output", output, ...(args.mode === "detail" ? ["--url", args.url] : [])], { cwd: ROOT, encoding: "utf8" });
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
if (result.status !== 0) process.exitCode = result.status || 1;
}
main().catch(error => {
console.error(`Chromium waterbody fetch failed: ${error.message}`);
process.exitCode = 1;
});
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Manually fetch RF4DB waterbody snapshots through the guarded research CLI."""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
from dataclasses import asdict
from pathlib import Path
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from rf4_research.community_cli import _validate_url_before_io
from rf4_research.community_sources import parse_rf4db_waterbodies, parse_rf4db_waterbody_detail
DEFAULT_STATE_FILE = ROOT / ".cache" / "community-fetch-state.json"
DEFAULT_OUTPUT_DIR = ROOT / ".cache" / "waterbodies"
CATALOG_SOURCE = "rf4db-waterbodies"
DETAIL_SOURCE = "rf4db-waterbody"
def _timestamp() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def _safe_detail_name(url: str) -> str:
name = Path(urlsplit(url).path.rstrip("/")).name or "detail"
return re.sub(r"[^A-Za-z0-9_.-]+", "-", name)
def _default_output(mode: str, url: str | None) -> Path:
stem = "rf4db-catalog" if mode == "catalog" else f"rf4db-{_safe_detail_name(url or '')}"
return DEFAULT_OUTPUT_DIR / f"{stem}-{_timestamp()}.json"
def _write_json_atomically(path: Path, payload: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
handle = tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.",
suffix=".tmp", delete=False,
)
temporary = Path(handle.name)
try:
with handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
except Exception:
temporary.unlink(missing_ok=True)
raise
def fetch_snapshot(
mode: str, *, url: str | None, html: Path | None = None, output: Path,
state_file: Path, limit: int,
) -> int:
if html is not None:
source_url = url or "https://rf4db.com/ru/maps"
try:
_validate_url_before_io(source_url)
document = html.read_text(encoding="utf-8")
parsed = (
parse_rf4db_waterbodies(document)
if mode == "catalog"
else parse_rf4db_waterbody_detail(document, source_url=source_url)
)
except Exception as exc:
print(f"local HTML parse failed: {exc}", file=sys.stderr)
return 1
payload = [asdict(item) for item in parsed] if mode == "catalog" else asdict(parsed)
_write_json_atomically(output, payload)
count = len(payload) if isinstance(payload, list) else 1
print(f"parsed and saved {count} waterbody snapshot(s) to {output}")
return 0
source = CATALOG_SOURCE if mode == "catalog" else DETAIL_SOURCE
command = [
sys.executable, "-m", "rf4_research.community_cli", source,
"--state-file", str(state_file), "--limit", str(limit),
]
if url:
command.extend(["--url", url])
result = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, check=False)
if result.returncode != 0:
if result.stderr:
print(result.stderr, file=sys.stderr, end="")
return result.returncode
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError as exc:
print(f"fetch succeeded but returned invalid JSON: {exc}", file=sys.stderr)
return 1
if mode == "catalog" and not isinstance(payload, list):
print("fetch succeeded but catalog payload is not a JSON array", file=sys.stderr)
return 1
if mode == "detail" and not isinstance(payload, dict):
print("fetch succeeded but detail payload is not a JSON object", file=sys.stderr)
return 1
_write_json_atomically(output, payload)
count = len(payload) if isinstance(payload, list) else 1
print(f"saved {count} waterbody snapshot(s) to {output}")
print(f"next import: python -m app.cli import-waterbody-{'catalog' if mode == 'catalog' else 'detail'} --input {output}")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Manually fetch RF4DB waterbody data with the shared cooldown guard"
)
subparsers = parser.add_subparsers(dest="mode", required=True)
catalog = subparsers.add_parser("catalog", help="fetch the public waterbody catalog")
catalog.add_argument("--output", type=Path, help="snapshot path; default: .cache/waterbodies/")
catalog.add_argument("--html", type=Path, help="parse an HTML file saved from an authorized browser session; no network")
catalog.add_argument("--limit", type=int, default=500, choices=range(1, 501), metavar="1..500")
detail = subparsers.add_parser("detail", help="fetch one waterbody detail page")
detail.add_argument("--url", required=True, help="authorized RF4DB detail URL")
detail.add_argument("--output", type=Path, help="snapshot path; default: .cache/waterbodies/")
detail.add_argument("--html", type=Path, help="parse an HTML file saved from an authorized browser session; no network")
detail.add_argument("--limit", type=int, default=100, choices=range(1, 501), metavar="1..500")
for command in (catalog, detail):
command.add_argument(
"--state-file", type=Path, default=DEFAULT_STATE_FILE,
help=f"cooldown state file (default: {DEFAULT_STATE_FILE})",
)
args = parser.parse_args(argv)
output = args.output or _default_output(args.mode, getattr(args, "url", None))
return fetch_snapshot(
args.mode,
url=getattr(args, "url", None),
html=args.html,
output=output,
state_file=args.state_file,
limit=args.limit,
)
if __name__ == "__main__":
raise SystemExit(main())
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<body>
<article class="gear-card" data-gear-card data-gear-id="spiker-2" data-category="lure" data-subcategory="spinner" data-brand="RF4" data-family="spoon" data-unlock-level="12">
<a href="/ru/wiki/lures/spiker-2"><h3 data-name>Spiker #2</h3></a>
<img src="https://oss.rf4db.com/game/gear/spiker-2.webp" alt="Spiker #2">
</article>
<article class="gear-card" data-gear-card data-gear-id="method-popup" data-category="rig" data-subcategory="feeder" data-unlock-level="0">
<a href="/ru/wiki/rigs/method-popup"><h3 data-name>Method Popup</h3></a>
</article>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="ru">
<body>
<main data-gear-detail data-category="lure" data-subcategory="spinner" data-brand="RF4" data-family="spoon" data-unlock-level="12">
<h1>Spiker #2</h1>
<dl>
<dd data-gear-attribute="weight" data-type="number">0</dd>
<dd data-gear-attribute="floating" data-state="not_applicable">Не применяется</dd>
<dd data-gear-attribute="target_fish" data-state="missing">Нет данных</dd>
</dl>
<a data-gear-variant>Spiker #2 01-015</a>
<a data-compatible-with>Light spinning rod</a>
<a data-rig-type>Spinning</a>
<img src="/game/gear/spiker-2.webp" alt="Spiker #2">
</main>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="ru">
<body>
<main>
<article>
<h1>Дачный пруд</h1>
<img src="https://oss.rf4db.com/game/maps/level_000_home.png" alt="Дачный пруд">
<img src="https://oss.rf4db.com/game/paper_maps_webp/map_level_000_home.webp" alt="Карта: Дачный пруд">
<img src="https://oss.rf4db.com/game/fish/frog.webp" alt="Лягушка">
<a href="/ru/fishes/frog">Лягушка 190 г</a>
<a href="/ru/fishes/a.sleeper">Ротан 400 г</a>
<a href="/ru/fishes/perch">Окунь 1.6 кг</a>
<a href="/ru/positions/example-position">Точка 13:9</a>
</article>
</main>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="ru">
<body>
<article class="waterbody-detail">
<h1>оз. Комариное</h1>
<p class="waterbody-description">Большое озеро с каменистыми берегами.</p>
<ul class="waterbody-aliases"><li>Комариное</li><li>оз. Комариное</li></ul>
<div class="waterbody-fish">
<a href="/ru/wiki/fish/pike">Щука</a>
<a href="/ru/wiki/fish/perch">Окунь</a>
</div>
<img src="https://oss.rf4db.com/game/maps/level_001_mosquito.webp" alt="Карта">
<img data-src="/game/maps/level_001_mosquito-depth.webp" alt="Глубины">
<a href="/ru/maps/level_001_mosquito/spots/12-34">Точка 12:34</a>
</article>
</body>
</html>
+50
View File
@@ -5,6 +5,7 @@ import pytest
from rf4_research import community_cli from rf4_research import community_cli
from rf4_research.community_cli import enforce_fetch_interval, fetch_site_key, mark_fetch from rf4_research.community_cli import enforce_fetch_interval, fetch_site_key, mark_fetch
from rf4_research.community_sources import RF4DBWaterbodyDetail
def test_fetch_cooldown_is_persistent_per_source(tmp_path: Path) -> None: def test_fetch_cooldown_is_persistent_per_source(tmp_path: Path) -> None:
@@ -55,6 +56,55 @@ def test_failed_fetch_still_reserves_site_cooldown(tmp_path: Path, monkeypatch:
assert "rf4db.com" in json.loads(state_file.read_text(encoding="utf-8")) assert "rf4db.com" in json.loads(state_file.read_text(encoding="utf-8"))
def test_reserve_only_does_not_make_http_request(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
) -> None:
state_file = tmp_path / "fetch-state.json"
def fail(_url: str) -> str:
raise AssertionError("reserve-only must stop before HTTP")
monkeypatch.setattr(community_cli, "fetch_html", fail)
assert community_cli.main([
"rf4db-waterbodies", "--state-file", str(state_file), "--reserve-only",
]) == 0
payload = json.loads(capsys.readouterr().out)
assert payload == {"reserved": True, "source": "rf4db-waterbodies", "site_key": "rf4db.com"}
def test_detail_fetch_serializes_single_record(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
) -> None:
state_file = tmp_path / "fetch-state.json"
source_url = "https://download.rf4db.com/ru/maps/level_002_kirzah_river"
detail = RF4DBWaterbodyDetail(
source_system="rf4db",
source_external_id="level_002_kirzah_river",
source_url=source_url,
name="р. Вьюнок",
description=None,
aliases=(),
fish_species=("Щука обыкновенная",),
fish_external_ids=("n.pike",),
image_urls=(),
point_urls=(),
)
monkeypatch.setattr(community_cli, "fetch_html", lambda _url: "<html></html>")
monkeypatch.setitem(
community_cli.DETAIL_SOURCES,
"rf4db-waterbody",
lambda _html, *, source_url: detail,
)
assert community_cli.main([
"rf4db-waterbody", "--url", source_url, "--state-file", str(state_file),
]) == 0
payload = json.loads(capsys.readouterr().out)
assert payload["source_external_id"] == "level_002_kirzah_river"
assert payload["fish_species"] == ["Щука обыкновенная"]
def test_validate_url_host_rejects_disallowed_hosts() -> None: def test_validate_url_host_rejects_disallowed_hosts() -> None:
from rf4_research.community_cli import _validate_url_host from rf4_research.community_cli import _validate_url_host
+66
View File
@@ -8,11 +8,16 @@ from rf4_research.community_sources import (
parse_rf4db_catches, parse_rf4db_catches,
parse_rf4db_detail, parse_rf4db_detail,
parse_rf4db_waterbodies, parse_rf4db_waterbodies,
parse_rf4db_waterbody_detail,
parse_rf4map_point, parse_rf4map_point,
parse_rf4posts_spot, parse_rf4posts_spot,
parse_rf4stat_fishing, parse_rf4stat_fishing,
parse_rf4stat_posts, parse_rf4stat_posts,
) )
from rf4_research.waterbody_crosswalk import (
CanonicalWaterbody, WaterbodyIdentity, missing_external_ids,
suggest_waterbody_crosswalk,
)
FIXTURES = Path(__file__).parent / "fixtures" FIXTURES = Path(__file__).parent / "fixtures"
@@ -74,6 +79,65 @@ def test_rf4db_waterbody_catalog_rejects_partial_results() -> None:
parse_rf4db_waterbodies(fixture("rf4db_waterbodies_sample.html")) parse_rf4db_waterbodies(fixture("rf4db_waterbodies_sample.html"))
def test_parses_rf4db_waterbody_detail_without_assigning_media_roles() -> None:
row = parse_rf4db_waterbody_detail(
fixture("rf4db_waterbody_detail_sample.html"),
source_url="https://rf4db.com/ru/maps/level_001_mosquito",
)
assert (row.source_external_id, row.name) == ("level_001_mosquito", "оз. Комариное")
assert row.description == "Большое озеро с каменистыми берегами."
assert row.aliases == ("Комариное",)
assert row.fish_species == ("Щука", "Окунь")
assert row.fish_external_ids == ("pike", "perch")
assert row.image_urls == (
"https://oss.rf4db.com/game/maps/level_001_mosquito.webp",
"https://rf4db.com/game/maps/level_001_mosquito-depth.webp",
)
assert row.point_urls == ("https://rf4db.com/ru/maps/level_001_mosquito/spots/12-34",)
def test_parses_live_waterbody_fish_links_without_fish_media() -> None:
row = parse_rf4db_waterbody_detail(
fixture("rf4db_waterbody_detail_live_sample.html"),
source_url="https://rf4db.com/ru/maps/level_000_home",
)
assert row.fish_species == ("Лягушка", "Ротан", "Окунь")
assert row.fish_external_ids == ("frog", "a.sleeper", "perch")
assert row.image_urls == (
"https://oss.rf4db.com/game/maps/level_000_home.png",
"https://oss.rf4db.com/game/paper_maps_webp/map_level_000_home.webp",
)
def test_rf4db_waterbody_detail_rejects_missing_fish_list() -> None:
with pytest.raises(CommunityParseError, match="detail not found or incomplete"):
parse_rf4db_waterbody_detail(
"<main><h1>Озеро</h1></main>",
source_url="https://rf4db.com/ru/maps/level_001_mosquito",
)
def test_waterbody_crosswalk_only_suggests_unique_exact_matches() -> None:
canonical = [
CanonicalWaterbody("mosquito", "оз. Комариное", ("Комариное",)),
CanonicalWaterbody("ambiguous-a", "Большое озеро"),
CanonicalWaterbody("ambiguous-b", "Другое", ("Большое озеро",)),
]
rows = suggest_waterbody_crosswalk(canonical, [
WaterbodyIdentity("rf4db", "level_001_mosquito", "КОМАРИНОЕ"),
WaterbodyIdentity("rf4map", "16", "Большое озеро"),
WaterbodyIdentity("rf4-posts", "lake-x", "Норвежское море"),
])
assert [(row.status, row.canonical_keys) for row in rows] == [
("exact", ("mosquito",)), ("ambiguous", ()), ("unmatched", ()),
]
def test_missing_source_ids_are_diagnostic_not_withdrawals() -> None:
assert missing_external_ids({"16", "17", "18"}, {"16"}) == ("17", "18")
def test_parses_rf4stat_fishing_rows() -> None: def test_parses_rf4stat_fishing_rows() -> None:
row = parse_rf4stat_fishing( row = parse_rf4stat_fishing(
fixture("rf4stat_fishing_sample.html"), fixture("rf4stat_fishing_sample.html"),
@@ -82,6 +146,7 @@ def test_parses_rf4stat_fishing_rows() -> None:
assert (row.source_system, row.source_external_id) == ("rf4stat-fishing", "123") assert (row.source_system, row.source_external_id) == ("rf4stat-fishing", "123")
assert (row.x, row.y, row.weight_g) == (71, 92, 11_584) assert (row.x, row.y, row.weight_g) == (71, 92, 11_584)
assert row.coordinate_raw == "71:92"
assert row.published_at == datetime(2026, 9, 3, 11, 27, tzinfo=timezone.utc) assert row.published_at == datetime(2026, 9, 3, 11, 27, tzinfo=timezone.utc)
assert (row.player_name, row.clip, row.fishing_style) == ("Игрок", "35", "Донная") assert (row.player_name, row.clip, row.fishing_style) == ("Игрок", "35", "Донная")
@@ -91,6 +156,7 @@ def test_parses_rf4stat_posts_without_using_locked_coordinates() -> None:
assert (row.source_system, row.source_external_id) == ("rf4stat-post", "456:0") assert (row.source_system, row.source_external_id) == ("rf4stat-post", "456:0")
assert (row.x, row.y, row.weight_g) == (None, None, 4_321) assert (row.x, row.y, row.weight_g) == (None, None, 4_321)
assert row.coordinate_raw == "XX:XX"
assert row.evidence_urls == ("https://img.example.test/proof.jpg",) assert row.evidence_urls == ("https://img.example.test/proof.jpg",)
+76
View File
@@ -0,0 +1,76 @@
import importlib.util
import json
from pathlib import Path
SCRIPT = Path(__file__).parents[1] / "scripts" / "fetch-waterbodies.py"
SPEC = importlib.util.spec_from_file_location("fetch_waterbodies", SCRIPT)
assert SPEC and SPEC.loader
fetch_waterbodies = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(fetch_waterbodies)
def test_fetch_snapshot_saves_catalog_atomically(tmp_path: Path, monkeypatch) -> None:
output = tmp_path / "nested" / "catalog.json"
calls = []
class Result:
returncode = 0
stdout = '[{"source_external_id": "level_019_american_pond"}]'
stderr = ""
def run(command, **kwargs):
calls.append((command, kwargs))
return Result()
monkeypatch.setattr(fetch_waterbodies.subprocess, "run", run)
assert fetch_waterbodies.fetch_snapshot(
"catalog", url=None, output=output, state_file=tmp_path / "state.json", limit=19,
) == 0
assert json.loads(output.read_text(encoding="utf-8"))[0]["source_external_id"] == "level_019_american_pond"
assert calls[0][0][0] == fetch_waterbodies.sys.executable
assert "--state-file" in calls[0][0]
assert "--limit" in calls[0][0]
def test_fetch_snapshot_parses_saved_catalog_without_network(tmp_path: Path, monkeypatch) -> None:
html = Path(__file__).parent / "fixtures" / "rf4db_waterbodies_sample.html"
output = tmp_path / "catalog.json"
parser = fetch_waterbodies.parse_rf4db_waterbodies
monkeypatch.setattr(fetch_waterbodies, "parse_rf4db_waterbodies", lambda document: parser(document, expected_count=2))
assert fetch_waterbodies.fetch_snapshot(
"catalog", url=None, html=html, output=output,
state_file=tmp_path / "state.json", limit=19,
) == 0
payload = json.loads(output.read_text(encoding="utf-8"))
assert len(payload) == 2
assert payload[0]["source_system"] == "rf4db"
def test_fetch_snapshot_reports_invalid_saved_detail_html(tmp_path: Path, capsys) -> None:
html = tmp_path / "challenge.html"
html.write_text("<html><title>Just a moment...</title></html>", encoding="utf-8")
output = tmp_path / "detail.json"
assert fetch_waterbodies.fetch_snapshot(
"detail", url="https://download.rf4db.com/ru/maps/level_019_american_pond",
html=html, output=output, state_file=tmp_path / "state.json", limit=100,
) == 1
assert not output.exists()
assert "local HTML parse failed" in capsys.readouterr().err
def test_fetch_snapshot_does_not_write_failed_fetch(tmp_path: Path, monkeypatch, capsys) -> None:
output = tmp_path / "detail.json"
class Result:
returncode = 1
stdout = ""
stderr = "community source failed: source cooldown is active\n"
monkeypatch.setattr(fetch_waterbodies.subprocess, "run", lambda *args, **kwargs: Result())
assert fetch_waterbodies.fetch_snapshot(
"detail", url="https://download.rf4db.com/ru/maps/level_019_american_pond",
output=output, state_file=tmp_path / "state.json", limit=100,
) == 1
assert not output.exists()
assert "cooldown" in capsys.readouterr().err
+20
View File
@@ -0,0 +1,20 @@
from rf4_research.community_sources import EquipmentItem
from rf4_research.gear_components import from_catch_fields, from_equipment
def test_from_equipment_preserves_source_order_and_external_ids() -> None:
rows = from_equipment((
EquipmentItem("Катушка", "Test Reel", "reel-1"),
EquipmentItem("Приманка поклёвки", "Test Lure", "lure-1"),
))
assert [(row.role, row.position, row.raw_value, row.source_external_id) for row in rows] == [
("reel", 0, "Test Reel", "reel-1"),
("lure", 1, "Test Lure", "lure-1"),
]
def test_from_catch_fields_keeps_unresolved_raw_values() -> None:
rows = from_catch_fields(bait="Spiker #2", rig_type="Method Popup")
assert [(row.role, row.raw_value, row.source_external_id) for row in rows] == [
("lure", "Spiker #2", None), ("rig", "Method Popup", None),
]
+43
View File
@@ -0,0 +1,43 @@
import pytest
from rf4_research.gear_crosswalk import (
CanonicalGear,
GearIdentity,
suggest_gear_crosswalk,
)
def test_gear_crosswalk_matches_exact_name_and_category_only() -> None:
canonical = [
CanonicalGear("lure:spiker-2", "Spiker #2", "lure", ("Spiker 2",), brand="RF4"),
CanonicalGear("rig:method-popup", "Method Popup", "rig"),
]
identities = [
GearIdentity("rf4db", "spiker-2", " spiker #2 ", "lure", brand="RF4"),
GearIdentity("rf4db", "method-popup", "Method Popup", "lure"),
GearIdentity("rf4db", "unknown", "Unknown bait", "bait"),
]
rows = suggest_gear_crosswalk(canonical, identities)
assert [(row.status, row.canonical_keys) for row in rows] == [
("exact", ("lure:spiker-2",)), ("unmatched", ()), ("unmatched", ()),
]
def test_gear_crosswalk_does_not_auto_select_ambiguous_or_brand_mismatch() -> None:
canonical = [
CanonicalGear("a", "Test Hook", "hook", brand="A"),
CanonicalGear("b", "Test Hook", "hook", brand="B"),
]
identities = [
GearIdentity("rf4db", "1", "Test Hook", "hook"),
GearIdentity("rf4db", "2", "Test Hook", "hook", brand="C"),
GearIdentity("rf4db", "3", "Test Hook", "float"),
]
rows = suggest_gear_crosswalk(canonical, identities)
assert [row.status for row in rows] == ["ambiguous", "unmatched", "unmatched"]
assert all(row.canonical_keys == () for row in rows)
def test_gear_crosswalk_rejects_invalid_canonical_category() -> None:
with pytest.raises(ValueError, match="invalid canonical gear category"):
suggest_gear_crosswalk([CanonicalGear("bad", "Bad", "equipment")], [])
+49
View File
@@ -0,0 +1,49 @@
from dataclasses import asdict
from pathlib import Path
import pytest
from rf4_research.community_sources import (
CommunityParseError,
parse_rf4db_gear_catalog,
parse_rf4db_gear_detail,
)
FIXTURES = Path(__file__).parent / "fixtures"
def test_gear_catalog_preserves_zero_level_and_unknown_total() -> None:
html = (FIXTURES / "rf4db_gear_catalog_sample.html").read_text(encoding="utf-8")
rows = parse_rf4db_gear_catalog(html)
assert len(rows) == 2
assert rows[0].category == "lure"
assert rows[0].source_external_id == "spiker-2"
assert rows[1].unlock_level == 0
assert rows[1].image_url is None
def test_gear_catalog_rejects_duplicate_ids() -> None:
html = '<article class="gear-card" data-gear-id="same" data-category="bait"><h3 data-name>A</h3></article><article class="gear-card" data-gear-id="same" data-category="bait"><h3 data-name>B</h3></article>'
with pytest.raises(CommunityParseError, match="duplicate"):
parse_rf4db_gear_catalog(html)
def test_gear_detail_distinguishes_value_not_applicable_and_missing() -> None:
html = (FIXTURES / "rf4db_gear_detail_sample.html").read_text(encoding="utf-8")
detail = parse_rf4db_gear_detail(html, source_url="https://rf4db.com/ru/wiki/lures/spiker-2")
attributes = {item.key: asdict(item) for item in detail.attributes}
assert attributes["weight"] == {"key": "weight", "state": "value", "value": 0, "source_text": "0"}
assert attributes["floating"]["state"] == "not_applicable"
assert attributes["floating"]["value"] is None
assert attributes["target_fish"]["state"] == "missing"
assert detail.variants == ("Spiker #2 01-015",)
assert detail.rig_types == ("Spinning",)
def test_gear_detail_rejects_unknown_category() -> None:
with pytest.raises(CommunityParseError, match="category"):
parse_rf4db_gear_detail(
'<main data-gear-detail data-category="unknown-category"><h1>Test</h1></main>',
source_url="https://rf4db.com/ru/wiki/gear/test",
)
+23
View File
@@ -173,6 +173,29 @@ def test_waterbody_media_role_rejects_unknown_role(tmp_path: Path) -> None:
) )
def test_tackle_media_roles_require_explicit_review_and_matching_entity(tmp_path: Path) -> None:
manifest = tmp_path / "manifest.json"
candidate = extract_media_candidates(
'<img src="/lure/spiker.webp" alt="Блесна Спайкер">',
source_page="https://rf4db.com/ru/wiki/lures/spiker",
)[0]
merge_manifest(manifest, [candidate])
with pytest.raises(ValueError, match="known tackle role"):
review_asset(
manifest, asset_url=candidate.asset_url, decision="rejected",
entity_type="tackle", entity_key="tackle:spiker", media_role="waterbody_map",
)
saved = json.loads(manifest.read_text(encoding="utf-8"))
saved["assets"][0].update({"status": "stored", "sha256": "a" * 64, "local_path": "files/spiker.webp", "width": 256, "height": 256, "content_type": "image/webp"})
manifest.write_text(json.dumps(saved), encoding="utf-8")
reviewed = review_asset(
manifest, asset_url=candidate.asset_url, decision="approved",
entity_type="tackle", entity_key="tackle:spiker", media_role="tackle_card",
)
assert reviewed["media_role"] == "tackle_card"
def test_image_inspection_rejects_invalid_body_and_mime_mismatch(tmp_path: Path) -> None: def test_image_inspection_rejects_invalid_body_and_mime_mismatch(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="valid raster"): with pytest.raises(ValueError, match="valid raster"):
inspect_image(b"not an image") inspect_image(b"not an image")