From 8207d3ae02be89112f27030c6f6d187160c8b70a Mon Sep 17 00:00:00 2001 From: IK Date: Sun, 13 Sep 2026 16:58:45 +0700 Subject: [PATCH] feat: download media in bounded domain batches --- README.md | 4 +- docs/ROADMAP.md | 4 +- docs/data-permissions.md | 2 +- docs/data-sources.md | 2 +- docs/source-permissions.md | 4 +- rf4_research/media_cli.py | 123 +++++++++++++++++++++++++++++++------ tests/test_media_cli.py | 51 ++++++++++++++- 7 files changed, 161 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index b15c27b..e8df80c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ RF4 Spotter — неофициальный сервис свежих точек ## Статус разработки -**Проверка 13 сентября 2026 (`73feb75`): локальный контур готов к развёртыванию открытой альфы, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **155 passed, 1 skipped**; Astro check/build, web unit и API-тесты проходят. Граф миграций имеет единственную голову `0016`; последний полный production bootstrap подтвердил Caddy, scheduler и браузерный сценарий отправки/модерации на предыдущей голове, а актуальная голова проверяется CI на чистой PostgreSQL. Реальные источники во время приёмки не опрашивались. +**Проверка 13 сентября 2026 (`787a506`): локальный контур готов к развёртыванию открытой альфы, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **157 passed, 1 skipped**; Astro check/build, web unit и API-тесты проходят. Граф миграций имеет единственную голову `0016`; последний полный production bootstrap подтвердил Caddy, scheduler и браузерный сценарий отправки/модерации на предыдущей голове, а актуальная голова проверяется CI на чистой PostgreSQL. Реальные источники во время приёмки не опрашивались. Актуальные следующие задачи находятся только в [ROADMAP](docs/ROADMAP.md). Старые планы и аудиты сохранены как история и больше не задают порядок работ. До внешнего запуска нужны сервер, DNS/TLS, production-секреты, публичные контакты, внешний backup и канал уведомлений. @@ -48,7 +48,7 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо `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 --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap. +`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-карт водоёмов пока нет. Локальный audit подтверждает 24 approved-ассета: 2 рыбы, 12 приманок/наживок и 10 справочных схем; 420 записей остаются в очереди, 8 URL признаны невалидными. Полное число «снастей» пока не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e19e824..ca036d9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -7,7 +7,7 @@ Подтверждено: - [x] пакет восстановления A01–A13 завершён; итог и доказательства собраны в [RECOVERY_FIXES_REPORT.md](RECOVERY_FIXES_REPORT.md); -- [x] полный Python suite: **155 passed, 1 skipped**; skip относится к интеграционной проверке PostgreSQL и покрывается Docker-приёмкой; +- [x] полный Python suite: **157 passed, 1 skipped**; skip относится к интеграционной проверке PostgreSQL и покрывается Docker-приёмкой; - [x] Astro check: 40 файлов, **0 errors / 0 warnings / 0 hints**; production build проходит; - [x] API после миграции healthy; `apps/api/tests/test_api.py`: **20 passed**; - [x] граф Alembic линеен и имеет единственную голову `0016`; CI применяет её на чистой PostgreSQL, полный production bootstrap запускается отдельным еженедельным drill; @@ -30,7 +30,7 @@ - [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] **B10 · Визуальные отпечатки водоёмов.** Для каждого slug воспроизводимо выбираются один из восьми береговых контуров, число волн, положение точки и двухсимвольный индекс. Знак используется в каталоге и detail-hero; это явно абстрактный отпечаток, а не карта или игровая география. -- [ ] **B11 · Разрешённый media pipeline — в работе.** Версионированный baseline на 12.09.2026 содержит 19 водоёмов и 252 рыбы; полное число снастей неизвестно и остаётся `null`, а не подменяется числом приманок. Воспроизводимый `media_cli --coverage` считает уникальные нормализованные подписи, а не файлы: 228/252 fish-кандидатов (2 approved), 0/19 waterbody, 149 tackle/bait (12 approved); ещё 10 общих схем approved как reference. Общий audit: 24 approved, 420 queued, 8 invalid, ошибок целостности и бесхозных файлов нет. Визуально подтверждена «Акула гигантская» (`rf4map-fish-1`); официальный reference URL вернул HTML и корректно помечен invalid. Offline `--queue-plan` группирует очередь и cooldown по доменам: после текущего окна осталось 363 кандидата media-хранилища RF4MAP и 57 official reference. Точные названия минимум 24 отсутствующих рыб пока неизвестны, потому что baseline подтверждает число, но не содержит канонический перечень имён; выдавать разность только по двум несогласованным каталогам нельзя. Следующие gaps: получить канонические перечни и устойчивые ID, затем загружать assets с соблюдением общего cooldown и вручную сопоставлять. Официальные `/media/fische/`, `/media/levels/` и `.bait_icon` records исследовать только через публичные контракты без обхода защиты. +- [ ] **B11 · Разрешённый media pipeline — в работе.** Версионированный baseline на 12.09.2026 содержит 19 водоёмов и 252 рыбы; полное число снастей неизвестно и остаётся `null`, а не подменяется числом приманок. `media_cli --coverage`: 228/252 fish-кандидатов (2 approved), 0/19 waterbody, 149 tackle/bait (12 approved); ещё 10 общих схем approved как reference. Audit: 24 approved, 420 queued, 8 invalid, ошибок целостности и бесхозных файлов нет. Offline `--queue-plan` группирует очередь и cooldown. По разрешению от 13.09 media-окно увеличено: `--download-batch --batch-limit 40` резервирует домен один раз, загружает до 40 assets и блокирует новый batch на 30 минут; 401/403/429/сетевая ошибка останавливает домен сразу, три последовательных invalid/missing — досрочно. Автоматического approve нет. Точные названия минимум 24 отсутствующих рыб неизвестны без канонического перечня. Следующие gaps: дождаться текущего cooldown, выполнить batch, визуально проверить stored-файлы и получить устойчивые ID для водоёмов/полного каталога снастей. - [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений. - [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`. - [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания. diff --git a/docs/data-permissions.md b/docs/data-permissions.md index 0795e69..b2dc0c8 100644 --- a/docs/data-permissions.md +++ b/docs/data-permissions.md @@ -33,7 +33,7 @@ - RF4MAP: не чаще одного запроса раз в 30 минут. - RF4 Posts: не чаще одного запроса раз в 30 минут. - все пять разрешённых community-адаптеров включены; scheduler хранит cooldown в PostgreSQL и не запускает источник чаще одного раза в 30 минут; -- владелец 12 сентября 2026 года явно разрешил локально собирать изображения рыб, водоёмов и снастей; медиакандидаты сохраняются с source URL и хэшем, не публикуются до проверки соответствия и используют тот же 30-минутный лимит домена; +- владелец 12 сентября 2026 года явно разрешил локально собирать изображения рыб, водоёмов и снастей, а 13 сентября увеличил окно до 40 assets; медиакандидаты сохраняются с source URL и хэшем, не публикуются до проверки соответствия. Media CLI резервирует одно batch-окно домена, выполняет в нём не более 40 загрузок и не начинает следующее окно того же домена раньше 30 минут; - при `429`, `403`, изменении DOM или неожиданном типе ответа сбор прекращается без попытки обхода ограничения. ## Что ещё требуется зафиксировать diff --git a/docs/data-sources.md b/docs/data-sources.md index da741fe..5fceaba 100644 --- a/docs/data-sources.md +++ b/docs/data-sources.md @@ -62,7 +62,7 @@ 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 площадки. Перед записью проверяются raster-формат, совпадение MIME и предел 40 Мп; manifest получает размеры, объём и время загрузки. Постоянные 404/410, запрет доступа и невалидный файл получают отдельные статусы и не запирают начало очереди. Статус `queued` или `stored` не означает соответствие сущности или разрешение на публичный вывод. Ручная команда с `--review-url`, `--decision`, `--entity-type` и `--entity-key` переводит сохранённый файл в `approved`; неподходящий материал явно получает `rejected`. +С 12 сентября 2026 года разрешён локальный сбор медиаресурсов; 13 сентября размер одного окна увеличен до 40 assets на домен. `python -m rf4_research.media_cli URL` делает один учтённый HTML-запрос и добавляет кандидатов в `data/media/manifest.json`; `--download-one` сохраняет один asset, а `--download-batch --batch-limit 40` — до 40 на домен под одной резервацией окна. Следующий batch домена возможен через 30 минут. При 401/403/429 или сетевой ошибке домен останавливается сразу, при трёх последовательных invalid/missing — досрочно; каждый результат сохраняется в manifest. Перед записью проверяются raster-формат, совпадение MIME и предел 40 Мп. Статус `queued` или `stored` не означает соответствие сущности или разрешение на публичный вывод. Ручная команда с `--review-url`, `--decision`, `--entity-type` и `--entity-key` переводит сохранённый файл в `approved`; неподходящий материал явно получает `rejected`. `python -m rf4_research.media_cli --audit` не использует сеть: команда выводит сводку статусов и завершается с ошибкой при отсутствующем/изменённом файле, несовпадении SHA-256 или dimensions/MIME, некорректном approved-сопоставлении и наличии бесхозного файла. diff --git a/docs/source-permissions.md b/docs/source-permissions.md index 73d171d..8ddd516 100644 --- a/docs/source-permissions.md +++ b/docs/source-permissions.md @@ -6,9 +6,9 @@ |---|---|---|---|---|---|---| | RF4DB | уловы, справочники, разрешённые изображения | название + ссылка на исходную запись/страницу | не чаще 1 запроса на площадку за 30 минут, ошибки учитываются | нормализованные данные; raw по retention policy | отключить источник, скрыть публикации, очистить raw и согласованные данные по запросу | **требуется приложить** | | RF4-STAT | уловы и посты | название + ссылка на запись | не чаще 1 запроса на площадку за 30 минут, общий для endpoint | то же | то же | **требуется приложить** | -| RF4MAP | точки и разрешённые media-кандидаты | название + ссылка на страницу | не чаще 1 запроса на площадку за 30 минут, включая media/CDN | media только локально по hash; публикация после review | удалить mapping и локальный оригинал, затем записи источника | **требуется приложить** | +| RF4MAP | точки и разрешённые media-кандидаты | название + ссылка на страницу | данные: 1 запрос/30 мин; media: до 40 assets в одном зарезервированном окне, следующее окно через 30 мин | media только локально по hash; публикация после review | удалить mapping и локальный оригинал, затем записи источника | **требуется приложить** | | RF4 Posts | точки | название + ссылка на запись | не чаще 1 запроса на площадку за 30 минут | нормализованные данные; raw по retention policy | отключить и удалить по source system | **требуется приложить** | -| Официальный RF4 | рекорды и отдельно проверенные справочные изображения | «Официальный сайт RF4» + ссылка | не чаще 1 запроса на домен за 30 минут | по общей retention policy; media в карантине до review | скрыть/удалить mapping и данные конкретного источника | **уточнить основание для media-публикации** | +| Официальный RF4 | рекорды и отдельно проверенные справочные изображения | «Официальный сайт RF4» + ссылка | данные: 1 запрос/30 мин; media: до 40 assets в одном зарезервированном окне, следующее окно через 30 мин | по общей retention policy; media в карантине до review | скрыть/удалить mapping и данные конкретного источника | **уточнить основание для media-публикации** | ## Требования к подтверждению diff --git a/rf4_research/media_cli.py b/rf4_research/media_cli.py index 3ab2d6a..a76b7eb 100644 --- a/rf4_research/media_cli.py +++ b/rf4_research/media_cli.py @@ -14,6 +14,16 @@ from .media_assets import audit_media_catalog, extract_media_candidates, media_c DEFAULT_ROOT = Path("data/media") MAX_ASSET_BYTES = 15 * 1024 * 1024 MEDIA_PRIORITY = {"waterbody": 0, "fish": 1, "tackle": 2, "reference": 3} +MAX_BATCH_ASSETS_PER_DOMAIN = 40 +MAX_CONSECUTIVE_FAILURES = 3 + + +def _queue_sort_key(item: dict) -> tuple: + return ( + MEDIA_PRIORITY.get(str(item.get("entity_type")), 99), + not bool(item.get("label")), str(item.get("label") or "").casefold(), + str(item.get("asset_url")), + ) def media_queue_plan(root: Path, state_file: Path, *, now: float | None = None) -> dict: @@ -28,11 +38,7 @@ def media_queue_plan(root: Path, state_file: Path, *, now: float | None = None) domains: dict[str, dict] = {} for site, items in sorted(grouped.items()): - items.sort(key=lambda item: ( - MEDIA_PRIORITY.get(str(item.get("entity_type")), 99), - not bool(item.get("label")), str(item.get("label") or "").casefold(), - str(item.get("asset_url")), - )) + items.sort(key=_queue_sort_key) last_attempt = state.get(site) retry_in = max(0, round(float(last_attempt) + MIN_FETCH_INTERVAL_SECONDS - current)) if isinstance(last_attempt, (int, float)) else 0 next_item = items[0] @@ -58,6 +64,7 @@ def media_queue_plan(root: Path, state_file: Path, *, now: float | None = None) "generated_at": datetime.fromtimestamp(current, timezone.utc).isoformat(), "network_requests": 0, "cooldown_seconds": MIN_FETCH_INTERVAL_SECONDS, + "batch_limit_per_domain": MAX_BATCH_ASSETS_PER_DOMAIN, "queued_total": sum(group["queued"] for group in domains.values()), "domains": domains, "coverage": coverage["entities"], @@ -66,15 +73,12 @@ def media_queue_plan(root: Path, state_file: Path, *, now: float | None = None) } -def _download_one(root: Path, state_file: Path, asset_url: str | None = None) -> str: - manifest_path = root / "manifest.json" - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - 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: - return "queue is empty" if asset_url is None else "asset is not queued" +def _save_manifest(path: Path, manifest: dict) -> None: + path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def _attempt_asset(root: Path, manifest_path: Path, manifest: dict, queued: dict) -> tuple[str, Exception | None]: url = queued["asset_url"] - _validate_url_before_io(url) - 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/*"}) @@ -89,19 +93,92 @@ def _download_one(root: Path, state_file: Path, asset_url: str | None = None) -> digest, relative, width, height, detected_mime = store_asset(root, body, content_type=content_type, source_url=url) 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" + status = "missing" if code in {404, 410} else "blocked" if code in {401, 403, 429} 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 + _save_manifest(manifest_path, manifest) + return f"{status} {url}: {str(exc)[:200]}", exc 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") - return f"stored {url} as {relative}" + _save_manifest(manifest_path, manifest) + return f"stored {url} as {relative}", None + + +def _download_one(root: Path, state_file: Path, asset_url: str | None = None) -> str: + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + 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: + return "queue is empty" if asset_url is None else "asset is not queued" + url = queued["asset_url"] + _validate_url_before_io(url) + check_and_reserve(fetch_site_key(url), state_file=state_file) + message, error = _attempt_asset(root, manifest_path, manifest, queued) + if error is not None: + raise error + return message + + +def _download_batch(root: Path, state_file: Path, *, limit: int = MAX_BATCH_ASSETS_PER_DOMAIN) -> dict: + """Download up to ``limit`` assets per domain under one reserved window.""" + if not 1 <= limit <= MAX_BATCH_ASSETS_PER_DOMAIN: + raise ValueError(f"batch limit must be 1..{MAX_BATCH_ASSETS_PER_DOMAIN}") + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + grouped: dict[str, list[dict]] = {} + for item in manifest.get("assets", []): + if item.get("status") != "queued": + continue + _validate_url_before_io(item["asset_url"]) + grouped.setdefault(fetch_site_key(item["asset_url"]), []).append(item) + + domains: dict[str, dict] = {} + for site, items in sorted(grouped.items()): + items.sort(key=_queue_sort_key) + try: + # One reservation governs the explicitly allowed asset batch. + check_and_reserve(site, state_file=state_file) + except RuntimeError as exc: + domains[site] = {"attempted": 0, "stored": 0, "failed": 0, "skipped": str(exc)} + continue + stored = failed = consecutive_failures = 0 + messages: list[str] = [] + stopped_reason: str | None = None + for item in items[:limit]: + message, error = _attempt_asset(root, manifest_path, manifest, item) + messages.append(message) + if error is None: + stored += 1 + consecutive_failures = 0 + continue + failed += 1 + consecutive_failures += 1 + status = item.get("status") + if status in {"blocked", "queued"}: + stopped_reason = f"domain stopped after {status} response" + break + if consecutive_failures >= MAX_CONSECUTIVE_FAILURES: + stopped_reason = f"domain stopped after {MAX_CONSECUTIVE_FAILURES} consecutive failures" + break + domains[site] = { + "attempted": stored + failed, "stored": stored, "failed": failed, + "stopped_reason": stopped_reason, "messages": messages, + } + return { + "batch_limit_per_domain": limit, + "cooldown_seconds": MIN_FETCH_INTERVAL_SECONDS, + "domains": domains, + "attempted_total": sum(item["attempted"] for item in domains.values()), + "stored_total": sum(item["stored"] for item in domains.values()), + "failed_total": sum(item["failed"] for item in domains.values()), + } def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Index authorized RF4 media without hotlinking") parser.add_argument("url", nargs="?") - parser.add_argument("--download-one", action="store_true", help="Store one queued asset while respecting site cooldown") + download_mode = parser.add_mutually_exclusive_group() + download_mode.add_argument("--download-one", action="store_true", help="Store one queued asset while respecting site cooldown") + download_mode.add_argument("--download-batch", action="store_true", help="Store up to 40 queued assets per domain in one reserved window") + 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("--review-url", help="Review an asset already present in the manifest") @@ -139,8 +216,14 @@ def main(argv: list[str] | None = None) -> int: if args.download_one: print(_download_one(args.root, args.state_file, args.asset_url)) return 0 + if args.download_batch: + if args.asset_url: + parser.error("--asset-url is only valid with --download-one") + report = _download_batch(args.root, args.state_file, limit=args.batch_limit) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 1 if any(item.get("stopped_reason") for item in report["domains"].values()) else 0 if not args.url: - parser.error("url is required unless --download-one is used") + parser.error("url is required unless a download or offline mode is used") check_and_reserve(fetch_site_key(args.url), state_file=args.state_file) html = fetch_html(args.url) candidates = extract_media_candidates(html, source_page=args.url) diff --git a/tests/test_media_cli.py b/tests/test_media_cli.py index 0bd3460..3bd31a2 100644 --- a/tests/test_media_cli.py +++ b/tests/test_media_cli.py @@ -1,7 +1,8 @@ import json from pathlib import Path -from rf4_research.media_cli import media_queue_plan +from rf4_research import media_cli +from rf4_research.media_cli import _download_batch, media_queue_plan def test_queue_plan_prioritizes_entity_gaps_and_respects_domain_cooldown(tmp_path: Path) -> None: @@ -26,6 +27,7 @@ def test_queue_plan_prioritizes_entity_gaps_and_respects_domain_cooldown(tmp_pat plan = media_queue_plan(root, state, now=2000) assert plan["network_requests"] == 0 + assert plan["batch_limit_per_domain"] == 40 assert plan["queued_total"] == 4 assert plan["domains"]["rf4map.ru"]["ready"] is False assert plan["domains"]["rf4map.ru"]["retry_in_seconds"] == 800 @@ -33,3 +35,50 @@ def test_queue_plan_prioritizes_entity_gaps_and_respects_domain_cooldown(tmp_pat assert plan["domains"]["rf4map.ru"]["next_asset"]["entity_type"] == "waterbody" assert plan["domains"]["rf4-posts.com"]["ready"] is True assert plan["exact_catalog_gaps_known"] is False + + +def test_batch_reserves_once_per_domain_and_caps_each_window(tmp_path: Path, monkeypatch) -> None: + root = tmp_path / "media" + root.mkdir() + assets = [ + {"status": "queued", "asset_url": f"https://rf4map.ru/fish-{index}.png", "source_page": "https://rf4map.ru/fishes", "entity_type": "fish", "label": f"Рыба {index}"} + for index in range(45) + ] + [ + {"status": "queued", "asset_url": f"https://rf4-posts.com/fish-{index}.png", "source_page": "https://rf4-posts.com/fishes", "entity_type": "fish", "label": f"Рыба {index}"} + for index in range(2) + ] + (root / "manifest.json").write_text(json.dumps({"version": 1, "assets": assets}), encoding="utf-8") + state = tmp_path / "state.json" + + monkeypatch.setattr(media_cli, "_attempt_asset", lambda *_: ("stored", None)) + report = _download_batch(root, state, limit=40) + + assert report["attempted_total"] == 42 + assert report["domains"]["rf4map.ru"]["attempted"] == 40 + assert report["domains"]["rf4-posts.com"]["attempted"] == 2 + assert set(json.loads(state.read_text())) == {"rf4map.ru", "rf4-posts.com"} + + cooling = _download_batch(root, state, limit=40) + assert cooling["attempted_total"] == 0 + assert all("cooldown is active" in item["skipped"] for item in cooling["domains"].values()) + + +def test_batch_stops_domain_after_three_consecutive_invalid_assets(tmp_path: Path, monkeypatch) -> None: + root = tmp_path / "media" + root.mkdir() + assets = [ + {"status": "queued", "asset_url": f"https://rf4map.ru/bad-{index}.png", "source_page": "https://rf4map.ru/fishes", "entity_type": "fish", "label": f"Рыба {index}"} + for index in range(10) + ] + (root / "manifest.json").write_text(json.dumps({"version": 1, "assets": assets}), encoding="utf-8") + + def invalid(_root, _path, _manifest, item): + item["status"] = "invalid" + return "invalid", ValueError("not an image") + + monkeypatch.setattr(media_cli, "_attempt_asset", invalid) + report = _download_batch(root, tmp_path / "state.json", limit=10) + + assert report["attempted_total"] == 3 + assert report["failed_total"] == 3 + assert "3 consecutive failures" in report["domains"]["rf4map.ru"]["stopped_reason"]