feat: reconcile RF4DB media catalog
This commit is contained in:
@@ -46,11 +46,11 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
|
||||
|
||||
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
|
||||
|
||||
`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает файлы, уникальные нормализованные подписи и кандидатов без подписи, поэтому дубли и общие учебные схемы не завышают покрытие. Сейчас не покрыты минимум 24 рыбы и все 19 водоёмов, а до ручного review не подтверждены 250 рыб и все 19 водоёмов. Общий target снастей остаётся `null`, пока разрешённый источник не отдаст проверяемый полный счётчик.
|
||||
`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL и общие учебные схемы не завышают покрытие. Объединение разрешённых каталогов RF4MAP и RF4DB теперь содержит все 252 названия рыб; 25 отсутствовавших изображений RF4DB ожидают загрузки, а 227 совпадений сохранены как provenance-only `duplicate`. Водоёмы пока не покрыты, а общий target снастей остаётся `null` до проверяемого полного счётчика.
|
||||
|
||||
`python -m rf4_research.media_cli --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Разрешённое media-окно загружается командой `--download-batch --batch-limit 40`: до 40 assets на домен под одной резервацией, затем 30 минут до нового batch. Блокировка/rate limit/сетевая ошибка останавливает домен сразу, три последовательных невалидных ответа — досрочно. Все файлы остаются в карантине до ручного review. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap.
|
||||
|
||||
Актуальный внешний ориентир — 19 водоёмов и 252 вида рыб; локальная альфа пока содержит 2+2 сущности. Media-manifest включает 452 кандидата: 228 изображений рыб, 149 приманок и 75 справочных изображений; подтверждённых entity-карт водоёмов пока нет. В репозитории хранятся 438 скачанных оригиналов (около 13 МБ), связанных с manifest через SHA-256 и provenance: 24 уже одобрены после ручной проверки, 414 ожидают review, 3 остаются в очереди, 11 URL признаны невалидными. Полное число «снастей» пока не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками.
|
||||
Актуальный внешний ориентир — 19 водоёмов и 252 вида рыб; локальная альфа пока содержит 2+2 сущности. Media-manifest включает 704 записи из RF4MAP, RF4DB и официального руководства: 24 одобрены, 417 сохранены для review, 227 являются альтернативными дубликатами, 25 новых рыб ожидают загрузки и 11 URL невалидны. В Git находятся 441 оригинал; подтверждённых entity-карт водоёмов пока нет. Полное число «снастей» не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками.
|
||||
|
||||
Исследовательские RF4-ассеты в `data/media/files/` версионируются обычным Git вместе с `data/media/manifest.json`, чтобы клон репозитория был самодостаточным и не зависел от локального кэша. Это не относится к пользовательским скриншотам: они по-прежнему хранятся в MinIO/S3 и не попадают в Git.
|
||||
|
||||
|
||||
+3504
-1
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,9 @@
|
||||
- [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
|
||||
- [x] **B15 · Целостность медиакаталога.** Локальный audit сводит статусы очереди и проверяет наличие файлов, SHA-256, фактические dimensions/MIME, каноническое соответствие approved-записей и бесхозные файлы. Проверка не обращается в сеть и может использоваться как pre-publication gate.
|
||||
- [x] **B16 · Самодостаточное хранение media dataset.** Все управляемые оригиналы из `data/media/files/` версионируются в Git вместе с manifest; локальный клон содержит полный исследовательский набор. Пользовательские скриншоты остаются в MinIO/S3, а наличие файла в Git не означает разрешение на публикацию без статуса `approved`.
|
||||
- [x] **B17 · Полный каталог рыб RF4DB.** Разрешённый RF4DB дал 252 подписанных изображения; 227 совпадений с RF4MAP сохранены как provenance-only `duplicate`, а 25 отсутствовавших видов поставлены в очередь. Все поддомены RF4DB объединены одним 30-минутным cooldown.
|
||||
- [ ] **B18 · Каталог водоёмов RF4DB.** После cooldown проиндексировать 19 русских карточек водоёмов, проверить полноразмерные карты и поставить только уникальные изображения в media queue.
|
||||
- [ ] **B19 · Каталог снастей RF4DB.** После водоёмов проиндексировать категории gear, построить crosswalk по типу, бренду, семейству и названию; не считать каталог полным до получения проверяемого общего счётчика.
|
||||
|
||||
### Тёмная тема
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ Fallback строится на собственных лёгких SVG: осмы
|
||||
|
||||
`python -m rf4_research.media_cli --queue-plan` также не использует сеть. Он читает manifest и общий cooldown-state, группирует queued-ассеты по фактическому домену, показывает готовность и оставшееся время, состав очереди по типам и один следующий кандидат с приоритетом waterbody → fish → tackle → reference. Команда намеренно сообщает, что точные catalog gaps неизвестны, пока baseline содержит только количества без канонических имён.
|
||||
|
||||
14 сентября разрешённый русский каталог RF4DB вернул ровно 252 подписанных изображения рыб с `oss.rf4db.com`. После консервативной нормализации регистра, пробелов и `е/ё` с RF4MAP совпали 227 видов, ещё 25 закрывают остаток полного каталога. `--dedupe-queue` сохраняет provenance альтернативного источника, но переводит совпавшие URL в статус `duplicate`, чтобы не скачивать одинаковые сущности повторно. `rf4db.com`, `download.rf4db.com` и `oss.rf4db.com` используют единый ключ cooldown `rf4db.com`.
|
||||
|
||||
`python -m rf4_research.media_cli --coverage` также работает локально и сравнивает manifest с датированным `data/media/catalog-baseline.json`. Неизвестные контрольные значения хранятся как `null`: отчёт не выдаёт число найденных приманок за полноту всего каталога снастей.
|
||||
|
||||
Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU.
|
||||
|
||||
@@ -35,7 +35,7 @@ USER_AGENT = "RF4-Spotter/0.1 (authorized data integration)"
|
||||
MIN_FETCH_INTERVAL_SECONDS = 30 * 60
|
||||
DEFAULT_STATE_FILE = Path(".cache/community-fetch-state.json")
|
||||
ALLOWED_HOSTS = frozenset({
|
||||
"download.rf4db.com", "rf4db.com",
|
||||
"download.rf4db.com", "rf4db.com", "oss.rf4db.com",
|
||||
"rf4-stat.ru",
|
||||
"rf4map.ru", "gw.rf4map.ru", "hb.ru-msk.vkcloud-storage.ru",
|
||||
"rf4-posts.com",
|
||||
@@ -99,11 +99,13 @@ def fetch_site_key(url: str) -> str:
|
||||
hostname = (urlsplit(url).hostname or "").lower()
|
||||
if hostname.startswith("www."):
|
||||
hostname = hostname[4:]
|
||||
if hostname.startswith("download."):
|
||||
if hostname.endswith(".rf4db.com"):
|
||||
hostname = "rf4db.com"
|
||||
elif hostname.startswith("download."):
|
||||
hostname = hostname[9:]
|
||||
if hostname.startswith("api."):
|
||||
elif hostname.startswith("api."):
|
||||
hostname = hostname[4:]
|
||||
if hostname.startswith("cdn."):
|
||||
elif hostname.startswith("cdn."):
|
||||
hostname = hostname[4:]
|
||||
if hostname == "gw.rf4map.ru":
|
||||
hostname = "rf4map.ru"
|
||||
|
||||
@@ -52,6 +52,16 @@ def _kind(url: str, label: str | None, context: str) -> str:
|
||||
return "reference"
|
||||
|
||||
|
||||
def _external_id(node: Tag, url: str) -> str | None:
|
||||
link = node.find_parent("a", href=True)
|
||||
if link:
|
||||
return str(link.get("href")).rstrip("/").rsplit("/", 1)[-1]
|
||||
parts = urlsplit(url)
|
||||
if parts.hostname == "oss.rf4db.com" and parts.path.startswith("/game/"):
|
||||
return Path(parts.path).stem
|
||||
return None
|
||||
|
||||
|
||||
def extract_media_candidates(html: str, *, source_page: str) -> list[MediaCandidate]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
result: dict[str, MediaCandidate] = {}
|
||||
@@ -65,8 +75,7 @@ def extract_media_candidates(html: str, *, source_page: str) -> list[MediaCandid
|
||||
continue
|
||||
label = _label(node)
|
||||
context = " ".join(node.parent.get_text(" ", strip=True).split())[:500] if node.parent else ""
|
||||
link = node.find_parent("a", href=True)
|
||||
external_id = str(link.get("href")).rstrip("/").rsplit("/", 1)[-1] if link else None
|
||||
external_id = _external_id(node, url)
|
||||
result.setdefault(url, MediaCandidate(source_page, url, _kind(url, label, f"{source_page} {context}"), label, external_id))
|
||||
for match in re.finditer(r"url\((['\"]?)(https://[^)'\"]+)\1\)", html, re.I):
|
||||
url = match.group(2)
|
||||
@@ -96,6 +105,37 @@ def merge_manifest(path: Path, candidates: list[MediaCandidate]) -> dict:
|
||||
return output
|
||||
|
||||
|
||||
def normalize_entity_label(value: str) -> str:
|
||||
"""Normalize conservative Russian catalog spelling variants for matching."""
|
||||
return " ".join(value.casefold().replace("ё", "е").split())
|
||||
|
||||
|
||||
def reconcile_queued_duplicates(path: Path) -> dict:
|
||||
"""Quarantine queued label duplicates while preserving their provenance."""
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
saved: dict[tuple[str, str], dict] = {}
|
||||
for item in manifest.get("assets", []):
|
||||
parts = urlsplit(str(item.get("asset_url") or ""))
|
||||
if not item.get("external_id") and parts.hostname == "oss.rf4db.com" and parts.path.startswith("/game/"):
|
||||
item["external_id"] = Path(parts.path).stem
|
||||
label = item.get("label")
|
||||
if item.get("status") not in {"stored", "approved"} or not isinstance(label, str):
|
||||
continue
|
||||
saved.setdefault((str(item.get("entity_type")), normalize_entity_label(label)), item)
|
||||
duplicates = 0
|
||||
for item in manifest.get("assets", []):
|
||||
label = item.get("label")
|
||||
if item.get("status") != "queued" or not isinstance(label, str):
|
||||
continue
|
||||
match = saved.get((str(item.get("entity_type")), normalize_entity_label(label)))
|
||||
if match and match.get("asset_url") != item.get("asset_url"):
|
||||
item.update({"status": "duplicate", "duplicate_of": match["asset_url"]})
|
||||
duplicates += 1
|
||||
manifest["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
return {"duplicates": duplicates, "queued": sum(item.get("status") == "queued" for item in manifest.get("assets", []))}
|
||||
|
||||
|
||||
def reclassify_manifest(path: Path) -> dict:
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
for item in manifest.get("assets", []):
|
||||
@@ -172,7 +212,7 @@ def media_coverage(root: Path) -> 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()
|
||||
normalize_entity_label(item["label"])
|
||||
for item in candidates
|
||||
if isinstance(item.get("label"), str) and item["label"].strip()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from .community_cli import MIN_FETCH_INTERVAL_SECONDS, USER_AGENT, _StrictRedirectHandler, _read_state, _validate_url_before_io, check_and_reserve, fetch_html, fetch_site_key
|
||||
from .media_assets import audit_media_catalog, extract_media_candidates, media_coverage, merge_manifest, reclassify_manifest, review_asset, store_asset
|
||||
from .media_assets import audit_media_catalog, extract_media_candidates, media_coverage, merge_manifest, reconcile_queued_duplicates, reclassify_manifest, review_asset, store_asset
|
||||
|
||||
|
||||
DEFAULT_ROOT = Path("data/media")
|
||||
@@ -181,6 +181,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
parser.add_argument("--batch-limit", type=int, default=MAX_BATCH_ASSETS_PER_DOMAIN, choices=range(1, MAX_BATCH_ASSETS_PER_DOMAIN + 1), metavar="1..40")
|
||||
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("--dedupe-queue", action="store_true", help="Mark queued label matches to stored assets as duplicates without network access")
|
||||
parser.add_argument("--review-url", help="Review an asset already present in the manifest")
|
||||
parser.add_argument("--decision", choices=("approved", "rejected"))
|
||||
parser.add_argument("--entity-type", choices=("fish", "waterbody", "tackle", "reference"))
|
||||
@@ -213,6 +214,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
manifest = reclassify_manifest(args.root / "manifest.json")
|
||||
print(f"reclassified {len(manifest['assets'])} candidates")
|
||||
return 0
|
||||
if args.dedupe_queue:
|
||||
print(json.dumps(reconcile_queued_duplicates(args.root / "manifest.json"), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if args.download_one:
|
||||
print(_download_one(args.root, args.state_file, args.asset_url))
|
||||
return 0
|
||||
|
||||
@@ -33,6 +33,7 @@ def test_fetch_site_key_normalizes_common_subdomains() -> None:
|
||||
assert fetch_site_key("https://download.rf4db.com/ru/catches") == "rf4db.com"
|
||||
assert fetch_site_key("https://api.rf4db.com/v1/catches") == "rf4db.com"
|
||||
assert fetch_site_key("https://cdn.rf4db.com/assets/img.jpg") == "rf4db.com"
|
||||
assert fetch_site_key("https://oss.rf4db.com/game/fish/pike.webp") == "rf4db.com"
|
||||
assert fetch_site_key("https://www.rf4db.com/") == "rf4db.com"
|
||||
# Base domain stays the same
|
||||
assert fetch_site_key("https://rf4db.com/") == "rf4db.com"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from rf4_research.media_assets import audit_media_catalog, extract_media_candidates, inspect_image, media_coverage, merge_manifest, review_asset, store_asset
|
||||
from rf4_research.media_assets import audit_media_catalog, extract_media_candidates, inspect_image, media_coverage, merge_manifest, reconcile_queued_duplicates, review_asset, store_asset
|
||||
|
||||
|
||||
def test_extracts_and_classifies_unique_https_media() -> None:
|
||||
@@ -42,6 +43,14 @@ def test_rf4map_gateway_fish_filename_is_classified_as_fish() -> None:
|
||||
assert items[0].entity_type == "fish"
|
||||
|
||||
|
||||
def test_rf4db_asset_filename_is_stable_external_id() -> None:
|
||||
item = extract_media_candidates(
|
||||
'<img src="https://oss.rf4db.com/game/fish/a.sleeper.webp" alt="Ротан">',
|
||||
source_page="https://download.rf4db.com/ru/fishes",
|
||||
)[0]
|
||||
assert (item.entity_type, item.external_id) == ("fish", "a.sleeper")
|
||||
|
||||
|
||||
def test_tackle_path_wins_over_fish_word_in_product_name() -> None:
|
||||
items = extract_media_candidates(
|
||||
'<img src="https://cdn.example/bait/crab.png" alt="Краб и рыба 14">',
|
||||
@@ -140,3 +149,20 @@ def test_media_coverage_deduplicates_candidate_labels(tmp_path: Path) -> None:
|
||||
)
|
||||
fish = media_coverage(tmp_path)["entities"]["fish"]
|
||||
assert (fish["candidate_assets"], fish["unique_candidates"], fish["candidate_gap"]) == (2, 1, 1)
|
||||
|
||||
|
||||
def test_reconcile_queue_preserves_source_but_skips_normalized_duplicate(tmp_path: Path) -> None:
|
||||
path = tmp_path / "manifest.json"
|
||||
path.write_text(json.dumps({"assets": [
|
||||
{"entity_type": "fish", "label": "Ерш", "status": "stored", "asset_url": "https://a/fish.png"},
|
||||
{"entity_type": "fish", "label": " ЁРШ ", "status": "queued", "asset_url": "https://b/fish.webp"},
|
||||
{"entity_type": "fish", "label": "Щука", "status": "queued", "asset_url": "https://b/pike.webp"},
|
||||
]}), encoding="utf-8")
|
||||
|
||||
report = reconcile_queued_duplicates(path)
|
||||
assets = json.loads(path.read_text(encoding="utf-8"))["assets"]
|
||||
|
||||
assert report == {"duplicates": 1, "queued": 1}
|
||||
assert assets[1]["status"] == "duplicate"
|
||||
assert assets[1]["duplicate_of"] == "https://a/fish.png"
|
||||
assert assets[2]["status"] == "queued"
|
||||
|
||||
Reference in New Issue
Block a user