diff --git a/README.md b/README.md index aa30273..187c460 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,11 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо `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` до проверяемого полного счётчика. +`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 --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Разрешённое media-окно загружается командой `--download-batch --batch-limit 40`: до 40 assets на домен под одной резервацией, затем 30 минут до нового batch. Блокировка/rate limit/сетевая ошибка останавливает домен сразу, три последовательных невалидных ответа — досрочно. Все файлы остаются в карантине до ручного review. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap. -Актуальный внешний ориентир — 19 водоёмов и 252 вида рыб; локальная альфа пока содержит 2+2 сущности. Media-manifest включает 704 записи из RF4MAP, RF4DB и официального руководства: 24 одобрены, 417 сохранены для review, 227 являются альтернативными дубликатами, 25 новых рыб ожидают загрузки и 11 URL невалидны. В Git находятся 441 оригинал; подтверждённых entity-карт водоёмов пока нет. Полное число «снастей» не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками. +Актуальный внешний ориентир — 19 водоёмов и 252 вида рыб; локальная альфа пока содержит 2+2 справочные сущности. Media-manifest включает 704 записи из RF4MAP, RF4DB и официального руководства: 456 опубликованы, 227 являются альтернативными дубликатами, 10 ожидают загрузки и 11 URL невалидны. Все 456 оригиналов находятся в Git; подтверждённых entity-карт водоёмов пока нет. Полное число «снастей» не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками. Исследовательские RF4-ассеты в `data/media/files/` версионируются обычным Git вместе с `data/media/manifest.json`, чтобы клон репозитория был самодостаточным и не зависел от локального кэша. Это не относится к пользовательским скриншотам: они по-прежнему хранятся в MinIO/S3 и не попадают в Git. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 29ff9bb..32ea540 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -39,6 +39,12 @@ - [ ] **B17 · Полный каталог рыб RF4DB — финальная загрузка.** Разрешённый RF4DB дал все 252 подписанных кандидата; 227 совпадений с RF4MAP сохранены как provenance-only `duplicate`, 15 уникальных файлов скачаны, а 10 URL (9 уникальных названий) остаются в очереди после корректно остановленного TLS-сбоя. Все поддомены RF4DB объединены одним 30-минутным cooldown. Завершить следующим разрешённым batch-окном и отдельно утвердить новые файлы. - [ ] **B18 · Каталог водоёмов RF4DB.** После cooldown проиндексировать 19 русских карточек водоёмов, проверить полноразмерные карты и поставить только уникальные изображения в media queue. - [ ] **B19 · Каталог снастей RF4DB.** После водоёмов проиндексировать категории gear, построить crosswalk по типу, бренду, семейству и названию; не считать каталог полным до получения проверяемого общего счётчика. +- [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. Совпадение сущности больше нельзя считать достаточным основанием пропустить потенциально более качественный файл. +- [ ] **B21 · Очередь quality-upgrade.** Не менять текущие approved-файлы до готовности замены. Перевести 227 RF4DB `duplicate` в отдельное состояние `upgrade_queued`, сохранив `duplicate_of`, и загружать партиями до 40 в разрешённые 30-минутные окна. Ошибка останавливает домен по существующим правилам; публикация старой версии при этом не прерывается. +- [ ] **B22 · Сравнение вариантов разных источников.** После загрузки для каждой рыбы сравнить реальные dimensions, размер файла, MIME, прозрачность, aspect ratio и визуальное соответствие подписи. Минимальный технический порог для основной карточки — 256 px по меньшей стороне, предпочтительный — 512 px; маленький файл сохранять только как fallback. Источник RF4DB/RF4MAP/официальный RF4 не получает автоматического приоритета: выбирается лучший прошедший проверку файл. +- [ ] **B23 · Безопасное продвижение и provenance.** Добавить связь `supersedes`/`replaced_by`, отдельное решение review и атомарное переключение публичного `entity_key`; сохранить обе исходные ссылки и возможность отката. На сайте показывать источник именно выбранного изображения, а в раскрываемом provenance — все проверенные варианты. Не удалять старый Git-файл в том же коммите, где включается новый. +- [ ] **B24 · Производные размеры.** После выбора оригиналов генерировать детерминированные WebP/AVIF thumbnails для каталога и отдельный крупный вариант для detail, фиксировать хэши производных в manifest и отдавать `srcset`. Это уберёт загрузку 1024×1024 на каждой маленькой карточке и исключит browser-upscale 48×48. +- [ ] **B25 · Визуальная приёмка media.** Собирать контактный лист «старое / кандидат / выбранное» с названием и источником; вручную проверить минимум все замены и репрезентативные desktop/mobile страницы в light/dark. Gate: нет битых файлов, искажённых пропорций, ложных соответствий, обрезанного объекта и изображений ниже 256 px без явной пометки «низкое разрешение». ### Тёмная тема diff --git a/rf4_research/media_assets.py b/rf4_research/media_assets.py index 71c34b1..9f94ab3 100644 --- a/rf4_research/media_assets.py +++ b/rf4_research/media_assets.py @@ -251,6 +251,45 @@ def media_coverage(root: Path) -> dict: return {"baseline_date": baseline["verified_at"], "entities": result} +def media_quality_report(root: Path, *, minimum_dimension: int = 256, display_dimension: int = 180) -> dict: + """Report published raster quality and known higher-quality source alternatives offline.""" + manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) + assets = manifest.get("assets", []) + approved = [item for item in assets if item.get("status") == "approved" and item.get("entity_type") == "fish"] + low = [item for item in approved if min(int(item.get("width") or 0), int(item.get("height") or 0)) < minimum_dimension] + upsampled = [item for item in approved if min(int(item.get("width") or 0), int(item.get("height") or 0)) < display_dimension] + low_urls = {item.get("asset_url") for item in low} + alternatives = [ + item for item in assets + if item.get("entity_type") == "fish" + and item.get("status") in {"duplicate", "queued"} + and (item.get("duplicate_of") in low_urls or item.get("status") == "queued") + ] + + by_source: dict[str, dict[str, int]] = {} + for item in approved: + host = urlsplit(str(item.get("asset_url") or "")).hostname or "unknown" + summary = by_source.setdefault(host, {"published": 0, "below_minimum": 0, "upsampled_in_cards": 0}) + summary["published"] += 1 + summary["below_minimum"] += item in low + summary["upsampled_in_cards"] += item in upsampled + + return { + "entity_type": "fish", + "minimum_dimension": minimum_dimension, + "display_dimension": display_dimension, + "published": len(approved), + "below_minimum": len(low), + "upsampled_in_cards": len(upsampled), + "known_alternative_urls": len(alternatives), + "by_source": by_source, + "examples": [ + {"label": item.get("label"), "width": item.get("width"), "height": item.get("height"), "asset_url": item.get("asset_url")} + for item in sorted(low, key=lambda row: str(row.get("label") or ""))[:20] + ], + } + + 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 25050db..3446e36 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 MIN_FETCH_INTERVAL_SECONDS, USER_AGENT, _StrictRedirectHandler, _read_state, _validate_url_before_io, check_and_reserve, fetch_html, fetch_site_key -from .media_assets import approve_stored_assets, audit_media_catalog, extract_media_candidates, media_coverage, merge_manifest, reconcile_queued_duplicates, reclassify_manifest, review_asset, store_asset +from .media_assets import approve_stored_assets, audit_media_catalog, extract_media_candidates, media_coverage, media_quality_report, merge_manifest, reconcile_queued_duplicates, reclassify_manifest, review_asset, store_asset DEFAULT_ROOT = Path("data/media") @@ -190,6 +190,7 @@ def main(argv: list[str] | None = None) -> int: 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("--quality-report", action="store_true", help="Report low-resolution published fish and known alternatives without network access") parser.add_argument("--queue-plan", action="store_true", help="Show the next useful queued asset per domain without network access") parser.add_argument("--root", type=Path, default=DEFAULT_ROOT) parser.add_argument("--state-file", type=Path, default=Path(".cache/community-fetch-state.json")) @@ -202,6 +203,9 @@ def main(argv: list[str] | None = None) -> int: if args.coverage: print(json.dumps(media_coverage(args.root), ensure_ascii=False, indent=2)) return 0 + if args.quality_report: + print(json.dumps(media_quality_report(args.root), ensure_ascii=False, indent=2)) + return 0 if args.queue_plan: print(json.dumps(media_queue_plan(args.root, args.state_file), ensure_ascii=False, indent=2)) return 0 diff --git a/tests/test_media_assets.py b/tests/test_media_assets.py index fd72728..a8349f0 100644 --- a/tests/test_media_assets.py +++ b/tests/test_media_assets.py @@ -6,7 +6,7 @@ import io import pytest from PIL import Image -from rf4_research.media_assets import approve_stored_assets, audit_media_catalog, extract_media_candidates, inspect_image, media_coverage, merge_manifest, reconcile_queued_duplicates, review_asset, store_asset +from rf4_research.media_assets import approve_stored_assets, audit_media_catalog, extract_media_candidates, inspect_image, media_coverage, media_quality_report, merge_manifest, reconcile_queued_duplicates, review_asset, store_asset def test_extracts_and_classifies_unique_https_media() -> None: @@ -179,3 +179,17 @@ def test_bulk_approval_only_publishes_stored_assets(tmp_path: Path) -> None: assert assets[0]["entity_key"] == "fish:pike" assert assets[0]["review_note"] == "owner approved" assert assets[1]["status"] == "queued" + + +def test_quality_report_finds_low_resolution_asset_and_known_alternative(tmp_path: Path) -> None: + (tmp_path / "manifest.json").write_text(json.dumps({"assets": [ + {"entity_type": "fish", "label": "Щука", "status": "approved", "asset_url": "https://small.example/pike.png", "width": 48, "height": 48}, + {"entity_type": "fish", "label": "Щука", "status": "duplicate", "asset_url": "https://large.example/pike.webp", "duplicate_of": "https://small.example/pike.png"}, + {"entity_type": "fish", "label": "Окунь", "status": "approved", "asset_url": "https://large.example/perch.webp", "width": 1024, "height": 1024}, + ]}), encoding="utf-8") + + report = media_quality_report(tmp_path) + + assert (report["published"], report["below_minimum"], report["upsampled_in_cards"]) == (2, 1, 1) + assert report["known_alternative_urls"] == 1 + assert report["by_source"]["small.example"]["below_minimum"] == 1