diff --git a/README.md b/README.md index 92588f9..0c491c1 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо Все пять community-парсеров подключены к отдельному scheduler-процессу. Попытка резервируется в PostgreSQL до HTTP-запроса, поэтому ошибки тоже расходуют cooldown. Блокировка и минимальный интервал 1800 секунд действуют на весь домен; endpoint одного сайта выбираются по самому давнему запуску и не голодают. Ручной production-запуск использует тот же журнал: `docker compose exec api python -m app.cli fetch-community rf4stat-fishing`. Локально scheduler включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения. -Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. +Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов. После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d9b4a93..71ddccf 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -34,6 +34,7 @@ - [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений. - [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`. - [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания. +- [x] **B15 · Целостность медиакаталога.** Локальный audit сводит статусы очереди и проверяет наличие файлов, SHA-256, фактические dimensions/MIME, каноническое соответствие approved-записей и бесхозные файлы. Проверка не обращается в сеть и может использоваться как pre-publication gate. - [ ] **Q01 · Документы источников.** Приложить или дать устойчивые ссылки на первичные разрешения RF4DB, RF4-STAT, RF4MAP и RF4 Posts; для каждого зафиксировать атрибуцию, точный production-лимит, срок хранения и процедуру удаления. - [ ] **Q02 · Управляемое удаление источника.** Добавить обнаружение изменённых/удалённых опубликованных записей без отдельного частого обхода: статус, журнал решения и безопасное исключение из активности после проверки. diff --git a/docs/data-sources.md b/docs/data-sources.md index a7043a4..661cdb4 100644 --- a/docs/data-sources.md +++ b/docs/data-sources.md @@ -58,6 +58,8 @@ Fallback строится на собственных лёгких SVG: осмы С 12 сентября 2026 года разрешён локальный сбор медиаресурсов. `python -m rf4_research.media_cli URL` делает один учтённый HTML-запрос и добавляет кандидатов в `data/media/manifest.json`; `--download-one` сохраняет ровно один ожидающий asset по SHA-256 в `data/media/files/`. Оба режима используют общий cooldown площадки. Перед записью проверяются raster-формат, совпадение MIME и предел 40 Мп; manifest получает размеры, объём и время загрузки. Постоянные 404/410, запрет доступа и невалидный файл получают отдельные статусы и не запирают начало очереди. Статус `queued` или `stored` не означает соответствие сущности или разрешение на публичный вывод. Ручная команда с `--review-url`, `--decision`, `--entity-type` и `--entity-key` переводит сохранённый файл в `approved`; неподходящий материал явно получает `rejected`. +`python -m rf4_research.media_cli --audit` не использует сеть: команда выводит сводку статусов и завершается с ошибкой при отсутствующем/изменённом файле, несовпадении SHA-256 или dimensions/MIME, некорректном approved-сопоставлении и наличии бесхозного файла. + Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU. ## Рекомендуемый режим получения diff --git a/rf4_research/media_assets.py b/rf4_research/media_assets.py index 55ac88b..071f148 100644 --- a/rf4_research/media_assets.py +++ b/rf4_research/media_assets.py @@ -120,6 +120,40 @@ def review_asset( return item +def audit_media_catalog(root: Path) -> dict: + manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) + statuses: dict[str, int] = {} + issues: list[str] = [] + referenced: set[str] = set() + for item in manifest.get("assets", []): + status = str(item.get("status", "unknown")) + statuses[status] = statuses.get(status, 0) + 1 + local_path = item.get("local_path") + if status in {"stored", "approved"}: + if not isinstance(local_path, str): + issues.append(f"{item['asset_url']}: {status} asset has no local_path") + continue + referenced.add(local_path) + target = root / local_path + if not target.is_file(): + issues.append(f"{item['asset_url']}: local file is missing") + continue + body = target.read_bytes() + if hashlib.sha256(body).hexdigest() != item.get("sha256"): + issues.append(f"{item['asset_url']}: SHA-256 mismatch") + try: + width, height, mime = inspect_image(body) + if (width, height, mime) != (item.get("width"), item.get("height"), item.get("content_type")): + issues.append(f"{item['asset_url']}: image metadata mismatch") + except ValueError as exc: + issues.append(f"{item['asset_url']}: {exc}") + if status == "approved" and (not item.get("entity_key") or item.get("entity_type") not in {"fish", "waterbody", "tackle", "reference"}): + issues.append(f"{item['asset_url']}: approved asset has no valid canonical mapping") + files_root = root / "files" + orphaned = sorted(str(path.relative_to(root)) for path in files_root.rglob("*") if path.is_file() and str(path.relative_to(root)) not in referenced) if files_root.exists() else [] + return {"total": sum(statuses.values()), "statuses": statuses, "issues": issues, "orphaned_files": orphaned} + + 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 d58e6e2..0335062 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 extract_media_candidates, merge_manifest, reclassify_manifest, review_asset, store_asset +from .media_assets import audit_media_catalog, extract_media_candidates, merge_manifest, reclassify_manifest, review_asset, store_asset DEFAULT_ROOT = Path("data/media") @@ -57,10 +57,15 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--entity-type", choices=("fish", "waterbody", "tackle", "reference")) 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("--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) try: + if args.audit: + 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.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 e0a3cb0..5cd7776 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 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, merge_manifest, review_asset, store_asset def test_extracts_and_classifies_unique_https_media() -> None: @@ -59,3 +59,20 @@ def test_review_requires_stored_asset_and_canonical_mapping(tmp_path: Path) -> N review_asset(path, asset_url=item.asset_url, decision="approved", entity_type="fish") reviewed = review_asset(path, asset_url=item.asset_url, decision="approved", entity_type="fish", entity_key="pike", note="matched by name") assert (reviewed["status"], reviewed["entity_key"]) == ("approved", "pike") + + +def test_catalog_audit_detects_tampering_and_orphans(tmp_path: Path) -> None: + item = extract_media_candidates('', source_page="https://example.test")[0] + path = tmp_path / "manifest.json" + manifest = merge_manifest(path, [item]) + image = io.BytesIO() + Image.new("RGB", (2, 2)).save(image, format="PNG") + digest, relative, width, height, mime = store_asset(tmp_path, image.getvalue(), content_type="image/png", source_url=item.asset_url) + manifest["assets"][0].update({"status": "stored", "sha256": digest, "local_path": relative, "width": width, "height": height, "content_type": mime}) + path.write_text(__import__("json").dumps(manifest), encoding="utf-8") + assert audit_media_catalog(tmp_path)["issues"] == [] + (tmp_path / relative).write_bytes(b"changed") + (tmp_path / "files" / "orphan.png").write_bytes(b"orphan") + 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"]