feat: validate and unblock media downloads
This commit is contained in:
+1
-1
@@ -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-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user