Compare commits
3
Commits
4042c80f00
...
0b7df7f721
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b7df7f721 | ||
|
|
7e464ef598 | ||
|
|
674bc5627f |
@@ -40,7 +40,9 @@ 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 отдаёт только элементы оформления, поскольку содержимое галереи динамическое. Поэтому сайт продолжает использовать честные фирменные силуэты/отпечатки/глифы, пока конкретное изображение не сохранено и не сопоставлено вручную.
|
`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает файлы, уникальные нормализованные подписи и кандидатов без подписи, поэтому дубли не завышают покрытие. Сейчас не покрыты минимум 21 рыба и 17 водоёмов, а до ручного review не подтверждены 251 рыба и все 19 водоёмов. Общий target снастей остаётся `null`, пока разрешённый источник не отдаст проверяемый полный счётчик.
|
||||||
|
|
||||||
|
Актуальный внешний ориентир — 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.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"verified_at": "2026-09-12",
|
||||||
|
"entities": {
|
||||||
|
"fish": {
|
||||||
|
"count": 252,
|
||||||
|
"source": "https://rf4db.com/ru/about/data"
|
||||||
|
},
|
||||||
|
"waterbody": {
|
||||||
|
"count": 19,
|
||||||
|
"source": "https://rf4db.com/ru/about/data"
|
||||||
|
},
|
||||||
|
"tackle": {
|
||||||
|
"count": null,
|
||||||
|
"source": "https://rf4db.com/ru/about/data",
|
||||||
|
"note": "Источник подтверждает наличие каталога, но не публикует общий счётчик; приманки не равны всем снастям."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5030
-9
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Этот файл — единственный актуальный список задач. Завершённые аудиты сохранены как история в [PROJECT_AUDIT_2026-09-08.md](PROJECT_AUDIT_2026-09-08.md), [REGRESSION_AUDIT_2026-09-09.md](REGRESSION_AUDIT_2026-09-09.md) и [RECOVERY_PLAN_2026-09-10.md](RECOVERY_PLAN_2026-09-10.md); их старые чекбоксы не являются текущей очередью.
|
Этот файл — единственный актуальный список задач. Завершённые аудиты сохранены как история в [PROJECT_AUDIT_2026-09-08.md](PROJECT_AUDIT_2026-09-08.md), [REGRESSION_AUDIT_2026-09-09.md](REGRESSION_AUDIT_2026-09-09.md) и [RECOVERY_PLAN_2026-09-10.md](RECOVERY_PLAN_2026-09-10.md); их старые чекбоксы не являются текущей очередью.
|
||||||
|
|
||||||
Последняя сверка: **12 сентября 2026**.
|
Последняя сверка: **13 сентября 2026**.
|
||||||
|
|
||||||
Подтверждено:
|
Подтверждено:
|
||||||
|
|
||||||
@@ -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 — в работе.** Версионированный baseline на 12.09.2026 содержит 19 водоёмов и 252 рыбы; полное число снастей неизвестно и остаётся `null`, а не подменяется числом приманок. Воспроизводимый `media_cli --coverage` считает уникальные нормализованные подписи, а не файлы: 231/252 fish-кандидатов (1 approved), 2/19 waterbody (0 approved), 146 tackle/bait (2 approved); ещё одна официальная Zig-Rig схема approved как reference. Следующие 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-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
|
||||||
|
|||||||
@@ -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, меняться независимо от названия и покрывать только приманки, попавшие в рекорды.
|
||||||
|
|
||||||
До отдельного разрешения на медиапубликацию изображения официального сайта не скачиваются, не хотлинкаются и не отображаются как собственные. Разрешение использовать фактические данные не трактуется как разрешение републиковать графические произведения.
|
До отдельного разрешения на медиапубликацию изображения официального сайта не скачиваются, не хотлинкаются и не отображаются как собственные. Разрешение использовать фактические данные не трактуется как разрешение републиковать графические произведения.
|
||||||
@@ -64,6 +66,8 @@ Fallback строится на собственных лёгких SVG: осмы
|
|||||||
|
|
||||||
`python -m rf4_research.media_cli --audit` не использует сеть: команда выводит сводку статусов и завершается с ошибкой при отсутствующем/изменённом файле, несовпадении SHA-256 или dimensions/MIME, некорректном approved-сопоставлении и наличии бесхозного файла.
|
`python -m rf4_research.media_cli --audit` не использует сеть: команда выводит сводку статусов и завершается с ошибкой при отсутствующем/изменённом файле, несовпадении SHA-256 или dimensions/MIME, некорректном approved-сопоставлении и наличии бесхозного файла.
|
||||||
|
|
||||||
|
`python -m rf4_research.media_cli --coverage` также работает локально и сравнивает manifest с датированным `data/media/catalog-baseline.json`. Неизвестные контрольные значения хранятся как `null`: отчёт не выдаёт число найденных приманок за полноту всего каталога снастей.
|
||||||
|
|
||||||
Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU.
|
Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU.
|
||||||
|
|
||||||
## Рекомендуемый режим получения
|
## Рекомендуемый режим получения
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -162,6 +162,32 @@ def audit_media_catalog(root: Path) -> dict:
|
|||||||
return {"total": sum(statuses.values()), "statuses": statuses, "issues": issues, "orphaned_files": orphaned}
|
return {"total": sum(statuses.values()), "statuses": statuses, "issues": issues, "orphaned_files": orphaned}
|
||||||
|
|
||||||
|
|
||||||
|
def media_coverage(root: Path) -> dict:
|
||||||
|
"""Compare quarantined and approved media with the versioned catalog baseline."""
|
||||||
|
manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
baseline = json.loads((root / "catalog-baseline.json").read_text(encoding="utf-8"))
|
||||||
|
result: dict[str, dict] = {}
|
||||||
|
for entity_type, target in baseline["entities"].items():
|
||||||
|
candidates = [item for item in manifest.get("assets", []) if item.get("entity_type") == entity_type]
|
||||||
|
candidate_labels = {
|
||||||
|
" ".join(item["label"].split()).casefold()
|
||||||
|
for item in candidates
|
||||||
|
if isinstance(item.get("label"), str) and item["label"].strip()
|
||||||
|
}
|
||||||
|
approved_keys = {item.get("entity_key") for item in candidates if item.get("status") == "approved" and item.get("entity_key")}
|
||||||
|
expected = target.get("count")
|
||||||
|
result[entity_type] = {
|
||||||
|
"expected": expected,
|
||||||
|
"candidate_assets": len(candidates),
|
||||||
|
"unique_candidates": len(candidate_labels),
|
||||||
|
"unidentified_assets": sum(1 for item in candidates if not isinstance(item.get("label"), str) or not item["label"].strip()),
|
||||||
|
"approved": len(approved_keys),
|
||||||
|
"candidate_gap": max(expected - len(candidate_labels), 0) if isinstance(expected, int) else None,
|
||||||
|
"approved_gap": max(expected - len(approved_keys), 0) if isinstance(expected, int) else None,
|
||||||
|
}
|
||||||
|
return {"baseline_date": baseline["verified_at"], "entities": result}
|
||||||
|
|
||||||
|
|
||||||
def inspect_image(body: bytes) -> tuple[int, int, str]:
|
def inspect_image(body: bytes) -> tuple[int, int, str]:
|
||||||
try:
|
try:
|
||||||
with Image.open(io.BytesIO(body)) as image:
|
with Image.open(io.BytesIO(body)) as image:
|
||||||
|
|||||||
@@ -8,19 +8,19 @@ 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
|
||||||
from .media_assets import audit_media_catalog, extract_media_candidates, merge_manifest, reclassify_manifest, review_asset, store_asset
|
from .media_assets import audit_media_catalog, extract_media_candidates, media_coverage, merge_manifest, reclassify_manifest, review_asset, store_asset
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_ROOT = Path("data/media")
|
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"))
|
||||||
@@ -58,6 +59,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
parser.add_argument("--entity-key")
|
parser.add_argument("--entity-key")
|
||||||
parser.add_argument("--note")
|
parser.add_argument("--note")
|
||||||
parser.add_argument("--audit", action="store_true", help="Verify manifest metadata, hashes and local files without network access")
|
parser.add_argument("--audit", action="store_true", help="Verify manifest metadata, hashes and local files without network access")
|
||||||
|
parser.add_argument("--coverage", action="store_true", help="Compare candidates and approvals with the catalog baseline")
|
||||||
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
|
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
|
||||||
parser.add_argument("--state-file", type=Path, default=Path(".cache/community-fetch-state.json"))
|
parser.add_argument("--state-file", type=Path, default=Path(".cache/community-fetch-state.json"))
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
@@ -66,6 +68,9 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
report = audit_media_catalog(args.root)
|
report = audit_media_catalog(args.root)
|
||||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||||
return 1 if report["issues"] or report["orphaned_files"] else 0
|
return 1 if report["issues"] or report["orphaned_files"] else 0
|
||||||
|
if args.coverage:
|
||||||
|
print(json.dumps(media_coverage(args.root), ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
if args.review_url:
|
if args.review_url:
|
||||||
if not args.decision:
|
if not args.decision:
|
||||||
parser.error("--decision is required with --review-url")
|
parser.error("--decision is required with --review-url")
|
||||||
@@ -77,7 +82,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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import io
|
|||||||
import pytest
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from rf4_research.media_assets import audit_media_catalog, extract_media_candidates, inspect_image, merge_manifest, review_asset, store_asset
|
from rf4_research.media_assets import audit_media_catalog, extract_media_candidates, inspect_image, media_coverage, merge_manifest, review_asset, store_asset
|
||||||
|
|
||||||
|
|
||||||
def test_extracts_and_classifies_unique_https_media() -> None:
|
def test_extracts_and_classifies_unique_https_media() -> None:
|
||||||
@@ -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])
|
||||||
@@ -86,3 +94,33 @@ def test_catalog_audit_detects_tampering_and_orphans(tmp_path: Path) -> None:
|
|||||||
report = audit_media_catalog(tmp_path)
|
report = audit_media_catalog(tmp_path)
|
||||||
assert any("SHA-256 mismatch" in issue for issue in report["issues"])
|
assert any("SHA-256 mismatch" in issue for issue in report["issues"])
|
||||||
assert "files/orphan.png" in report["orphaned_files"]
|
assert "files/orphan.png" in report["orphaned_files"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_media_coverage_keeps_unknown_tackle_total_honest(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "manifest.json").write_text(
|
||||||
|
'{"assets":[{"entity_type":"fish","status":"approved","entity_key":"ruffe"},'
|
||||||
|
'{"entity_type":"fish","status":"queued"},{"entity_type":"tackle","status":"queued"}]}',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(tmp_path / "catalog-baseline.json").write_text(
|
||||||
|
'{"verified_at":"2026-09-12","entities":{"fish":{"count":2},"tackle":{"count":null}}}',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
report = media_coverage(tmp_path)["entities"]
|
||||||
|
assert report["fish"] == {
|
||||||
|
"expected": 2, "candidate_assets": 2, "unique_candidates": 0,
|
||||||
|
"unidentified_assets": 2, "approved": 1, "candidate_gap": 2, "approved_gap": 1,
|
||||||
|
}
|
||||||
|
assert report["tackle"]["candidate_gap"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_media_coverage_deduplicates_candidate_labels(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "manifest.json").write_text(
|
||||||
|
'{"assets":[{"entity_type":"fish","status":"queued","label":" Ерш "},'
|
||||||
|
'{"entity_type":"fish","status":"queued","label":"ерш"}]}', encoding="utf-8",
|
||||||
|
)
|
||||||
|
(tmp_path / "catalog-baseline.json").write_text(
|
||||||
|
'{"verified_at":"2026-09-12","entities":{"fish":{"count":2}}}', encoding="utf-8",
|
||||||
|
)
|
||||||
|
fish = media_coverage(tmp_path)["entities"]["fish"]
|
||||||
|
assert (fish["candidate_assets"], fish["unique_candidates"], fish["candidate_gap"]) == (2, 1, 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user