sync
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-17 07:35:23 +07:00
parent 722c88d436
commit d438c40542
21 changed files with 2181 additions and 0 deletions
@@ -0,0 +1,51 @@
# План работы RF4 Spotter — Регрессионный аудит
На основе: [REGRESSION_AUDIT_2026-09-09.md](../docs/REGRESSION_AUDIT_2026-09-09.md)
Дата: 2026-09-09
## Выполнено
### R01 ✅ — Caddy body_limit → request_body max_size
- **Файл**: `deploy/Caddyfile`
- **Проблема**: `body_limit 10M` не поддерживается в Caddy 2.10.2
- **Решение**: `request_body { max_size 10M }`
- **Верификация**: `caddy adapt` проходит без ошибок
### R02 ✅ — Activity API contract regression
- **API**: `main.py``/api/v1/activity` возвращает `PaginatedActivityOut`
- **Frontend**: все 4 потребителя обновлены:
- `index.astro` ✅ (из предыдущего коммита)
- `fish/[slug].astro`
- `waterbodies/[slug].astro`
- `waterbodies/[slug]/[fish].astro`
- `spots/[id].astro`
- **Тесты**: 4 теста обновлены под новый контракт
- **Результат**: 76 passed, 1 skipped, 0 failures
### R14 ✅ — Registry источников больше не зависит от БД при парсинге CLI
- **Файл**: `community_scheduler.py`
- **Решение**: `_static_registry()` — без БД; `configured_sources(enabled_keys)` — с опциональной БД
- **Тесты**: scheduler unit-тесты проходят без PostgreSQL
## Итоги тестов
- **Python**: 76 passed, 1 skipped ✅
- **Astro check**: 0 errors, 0 warnings, 0 hints ✅
- **Caddy adapt**: passes ✅
## Коммиты
1. `a37f9c4` — R01 Caddy body_limit → request_body
2. `d962ba2` — R02 activity API contract + R14 static registry
## Осталось из регрессий (R03-R15)
- R03: Research CLI cooldown broken (fcntl `r`/`r+` + encoding)
- R04: Disabled-фильтрация обходит site cooldown
- R05: Bootstrap небезопасен для источников
- R06: Фильтры сигналов сравнивают slug с названием
- R07: Ошибка главной остаётся HTTP 200
- R08: Фильтры UI не доведены до responsive-состояния
- R09: Пагинация (частично исправлено в U03)
- R10: Ошибки формы теряют черновик
- R11: Проверка URL после сетевого обращения
- R12: Мониторинг не сигнализирует о зависшем импорте
- R13: Доверие к IP клиента не ограничено proxy
- R15: D04/D06/D07/D08 выполнены не полностью
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Environment-dependent path to Maven home directory
/mavenHomeManager.xml
+266
View File
@@ -0,0 +1,266 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GigaCodeAgentSettings">
<option name="autoApproveEdits" value="true" />
<option name="autoApprovedCommands">
<list>
<Execute>
<option name="pattern" value="mkdir -p /home/ik/git/rf4-help/.gigacode/plans" />
</Execute>
<Execute>
<option name="pattern" value="cd *" />
</Execute>
<Execute>
<option name="pattern" value="ls *" />
</Execute>
<Execute>
<option name="pattern" value="pip install *" />
</Execute>
<Execute>
<option name="pattern" value="tail *" />
</Execute>
<Execute>
<option name="pattern" value="python pytest *" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;&#10;import ast&#10;import sys&#10;&#10;files = [&#10; 'apps/api/app/main.py',&#10; 'apps/api/app/readiness.py',&#10; 'apps/api/app/schemas.py',&#10; 'apps/api/app/activity.py',&#10; 'apps/api/app/community_scheduler.py',&#10; 'apps/api/app/community_importer.py',&#10; 'rf4_research/community_cli.py',&#10;]&#10;&#10;for f in files:&#10; try:&#10; with open(f) as fh:&#10; ast.parse(fh.read())&#10; print(f'OK: {f}')&#10; except SyntaxError as e:&#10; print(f'ERROR: {f}: {e}')&#10; sys.exit(1)&#10;&#10;print('All Python files syntax OK')&#10;&quot;" />
</Execute>
<Execute>
<option name="pattern" value="npm run *" />
</Execute>
<Execute>
<option name="pattern" value="head *" />
</Execute>
<Execute>
<option name="pattern" value="npm install *" />
</Execute>
<Execute>
<option name="pattern" value="git add *" />
</Execute>
<Execute>
<option name="pattern" value="git reset *" />
</Execute>
<Execute>
<option name="pattern" value="git diff *" />
</Execute>
<Execute>
<option name="pattern" value="git log *" />
</Execute>
<Execute>
<option name="pattern" value="git commit *" />
</Execute>
<Execute>
<option name="pattern" value="git status" />
</Execute>
<Execute>
<option name="pattern" value="echo *" />
</Execute>
<Execute>
<option name="pattern" value="printf 'localhost {\n body_limit 10M\n}\n' &gt; /tmp/test-caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="cp /home/ik/git/rf4-help/deploy/Caddyfile /tmp/Caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="printf 'example.com {\n body_limit 10M\n}\n' &gt; /tmp/Caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="printf 'example.com {\n request_body {\n max_size 10M\n }\n}\n' &gt; /tmp/Caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="docker run *" />
</Execute>
<Execute>
<option name="pattern" value="sed 's/email {$ACME_EMAIL}/email test@test.com/' /home/ik/git/rf4-help/deploy/Caddyfile &gt; /tmp/Caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="grep *" />
</Execute>
<Execute>
<option name="pattern" value="printf 'example.com {\n request_body {\n max_size 10M\n }\n handle {\n respond \&quot;ok\&quot;\n }\n}\n' &gt; /tmp/Caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;import ast; ast.parse(open('rf4_research/community_cli.py').read()); print('OK')&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;import ast; ast.parse(open('apps/api/app/community_scheduler.py').read()); print('OK')&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;import ast; ast.parse(open('apps/api/app/main.py').read()); print('OK')&quot;" />
</Execute>
<Execute>
<option name="pattern" value="node -e &quot;try { fetch('http://localhost:1', { signal: AbortSignal.timeout(100) }); } catch(e) { console.log(e.constructor.name, e.message); }&quot; 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="node -e &quot;fetch('http://httpbin.org/delay/10', { signal: AbortSignal.timeout(200) }).catch(e =&gt; console.log(e.constructor.name, e.message));&quot; 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="pip-compile requirements.txt --output-file requirements-lock.txt -q 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="pip-compile requirements-dev.txt --output-file requirements-dev-lock.txt -q 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="wc *" />
</Execute>
<Execute>
<option name="pattern" value="alembic revision *" />
</Execute>
<Execute>
<option name="pattern" value="python -m app.cli --help 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="python -m rf4_research.community_cli --help 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="alembic heads *" />
</Execute>
<Execute>
<option name="pattern" value="python3 -c &quot;&#10;from urllib.request import Request, urlopen&#10;from urllib.error import HTTPError&#10;# Test with a URL that redirects&#10;try:&#10; req = Request('https://httpbin.org/redirect/1', headers={'User-Agent': 'test'})&#10; resp = urlopen(req, timeout=5)&#10; print(f'Final URL: {resp.url}')&#10; print(f'Response URL: {resp.url}')&#10;except Exception as e:&#10; print(f'Error: {type(e).__name__}: {e}')&#10;&quot; 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="pwd" />
</Execute>
<Execute>
<option name="pattern" value="python3" />
</Execute>
<Execute>
<option name="pattern" value="python3 -c &quot;from rf4_research import community_cli; print('Syntax OK')&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/append_tests.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 pytest *" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_redirect_test.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a04.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a04_v2.py" />
</Execute>
<Execute>
<option name="pattern" value="git show *" />
</Execute>
<Execute>
<option name="pattern" value="git branch *" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/test_double_reservation_bug.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a02_double_reservation.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/test_debug.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/test_a02_final.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/test_a02_v2.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/test_a02_code.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a04_pagination.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a01_monitoring.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a01_tests.py" />
</Execute>
<Execute>
<option name="pattern" value="*" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a01_tests_v2.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a04_filters.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a05_sessionstorage.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a06_tests.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a07_review_note.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a08_errorpage.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a08_index.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a11_pip_audit.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a04_pagination_final.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a08_index_unavailable.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a08_spots.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a08_records.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a01_vars.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a01_test.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a07_auto_publish.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a07_review_note_auto.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a09_cli.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 -c &quot;from app.cli import main; import sys; sys.argv = ['cli', '--help']; main()&quot; 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="find *" />
</Execute>
<Execute>
<option name="pattern" value="git push *" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;&#10;from sqlalchemy import create_engine, select&#10;from sqlalchemy.orm import Session&#10;from sqlalchemy.pool import StaticPool&#10;from app.database import Base&#10;from app.models import SubmissionAttempt&#10;import hashlib, hmac&#10;&#10;engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)&#10;Base.metadata.create_all(engine)&#10;&#10;with Session(engine) as db:&#10; # Simulate first request&#10; key = 'idem-test-key-001'&#10; key_hash = hmac.new('change-rate-limit-secret'.encode(), key.encode(), hashlib.sha256).hexdigest()&#10; print(f'Key hash: {key_hash}')&#10; &#10; # Check before storing&#10; existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))&#10; print(f'Found before: {existing}')&#10; &#10; # Store&#10; from datetime import datetime, timezone&#10; db.add(SubmissionAttempt(client_hash='test', idempotency_key=key_hash, created_at=datetime.now(timezone.utc)))&#10; db.commit()&#10; &#10; # Check after storing&#10; existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))&#10; print(f'Found after: {existing}')&#10; if existing:&#10; print(f' idempotency_key: {existing.idempotency_key}')&#10;&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;&#10;from sqlalchemy import create_engine, select&#10;from sqlalchemy.orm import Session&#10;from sqlalchemy.pool import StaticPool&#10;from app.database import Base&#10;from app.models import SubmissionAttempt&#10;import hashlib, hmac&#10;&#10;engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)&#10;Base.metadata.create_all(engine)&#10;&#10;with Session(engine) as db:&#10; key = 'idem-test-key-001'&#10; key_hash = hmac.new('change-rate-limit-secret'.encode(), key.encode(), hashlib.sha256).hexdigest()&#10; print(f'Key hash: {key_hash}')&#10; &#10; existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))&#10; print(f'Found before: {existing}')&#10; &#10; from datetime import datetime, timezone&#10; db.add(SubmissionAttempt(client_hash='test', idempotency_key=key_hash, created_at=datetime.now(timezone.utc)))&#10; db.commit()&#10; &#10; existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))&#10; print(f'Found after: {existing}')&#10; if existing:&#10; print(f' idempotency_key: {existing.idempotency_key}')&#10;&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;&#10;from sqlalchemy import create_engine, inspect, text&#10;from sqlalchemy.pool import StaticPool&#10;from app.database import Base&#10;from app.models import SubmissionAttempt&#10;&#10;engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)&#10;Base.metadata.create_all(engine)&#10;&#10;inspector = inspect(engine)&#10;columns = inspector.get_columns('submission_attempt')&#10;print('Columns:', [c['name'] for c in columns])&#10;&quot;" />
</Execute>
</list>
</option>
<option name="autoApprovedReads">
<list>
<Read>
<option name="pattern" value="/path/to/apps/web/src/pages/index.astro" />
</Read>
<Read>
<option name="pattern" value="/path/to/apps/web/src/lib/api.ts" />
</Read>
<Read>
<option name="pattern" value="/path/to/rf4_spotter/rf4_research/community_cli.py" />
</Read>
</list>
</option>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/rf4-help.iml" filepath="$PROJECT_DIR$/.idea/rf4-help.iml" />
</modules>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+65
View File
@@ -0,0 +1,65 @@
# Repository Guidelines
## Project Structure & Module Organization
RF4 Spotter is an Astro/FastAPI/PostgreSQL application with an offline
research and media-ingestion toolkit. The main areas are:
- `apps/api/` — FastAPI application, routers, models, migrations, and API tests.
- `apps/web/` — Astro pages, components, styles, unit tests, and Playwright tests.
- `rf4_research/` — source parsers, media manifest tooling, and CLI commands.
- `tests/` — Python research/tooling tests and fixtures.
- `data/media/` — versioned manifest and content-addressed local media files.
- `docs/` — specification, roadmap, ADRs, runbooks, and acceptance procedures.
- `compose.yaml` — local PostgreSQL, API, web, and supporting services.
Keep generated reports and temporary downloads outside committed paths unless a
task explicitly requires versioning them.
## Build, Test, and Development Commands
From the repository root:
```bash
.venv/bin/pytest -q # Python suites
npm --prefix apps/web run check # Astro type/template checks
npm --prefix apps/web run build # Check and production build
npm --prefix apps/web run test:unit # Web unit tests
WEB_URL=http://127.0.0.1:4321 npm --prefix apps/web run test:e2e
docker compose up --build # Full local stack
```
For media work, use `.venv/bin/python -m rf4_research.media_cli --audit` and
respect the manifests cooldown and approval states. Do not hotlink or replace
approved media without explicit review.
## Coding Style & Naming Conventions
Use four-space indentation for Python and two spaces for Astro/TypeScript.
Prefer typed Python functions, `snake_case` for Python identifiers, and
`camelCase` for TypeScript variables/functions. Astro components use
`PascalCase.astro`; tests use descriptive `test_*.py` or `*.test.ts` names.
Keep UI text and data-source labels explicit and accessible; run `astro check`
before committing web changes.
## Testing Guidelines
Add focused regression tests beside the affected suite. Python tests use
pytest; web behavior uses Node unit tests and Playwright. Run the smallest
relevant test first, then the full suite before handoff. Never use live external
sources in tests; use fixtures or isolated Docker services.
## Commit & Pull Request Guidelines
Use imperative, concise commit subjects with the repositorys existing scope
style, such as `feat:`, `fix:`, `data:`, or `chore:`. Keep commits focused and
exclude unrelated user files. Pull requests should describe behavior changes,
verification commands, migration or configuration impact, and screenshots for
visual/UI work. Call out any media provenance, approval, or rollback decision.
## Security & Configuration Tips
Do not commit secrets, production `.env` files, tokens, or private payloads.
Use local fixtures and documented environment variables. Preserve the strict
CSP, admin authentication barriers, source attribution, and media provenance
when changing application code.
@@ -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)
+27
View File
@@ -0,0 +1,27 @@
---
import { pageHref, pageWindow } from "../lib/pagination";
interface Props {
path: string;
params: URLSearchParams;
total: number;
limit: number;
offset: number;
anchor?: string;
itemLabel?: string;
}
const { path, params, total, limit, offset, anchor = "", itemLabel = "записей" } = Astro.props;
const state = pageWindow(total, limit, offset);
---
{total > limit && <nav class="pagination" aria-label="Пагинация">
<span>{state.start}{state.end} из {total} {itemLabel} · страница {state.page} из {state.pages}</span>
<div>
{state.previousOffset !== null
? <a data-action="secondary" rel="prev" href={pageHref(path, params, state.previousOffset, anchor)}>← Предыдущая</a>
: <span class="pagination__disabled" aria-disabled="true">← Предыдущая</span>}
{state.nextOffset !== null
? <a data-action="secondary" rel="next" href={pageHref(path, params, state.nextOffset, anchor)}>Следующая →</a>
: <span class="pagination__disabled" aria-disabled="true">Следующая →</span>}
</div>
</nav>}
+31
View File
@@ -0,0 +1,31 @@
export type PageWindow = {
start: number;
end: number;
page: number;
pages: number;
previousOffset: number | null;
nextOffset: number | null;
};
export const pageWindow = (total: number, limit: number, offset: number): PageWindow => {
const safeTotal = Math.max(0, Math.trunc(total));
const safeLimit = Math.max(1, Math.trunc(limit));
const safeOffset = Math.max(0, Math.trunc(offset));
const end = Math.min(safeOffset + safeLimit, safeTotal);
return {
start: safeTotal > 0 && safeOffset < safeTotal ? safeOffset + 1 : 0,
end,
page: Math.floor(safeOffset / safeLimit) + 1,
pages: Math.max(1, Math.ceil(safeTotal / safeLimit)),
previousOffset: safeOffset > 0 ? Math.max(0, safeOffset - safeLimit) : null,
nextOffset: end < safeTotal ? safeOffset + safeLimit : null,
};
};
export const pageHref = (path: string, search: URLSearchParams, offset: number, anchor = "") => {
const params = new URLSearchParams(search);
if (offset > 0) params.set("offset", String(offset));
else params.delete("offset");
const query = params.toString();
return `${path}${query ? `?${query}` : ""}${anchor}`;
};
+1
View File
@@ -0,0 +1 @@
.pagination{width:min(1360px,calc(100% - 64px));margin:22px auto 48px;display:flex;align-items:center;justify-content:space-between;gap:16px;color:var(--text-secondary);font-size:13px}.pagination>div{display:flex;gap:10px}.pagination a,.pagination__disabled{display:inline-flex;align-items:center;min-height:42px;padding:10px 15px;border:1px solid var(--border-strong);border-radius:10px;background:var(--surface-elevated);color:var(--text-secondary);text-decoration:none}.pagination a:hover{background:var(--surface-raised);color:var(--text-primary)}.pagination__disabled{opacity:.5}@media(max-width:720px){.pagination{width:calc(100% - 28px);align-items:stretch;flex-direction:column}.pagination>div{display:grid;grid-template-columns:1fr 1fr}.pagination a,.pagination__disabled{justify-content:center;text-align:center}}
@@ -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,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"}
]
}
+59
View File
@@ -0,0 +1,59 @@
"""Conservative, offline crosswalk suggestions for waterbody identities."""
from __future__ import annotations
from dataclasses import dataclass
from .media_assets import normalize_entity_label
@dataclass(frozen=True, slots=True)
class CanonicalWaterbody:
key: str
name: str
aliases: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class WaterbodyIdentity:
source_system: str
external_id: str
name: str
@dataclass(frozen=True, slots=True)
class CrosswalkSuggestion:
source_system: str
external_id: str
external_name: str
status: str
canonical_keys: tuple[str, ...]
def suggest_waterbody_crosswalk(
canonical: list[CanonicalWaterbody], identities: list[WaterbodyIdentity],
) -> list[CrosswalkSuggestion]:
"""Suggest only unique exact normalized-name matches.
``ambiguous`` and ``unmatched`` rows intentionally have no selected key;
callers must not turn this report into aliases without human review.
"""
by_name: dict[str, set[str]] = {}
for item in canonical:
for name in (item.name, *item.aliases):
by_name.setdefault(normalize_entity_label(name), set()).add(item.key)
result: list[CrosswalkSuggestion] = []
for identity in identities:
keys = tuple(sorted(by_name.get(normalize_entity_label(identity.name), set())))
status = "exact" if len(keys) == 1 else "ambiguous" if keys else "unmatched"
result.append(CrosswalkSuggestion(
source_system=identity.source_system, external_id=identity.external_id,
external_name=identity.name, status=status,
canonical_keys=keys if status == "exact" else (),
))
return result
def missing_external_ids(expected: set[str], observed: set[str]) -> tuple[str, ...]:
"""Return stable missing IDs without interpreting absence as deletion."""
return tuple(sorted(expected - observed))
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="ru">
<body>
<main>
<article>
<h1>Дачный пруд</h1>
<img src="https://oss.rf4db.com/game/maps/level_000_home.png" alt="Дачный пруд">
<img src="https://oss.rf4db.com/game/paper_maps_webp/map_level_000_home.webp" alt="Карта: Дачный пруд">
<img src="https://oss.rf4db.com/game/fish/frog.webp" alt="Лягушка">
<a href="/ru/fishes/frog">Лягушка 190 г</a>
<a href="/ru/fishes/a.sleeper">Ротан 400 г</a>
<a href="/ru/fishes/perch">Окунь 1.6 кг</a>
<a href="/ru/positions/example-position">Точка 13:9</a>
</article>
</main>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="ru">
<body>
<article class="waterbody-detail">
<h1>оз. Комариное</h1>
<p class="waterbody-description">Большое озеро с каменистыми берегами.</p>
<ul class="waterbody-aliases"><li>Комариное</li><li>оз. Комариное</li></ul>
<div class="waterbody-fish">
<a href="/ru/wiki/fish/pike">Щука</a>
<a href="/ru/wiki/fish/perch">Окунь</a>
</div>
<img src="https://oss.rf4db.com/game/maps/level_001_mosquito.webp" alt="Карта">
<img data-src="/game/maps/level_001_mosquito-depth.webp" alt="Глубины">
<a href="/ru/maps/level_001_mosquito/spots/12-34">Точка 12:34</a>
</article>
</body>
</html>