feat: validate and unblock media downloads

This commit is contained in:
ik
2026-09-12 15:54:53 +07:00
parent bda0a0ef5e
commit 26724c7675
6 changed files with 72 additions and 21 deletions
+1 -1
View File
@@ -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-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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"]
+28 -4
View File
@@ -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
+12 -2
View File
@@ -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,6 +24,8 @@ 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)
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:
@@ -31,8 +35,14 @@ def _download_one(root: Path, state_file: Path) -> str:
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)})
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}"
+20 -3
View File
@@ -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")