feat: add rate-limited RF4 media collector

This commit is contained in:
ik
2026-09-12 15:52:27 +07:00
parent 52ff29668e
commit bda0a0ef5e
10 changed files with 905 additions and 1 deletions
+1
View File
@@ -2,6 +2,7 @@ __pycache__/
*.py[cod]
.pytest_cache/
.cache/
data/media/files/
.env.production
.maintenance.lock
.venv/
+2
View File
@@ -38,6 +38,8 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
Все пять community-парсеров подключены к отдельному scheduler-процессу. Попытка резервируется в PostgreSQL до HTTP-запроса, поэтому ошибки тоже расходуют cooldown. Блокировка и минимальный интервал 1800 секунд действуют на весь домен; endpoint одного сайта выбираются по самому давнему запуску и не голодают. Ручной production-запуск использует тот же журнал: `docker compose exec api python -m app.cli fetch-community rf4stat-fishing`. Локально scheduler включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически.
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
+696
View File
@@ -0,0 +1,696 @@
{
"version": 1,
"updated_at": "2026-09-12T08:50:41.276898+00:00",
"assets": [
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/bottom_zigrig-1.jpeg",
"entity_type": "reference",
"label": "Zig-Rig Guide Unterschiedliche Wetterbedingungen führen zu Veränderungen der Wassertemperatur. Bei niedrigen Temperaturen können Karpfen in die sonnenerwärmten Oberflächenschichten des Gewässers aufsteigen. In solchen Fällen können Zig-Rig Montagen die besten Ergebnisse erzielen. Beim Zig-Rig wird a",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/bottom_zigrig-2.jpeg",
"entity_type": "reference",
"label": "Zig-Rig Guide Unterschiedliche Wetterbedingungen führen zu Veränderungen der Wassertemperatur. Bei niedrigen Temperaturen können Karpfen in die sonnenerwärmten Oberflächenschichten des Gewässers aufsteigen. In solchen Fällen können Zig-Rig Montagen die besten Ergebnisse erzielen. Beim Zig-Rig wird a",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/bottom_zigrig-3.jpeg",
"entity_type": "reference",
"label": "Zig-Rig Guide Unterschiedliche Wetterbedingungen führen zu Veränderungen der Wassertemperatur. Bei niedrigen Temperaturen können Karpfen in die sonnenerwärmten Oberflächenschichten des Gewässers aufsteigen. In solchen Fällen können Zig-Rig Montagen die besten Ergebnisse erzielen. Beim Zig-Rig wird a",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/seafishing-1.jpeg",
"entity_type": "reference",
"label": "RATSCHLÄGE Wurfweite und Wurfgenauigkeit Wurfweite hängt ab von: Geschicklichkeit des Spielers Fähigkeiten dieser Angelrutenart Fähigkeiten, diese Rollenart zu verwenden Länge der Angelrute Grad der Trunkenheit Beeinflussung durch den Wind Als allererstes geben Sie acht auf Ihre Fähigkeiten für dies",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/seafishing-10.jpeg",
"entity_type": "reference",
"label": "RATSCHLÄGE Wurfweite und Wurfgenauigkeit Wurfweite hängt ab von: Geschicklichkeit des Spielers Fähigkeiten dieser Angelrutenart Fähigkeiten, diese Rollenart zu verwenden Länge der Angelrute Grad der Trunkenheit Beeinflussung durch den Wind Als allererstes geben Sie acht auf Ihre Fähigkeiten für dies",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/seafishing-2.jpeg",
"entity_type": "reference",
"label": "RATSCHLÄGE Wurfweite und Wurfgenauigkeit Wurfweite hängt ab von: Geschicklichkeit des Spielers Fähigkeiten dieser Angelrutenart Fähigkeiten, diese Rollenart zu verwenden Länge der Angelrute Grad der Trunkenheit Beeinflussung durch den Wind Als allererstes geben Sie acht auf Ihre Fähigkeiten für dies",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/seafishing-3.jpeg",
"entity_type": "reference",
"label": "RATSCHLÄGE Wurfweite und Wurfgenauigkeit Wurfweite hängt ab von: Geschicklichkeit des Spielers Fähigkeiten dieser Angelrutenart Fähigkeiten, diese Rollenart zu verwenden Länge der Angelrute Grad der Trunkenheit Beeinflussung durch den Wind Als allererstes geben Sie acht auf Ihre Fähigkeiten für dies",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/seafishing-4.jpeg",
"entity_type": "reference",
"label": "RATSCHLÄGE Wurfweite und Wurfgenauigkeit Wurfweite hängt ab von: Geschicklichkeit des Spielers Fähigkeiten dieser Angelrutenart Fähigkeiten, diese Rollenart zu verwenden Länge der Angelrute Grad der Trunkenheit Beeinflussung durch den Wind Als allererstes geben Sie acht auf Ihre Fähigkeiten für dies",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/seafishing-5.jpeg",
"entity_type": "reference",
"label": "RATSCHLÄGE Wurfweite und Wurfgenauigkeit Wurfweite hängt ab von: Geschicklichkeit des Spielers Fähigkeiten dieser Angelrutenart Fähigkeiten, diese Rollenart zu verwenden Länge der Angelrute Grad der Trunkenheit Beeinflussung durch den Wind Als allererstes geben Sie acht auf Ihre Fähigkeiten für dies",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/seafishing-6.jpeg",
"entity_type": "reference",
"label": "RATSCHLÄGE Wurfweite und Wurfgenauigkeit Wurfweite hängt ab von: Geschicklichkeit des Spielers Fähigkeiten dieser Angelrutenart Fähigkeiten, diese Rollenart zu verwenden Länge der Angelrute Grad der Trunkenheit Beeinflussung durch den Wind Als allererstes geben Sie acht auf Ihre Fähigkeiten für dies",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/seafishing-7.jpeg",
"entity_type": "reference",
"label": "RATSCHLÄGE Wurfweite und Wurfgenauigkeit Wurfweite hängt ab von: Geschicklichkeit des Spielers Fähigkeiten dieser Angelrutenart Fähigkeiten, diese Rollenart zu verwenden Länge der Angelrute Grad der Trunkenheit Beeinflussung durch den Wind Als allererstes geben Sie acht auf Ihre Fähigkeiten für dies",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/seafishing-8.jpeg",
"entity_type": "reference",
"label": "RATSCHLÄGE Wurfweite und Wurfgenauigkeit Wurfweite hängt ab von: Geschicklichkeit des Spielers Fähigkeiten dieser Angelrutenart Fähigkeiten, diese Rollenart zu verwenden Länge der Angelrute Grad der Trunkenheit Beeinflussung durch den Wind Als allererstes geben Sie acht auf Ihre Fähigkeiten für dies",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/seafishing-9.jpeg",
"entity_type": "reference",
"label": "RATSCHLÄGE Wurfweite und Wurfgenauigkeit Wurfweite hängt ab von: Geschicklichkeit des Spielers Fähigkeiten dieser Angelrutenart Fähigkeiten, diese Rollenart zu verwenden Länge der Angelrute Grad der Trunkenheit Beeinflussung durch den Wind Als allererstes geben Sie acht auf Ihre Fähigkeiten für dies",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-1.jpeg",
"entity_type": "reference",
"label": "Bild 2: Erfahrungspunkte-Indikator",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-10.jpeg",
"entity_type": "reference",
"label": "Bild 11: Sbirolino-Anwendung",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-11.jpeg",
"entity_type": "reference",
"label": "Bild 12: Ein idealer Ort für den Hecht",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-12.jpeg",
"entity_type": "reference",
"label": "Bild 13: Angeln mit mehreren Feederruten gleichzeitig",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-13.jpeg",
"entity_type": "reference",
"label": "Bild 14: Einsatz des Unterfangkeschers",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-14.jpeg",
"entity_type": "reference",
"label": "Bild 15: Beispiel: Light-Spinnangel mit Bonus Wurfweite",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-15.jpeg",
"entity_type": "reference",
"label": "Bild 16: Beispiel: kleine Rolle für Light-Gerät",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-16.jpeg",
"entity_type": "reference",
"label": "Bild 17: Große Feeder-Rolle",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-17.jpeg",
"entity_type": "reference",
"label": "Bild 18: Baitcastingrolle",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-18.jpeg",
"entity_type": "reference",
"label": "Bild 19: Angelhaken",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-19.jpeg",
"entity_type": "reference",
"label": "Bild 20: Dünne Fluorocarbonschnur",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-2.jpeg",
"entity_type": "reference",
"label": "Bild 3: Kompass",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-20.jpeg",
"entity_type": "reference",
"label": "Bild 21: Zustand der Ausrüstung",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-21.jpeg",
"entity_type": "reference",
"label": "Bild 22: Fischmarkt und Bewertung des Fisches. Der Screenshot zeigt auch verdorbenen Fisch (rot markiert). Super Trophäe Barsch, oben rechts.",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-22.jpeg",
"entity_type": "reference",
"label": "Bild 23: Futterkörbe",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-23.jpeg",
"entity_type": "reference",
"label": "Bild 24: Infofenster Futterkorb",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-24.jpeg",
"entity_type": "reference",
"label": "Bild 25: Werkstattgebäude auf dem langen Graben (Fluss Vjunok)",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-25.jpeg",
"entity_type": "reference",
"label": "Bild 26: Werkstatt. Alles im grünem Bereich, diese Rolle ist normal und erfordert keine Reparatur",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-26.jpeg",
"entity_type": "waterbody",
"label": "Bild 27: Gewässerkarte",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-27.jpeg",
"entity_type": "waterbody",
"label": "Bild 28: Ansicht der Gewässerkarte",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-28.jpeg",
"entity_type": "reference",
"label": "Bild 29: Bootsverleih auf dem großen Storm (Fluss Wolchow)",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-29.jpeg",
"entity_type": "reference",
"label": "Bild 30: Bedienfeld",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-3.jpeg",
"entity_type": "reference",
"label": "Bild 4: Im Setzkescher befinden sich 3 Fische von 100 möglichen",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-30.jpeg",
"entity_type": "reference",
"label": "Bild 31: Gewässerwahlfenster",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-31.jpeg",
"entity_type": "reference",
"label": "Bild 32: Fähigkeiten",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-32.jpeg",
"entity_type": "reference",
"label": "Bild 33: Detaillierte Fähigkeiten-Informationen. (Posenfischen)",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-33-1.jpeg",
"entity_type": "reference",
"label": "Bild 35: Setzkescher mit etwas Fisch",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-33-2.jpeg",
"entity_type": "reference",
"label": "Bild 36: Detailfensteransicht. Im oberen linken Bereich ist der Fisch mit einer gelben Fahne gekennzeichnet, dies bedeutet, dass dieser Fisch einen guten Preis beim Verkauf erzielen wird. (Super - Trophäe)",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-33.jpeg",
"entity_type": "reference",
"label": "Bild 34: Spielerinfo",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-34.jpeg",
"entity_type": "reference",
"label": "Bild 37: Errungenschaften des Spielers",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-35.jpeg",
"entity_type": "reference",
"label": "Bild 38: Handwerksfenster und Rezeptliste",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-36.jpeg",
"entity_type": "reference",
"label": "Bild 39: Detailfenster über das Herstellungsrezept des Moskauer Kunstköders",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-37.jpeg",
"entity_type": "reference",
"label": "Bild 40: Schaufel in der Hand, zum Auftreiben von Würmern etc.",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-38.jpeg",
"entity_type": "reference",
"label": "Bild 41: Ausgegrabener Wurm. Es gibt eine zusätzliche Belohnung in Form von Punkten auf die Fähigkeit",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-39.jpeg",
"entity_type": "reference",
"label": "Bild 42: Futterzubereitung",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-4.jpeg",
"entity_type": "reference",
"label": "Bild 5: Wetter",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-40.jpeg",
"entity_type": "reference",
"label": "Bild 43: Vorbereitung zum Auswurf des Futters",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-41.jpeg",
"entity_type": "reference",
"label": "Bild 44: Wettervorhersage",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-42.jpeg",
"entity_type": "reference",
"label": "Bild 45: Texteingabefeld und Private Nachrichten",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-43.jpeg",
"entity_type": "reference",
"label": "Bild 46: Aufträge",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-44.jpeg",
"entity_type": "reference",
"label": "Bild 47: Auftrag abgeben",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-45.jpeg",
"entity_type": "reference",
"label": "Bild 48: Wettbewerbsliste",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-46.jpeg",
"entity_type": "reference",
"label": "Bild 49: Wettbewerb erstellen",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-47.jpeg",
"entity_type": "reference",
"label": "Bild 50: Wettbewerbsvorbereitung",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-48.jpeg",
"entity_type": "reference",
"label": "Bild 51: Zwischenergebnisse",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-49.jpeg",
"entity_type": "reference",
"label": "Bild 52: Wettbewerbsergebnisse",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-5.jpeg",
"entity_type": "reference",
"label": "Bild 6: Indikator für aktuelle Angelköder, Futtermischung und PVA-Netz",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-50.jpeg",
"entity_type": "reference",
"label": "Abbildung. 53: Rute mit Bonus Werten",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-51.jpeg",
"entity_type": "reference",
"label": "Abbildung. 54: Bestimmung der Tiefe und Art des Gewässergrundes mit einer Markerrute.",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-6.jpeg",
"entity_type": "reference",
"label": "Bild 7: Belastungsindikator der Angelrute",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-7.jpeg",
"entity_type": "reference",
"label": "Bild. 8: Rucksack mit Inhalt",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-8.jpeg",
"entity_type": "reference",
"label": "Bild 9: Detailansichtsfenster",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image-9.jpeg",
"entity_type": "reference",
"label": "Bild 10: Indikator für die Auswurfweite",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/res/usergide/word-image.jpeg",
"entity_type": "reference",
"label": "Bild 1: Spieler Statusanzeigen.",
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/wp-content/plugins/qtranslate-xt-master/flags/de.png",
"entity_type": "reference",
"label": null,
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
},
{
"source_page": "https://rf4game.de/userguide/",
"asset_url": "https://rf4game.de/wp-content/themes/rf4/img/banner_de.png",
"entity_type": "reference",
"label": null,
"external_id": null,
"status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
}
]
}
+1 -1
View File
@@ -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.** После отдельного письменного разрешения на изображения добавить allowlist официальных asset URL, локальное сохранение без hotlink, content hash, MIME/dimensions, attribution/source URL, дату получения и fallback на SVG. Изменение/удаление оригинала должно отправлять media asset на повторную проверку, а не молча менять публичную карточку.
- [ ] **B11 · Разрешённый media pipeline — в работе.** Получено явное разрешение на локальный сбор. Добавлены allowlist, индекс кандидатов, provenance-manifest, очередь и content-addressed хранение без hotlink; первый официальный обход нашёл 69 кандидатов. Осталось постепенно скачать очередь с общим cooldown 30 минут, определить dimensions и вручную подтвердить соответствия сущностям перед публикацией.
- [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений.
- [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`.
- [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
+1
View File
@@ -33,6 +33,7 @@
- RF4MAP: не чаще одного запроса раз в 30 минут.
- RF4 Posts: не чаще одного запроса раз в 30 минут.
- все пять разрешённых community-адаптеров включены; scheduler хранит cooldown в PostgreSQL и не запускает источник чаще одного раза в 30 минут;
- владелец 12 сентября 2026 года явно разрешил локально собирать изображения рыб, водоёмов и снастей; медиакандидаты сохраняются с source URL и хэшем, не публикуются до проверки соответствия и используют тот же 30-минутный лимит домена;
- при `429`, `403`, изменении DOM или неожиданном типе ответа сбор прекращается без попытки обхода ограничения.
## Что ещё требуется зафиксировать
+2
View File
@@ -56,6 +56,8 @@
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 площадки. Статус `queued` не означает соответствие сущности или разрешение на публичный вывод: до ручной проверки файл остаётся исследовательским материалом.
Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU.
## Рекомендуемый режим получения
+1
View File
@@ -39,6 +39,7 @@ ALLOWED_HOSTS = frozenset({
"rf4-stat.ru",
"rf4map.ru",
"rf4-posts.com",
"rf4game.de",
})
MAX_RESPONSE_BYTES = 5 * 1024 * 1024 # 5 MB
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
import hashlib
import json
import mimetypes
import re
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urljoin, urlsplit
from bs4 import BeautifulSoup, Tag
@dataclass(frozen=True, slots=True)
class MediaCandidate:
source_page: str
asset_url: str
entity_type: str
label: str | None
external_id: str | None
def _label(node: Tag) -> str | None:
value = node.get("alt") or node.get("title")
if isinstance(value, str) and value.strip():
return " ".join(value.split())[:300]
parent = node.find_parent(["article", "figure", "a", "section"])
if parent:
text = " ".join(parent.get_text(" ", strip=True).split())
return text[:300] or None
return None
def _kind(url: str, label: str | None, context: str) -> str:
path = urlsplit(url).path.casefold()
value = f"{label or ''} {context}".casefold()
if re.search(r"/(?:fish|fishes|fische|species)/", path) or any(word in value for word in ("рыба ", "fish: ", "fisch: ")):
return "fish"
if re.search(r"/(?:maps?|levels?|lakes?|waterbodies)/", path) or any(word in value for word in ("карта ", "map of ", "gewässerkarte")):
return "waterbody"
if re.search(r"(?:^|[/_-])(?:bait|lure|rig|rod|reel|hook|tackle|köder|rute|rolle)(?:[/_.-]|$)", path):
return "tackle"
return "reference"
def extract_media_candidates(html: str, *, source_page: str) -> list[MediaCandidate]:
soup = BeautifulSoup(html, "html.parser")
result: dict[str, MediaCandidate] = {}
for node in soup.select("img[src], img[data-src], source[srcset]"):
raw = node.get("src") or node.get("data-src") or node.get("srcset")
if not isinstance(raw, str):
continue
raw = raw.split(",", 1)[0].strip().split(" ", 1)[0]
url = urljoin(source_page, raw)
if urlsplit(url).scheme != "https":
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
result.setdefault(url, MediaCandidate(source_page, url, _kind(url, label, context), label, external_id))
for match in re.finditer(r"url\((['\"]?)(https://[^)'\"]+)\1\)", html, re.I):
url = match.group(2)
result.setdefault(url, MediaCandidate(source_page, url, _kind(url, None, ""), None, None))
return list(result.values())
def merge_manifest(path: Path, candidates: list[MediaCandidate]) -> dict:
current = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {"version": 1, "assets": []}
assets = {item["asset_url"]: item for item in current.get("assets", [])}
seen_at = datetime.now(timezone.utc).isoformat()
for candidate in candidates:
item = assets.get(candidate.asset_url, {})
item.update(asdict(candidate))
item.setdefault("status", "queued")
item.setdefault("first_seen_at", seen_at)
item["last_seen_at"] = seen_at
assets[candidate.asset_url] = item
output = {"version": 1, "updated_at": seen_at, "assets": sorted(assets.values(), key=lambda item: item["asset_url"])}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(output, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return output
def reclassify_manifest(path: Path) -> dict:
manifest = json.loads(path.read_text(encoding="utf-8"))
for item in manifest.get("assets", []):
item["entity_type"] = _kind(item["asset_url"], item.get("label"), "")
path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return manifest
def store_asset(root: Path, body: bytes, *, content_type: str, source_url: str) -> tuple[str, str]:
digest = hashlib.sha256(body).hexdigest()
extension = mimetypes.guess_extension(content_type.split(";", 1)[0]) or Path(urlsplit(source_url).path).suffix
extension = extension if extension in {".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg"} else ".bin"
target = root / "files" / digest[:2] / f"{digest}{extension}"
target.parent.mkdir(parents=True, exist_ok=True)
if not target.exists():
target.write_bytes(body)
return digest, str(target.relative_to(root))
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
import urllib.request
from .community_cli import USER_AGENT, _StrictRedirectHandler, _validate_url_before_io, check_and_reserve, fetch_html, fetch_site_key
from .media_assets import extract_media_candidates, merge_manifest, reclassify_manifest, store_asset
DEFAULT_ROOT = Path("data/media")
MAX_ASSET_BYTES = 15 * 1024 * 1024
def _download_one(root: Path, state_file: Path) -> 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"), None)
if queued is None:
return "queue is empty"
url = queued["asset_url"]
_validate_url_before_io(url)
check_and_reserve(fetch_site_key(url), state_file=state_file)
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "image/*"})
opener = urllib.request.build_opener(_StrictRedirectHandler())
with opener.open(request, timeout=30) as response:
content_type = response.headers.get_content_type()
if not content_type.startswith("image/"):
raise ValueError(f"expected image, got {content_type}")
body = response.read(MAX_ASSET_BYTES + 1)
if len(body) > MAX_ASSET_BYTES:
raise ValueError("asset exceeded 15MB limit")
digest, relative = store_asset(root, body, content_type=content_type, source_url=url)
queued.update({"status": "stored", "sha256": digest, "local_path": relative, "content_type": content_type, "bytes": len(body)})
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return f"stored {url} as {relative}"
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")
parser.add_argument("--reclassify", action="store_true", help="Reapply current conservative classifier 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)
try:
if args.reclassify:
manifest = reclassify_manifest(args.root / "manifest.json")
print(f"reclassified {len(manifest['assets'])} candidates")
return 0
if args.download_one:
print(_download_one(args.root, args.state_file))
return 0
if not args.url:
parser.error("url is required unless --download-one 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)
manifest = merge_manifest(args.root / "manifest.json", candidates)
except Exception as exc:
parser.exit(1, f"media index failed: {exc}\n")
print(f"indexed {len(candidates)} candidates; manifest contains {len(manifest['assets'])}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+30
View File
@@ -0,0 +1,30 @@
from pathlib import Path
from rf4_research.media_assets import extract_media_candidates, merge_manifest, store_asset
def test_extracts_and_classifies_unique_https_media() -> None:
html = '''<article><a href="/fish/pike"><img src="/media/fish/pike.png" alt="Щука"></a></article>
<figure><img data-src="https://cdn.example/maps/kuori.webp" alt="Карта Куори"></figure>
<img src="http://unsafe.example/bait.png"><img src="/media/fish/pike.png">'''
items = extract_media_candidates(html, source_page="https://example.test/guide")
assert [(item.entity_type, item.asset_url) for item in items] == [
("fish", "https://example.test/media/fish/pike.png"),
("waterbody", "https://cdn.example/maps/kuori.webp"),
]
assert items[0].external_id == "pike"
def test_does_not_mistake_seafishing_screenshot_for_fish_entity() -> None:
items = extract_media_candidates('<img src="/res/userguide/seafishing-1.jpeg">', source_page="https://rf4game.de/userguide/")
assert items[0].entity_type == "reference"
def test_manifest_merges_and_binary_store_is_content_addressed(tmp_path: Path) -> None:
item = extract_media_candidates('<img src="/bait/worm.png" alt="Bait worm">', source_page="https://example.test")[0]
first = merge_manifest(tmp_path / "manifest.json", [item])
second = merge_manifest(tmp_path / "manifest.json", [item])
assert len(first["assets"]) == len(second["assets"]) == 1
digest, relative = store_asset(tmp_path, b"image", content_type="image/png", source_url=item.asset_url)
assert len(digest) == 64
assert (tmp_path / relative).read_bytes() == b"image"