From 7e464ef5983198c2d6e9e813111996a6e88728a5 Mon Sep 17 00:00:00 2001 From: IK Date: Sun, 13 Sep 2026 08:14:56 +0700 Subject: [PATCH] feat: track media catalog coverage --- README.md | 2 ++ data/media/catalog-baseline.json | 19 +++++++++++++++++++ docs/ROADMAP.md | 4 ++-- docs/data-sources.md | 2 ++ rf4_research/media_assets.py | 19 +++++++++++++++++++ rf4_research/media_cli.py | 6 +++++- tests/test_media_assets.py | 17 ++++++++++++++++- 7 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 data/media/catalog-baseline.json diff --git a/README.md b/README.md index 99710ed..b339940 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов. +`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: сейчас кандидатами не покрыты минимум 21 рыба и 17 водоёмов, а до ручного review не подтверждены 251 рыба и все 19 водоёмов. Общий target снастей остаётся `null`, пока разрешённый источник не отдаст проверяемый полный счётчик. + Актуальный внешний ориентир — 19 водоёмов и 252 вида рыб; локальная альфа пока содержит 2+2 сущности. Media-manifest включает 452 кандидата: 231 изображение рыбы, 146 приманок, 2 карты и 73 справочных изображения. Вручную проверены и сопоставлены 1 рыба, 2 приманки и 1 официальная схема; 446 файлов остаются в очереди, 1 URL признан невалидным. Полное число «снастей» пока не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками. Для измерений на собственном сервере подготовлен read-only `deploy/load-smoke.py`: он считает p50/p95/max и HTTP-коды для activity/records, а при наличии `ADMIN_TOKEN` — staging/moderation. Методика и безопасные ступени нагрузки описаны в [docs/load-testing.md](docs/load-testing.md); локальные цифры не выдаются за production baseline. diff --git a/data/media/catalog-baseline.json b/data/media/catalog-baseline.json new file mode 100644 index 0000000..eec1838 --- /dev/null +++ b/data/media/catalog-baseline.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "verified_at": "2026-09-12", + "entities": { + "fish": { + "count": 252, + "source": "https://rf4db.com/ru/about/data" + }, + "waterbody": { + "count": 19, + "source": "https://rf4db.com/ru/about/data" + }, + "tackle": { + "count": null, + "source": "https://rf4db.com/ru/about/data", + "note": "Источник подтверждает наличие каталога, но не публикует общий счётчик; приманки не равны всем снастям." + } + } +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index eabd332..e71c2db 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,7 +2,7 @@ Этот файл — единственный актуальный список задач. Завершённые аудиты сохранены как история в [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); их старые чекбоксы не являются текущей очередью. -Последняя сверка: **12 сентября 2026**. +Последняя сверка: **13 сентября 2026**. Подтверждено: @@ -30,7 +30,7 @@ - [x] **B08 · Семейства силуэтов рыб.** Три случайных hash-варианта заменены классификатором и отдельными формами `pike`, `salmonid`, `cyprinid`, `perch`, `catfish`, `eel`, `flatfish`, `marine`, плюс честный `generic`. Компонент допускает ручное переопределение family; название остаётся главным идентификатором. - [x] **B09 · Глифы снастей и приманок.** Добавлены SVG-глифы `spinner`, `wobbler`, `soft`, `boilie`, `worm`, `rig`, `unknown` и стабильная палитра по normalized name. Классификация срабатывает только по явным словам; глиф сопровождает текст в activity, лидере, уловах, рекордах и списке лучших приманок, не выдавая категорию за точную модель. - [x] **B10 · Визуальные отпечатки водоёмов.** Для каждого slug воспроизводимо выбираются один из восьми береговых контуров, число волн, положение точки и двухсимвольный индекс. Знак используется в каталоге и detail-hero; это явно абстрактный отпечаток, а не карта или игровая география. -- [ ] **B11 · Разрешённый media pipeline — в работе.** Внешний контрольный каталог на 12.09.2026 содержит 19 водоёмов и 252 рыбы; полное число снастей неизвестно и не подменяется числом приманок. RF4MAP добавил 383 кандидата, итог manifest: 231 fish, 146 tackle/bait, 2 waterbody и 73 reference. Вручную проверены и approved 1 рыба (Ерш), 2 приманки и 1 официальная Zig-Rig схема; 446 файлов ожидают, один URL rf4game.ru вернул HTML вместо PNG и помечен invalid. Следующие gaps: минимум 21 рыба, 17 водоёмов и все категории снастей за пределами найденных приманок; получить канонические перечни и устойчивые ID, затем загружать не более одного asset с домена за окно и вручную сопоставлять. RF4DB вернул 403 — до конца cooldown не повторять. Официальные `/media/fische/`, `/media/levels/` и `.bait_icon` records исследовать только через публичные контракты без обхода защиты. +- [ ] **B11 · Разрешённый media pipeline — в работе.** Версионированный baseline на 12.09.2026 содержит 19 водоёмов и 252 рыбы; полное число снастей неизвестно и остаётся `null`, а не подменяется числом приманок. Воспроизводимый `media_cli --coverage` показывает: 231/252 fish-кандидатов (1 approved), 2/19 waterbody (0 approved), 146 tackle/bait (2 approved); ещё одна официальная Zig-Rig схема approved как reference. Следующие gaps: минимум 21 рыба, 17 водоёмов и все категории снастей за пределами найденных приманок; получить канонические перечни и устойчивые ID, затем загружать не более одного asset с домена за окно и вручную сопоставлять. RF4DB вернул 403 — повторять только после cooldown. Официальные `/media/fische/`, `/media/levels/` и `.bait_icon` records исследовать только через публичные контракты без обхода защиты. - [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений. - [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`. - [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания. diff --git a/docs/data-sources.md b/docs/data-sources.md index 1b743cf..b31cc24 100644 --- a/docs/data-sources.md +++ b/docs/data-sources.md @@ -66,6 +66,8 @@ Fallback строится на собственных лёгких SVG: осмы `python -m rf4_research.media_cli --audit` не использует сеть: команда выводит сводку статусов и завершается с ошибкой при отсутствующем/изменённом файле, несовпадении SHA-256 или dimensions/MIME, некорректном approved-сопоставлении и наличии бесхозного файла. +`python -m rf4_research.media_cli --coverage` также работает локально и сравнивает manifest с датированным `data/media/catalog-baseline.json`. Неизвестные контрольные значения хранятся как `null`: отчёт не выдаёт число найденных приманок за полноту всего каталога снастей. + Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU. ## Рекомендуемый режим получения diff --git a/rf4_research/media_assets.py b/rf4_research/media_assets.py index af09d23..dfef961 100644 --- a/rf4_research/media_assets.py +++ b/rf4_research/media_assets.py @@ -162,6 +162,25 @@ def audit_media_catalog(root: Path) -> dict: return {"total": sum(statuses.values()), "statuses": statuses, "issues": issues, "orphaned_files": orphaned} +def media_coverage(root: Path) -> dict: + """Compare quarantined and approved media with the versioned catalog baseline.""" + manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) + baseline = json.loads((root / "catalog-baseline.json").read_text(encoding="utf-8")) + result: dict[str, dict] = {} + for entity_type, target in baseline["entities"].items(): + candidates = [item for item in manifest.get("assets", []) if item.get("entity_type") == entity_type] + approved_keys = {item.get("entity_key") for item in candidates if item.get("status") == "approved" and item.get("entity_key")} + expected = target.get("count") + result[entity_type] = { + "expected": expected, + "candidates": len(candidates), + "approved": len(approved_keys), + "candidate_gap": max(expected - len(candidates), 0) if isinstance(expected, int) else None, + "approved_gap": max(expected - len(approved_keys), 0) if isinstance(expected, int) else None, + } + return {"baseline_date": baseline["verified_at"], "entities": result} + + def inspect_image(body: bytes) -> tuple[int, int, str]: try: with Image.open(io.BytesIO(body)) as image: diff --git a/rf4_research/media_cli.py b/rf4_research/media_cli.py index 81f4183..179f999 100644 --- a/rf4_research/media_cli.py +++ b/rf4_research/media_cli.py @@ -8,7 +8,7 @@ import urllib.error import urllib.request from .community_cli import USER_AGENT, _StrictRedirectHandler, _validate_url_before_io, check_and_reserve, fetch_html, fetch_site_key -from .media_assets import audit_media_catalog, extract_media_candidates, merge_manifest, reclassify_manifest, review_asset, store_asset +from .media_assets import audit_media_catalog, extract_media_candidates, media_coverage, merge_manifest, reclassify_manifest, review_asset, store_asset DEFAULT_ROOT = Path("data/media") @@ -59,6 +59,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--entity-key") parser.add_argument("--note") parser.add_argument("--audit", action="store_true", help="Verify manifest metadata, hashes and local files without network access") + parser.add_argument("--coverage", action="store_true", help="Compare candidates and approvals with the catalog baseline") parser.add_argument("--root", type=Path, default=DEFAULT_ROOT) parser.add_argument("--state-file", type=Path, default=Path(".cache/community-fetch-state.json")) args = parser.parse_args(argv) @@ -67,6 +68,9 @@ def main(argv: list[str] | None = None) -> int: report = audit_media_catalog(args.root) print(json.dumps(report, ensure_ascii=False, indent=2)) return 1 if report["issues"] or report["orphaned_files"] else 0 + if args.coverage: + print(json.dumps(media_coverage(args.root), ensure_ascii=False, indent=2)) + return 0 if args.review_url: if not args.decision: parser.error("--decision is required with --review-url") diff --git a/tests/test_media_assets.py b/tests/test_media_assets.py index bbf2e27..eeffec2 100644 --- a/tests/test_media_assets.py +++ b/tests/test_media_assets.py @@ -5,7 +5,7 @@ import io import pytest from PIL import Image -from rf4_research.media_assets import audit_media_catalog, extract_media_candidates, inspect_image, merge_manifest, review_asset, store_asset +from rf4_research.media_assets import audit_media_catalog, extract_media_candidates, inspect_image, media_coverage, merge_manifest, review_asset, store_asset def test_extracts_and_classifies_unique_https_media() -> None: @@ -94,3 +94,18 @@ def test_catalog_audit_detects_tampering_and_orphans(tmp_path: Path) -> None: report = audit_media_catalog(tmp_path) assert any("SHA-256 mismatch" in issue for issue in report["issues"]) assert "files/orphan.png" in report["orphaned_files"] + + +def test_media_coverage_keeps_unknown_tackle_total_honest(tmp_path: Path) -> None: + (tmp_path / "manifest.json").write_text( + '{"assets":[{"entity_type":"fish","status":"approved","entity_key":"ruffe"},' + '{"entity_type":"fish","status":"queued"},{"entity_type":"tackle","status":"queued"}]}', + encoding="utf-8", + ) + (tmp_path / "catalog-baseline.json").write_text( + '{"verified_at":"2026-09-12","entities":{"fish":{"count":2},"tackle":{"count":null}}}', + encoding="utf-8", + ) + report = media_coverage(tmp_path)["entities"] + assert report["fish"] == {"expected": 2, "candidates": 2, "approved": 1, "candidate_gap": 0, "approved_gap": 1} + assert report["tackle"]["candidate_gap"] is None