diff --git a/README.md b/README.md index f4f802b..deeabf9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ RF4 Spotter — неофициальный сервис свежих точек ## Статус разработки -**Проверка 13 сентября 2026 (`86ed3a9`): локальный контур готов к развёртыванию открытой альфы, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **154 passed, 1 skipped**; Astro check/build, web unit и API-тесты проходят. Граф миграций имеет единственную голову `0016`; последний полный production bootstrap подтвердил Caddy, scheduler и браузерный сценарий отправки/модерации на предыдущей голове, а актуальная голова проверяется CI на чистой PostgreSQL. Реальные источники во время приёмки не опрашивались. +**Проверка 13 сентября 2026 (`73feb75`): локальный контур готов к развёртыванию открытой альфы, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **155 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,6 +48,8 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо `python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает файлы, уникальные нормализованные подписи и кандидатов без подписи, поэтому дубли и общие учебные схемы не завышают покрытие. Сейчас не покрыты минимум 24 рыбы и все 19 водоёмов, а до ручного review не подтверждены 251 рыба и все 19 водоёмов. Общий target снастей остаётся `null`, пока разрешённый источник не отдаст проверяемый полный счётчик. +`python -m rf4_research.media_cli --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap. + Актуальный внешний ориентир — 19 водоёмов и 252 вида рыб; локальная альфа пока содержит 2+2 сущности. Media-manifest включает 452 кандидата: 228 изображений рыб, 149 приманок и 75 справочных изображений; подтверждённых entity-карт водоёмов пока нет. Локальный audit подтверждает 23 approved-ассета: 1 рыба, 12 приманок/наживок и 10 справочных схем; 422 записи остаются в очереди, 7 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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f85b826..059b4dd 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: **154 passed, 1 skipped**; skip относится к интеграционной проверке PostgreSQL и покрывается Docker-приёмкой; +- [x] полный Python suite: **155 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-кандидатов (1 approved), 0/19 waterbody, 149 tackle/bait (12 approved); ещё 10 общих схем approved как reference. Общий audit: 23 approved, 422 queued, 7 invalid, ошибок целостности и бесхозных файлов нет. URL-классификация имеет приоритет над словами в названии, а общие userguide-схемы карты не выдаются за водоёмы. Следующие gaps: минимум 24 рыбы, все 19 водоёмов и все категории снастей за пределами найденных приманок; получить канонические перечни и устойчивые 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-кандидатов (1 approved), 0/19 waterbody, 149 tackle/bait (12 approved); ещё 10 общих схем approved как reference. Общий audit: 23 approved, 422 queued, 7 invalid, ошибок целостности и бесхозных файлов нет. Offline `--queue-plan` группирует очередь и cooldown по доменам и выбирает наиболее полезный следующий asset: сейчас 364 кандидата media-хранилища RF4MAP и 58 official reference. Точные названия минимум 24 отсутствующих рыб пока неизвестны, потому что baseline подтверждает число, но не содержит канонический перечень имён; выдавать разность только по двум несогласованным каталогам нельзя. Следующие gaps: получить канонические перечни и устойчивые ID, затем загружать assets с соблюдением общего cooldown и вручную сопоставлять. Официальные `/media/fische/`, `/media/levels/` и `.bait_icon` records исследовать только через публичные контракты без обхода защиты. - [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений. - [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`. - [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания. diff --git a/docs/data-sources.md b/docs/data-sources.md index 3a170b4..f7bd13f 100644 --- a/docs/data-sources.md +++ b/docs/data-sources.md @@ -66,6 +66,8 @@ Fallback строится на собственных лёгких SVG: осмы `python -m rf4_research.media_cli --audit` не использует сеть: команда выводит сводку статусов и завершается с ошибкой при отсутствующем/изменённом файле, несовпадении SHA-256 или dimensions/MIME, некорректном approved-сопоставлении и наличии бесхозного файла. +`python -m rf4_research.media_cli --queue-plan` также не использует сеть. Он читает manifest и общий cooldown-state, группирует queued-ассеты по фактическому домену, показывает готовность и оставшееся время, состав очереди по типам и один следующий кандидат с приоритетом waterbody → fish → tackle → reference. Команда намеренно сообщает, что точные catalog gaps неизвестны, пока baseline содержит только количества без канонических имён. + `python -m rf4_research.media_cli --coverage` также работает локально и сравнивает manifest с датированным `data/media/catalog-baseline.json`. Неизвестные контрольные значения хранятся как `null`: отчёт не выдаёт число найденных приманок за полноту всего каталога снастей. Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU. diff --git a/rf4_research/media_cli.py b/rf4_research/media_cli.py index 179f999..3ab2d6a 100644 --- a/rf4_research/media_cli.py +++ b/rf4_research/media_cli.py @@ -7,12 +7,63 @@ from pathlib import Path import urllib.error 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 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 DEFAULT_ROOT = Path("data/media") MAX_ASSET_BYTES = 15 * 1024 * 1024 +MEDIA_PRIORITY = {"waterbody": 0, "fish": 1, "tackle": 2, "reference": 3} + + +def media_queue_plan(root: Path, state_file: Path, *, now: float | None = None) -> dict: + """Plan the next useful download per domain without network access.""" + manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) + state = _read_state(state_file) + current = datetime.now(timezone.utc).timestamp() if now is None else now + grouped: dict[str, list[dict]] = {} + for item in manifest.get("assets", []): + if item.get("status") == "queued": + grouped.setdefault(fetch_site_key(item["asset_url"]), []).append(item) + + 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")), + )) + 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] + queued_by_type: dict[str, int] = {} + for item in items: + entity_type = str(item.get("entity_type") or "unknown") + queued_by_type[entity_type] = queued_by_type.get(entity_type, 0) + 1 + domains[site] = { + "queued": len(items), + "queued_by_type": dict(sorted(queued_by_type.items())), + "ready": retry_in == 0, + "retry_in_seconds": retry_in, + "next_asset": { + "asset_url": next_item["asset_url"], + "entity_type": next_item.get("entity_type"), + "label": next_item.get("label"), + "external_id": next_item.get("external_id"), + "source_page": next_item.get("source_page"), + }, + } + coverage = media_coverage(root) + return { + "generated_at": datetime.fromtimestamp(current, timezone.utc).isoformat(), + "network_requests": 0, + "cooldown_seconds": MIN_FETCH_INTERVAL_SECONDS, + "queued_total": sum(group["queued"] for group in domains.values()), + "domains": domains, + "coverage": coverage["entities"], + "exact_catalog_gaps_known": False, + "catalog_gap_note": "Baseline stores verified totals, not a canonical name list; exact missing entity names cannot be claimed yet.", + } def _download_one(root: Path, state_file: Path, asset_url: str | None = None) -> str: @@ -60,6 +111,7 @@ def main(argv: list[str] | None = None) -> int: 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("--coverage", action="store_true", help="Compare candidates and approvals with the catalog baseline") + parser.add_argument("--queue-plan", action="store_true", help="Show the next useful queued asset per domain without network access") parser.add_argument("--root", type=Path, default=DEFAULT_ROOT) parser.add_argument("--state-file", type=Path, default=Path(".cache/community-fetch-state.json")) args = parser.parse_args(argv) @@ -71,6 +123,9 @@ def main(argv: list[str] | None = None) -> int: if args.coverage: print(json.dumps(media_coverage(args.root), ensure_ascii=False, indent=2)) return 0 + if args.queue_plan: + print(json.dumps(media_queue_plan(args.root, args.state_file), ensure_ascii=False, indent=2)) + return 0 if args.review_url: if not args.decision: parser.error("--decision is required with --review-url") diff --git a/tests/test_media_cli.py b/tests/test_media_cli.py new file mode 100644 index 0000000..0bd3460 --- /dev/null +++ b/tests/test_media_cli.py @@ -0,0 +1,35 @@ +import json +from pathlib import Path + +from rf4_research.media_cli import media_queue_plan + + +def test_queue_plan_prioritizes_entity_gaps_and_respects_domain_cooldown(tmp_path: Path) -> None: + root = tmp_path / "media" + root.mkdir() + (root / "catalog-baseline.json").write_text(json.dumps({ + "verified_at": "2026-09-12", + "entities": { + "fish": {"count": 2}, "waterbody": {"count": 1}, "tackle": {"count": None}, + }, + }), encoding="utf-8") + (root / "manifest.json").write_text(json.dumps({"version": 1, "assets": [ + {"status": "queued", "asset_url": "https://rf4map.ru/tackle.png", "source_page": "https://rf4map.ru/fishes", "entity_type": "tackle", "label": "Катушка", "external_id": "3"}, + {"status": "queued", "asset_url": "https://rf4map.ru/fish.png", "source_page": "https://rf4map.ru/fishes", "entity_type": "fish", "label": "Щука", "external_id": "2"}, + {"status": "queued", "asset_url": "https://rf4map.ru/water.png", "source_page": "https://rf4map.ru/maps", "entity_type": "waterbody", "label": "Ладога", "external_id": "1"}, + {"status": "queued", "asset_url": "https://rf4-posts.com/fish.png", "source_page": "https://rf4-posts.com/fishes", "entity_type": "fish", "label": "Ёрш", "external_id": "4"}, + {"status": "approved", "asset_url": "https://rf4map.ru/approved.png", "source_page": "https://rf4map.ru/fishes", "entity_type": "fish", "label": "Окунь", "entity_key": "perch"}, + ]}), encoding="utf-8") + state = tmp_path / "state.json" + state.write_text(json.dumps({"rf4map.ru": 1000}), encoding="utf-8") + + plan = media_queue_plan(root, state, now=2000) + + assert plan["network_requests"] == 0 + assert plan["queued_total"] == 4 + assert plan["domains"]["rf4map.ru"]["ready"] is False + assert plan["domains"]["rf4map.ru"]["retry_in_seconds"] == 800 + assert plan["domains"]["rf4map.ru"]["queued_by_type"] == {"fish": 1, "tackle": 1, "waterbody": 1} + 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