From 26724c7675b831226c7487a56631c6ea1699059c Mon Sep 17 00:00:00 2001 From: IK Date: Sat, 12 Sep 2026 15:54:53 +0700 Subject: [PATCH] feat: validate and unblock media downloads --- docs/ROADMAP.md | 2 +- docs/data-sources.md | 2 +- pyproject.toml | 2 +- rf4_research/media_assets.py | 32 ++++++++++++++++++++++++++++---- rf4_research/media_cli.py | 32 +++++++++++++++++++++----------- tests/test_media_assets.py | 23 ++++++++++++++++++++--- 6 files changed, 72 insertions(+), 21 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 908c6f6..a89c037 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 — в работе.** Получено явное разрешение на локальный сбор. Добавлены allowlist, индекс кандидатов, provenance-manifest, очередь и content-addressed хранение без hotlink; первый официальный обход нашёл 69 кандидатов. Осталось постепенно скачать очередь с общим cooldown 30 минут, определить dimensions и вручную подтвердить соответствия сущностям перед публикацией. +- [ ] **B11 · Разрешённый media pipeline — в работе.** Получено явное разрешение на локальный сбор. Добавлены allowlist, индекс кандидатов, provenance-manifest, очередь и content-addressed хранение без hotlink; первый официальный обход нашёл 69 кандидатов. Downloader проверяет реальный формат/MIME, лимит 40 Мп, сохраняет dimensions и не блокирует очередь постоянными `missing`/`blocked`/`invalid`. Осталось постепенно скачать очередь с общим cooldown 30 минут и вручную подтвердить соответствия сущностям перед публикацией. - [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 69b4a8d..bd7f572 100644 --- a/docs/data-sources.md +++ b/docs/data-sources.md @@ -56,7 +56,7 @@ 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 площадки. Статус `queued` не означает соответствие сущности или разрешение на публичный вывод: до ручной проверки файл остаётся исследовательским материалом. +С 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` не означает соответствие сущности или разрешение на публичный вывод: до ручной проверки файл остаётся исследовательским материалом. Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU. diff --git a/pyproject.toml b/pyproject.toml index d3ade4a..973f307 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "rf4-spotter-research" version = "0.1.0" requires-python = ">=3.11" -dependencies = ["beautifulsoup4>=4.12,<5"] +dependencies = ["beautifulsoup4>=4.12,<5", "Pillow>=10,<12"] [build-system] requires = ["setuptools>=68"] diff --git a/rf4_research/media_assets.py b/rf4_research/media_assets.py index c88fc52..bf7252a 100644 --- a/rf4_research/media_assets.py +++ b/rf4_research/media_assets.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +import io import json import mimetypes import re @@ -10,6 +11,9 @@ from pathlib import Path from urllib.parse import urljoin, urlsplit from bs4 import BeautifulSoup, Tag +from PIL import Image, UnidentifiedImageError + +MAX_IMAGE_PIXELS = 40_000_000 @dataclass(frozen=True, slots=True) @@ -91,12 +95,32 @@ def reclassify_manifest(path: Path) -> dict: return manifest -def store_asset(root: Path, body: bytes, *, content_type: str, source_url: str) -> tuple[str, str]: +def inspect_image(body: bytes) -> tuple[int, int, str]: + try: + with Image.open(io.BytesIO(body)) as image: + width, height = image.size + image_format = image.format + image.verify() + except (UnidentifiedImageError, OSError, ValueError) as exc: + raise ValueError("asset is not a valid raster image") from exc + if width < 1 or height < 1 or width * height > MAX_IMAGE_PIXELS: + raise ValueError("asset dimensions are outside safe limits") + mime = Image.MIME.get(image_format or "") + if mime not in {"image/jpeg", "image/png", "image/webp", "image/gif"}: + raise ValueError(f"unsupported image format: {image_format}") + return width, height, mime + + +def store_asset(root: Path, body: bytes, *, content_type: str, source_url: str) -> tuple[str, str, int, int, str]: + width, height, detected_mime = inspect_image(body) + declared_mime = content_type.split(";", 1)[0] + if declared_mime != detected_mime: + raise ValueError(f"image MIME mismatch: declared {declared_mime}, detected {detected_mime}") digest = hashlib.sha256(body).hexdigest() - extension = mimetypes.guess_extension(content_type.split(";", 1)[0]) or Path(urlsplit(source_url).path).suffix - extension = extension if extension in {".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg"} else ".bin" + extension = mimetypes.guess_extension(detected_mime) or Path(urlsplit(source_url).path).suffix + extension = ".jpg" if extension == ".jpe" else extension target = root / "files" / digest[:2] / f"{digest}{extension}" target.parent.mkdir(parents=True, exist_ok=True) if not target.exists(): target.write_bytes(body) - return digest, str(target.relative_to(root)) + return digest, str(target.relative_to(root)), width, height, detected_mime diff --git a/rf4_research/media_cli.py b/rf4_research/media_cli.py index 8e6baf6..aa7f9d8 100644 --- a/rf4_research/media_cli.py +++ b/rf4_research/media_cli.py @@ -1,8 +1,10 @@ from __future__ import annotations import argparse +from datetime import datetime, timezone import json from pathlib import Path +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 @@ -22,17 +24,25 @@ def _download_one(root: Path, state_file: Path) -> str: url = queued["asset_url"] _validate_url_before_io(url) check_and_reserve(fetch_site_key(url), state_file=state_file) - request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "image/*"}) - opener = urllib.request.build_opener(_StrictRedirectHandler()) - with opener.open(request, timeout=30) as response: - content_type = response.headers.get_content_type() - if not content_type.startswith("image/"): - raise ValueError(f"expected image, got {content_type}") - body = response.read(MAX_ASSET_BYTES + 1) - if len(body) > MAX_ASSET_BYTES: - raise ValueError("asset exceeded 15MB limit") - digest, relative = store_asset(root, body, content_type=content_type, source_url=url) - queued.update({"status": "stored", "sha256": digest, "local_path": relative, "content_type": content_type, "bytes": len(body)}) + attempted_at = datetime.now(timezone.utc).isoformat() + try: + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "image/*"}) + opener = urllib.request.build_opener(_StrictRedirectHandler()) + with opener.open(request, timeout=30) as response: + content_type = response.headers.get_content_type() + if not content_type.startswith("image/"): + raise ValueError(f"expected image, got {content_type}") + body = response.read(MAX_ASSET_BYTES + 1) + if len(body) > MAX_ASSET_BYTES: + raise ValueError("asset exceeded 15MB limit") + digest, relative, width, height, detected_mime = store_asset(root, body, content_type=content_type, source_url=url) + except Exception as exc: + code = exc.code if isinstance(exc, urllib.error.HTTPError) else None + status = "missing" if code in {404, 410} else "blocked" if code in {401, 403} else "invalid" if isinstance(exc, ValueError) else "queued" + queued.update({"status": status, "last_attempt_at": attempted_at, "last_error": str(exc)[:500]}) + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + raise + queued.update({"status": "stored", "sha256": digest, "local_path": relative, "content_type": detected_mime, "bytes": len(body), "width": width, "height": height, "fetched_at": attempted_at}) manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return f"stored {url} as {relative}" diff --git a/tests/test_media_assets.py b/tests/test_media_assets.py index 9365ab3..5ac0e0d 100644 --- a/tests/test_media_assets.py +++ b/tests/test_media_assets.py @@ -1,6 +1,11 @@ from pathlib import Path -from rf4_research.media_assets import extract_media_candidates, merge_manifest, store_asset +import io + +import pytest +from PIL import Image + +from rf4_research.media_assets import extract_media_candidates, inspect_image, merge_manifest, store_asset def test_extracts_and_classifies_unique_https_media() -> None: @@ -25,6 +30,18 @@ def test_manifest_merges_and_binary_store_is_content_addressed(tmp_path: Path) - first = merge_manifest(tmp_path / "manifest.json", [item]) second = merge_manifest(tmp_path / "manifest.json", [item]) assert len(first["assets"]) == len(second["assets"]) == 1 - digest, relative = store_asset(tmp_path, b"image", content_type="image/png", source_url=item.asset_url) + image = io.BytesIO() + Image.new("RGB", (3, 2), "green").save(image, format="PNG") + digest, relative, width, height, mime = store_asset(tmp_path, image.getvalue(), content_type="image/png", source_url=item.asset_url) assert len(digest) == 64 - assert (tmp_path / relative).read_bytes() == b"image" + assert (tmp_path / relative).read_bytes() == image.getvalue() + assert (width, height, mime) == (3, 2, "image/png") + + +def test_image_inspection_rejects_invalid_body_and_mime_mismatch(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="valid raster"): + inspect_image(b"not an image") + image = io.BytesIO() + Image.new("RGB", (1, 1)).save(image, format="PNG") + with pytest.raises(ValueError, match="MIME mismatch"): + store_asset(tmp_path, image.getvalue(), content_type="image/jpeg", source_url="https://example.test/a.jpg")