feat: plan media queue without network access

This commit is contained in:
ik
2026-09-13 16:50:27 +07:00
parent 73feb75767
commit f243807fc8
5 changed files with 98 additions and 4 deletions
+3 -1
View File
@@ -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 и канал уведомлений. Актуальные следующие задачи находятся только в [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 --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 признаны невалидными. Полное число «снастей» пока не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками. Актуальный внешний ориентир — 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. Для измерений на собственном сервере подготовлен 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.
+2 -2
View File
@@ -7,7 +7,7 @@
Подтверждено: Подтверждено:
- [x] пакет восстановления A01–A13 завершён; итог и доказательства собраны в [RECOVERY_FIXES_REPORT.md](RECOVERY_FIXES_REPORT.md); - [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] Astro check: 40 файлов, **0 errors / 0 warnings / 0 hints**; production build проходит;
- [x] API после миграции healthy; `apps/api/tests/test_api.py`: **20 passed**; - [x] API после миграции healthy; `apps/api/tests/test_api.py`: **20 passed**;
- [x] граф Alembic линеен и имеет единственную голову `0016`; CI применяет её на чистой PostgreSQL, полный production bootstrap запускается отдельным еженедельным drill; - [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] **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 — в работе.** Версионированный 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] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений.
- [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`. - [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`.
- [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания. - [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
+2
View File
@@ -66,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 --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`: отчёт не выдаёт число найденных приманок за полноту всего каталога снастей. `python -m rf4_research.media_cli --coverage` также работает локально и сравнивает manifest с датированным `data/media/catalog-baseline.json`. Неизвестные контрольные значения хранятся как `null`: отчёт не выдаёт число найденных приманок за полноту всего каталога снастей.
Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU. Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU.
+56 -1
View File
@@ -7,12 +7,63 @@ from pathlib import Path
import urllib.error 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 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, 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
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: 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("--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("--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("--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)
@@ -71,6 +123,9 @@ def main(argv: list[str] | None = None) -> int:
if args.coverage: if args.coverage:
print(json.dumps(media_coverage(args.root), ensure_ascii=False, indent=2)) print(json.dumps(media_coverage(args.root), ensure_ascii=False, indent=2))
return 0 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 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")
+35
View File
@@ -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