feat: download media in bounded domain batches

This commit is contained in:
ik
2026-09-13 16:58:45 +07:00
parent 787a5065bc
commit 8207d3ae02
7 changed files with 161 additions and 29 deletions
+2 -2
View File
@@ -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 признаны невалидными. Полное число «снастей» пока не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками.
+2 -2
View File
@@ -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-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
+1 -1
View File
@@ -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 или неожиданном типе ответа сбор прекращается без попытки обхода ограничения.
## Что ещё требуется зафиксировать
+1 -1
View File
@@ -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-сопоставлении и наличии бесхозного файла.
+2 -2
View File
@@ -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-публикации** |
## Требования к подтверждению
+103 -20
View File
@@ -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)
+50 -1
View File
@@ -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"]