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] **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] **B09 · Глифы снастей и приманок.** Добавлены SVG-глифы `spinner`, `wobbler`, `soft`, `boilie`, `worm`, `rig`, `unknown` и стабильная палитра по normalized name. Классификация срабатывает только по явным словам; глиф сопровождает текст в activity, лидере, уловах, рекордах и списке лучших приманок, не выдавая категорию за точную модель.
- [x] **B10 · Визуальные отпечатки водоёмов.** Для каждого slug воспроизводимо выбираются один из восьми береговых контуров, число волн, положение точки и двухсимвольный индекс. Знак используется в каталоге и detail-hero; это явно абстрактный отпечаток, а не карта или игровая география. - [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] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений.
- [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`. - [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`.
- [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания. - [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
+1 -1
View File
@@ -56,7 +56,7 @@
Fallback строится на собственных лёгких SVG: осмысленные семейства силуэтов рыб, типовые глифы снастей и детерминированные абстрактные отпечатки водоёмов. Текстовое название всегда остаётся основным идентификатором; абстрактный контур водоёма не называется картой, а глиф приманки — изображением конкретной модели. 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. Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU.
+1 -1
View File
@@ -2,7 +2,7 @@
name = "rf4-spotter-research" name = "rf4-spotter-research"
version = "0.1.0" version = "0.1.0"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["beautifulsoup4>=4.12,<5"] dependencies = ["beautifulsoup4>=4.12,<5", "Pillow>=10,<12"]
[build-system] [build-system]
requires = ["setuptools>=68"] requires = ["setuptools>=68"]
+28 -4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import io
import json import json
import mimetypes import mimetypes
import re import re
@@ -10,6 +11,9 @@ from pathlib import Path
from urllib.parse import urljoin, urlsplit from urllib.parse import urljoin, urlsplit
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup, Tag
from PIL import Image, UnidentifiedImageError
MAX_IMAGE_PIXELS = 40_000_000
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -91,12 +95,32 @@ def reclassify_manifest(path: Path) -> dict:
return manifest 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() digest = hashlib.sha256(body).hexdigest()
extension = mimetypes.guess_extension(content_type.split(";", 1)[0]) or Path(urlsplit(source_url).path).suffix extension = mimetypes.guess_extension(detected_mime) or Path(urlsplit(source_url).path).suffix
extension = extension if extension in {".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg"} else ".bin" extension = ".jpg" if extension == ".jpe" else extension
target = root / "files" / digest[:2] / f"{digest}{extension}" target = root / "files" / digest[:2] / f"{digest}{extension}"
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
if not target.exists(): if not target.exists():
target.write_bytes(body) 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 from __future__ import annotations
import argparse import argparse
from datetime import datetime, timezone
import json import json
from pathlib import Path from pathlib import Path
import urllib.error
import urllib.request import urllib.request
from .community_cli import USER_AGENT, _StrictRedirectHandler, _validate_url_before_io, check_and_reserve, fetch_html, fetch_site_key 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"] url = queued["asset_url"]
_validate_url_before_io(url) _validate_url_before_io(url)
check_and_reserve(fetch_site_key(url), state_file=state_file) 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/*"}) request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "image/*"})
opener = urllib.request.build_opener(_StrictRedirectHandler()) opener = urllib.request.build_opener(_StrictRedirectHandler())
with opener.open(request, timeout=30) as response: 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) body = response.read(MAX_ASSET_BYTES + 1)
if len(body) > MAX_ASSET_BYTES: if len(body) > MAX_ASSET_BYTES:
raise ValueError("asset exceeded 15MB limit") raise ValueError("asset exceeded 15MB limit")
digest, relative = store_asset(root, body, content_type=content_type, source_url=url) digest, relative, width, height, detected_mime = 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)}) 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") manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return f"stored {url} as {relative}" return f"stored {url} as {relative}"
+20 -3
View File
@@ -1,6 +1,11 @@
from pathlib import Path 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: 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]) first = merge_manifest(tmp_path / "manifest.json", [item])
second = merge_manifest(tmp_path / "manifest.json", [item]) second = merge_manifest(tmp_path / "manifest.json", [item])
assert len(first["assets"]) == len(second["assets"]) == 1 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 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")