feat: expand RF4 media inventory

This commit is contained in:
ik
2026-09-13 08:06:48 +07:00
parent 4042c80f00
commit 674bc5627f
9 changed files with 5054 additions and 19 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов. Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
Текущее media-покрытие каталога — 0 approved соответствий для 2 рыб, 2 водоёмов и 3 приманок локальной альфы. Userguide дал 69 кандидатов, преимущественно справочные скриншоты; обычный HTML официальной fish gallery отдаёт только элементы оформления, поскольку содержимое галереи динамическое. Поэтому сайт продолжает использовать честные фирменные силуэты/отпечатки/глифы, пока конкретное изображение не сохранено и не сопоставлено вручную. Актуальный внешний ориентир — 19 водоёмов и 252 вида рыб; локальная альфа пока содержит 2+2 сущности. Media-manifest включает 452 кандидата: 231 изображение рыбы, 146 приманок, 2 карты и 73 справочных изображения. Вручную проверены и сопоставлены 1 рыба, 2 приманки и 1 официальная схема; 446 файлов остаются в очереди, 1 URL признан невалидным. Полное число «снастей» пока не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками.
Для измерений на собственном сервере подготовлен read-only `deploy/load-smoke.py`: он считает p50/p95/max и HTTP-коды для activity/records, а при наличии `ADMIN_TOKEN` — staging/moderation. Методика и безопасные ступени нагрузки описаны в [docs/load-testing.md](docs/load-testing.md); локальные цифры не выдаются за production baseline. Для измерений на собственном сервере подготовлен read-only `deploy/load-smoke.py`: он считает p50/p95/max и HTTP-коды для activity/records, а при наличии `ADMIN_TOKEN` — staging/moderation. Методика и безопасные ступени нагрузки описаны в [docs/load-testing.md](docs/load-testing.md); локальные цифры не выдаются за production baseline.
+5030 -9
View File
File diff suppressed because it is too large Load Diff
+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 — в работе.** Получено явное разрешение на локальный сбор. Первый обход userguide нашёл 69 кандидатов, из которых 1 сохранён, 68 ожидают. Аудит покрытия выявил отсутствие approved-соответствий для всех 2 рыб, 2 водоёмов и 3 приманок локальной альфы. Прямой HTML `/media/fische/` содержит только флаг и banner: галерея динамическая, рыб через текущий HTML-parser не отдаёт. Классификатор учитывает provenance страницы, но исключает theme/flag/logo chrome; manifest сохраняет все страницы обнаружения дубликата. Далее исследовать официальный gallery endpoint без обхода ограничений, отдельно индексировать `/media/levels/` и проверить `.bait_icon` records DOM в следующие 30-минутные окна. Публикация возможна только после ручного canonical review. - [ ] **B11 · Разрешённый media pipeline — в работе.** Внешний контрольный каталог на 12.09.2026 содержит 19 водоёмов и 252 рыбы; полное число снастей неизвестно и не подменяется числом приманок. RF4MAP добавил 383 кандидата, итог manifest: 231 fish, 146 tackle/bait, 2 waterbody и 73 reference. Вручную проверены и approved 1 рыба (Ерш), 2 приманки и 1 официальная Zig-Rig схема; 446 файлов ожидают, один URL rf4game.ru вернул HTML вместо PNG и помечен invalid. Следующие gaps: минимум 21 рыба, 17 водоёмов и все категории снастей за пределами найденных приманок; получить канонические перечни и устойчивые ID, затем загружать не более одного asset с домена за окно и вручную сопоставлять. RF4DB вернул 403 — до конца cooldown не повторять. Официальные `/media/fische/`, `/media/levels/` и `.bait_icon` records исследовать только через публичные контракты без обхода защиты.
- [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений. - [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений.
- [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`. - [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`.
- [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания. - [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
+3 -1
View File
@@ -50,10 +50,12 @@
Проверка 12 сентября 2026 года не обнаружила стабильного публичного каталога вида `канонический slug → изображение` для рыб, водоёмов или снастей. Официальная [галерея водоёмов](https://rf4game.de/media/levels/) и категория [рыб](https://rf4game.de/media/fische/) содержат тематические публикации, а [руководство](https://rf4game.de/userguide/) — иллюстрации интерфейса, оснасток и карт. Это медиа-галереи, а не полный справочник с устойчивыми entity ID. Проверка 12 сентября 2026 года не обнаружила стабильного публичного каталога вида `канонический slug → изображение` для рыб, водоёмов или снастей. Официальная [галерея водоёмов](https://rf4game.de/media/levels/) и категория [рыб](https://rf4game.de/media/fische/) содержат тематические публикации, а [руководство](https://rf4game.de/userguide/) — иллюстрации интерфейса, оснасток и карт. Это медиа-галереи, а не полный справочник с устойчивыми entity ID.
Повторная сверка локальной альфы показала 2 рыбы, 2 водоёма и 3 приманки при нулевом числе approved media mappings. В userguide-manifest находится 69 кандидатов: 67 общих reference и 2 изображения карт. Поэтому наличие картинки в очереди не считается покрытием каталога; coverage появляется только после сохранения, визуальной проверки и назначения canonical `entity_key`. Повторная сверка локальной альфы показала 2 рыбы, 2 водоёма и 3 приманки. Первый userguide-manifest содержал 69 кандидатов: 67 общих reference и 2 изображения карт. После расширения сбора вручную подтверждены первые четыре соответствия, включая Ерша и две приманки. Наличие картинки в очереди всё равно не считается покрытием каталога; coverage появляется только после сохранения, визуальной проверки и назначения canonical `entity_key`.
Прямой индекс `/media/fische/` вернул только общие изображения оформления (`flags/de.png`, `banner_de.png`): содержимое галереи загружается динамически. Эти файлы остаются `reference`, а не ошибочно маркируются как рыбы. Исследование допустимого gallery endpoint должно использовать обычный публичный контракт сайта и тот же общий cooldown, без обхода защит. Прямой индекс `/media/fische/` вернул только общие изображения оформления (`flags/de.png`, `banner_de.png`): содержимое галереи загружается динамически. Эти файлы остаются `reference`, а не ошибочно маркируются как рыбы. Исследование допустимого gallery endpoint должно использовать обычный публичный контракт сайта и тот же общий cooldown, без обхода защит.
Контрольная сверка 12–13 сентября 2026 года: RF4DB показывает 19 водоёмов и 252 вида рыб. Страница RF4MAP `/fishes` дала 231 уникальный кандидат изображения рыбы и 146 изображений приманок. Это означает минимум 21 отсутствующую рыбью иконку; 146 нельзя считать числом всех снастей, поскольку в него не входят полные каталоги удилищ, катушек, лесок, крючков и прочих компонентов. Вручную подтверждены Ерш, Nasty Worm 4.5-002 и Личинка веснянки; остальные entity-кандидаты остаются карантинными.
Страница рекордов визуально использует `.bait_icon`, но подтверждённый parser-контракт извлекает только `.bait_icon[title]`. Ни parser, ни `Fish`/`Waterbody`/`Bait` модели и API-схемы сейчас не сохраняют image URL. Даже если URL удастся извлечь из CSS или обновлённого DOM, он может быть presentation asset, меняться независимо от названия и покрывать только приманки, попавшие в рекорды. Страница рекордов визуально использует `.bait_icon`, но подтверждённый parser-контракт извлекает только `.bait_icon[title]`. Ни parser, ни `Fish`/`Waterbody`/`Bait` модели и API-схемы сейчас не сохраняют image URL. Даже если URL удастся извлечь из CSS или обновлённого DOM, он может быть presentation asset, меняться независимо от названия и покрывать только приманки, попавшие в рекорды.
До отдельного разрешения на медиапубликацию изображения официального сайта не скачиваются, не хотлинкаются и не отображаются как собственные. Разрешение использовать фактические данные не трактуется как разрешение републиковать графические произведения. До отдельного разрешения на медиапубликацию изображения официального сайта не скачиваются, не хотлинкаются и не отображаются как собственные. Разрешение использовать фактические данные не трактуется как разрешение републиковать графические произведения.
+4 -2
View File
@@ -37,9 +37,9 @@ DEFAULT_STATE_FILE = Path(".cache/community-fetch-state.json")
ALLOWED_HOSTS = frozenset({ ALLOWED_HOSTS = frozenset({
"download.rf4db.com", "rf4db.com", "download.rf4db.com", "rf4db.com",
"rf4-stat.ru", "rf4-stat.ru",
"rf4map.ru", "rf4map.ru", "gw.rf4map.ru", "hb.ru-msk.vkcloud-storage.ru",
"rf4-posts.com", "rf4-posts.com",
"rf4game.de", "rf4game.de", "rf4game.ru",
}) })
MAX_RESPONSE_BYTES = 5 * 1024 * 1024 # 5 MB MAX_RESPONSE_BYTES = 5 * 1024 * 1024 # 5 MB
@@ -105,6 +105,8 @@ def fetch_site_key(url: str) -> str:
hostname = hostname[4:] hostname = hostname[4:]
if hostname.startswith("cdn."): if hostname.startswith("cdn."):
hostname = hostname[4:] hostname = hostname[4:]
if hostname == "gw.rf4map.ru":
hostname = "rf4map.ru"
if not hostname: if not hostname:
raise ValueError("source URL must include a hostname") raise ValueError("source URL must include a hostname")
return hostname return hostname
+1 -1
View File
@@ -41,7 +41,7 @@ def _kind(url: str, label: str | None, context: str) -> str:
value = f"{label or ''} {context}".casefold() value = f"{label or ''} {context}".casefold()
if "/flags/" in path or "/themes/" in path or path.endswith(("/logo.png", "/logo.svg")): if "/flags/" in path or "/themes/" in path or path.endswith(("/logo.png", "/logo.svg")):
return "reference" return "reference"
if re.search(r"/(?:fish|fishes|fische|species)/", path) or "/media/fische/" in value or any(word in value for word in ("рыба ", "fish: ", "fisch: ")): if re.search(r"/(?:fish|fishes|fische|species)/", path) or re.search(r"/(?:fish|species)[_-]", path) or "/media/fische/" in value or any(word in value for word in ("рыба ", "fish: ", "fisch: ")):
return "fish" return "fish"
if re.search(r"/(?:maps?|levels?|lakes?|waterbodies)/", path) or "/media/levels/" in value or any(word in value for word in ("карта ", "map of ", "gewässerkarte")): if re.search(r"/(?:maps?|levels?|lakes?|waterbodies)/", path) or "/media/levels/" in value or any(word in value for word in ("карта ", "map of ", "gewässerkarte")):
return "waterbody" return "waterbody"
+5 -4
View File
@@ -15,12 +15,12 @@ DEFAULT_ROOT = Path("data/media")
MAX_ASSET_BYTES = 15 * 1024 * 1024 MAX_ASSET_BYTES = 15 * 1024 * 1024
def _download_one(root: Path, state_file: Path) -> str: def _download_one(root: Path, state_file: Path, asset_url: str | None = None) -> str:
manifest_path = root / "manifest.json" manifest_path = root / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
queued = next((item for item in manifest["assets"] if item.get("status") == "queued"), None) queued = next((item for item in manifest["assets"] if item.get("status") == "queued" and (asset_url is None or item["asset_url"] == asset_url)), None)
if queued is None: if queued is None:
return "queue is empty" return "queue is empty" if asset_url is None else "asset is not queued"
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)
@@ -51,6 +51,7 @@ def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Index authorized RF4 media without hotlinking") parser = argparse.ArgumentParser(description="Index authorized RF4 media without hotlinking")
parser.add_argument("url", nargs="?") parser.add_argument("url", nargs="?")
parser.add_argument("--download-one", action="store_true", help="Store one queued asset while respecting site cooldown") parser.add_argument("--download-one", action="store_true", help="Store one queued asset while respecting site cooldown")
parser.add_argument("--asset-url", help="Download this exact queued asset instead of the first queue item")
parser.add_argument("--reclassify", action="store_true", help="Reapply current conservative classifier without network access") parser.add_argument("--reclassify", action="store_true", help="Reapply current conservative classifier without network access")
parser.add_argument("--review-url", help="Review an asset already present in the manifest") parser.add_argument("--review-url", help="Review an asset already present in the manifest")
parser.add_argument("--decision", choices=("approved", "rejected")) parser.add_argument("--decision", choices=("approved", "rejected"))
@@ -77,7 +78,7 @@ def main(argv: list[str] | None = None) -> int:
print(f"reclassified {len(manifest['assets'])} candidates") print(f"reclassified {len(manifest['assets'])} candidates")
return 0 return 0
if args.download_one: if args.download_one:
print(_download_one(args.root, args.state_file)) print(_download_one(args.root, args.state_file, args.asset_url))
return 0 return 0
if not args.url: if not args.url:
parser.error("url is required unless --download-one is used") parser.error("url is required unless --download-one is used")
+1
View File
@@ -36,6 +36,7 @@ def test_fetch_site_key_normalizes_common_subdomains() -> None:
assert fetch_site_key("https://www.rf4db.com/") == "rf4db.com" assert fetch_site_key("https://www.rf4db.com/") == "rf4db.com"
# Base domain stays the same # Base domain stays the same
assert fetch_site_key("https://rf4db.com/") == "rf4db.com" assert fetch_site_key("https://rf4db.com/") == "rf4db.com"
assert fetch_site_key("https://gw.rf4map.ru/public/images/fish.webp") == "rf4map.ru"
# Non-normalized subdomains stay as-is # Non-normalized subdomains stay as-is
assert fetch_site_key("https://rf4map.ru/point/1") == "rf4map.ru" assert fetch_site_key("https://rf4map.ru/point/1") == "rf4map.ru"
assert fetch_site_key("https://rf4-posts.com/spots/") == "rf4-posts.com" assert fetch_site_key("https://rf4-posts.com/spots/") == "rf4-posts.com"
+8
View File
@@ -34,6 +34,14 @@ def test_gallery_page_provenance_classifies_generic_asset_urls() -> None:
assert chrome[0].entity_type == "reference" assert chrome[0].entity_type == "reference"
def test_rf4map_gateway_fish_filename_is_classified_as_fish() -> None:
items = extract_media_candidates(
'<img src="https://gw.rf4map.ru/public/images/fish_123.webp" alt="Ерш">',
source_page="https://rf4map.ru/fishes",
)
assert items[0].entity_type == "fish"
def test_manifest_merges_and_binary_store_is_content_addressed(tmp_path: Path) -> None: def test_manifest_merges_and_binary_store_is_content_addressed(tmp_path: Path) -> None:
item = extract_media_candidates('<img src="/bait/worm.png" alt="Bait worm">', source_page="https://example.test")[0] item = extract_media_candidates('<img src="/bait/worm.png" alt="Bait worm">', source_page="https://example.test")[0]
first = merge_manifest(tmp_path / "manifest.json", [item]) first = merge_manifest(tmp_path / "manifest.json", [item])