Compare commits
11
Commits
137aa806c0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f0c2d23de | ||
|
|
4e9895fbf4 | ||
|
|
b0edae98c6 | ||
|
|
d438c40542 | ||
|
|
722c88d436 | ||
|
|
5221442aeb | ||
|
|
85d81aa996 | ||
|
|
53e7b0e4ae | ||
|
|
d63eedf41b | ||
|
|
28fffb3acb | ||
|
|
4303b145b0 |
@@ -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
Generated
+5
@@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Environment-dependent path to Maven home directory
|
||||||
|
/mavenHomeManager.xml
|
||||||
Generated
+266
@@ -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 " import ast import sys files = [ 'apps/api/app/main.py', 'apps/api/app/readiness.py', 'apps/api/app/schemas.py', 'apps/api/app/activity.py', 'apps/api/app/community_scheduler.py', 'apps/api/app/community_importer.py', 'rf4_research/community_cli.py', ] for f in files: try: with open(f) as fh: ast.parse(fh.read()) print(f'OK: {f}') except SyntaxError as e: print(f'ERROR: {f}: {e}') sys.exit(1) print('All Python files syntax OK') "" />
|
||||||
|
</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' > /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' > /tmp/Caddyfile" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="printf 'example.com {\n request_body {\n max_size 10M\n }\n}\n' > /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 > /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 \"ok\"\n }\n}\n' > /tmp/Caddyfile" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c "import ast; ast.parse(open('rf4_research/community_cli.py').read()); print('OK')"" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c "import ast; ast.parse(open('apps/api/app/community_scheduler.py').read()); print('OK')"" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c "import ast; ast.parse(open('apps/api/app/main.py').read()); print('OK')"" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="node -e "try { fetch('http://localhost:1', { signal: AbortSignal.timeout(100) }); } catch(e) { console.log(e.constructor.name, e.message); }" 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="node -e "fetch('http://httpbin.org/delay/10', { signal: AbortSignal.timeout(200) }).catch(e => console.log(e.constructor.name, e.message));" 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="pip-compile requirements.txt --output-file requirements-lock.txt -q 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="pip-compile requirements-dev.txt --output-file requirements-dev-lock.txt -q 2>&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>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -m rf4_research.community_cli --help 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="alembic heads *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 -c " from urllib.request import Request, urlopen from urllib.error import HTTPError # Test with a URL that redirects try: req = Request('https://httpbin.org/redirect/1', headers={'User-Agent': 'test'}) resp = urlopen(req, timeout=5) print(f'Final URL: {resp.url}') print(f'Response URL: {resp.url}') except Exception as e: print(f'Error: {type(e).__name__}: {e}') " 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="pwd" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 -c "from rf4_research import community_cli; print('Syntax OK')"" />
|
||||||
|
</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 "from app.cli import main; import sys; sys.argv = ['cli', '--help']; main()" 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="find *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git push *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c " from sqlalchemy import create_engine, select from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool from app.database import Base from app.models import SubmissionAttempt import hashlib, hmac engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool) Base.metadata.create_all(engine) with Session(engine) as db: # Simulate first request key = 'idem-test-key-001' key_hash = hmac.new('change-rate-limit-secret'.encode(), key.encode(), hashlib.sha256).hexdigest() print(f'Key hash: {key_hash}') # Check before storing existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash)) print(f'Found before: {existing}') # Store from datetime import datetime, timezone db.add(SubmissionAttempt(client_hash='test', idempotency_key=key_hash, created_at=datetime.now(timezone.utc))) db.commit() # Check after storing existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash)) print(f'Found after: {existing}') if existing: print(f' idempotency_key: {existing.idempotency_key}') "" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c " from sqlalchemy import create_engine, select from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool from app.database import Base from app.models import SubmissionAttempt import hashlib, hmac engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool) Base.metadata.create_all(engine) with Session(engine) as db: key = 'idem-test-key-001' key_hash = hmac.new('change-rate-limit-secret'.encode(), key.encode(), hashlib.sha256).hexdigest() print(f'Key hash: {key_hash}') existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash)) print(f'Found before: {existing}') from datetime import datetime, timezone db.add(SubmissionAttempt(client_hash='test', idempotency_key=key_hash, created_at=datetime.now(timezone.utc))) db.commit() existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash)) print(f'Found after: {existing}') if existing: print(f' idempotency_key: {existing.idempotency_key}') "" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c " from sqlalchemy import create_engine, inspect, text from sqlalchemy.pool import StaticPool from app.database import Base from app.models import SubmissionAttempt engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool) Base.metadata.create_all(engine) inspector = inspect(engine) columns = inspector.get_columns('submission_attempt') print('Columns:', [c['name'] for c in columns]) "" />
|
||||||
|
</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>
|
||||||
Generated
+6
@@ -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>
|
||||||
Generated
+8
@@ -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>
|
||||||
Generated
+9
@@ -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
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -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 manifest’s 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 repository’s 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.
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -48,6 +58,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):
|
||||||
@@ -82,6 +94,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 +332,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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>}
|
||||||
@@ -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="Цветовая тема">
|
||||||
|
|||||||
@@ -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,9 @@ 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 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 +27,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}`;
|
||||||
|
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
};
|
||||||
@@ -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 : "Ошибка фильтрации."); } });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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))}}
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
"/", "/waterbodies", "/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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. W02–W08 remain open.
|
||||||
|
|
||||||
|
## Frontend, accessibility and visual system
|
||||||
|
|
||||||
|
Astro SSR pages include public catalogs, fish/waterbody/spot detail, official
|
||||||
|
records, reports, status, media and admin screens. The current implementation
|
||||||
|
has semantic light/dark tokens, system/manual theme selection, forced-colors
|
||||||
|
rules, nonce-based JSON-LD CSP, noindex/no-store handling for admin/error
|
||||||
|
surfaces, responsive pagination and reduced-motion styles.
|
||||||
|
|
||||||
|
Automated structural 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 W03–W08 without assigning unreviewed media
|
||||||
|
roles.
|
||||||
|
4. **Source governance:** attach durable primary permission evidence and image
|
||||||
|
rights before expanding public catalog coverage (Q01).
|
||||||
|
5. **After pilot:** personal admin identities (A05/M06), shared cache only after
|
||||||
|
measurement, production p95 and external search/analytics integrations.
|
||||||
|
|
||||||
|
## Scope decisions
|
||||||
|
|
||||||
|
- No Next.js/Vinext migration, Redis addition, broad CSS rewrite or unrelated
|
||||||
|
refactor is justified by this audit.
|
||||||
|
- No external source was fetched during this audit.
|
||||||
|
- No media was downloaded, replaced or automatically promoted.
|
||||||
|
- Historical audits remain historical; this document and `ROADMAP.md` are the
|
||||||
|
current decision context.
|
||||||
@@ -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-тестом. Источники в сеть для этой проверки не опрашивались.
|
||||||
|
|
||||||
|
| Исторический пакет | Текущий статус | Актуальное подтверждение |
|
||||||
|
|---|---|---|
|
||||||
|
| A01–A03 | закрыт | `RECOVERY_FIXES_REPORT.md`, тесты readiness/cooldown/redirect |
|
||||||
|
| A04 | закрыт, повторно усилен 15.09 | серверные previous/next, диапазон и unit-тест 45 элементов |
|
||||||
|
| A05–A12 | закрыт | `RECOVERY_FIXES_REPORT.md`, адресные regression-тесты |
|
||||||
|
| A13 | закрыт | итоговый отчёт и единственная активная очередь в `ROADMAP.md` |
|
||||||
|
|
||||||
## Порядок и критерии приёмки
|
## Порядок и критерии приёмки
|
||||||
|
|
||||||
|
|||||||
@@ -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()`.
|
||||||
|
|||||||
+31
-30
@@ -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, связи с уловами и публичные страницы выполняются по пакету **G01–G09** ниже. Не считать каталог полным до получения проверяемого общего счётчика по категориям.
|
- [ ] **B19 · Каталог снастей RF4DB.** Подготовительный media-контур; полный справочник, crosswalk, связи с уловами и публичные страницы выполняются по пакету **G01–G09** ниже. Не считать каталог полным до получения проверяемого общего счётчика по категориям.
|
||||||
- [x] **B20 · Аудит качества рыбных изображений.** Offline `media_cli --quality-report` проверяет фактические dimensions опубликованных файлов, отдельно считает неизбежный upscale в карточках и известные альтернативы. На 14.09: 227 из 243 опубликованных рыб имеют только 48×48 PNG RF4MAP и растягиваются до 180 px; 16 имеют 1024×1024 WebP. Для всех 227 низких файлов уже известны альтернативные URL RF4DB. Совпадение сущности больше нельзя считать достаточным основанием пропустить потенциально более качественный файл.
|
- [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`.
|
||||||
- [ ] **B23 · Безопасное продвижение и provenance.** Добавить связь `supersedes`/`replaced_by`, отдельное решение review и атомарное переключение публичного `entity_key`; сохранить обе исходные ссылки и возможность отката. На сайте показывать источник именно выбранного изображения, а в раскрываемом provenance — все проверенные варианты. Не удалять старый Git-файл в том же коммите, где включается новый.
|
- [x] **B23 · Безопасное продвижение и provenance.** Связи `supersedes`/`replaced_by`, отдельное решение review, атомарное переключение публичного `entity_key` и явный CLI rollback сохраняют обе исходные ссылки и возможность отката. Публичная карточка показывает источник выбранного изображения, а manifest сохраняет проверенные варианты. Старый Git-файл не удаляется при включении нового.
|
||||||
- [ ] **B24 · Производные размеры.** После выбора оригиналов генерировать детерминированные WebP/AVIF thumbnails для каталога и отдельный крупный вариант для detail, фиксировать хэши производных в manifest и отдавать `srcset`. Это уберёт загрузку 1024×1024 на каждой маленькой карточке и исключит browser-upscale 48×48.
|
- [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,11 +66,11 @@
|
|||||||
собранная схема или монтаж (например, донная, поплавочная, 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 и публичный каталог.** Добавить пагинированные каталоги и detail endpoints с фильтрами по категории, бренду, семейству и уровню, а также безопасные ссылки из улова/точки на использованную приманку, снасть и монтаж. Показывать только подтверждённые характеристики, источник, свежесть и неполноту; не выдавать рейтинг эффективности, если его нельзя объяснить числом наблюдений, игроками, периодом и качеством источников.
|
||||||
- [ ] **G07 · Аналитика сочетаний и рекомендации.** После появления достаточных данных считать отдельно «водоём + рыба + предмет», «точка + рыба + предмет» и «способ ловли + монтаж». Зафиксировать минимальный объём выборки, защиту от одного игрока/дубликатов и decay по свежести; разделить факт использования, частоту и рекомендацию. Пустая или малая выборка должна показывать «данных мало», а не советовать конкретную снасть.
|
- [ ] **G07 · Аналитика сочетаний и рекомендации.** После появления достаточных данных считать отдельно «водоём + рыба + предмет», «точка + рыба + предмет» и «способ ловли + монтаж». Зафиксировать минимальный объём выборки, защиту от одного игрока/дубликатов и decay по свежести; разделить факт использования, частоту и рекомендацию. Пустая или малая выборка должна показывать «данных мало», а не советовать конкретную снасть.
|
||||||
- [ ] **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 · Медиа и качество.** Разнести 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.
|
||||||
@@ -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 и процедурой отзыва доступа.
|
||||||
|
|
||||||
## Готовность открытой альфы — требуется сервер или внешний сервис
|
## Готовность открытой альфы — требуется сервер или внешний сервис
|
||||||
|
|
||||||
|
|||||||
@@ -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 секунд.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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))
|
||||||
Executable
+81
@@ -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", "/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;
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import fs 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
|
||||||
|
--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,
|
||||||
|
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 === "--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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 context = await chromium.launchPersistentContext(path.resolve(args.profile), { headless: args.headless });
|
||||||
|
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;
|
||||||
|
});
|
||||||
Executable
+153
@@ -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
@@ -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
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -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")], [])
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user