Compare commits
5
Commits
cadd5607b8
...
215af73388
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
215af73388 | ||
|
|
26724c7675 | ||
|
|
bda0a0ef5e | ||
|
|
52ff29668e | ||
|
|
45c7e65bc4 |
@@ -2,6 +2,7 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.cache/
|
.cache/
|
||||||
|
data/media/files/
|
||||||
.env.production
|
.env.production
|
||||||
.maintenance.lock
|
.maintenance.lock
|
||||||
.venv/
|
.venv/
|
||||||
|
|||||||
@@ -34,10 +34,12 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
|
|||||||
|
|
||||||
Базовый SEO-контур готов для `rf4spotter.ru`: страницы имеют уникальные метаданные, canonical, Open Graph/Twitter Card, фирменное изображение 1200×630 и JSON-LD; доступны динамические `/robots.txt` и `/sitemap.xml`, административные и ошибочные страницы закрыты от индексации, добавлена собственная страница 404. Индексируемые каталоги рыб и водоёмов, detail-страницы и сочетания водоём + рыба строятся из актуального разрешённого справочника и включаются в sitemap. Подключены резкие favicon/app icons из SVG-мастера, отдельные полнофоновые maskable-иконки, web manifest и production-кэширование статических ресурсов. Карточки активности показывают единый паспорт данных: источники, свежесть, полноту и уровень доверия. На `/status` опубликована легенда цветов всех источников и статусов качества.
|
Базовый SEO-контур готов для `rf4spotter.ru`: страницы имеют уникальные метаданные, canonical, Open Graph/Twitter Card, фирменное изображение 1200×630 и JSON-LD; доступны динамические `/robots.txt` и `/sitemap.xml`, административные и ошибочные страницы закрыты от индексации, добавлена собственная страница 404. Индексируемые каталоги рыб и водоёмов, detail-страницы и сочетания водоём + рыба строятся из актуального разрешённого справочника и включаются в sitemap. Подключены резкие favicon/app icons из SVG-мастера, отдельные полнофоновые maskable-иконки, web manifest и production-кэширование статических ресурсов. Карточки активности показывают единый паспорт данных: источники, свежесть, полноту и уровень доверия. На `/status` опубликована легенда цветов всех источников и статусов качества.
|
||||||
|
|
||||||
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Каждый улов показывает источник, относительную свежесть и точное время UTC; время получения явно отделено от времени улова. Каталоги оформлены как полевой атлас: тёмный seal показывает объём справочника, карточки рыб используют смысловые SVG-силуэты, а каждый водоём — собственный детерминированный абстрактный отпечаток берега, волн, точки и индекса. На странице сочетания оба знака собираются в единую атласную эмблему. Это не карта и не игровая география. Пустые состояния используют статичную CSS-иллюстрацию поплавка; смысловые анимации полностью учитывают системное ограничение движения.
|
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Каждый улов показывает источник, относительную свежесть и точное время UTC; время получения явно отделено от времени улова. Каталоги оформлены как полевой атлас: тёмный seal показывает объём справочника, карточки рыб используют смысловые SVG-силуэты, а каждый водоём — собственный детерминированный абстрактный отпечаток берега, волн, точки и индекса. На странице сочетания оба знака собираются в единую атласную эмблему, detail-иерархию связывает breadcrumb-леска с текстовыми узлами, а боковые переходы повторяют знаки связанных сущностей. Это не карта и не игровая география. Пустые состояния используют статичную CSS-иллюстрацию поплавка; смысловые анимации полностью учитывают системное ограничение движения.
|
||||||
|
|
||||||
Все пять 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 задаются переменными окружения.
|
Все пять 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 запросов, внутренних ошибок и другой диагностической информации.
|
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
|
||||||
|
|
||||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
|
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
type Item = { label: string; href?: string };
|
||||||
|
const { items } = Astro.props as { items: Item[] };
|
||||||
|
---
|
||||||
|
|
||||||
|
<nav class="atlas-breadcrumbs content-grid" aria-label="Навигационная цепочка">
|
||||||
|
<ol>
|
||||||
|
{items.map((item, index) => <li>
|
||||||
|
<span class="atlas-breadcrumbs__knot" aria-hidden="true"></span>
|
||||||
|
{item.href ? <a href={item.href}>{item.label}</a> : <span aria-current="page">{item.label}</span>}
|
||||||
|
{index < items.length - 1 && <span class="atlas-breadcrumbs__line" aria-hidden="true"></span>}
|
||||||
|
</li>)}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.atlas-breadcrumbs { padding-top: 34px; }
|
||||||
|
ol { display: flex; align-items: center; gap: 0; margin: 0; padding: 0; list-style: none; overflow-x: auto; scrollbar-width: none; }
|
||||||
|
ol::-webkit-scrollbar { display: none; }
|
||||||
|
li { display: flex; align-items: center; flex: 0 0 auto; color: var(--text-muted); font-size: 12px; }
|
||||||
|
a, li > span[aria-current] { padding: 8px 9px; color: inherit; text-decoration: none; white-space: nowrap; }
|
||||||
|
a:hover { color: var(--deep); text-decoration: underline; text-underline-offset: 4px; }
|
||||||
|
li > span[aria-current] { color: var(--deep); font-weight: 750; }
|
||||||
|
.atlas-breadcrumbs__knot { width: 7px; height: 7px; flex: 0 0 auto; border: 1px solid #78918a; border-radius: 50%; background: var(--paper); }
|
||||||
|
li:last-child .atlas-breadcrumbs__knot { border-color: #6f922d; background: var(--lime); box-shadow: 0 0 0 4px #c9f45b26; }
|
||||||
|
.atlas-breadcrumbs__line { width: clamp(18px, 3vw, 42px); height: 1px; background: repeating-linear-gradient(90deg,#9aaca5 0 5px,transparent 5px 8px); }
|
||||||
|
@media (max-width: 720px) { .atlas-breadcrumbs { width: calc(100% - 28px); padding-top: 22px; } a, li > span[aria-current] { padding-inline: 7px; } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
import FishSilhouette from "./FishSilhouette.astro";
|
||||||
|
import WaterbodyMark from "./WaterbodyMark.astro";
|
||||||
|
const { href, label, kind, identity } = Astro.props as { href: string; label: string; kind: "fish" | "water"; identity: string };
|
||||||
|
---
|
||||||
|
<a class="atlas-entity-link" href={href}>
|
||||||
|
<span class="atlas-entity-link__mark" aria-hidden="true">{kind === "fish" ? <FishSilhouette name={identity} size={45} /> : <WaterbodyMark identity={identity} size={48} />}</span>
|
||||||
|
<span>{label}</span><b aria-hidden="true">→</b>
|
||||||
|
</a>
|
||||||
|
<style>
|
||||||
|
.atlas-entity-link { display:grid;grid-template-columns:52px minmax(0,1fr) auto;align-items:center;gap:9px;min-height:54px;padding:5px 2px;border-bottom:1px solid var(--border-soft);color:var(--text-muted);font-size:13px;text-decoration:none }
|
||||||
|
.atlas-entity-link__mark { width:48px;height:38px;display:grid;place-items:center;color:#315f63 }
|
||||||
|
.atlas-entity-link__mark :global(.fish-silhouette),.atlas-entity-link__mark :global(.waterbody-mark) { position:static;opacity:.68 }
|
||||||
|
.atlas-entity-link__mark :global(.waterbody-mark) { stroke:currentColor }.atlas-entity-link__mark :global(.waterbody-mark text) { fill:currentColor }
|
||||||
|
b { color:#6d8d2a;font-size:15px;transition:transform var(--motion-fast) var(--ease-out) }.atlas-entity-link:hover { color:var(--deep) }.atlas-entity-link:hover b { transform:translateX(2px) }
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
---
|
---
|
||||||
import ActivityCard from "../../components/ActivityCard.astro";
|
import ActivityCard from "../../components/ActivityCard.astro";
|
||||||
|
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
|
||||||
|
import AtlasEntityLink from "../../components/AtlasEntityLink.astro";
|
||||||
import PageHero from "../../components/PageHero.astro";
|
import PageHero from "../../components/PageHero.astro";
|
||||||
import StatePanel from "../../components/StatePanel.astro";
|
import StatePanel from "../../components/StatePanel.astro";
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
@@ -21,7 +23,7 @@ const waters = [...new Map(items.map(item => [item.waterbody_slug, item.waterbod
|
|||||||
const schema = fish ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Где ловить ${fish.name_ru} в RF4`, url:`https://rf4spotter.ru/fish/${fish.slug}` } : null;
|
const schema = fish ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Где ловить ${fish.name_ru} в RF4`, url:`https://rf4spotter.ru/fish/${fish.slug}` } : null;
|
||||||
---
|
---
|
||||||
<Layout title={fish ? `Где ловить ${fish.name_ru} в RF4 — свежие точки` : "Рыба не найдена — RF4 Spotter"} description={fish ? `Свежие точки ловли ${fish.name_ru} в Russian Fishing 4: водоёмы, координаты, приманки, активность и источники наблюдений.` : "Такого вида рыбы нет в каталоге RF4 Spotter."} noindex={!fish || unavailable} structuredData={schema} errorPage={!fish || unavailable}>
|
<Layout title={fish ? `Где ловить ${fish.name_ru} в RF4 — свежие точки` : "Рыба не найдена — RF4 Spotter"} description={fish ? `Свежие точки ловли ${fish.name_ru} в Russian Fishing 4: водоёмы, координаты, приманки, активность и источники наблюдений.` : "Такого вида рыбы нет в каталоге RF4 Spotter."} noindex={!fish || unavailable} structuredData={schema} errorPage={!fish || unavailable}>
|
||||||
<a class="back" href="/fish">← Все виды рыб</a>
|
<AtlasBreadcrumbs items={[{ label: "Рыбы", href: "/fish" }, { label: fish?.name_ru ?? "Не найдено" }]} />
|
||||||
<PageHero eyebrow="Свежие данные за 72 часа" title={fish?.name_ru ?? "Рыба не найдена"} description={fish ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} на ${waters.length} ${plural(waters.length,["водоёме","водоёмах","водоёмах"])}.` : undefined} variant={fish ? "fish" : undefined} identity={fish?.name_ru} />
|
<PageHero eyebrow="Свежие данные за 72 часа" title={fish?.name_ru ?? "Рыба не найдена"} description={fish ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} на ${waters.length} ${plural(waters.length,["водоёме","водоёмах","водоёмах"])}.` : undefined} variant={fish ? "fish" : undefined} identity={fish?.name_ru} />
|
||||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !fish ? <StatePanel tone="error" title="Такой рыбы нет в справочнике" actionHref="/fish" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Водоёмы</span>{waters.length ? <nav>{waters.map(([waterSlug,name]) => <a href={`/waterbodies/${waterSlug}/${fish!.slug}`}>{name}</a>)}</nav> : <p>Свежих подтверждённых водоёмов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !fish ? <StatePanel tone="error" title="Такой рыбы нет в справочнике" actionHref="/fish" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Водоёмы</span>{waters.length ? <nav>{waters.map(([waterSlug,name]) => <AtlasEntityLink href={`/waterbodies/${waterSlug}/${fish!.slug}`} label={name} kind="water" identity={waterSlug} />)}</nav> : <p>Свежих подтверждённых водоёмов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
|
||||||
import SourceBadge from "../../components/SourceBadge.astro";
|
import SourceBadge from "../../components/SourceBadge.astro";
|
||||||
import CoordinateRadar from "../../components/CoordinateRadar.astro";
|
import CoordinateRadar from "../../components/CoordinateRadar.astro";
|
||||||
import TackleGlyph from "../../components/TackleGlyph.astro";
|
import TackleGlyph from "../../components/TackleGlyph.astro";
|
||||||
@@ -31,7 +32,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
|
|||||||
] } : null;
|
] } : null;
|
||||||
---
|
---
|
||||||
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}>
|
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}>
|
||||||
<a class="back" href="/">← Все активные точки</a>
|
<AtlasBreadcrumbs items={[{ label: "Сейчас клюёт", href: "/" }, ...(spot ? [{ label: spot.waterbody, href: `/waterbodies/${spot.waterbody_slug}` }, { label: `Точка ${spot.x}:${spot.y}` }] : [{ label: "Точка недоступна" }])]} />
|
||||||
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
||||||
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><button class="coordinate-copy" data-action="inverse" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><small class="copy-status" aria-live="polite"></small></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
|
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><button class="coordinate-copy" data-action="inverse" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><small class="copy-status" aria-live="polite"></small></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
|
||||||
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
---
|
---
|
||||||
import ActivityCard from "../../components/ActivityCard.astro";
|
import ActivityCard from "../../components/ActivityCard.astro";
|
||||||
|
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
|
||||||
|
import AtlasEntityLink from "../../components/AtlasEntityLink.astro";
|
||||||
import PageHero from "../../components/PageHero.astro";
|
import PageHero from "../../components/PageHero.astro";
|
||||||
import StatePanel from "../../components/StatePanel.astro";
|
import StatePanel from "../../components/StatePanel.astro";
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
@@ -21,7 +23,7 @@ const fishes = [...new Map(items.map(item => [item.fish_slug, item.fish])).entri
|
|||||||
const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Что ловить на ${water.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water.slug}` } : null;
|
const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Что ловить на ${water.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water.slug}` } : null;
|
||||||
---
|
---
|
||||||
<Layout title={water ? `${water.name_ru} в RF4 — рыба и свежие точки` : "Водоём не найден — RF4 Spotter"} description={water ? `${water.name_ru} в Russian Fishing 4: свежие координаты, активные виды рыб, приманки и источники наблюдений.` : "Такого водоёма нет в каталоге RF4 Spotter."} noindex={!water || unavailable} structuredData={schema} errorPage={!water || unavailable}>
|
<Layout title={water ? `${water.name_ru} в RF4 — рыба и свежие точки` : "Водоём не найден — RF4 Spotter"} description={water ? `${water.name_ru} в Russian Fishing 4: свежие координаты, активные виды рыб, приманки и источники наблюдений.` : "Такого водоёма нет в каталоге RF4 Spotter."} noindex={!water || unavailable} structuredData={schema} errorPage={!water || unavailable}>
|
||||||
<a class="back" href="/waterbodies">← Все водоёмы</a>
|
<AtlasBreadcrumbs items={[{ label: "Водоёмы", href: "/waterbodies" }, { label: water?.name_ru ?? "Не найдено" }]} />
|
||||||
<PageHero eyebrow="Свежие данные за 72 часа" title={water?.name_ru ?? "Водоём не найден"} description={water ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} для ${fishes.length} ${plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.` : undefined} variant={water ? "water" : undefined} identity={water?.slug} />
|
<PageHero eyebrow="Свежие данные за 72 часа" title={water?.name_ru ?? "Водоём не найден"} description={water ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} для ${fishes.length} ${plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.` : undefined} variant={water ? "water" : undefined} identity={water?.slug} />
|
||||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !water ? <StatePanel tone="error" title="Такого водоёма нет в справочнике" actionHref="/waterbodies" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <a href={`/waterbodies/${water!.slug}/${fishSlug}`}>{name}</a>)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !water ? <StatePanel tone="error" title="Такого водоёма нет в справочнике" actionHref="/waterbodies" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <AtlasEntityLink href={`/waterbodies/${water!.slug}/${fishSlug}`} label={name} kind="fish" identity={name} />)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import ActivityCard from "../../../components/ActivityCard.astro";
|
import ActivityCard from "../../../components/ActivityCard.astro";
|
||||||
|
import AtlasBreadcrumbs from "../../../components/AtlasBreadcrumbs.astro";
|
||||||
import PageHero from "../../../components/PageHero.astro";
|
import PageHero from "../../../components/PageHero.astro";
|
||||||
import StatePanel from "../../../components/StatePanel.astro";
|
import StatePanel from "../../../components/StatePanel.astro";
|
||||||
import Layout from "../../../layouts/Layout.astro";
|
import Layout from "../../../layouts/Layout.astro";
|
||||||
@@ -21,7 +22,7 @@ const valid = Boolean(water && fish);
|
|||||||
const schema = valid ? { "@type":"CollectionPage", name:`${fish!.name_ru} на ${water!.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water!.slug}/${fish!.slug}` } : null;
|
const schema = valid ? { "@type":"CollectionPage", name:`${fish!.name_ru} на ${water!.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water!.slug}/${fish!.slug}` } : null;
|
||||||
---
|
---
|
||||||
<Layout title={valid ? `${fish!.name_ru} на ${water!.name_ru} в RF4 — точки и приманки` : "Страница не найдена — RF4 Spotter"} description={valid ? `Где ловить ${fish!.name_ru} на ${water!.name_ru} в Russian Fishing 4: свежие координаты, приманки, активность и источники.` : "Такого сочетания рыбы и водоёма нет в каталоге."} noindex={!valid || unavailable} structuredData={schema} errorPage={!valid || unavailable}>
|
<Layout title={valid ? `${fish!.name_ru} на ${water!.name_ru} в RF4 — точки и приманки` : "Страница не найдена — RF4 Spotter"} description={valid ? `Где ловить ${fish!.name_ru} на ${water!.name_ru} в Russian Fishing 4: свежие координаты, приманки, активность и источники.` : "Такого сочетания рыбы и водоёма нет в каталоге."} noindex={!valid || unavailable} structuredData={schema} errorPage={!valid || unavailable}>
|
||||||
<a class="back" href={water ? `/waterbodies/${water.slug}` : "/waterbodies"}>← К водоёму</a>
|
<AtlasBreadcrumbs items={[{ label: "Водоёмы", href: "/waterbodies" }, { label: water?.name_ru ?? "Не найдено", href: water ? `/waterbodies/${water.slug}` : undefined }, { label: fish?.name_ru ?? "Не найдено" }]} />
|
||||||
<PageHero eyebrow={water?.name_ru ?? "Каталог RF4"} title={fish?.name_ru ?? "Данные не найдены"} description={valid ? `${items.length} ${plural(items.length,["свежая точка","свежие точки","свежих точек"])} за последние 72 часа.` : undefined} variant={valid ? "pair" : undefined} identity={fish?.name_ru} secondaryIdentity={water?.slug} />
|
<PageHero eyebrow={water?.name_ru ?? "Каталог RF4"} title={fish?.name_ru ?? "Данные не найдены"} description={valid ? `${items.length} ${plural(items.length,["свежая точка","свежие точки","свежих точек"])} за последние 72 часа.` : undefined} variant={valid ? "pair" : undefined} identity={fish?.name_ru} secondaryIdentity={water?.slug} />
|
||||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Свежие наблюдения сейчас не получены. Попробуйте позднее." /> : !valid ? <StatePanel tone="error" title="Такого сочетания нет в справочнике" actionHref="/" actionLabel="Вернуться на главную" /> : <section class="catalog-results catalog-results--single content-grid"><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Данные появятся после новых подтверждённых наблюдений." />}</div></section>}
|
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Свежие наблюдения сейчас не получены. Попробуйте позднее." /> : !valid ? <StatePanel tone="error" title="Такого сочетания нет в справочнике" actionHref="/" actionLabel="Вернуться на главную" /> : <section class="catalog-results catalog-results--single content-grid"><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Данные появятся после новых подтверждённых наблюдений." />}</div></section>}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+3
-1
@@ -30,8 +30,10 @@
|
|||||||
- [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.** После отдельного письменного разрешения на изображения добавить 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 кандидатов. Downloader проверяет реальный формат/MIME, лимит 40 Мп, сохраняет dimensions и не блокирует очередь постоянными `missing`/`blocked`/`invalid`. Review допускает `approved` только для сохранённого файла с типом и каноническим ключом сущности; доступен явный `rejected`. Осталось постепенно скачать очередь с общим cooldown 30 минут и проверить соответствия перед публикацией.
|
||||||
- [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений.
|
- [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений.
|
||||||
|
- [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`.
|
||||||
|
- [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
|
||||||
|
|
||||||
- [ ] **Q01 · Документы источников.** Приложить или дать устойчивые ссылки на первичные разрешения RF4DB, RF4-STAT, RF4MAP и RF4 Posts; для каждого зафиксировать атрибуцию, точный production-лимит, срок хранения и процедуру удаления.
|
- [ ] **Q01 · Документы источников.** Приложить или дать устойчивые ссылки на первичные разрешения RF4DB, RF4-STAT, RF4MAP и RF4 Posts; для каждого зафиксировать атрибуцию, точный production-лимит, срок хранения и процедуру удаления.
|
||||||
- [ ] **Q02 · Управляемое удаление источника.** Добавить обнаружение изменённых/удалённых опубликованных записей без отдельного частого обхода: статус, журнал решения и безопасное исключение из активности после проверки.
|
- [ ] **Q02 · Управляемое удаление источника.** Добавить обнаружение изменённых/удалённых опубликованных записей без отдельного частого обхода: статус, журнал решения и безопасное исключение из активности после проверки.
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ CTA задаются смыслом через `data-action`: `primary` — ос
|
|||||||
|
|
||||||
На странице сочетания два знака образуют одну атласную эмблему: отпечаток водоёма служит нижним слоем, силуэт рыбы — верхним, а маленький знак пересечения подчёркивает смысл «вид на конкретном водоёме». Эмблема декоративна; названия обеих сущностей остаются в текстовой и навигационной иерархии.
|
На странице сочетания два знака образуют одну атласную эмблему: отпечаток водоёма служит нижним слоем, силуэт рыбы — верхним, а маленький знак пересечения подчёркивает смысл «вид на конкретном водоёме». Эмблема декоративна; названия обеих сущностей остаются в текстовой и навигационной иерархии.
|
||||||
|
|
||||||
|
Detail-страницы связывает навигационная леска: каждый уровень представлен текстовым узлом, пунктир между узлами показывает путь, а лаймовый конечный узел — текущую страницу. На мобильном цепочка остаётся однострочной и прокручиваемой, не сокращая названия и не превращая мотив в единственный носитель смысла.
|
||||||
|
|
||||||
|
Списки связанных сущностей используют уменьшенные версии тех же знаков: рыбный силуэт или отпечаток водоёма слева, полное название в центре и стрелку перехода справа. Это рабочая навигация, поэтому знак приглушён и никогда не вытесняет название.
|
||||||
|
|
||||||
## Brand assets и QA
|
## Brand assets и QA
|
||||||
|
|
||||||
`favicon.svg` — мастер обычных favicon, Apple Touch и PWA `purpose: any`; PNG генерируются из него напрямую в целевом размере, а не масштабируются из малого растра. `icon-maskable.svg` — полнофоновый мастер для отдельных `purpose: maskable` 192/512 px, чтобы системная маска не открывала прозрачные углы. OG имеет ровно 1200×630 px и использует тот же тёмный водный фон, лаймовый поплавок и координатную сетку. Проверены desktop и 390 px каталоги, 32/180/192/512 px assets, светлая поверхность сайта и тёмные бренд-носители.
|
`favicon.svg` — мастер обычных favicon, Apple Touch и PWA `purpose: any`; PNG генерируются из него напрямую в целевом размере, а не масштабируются из малого растра. `icon-maskable.svg` — полнофоновый мастер для отдельных `purpose: maskable` 192/512 px, чтобы системная маска не открывала прозрачные углы. OG имеет ровно 1200×630 px и использует тот же тёмный водный фон, лаймовый поплавок и координатную сетку. Проверены desktop и 390 px каталоги, 32/180/192/512 px assets, светлая поверхность сайта и тёмные бренд-носители.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
- RF4MAP: не чаще одного запроса раз в 30 минут.
|
- RF4MAP: не чаще одного запроса раз в 30 минут.
|
||||||
- RF4 Posts: не чаще одного запроса раз в 30 минут.
|
- RF4 Posts: не чаще одного запроса раз в 30 минут.
|
||||||
- все пять разрешённых community-адаптеров включены; scheduler хранит cooldown в PostgreSQL и не запускает источник чаще одного раза в 30 минут;
|
- все пять разрешённых community-адаптеров включены; scheduler хранит cooldown в PostgreSQL и не запускает источник чаще одного раза в 30 минут;
|
||||||
|
- владелец 12 сентября 2026 года явно разрешил локально собирать изображения рыб, водоёмов и снастей; медиакандидаты сохраняются с source URL и хэшем, не публикуются до проверки соответствия и используют тот же 30-минутный лимит домена;
|
||||||
- при `429`, `403`, изменении DOM или неожиданном типе ответа сбор прекращается без попытки обхода ограничения.
|
- при `429`, `403`, изменении DOM или неожиданном типе ответа сбор прекращается без попытки обхода ограничения.
|
||||||
|
|
||||||
## Что ещё требуется зафиксировать
|
## Что ещё требуется зафиксировать
|
||||||
|
|||||||
@@ -56,6 +56,8 @@
|
|||||||
|
|
||||||
Fallback строится на собственных лёгких SVG: осмысленные семейства силуэтов рыб, типовые глифы снастей и детерминированные абстрактные отпечатки водоёмов. Текстовое название всегда остаётся основным идентификатором; абстрактный контур водоёма не называется картой, а глиф приманки — изображением конкретной модели.
|
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`.
|
||||||
|
|
||||||
Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU.
|
Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU.
|
||||||
|
|
||||||
## Рекомендуемый режим получения
|
## Рекомендуемый режим получения
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
name = "rf4-spotter-research"
|
name = "rf4-spotter-research"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["beautifulsoup4>=4.12,<5"]
|
dependencies = ["beautifulsoup4>=4.12,<5", "Pillow>=10,<12"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=68"]
|
requires = ["setuptools>=68"]
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ ALLOWED_HOSTS = frozenset({
|
|||||||
"rf4-stat.ru",
|
"rf4-stat.ru",
|
||||||
"rf4map.ru",
|
"rf4map.ru",
|
||||||
"rf4-posts.com",
|
"rf4-posts.com",
|
||||||
|
"rf4game.de",
|
||||||
})
|
})
|
||||||
MAX_RESPONSE_BYTES = 5 * 1024 * 1024 # 5 MB
|
MAX_RESPONSE_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
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
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
|
MAX_IMAGE_PIXELS = 40_000_000
|
||||||
|
|
||||||
|
|
||||||
|
@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 review_asset(
|
||||||
|
path: Path, *, asset_url: str, decision: str, entity_type: str | None = None,
|
||||||
|
entity_key: str | None = None, note: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
if decision not in {"approved", "rejected"}:
|
||||||
|
raise ValueError("decision must be approved or rejected")
|
||||||
|
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
item = next((asset for asset in manifest.get("assets", []) if asset["asset_url"] == asset_url), None)
|
||||||
|
if item is None:
|
||||||
|
raise ValueError("asset URL is not present in manifest")
|
||||||
|
if decision == "approved":
|
||||||
|
if item.get("status") != "stored":
|
||||||
|
raise ValueError("only a stored asset can be approved")
|
||||||
|
if entity_type not in {"fish", "waterbody", "tackle", "reference"} or not entity_key:
|
||||||
|
raise ValueError("approved asset requires entity type and canonical key")
|
||||||
|
item.update({"entity_type": entity_type, "entity_key": entity_key})
|
||||||
|
item.update({
|
||||||
|
"status": decision,
|
||||||
|
"reviewed_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"review_note": note or None,
|
||||||
|
})
|
||||||
|
path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_image(body: bytes) -> tuple[int, int, str]:
|
||||||
|
try:
|
||||||
|
with Image.open(io.BytesIO(body)) as image:
|
||||||
|
width, height = image.size
|
||||||
|
image_format = image.format
|
||||||
|
image.verify()
|
||||||
|
except (UnidentifiedImageError, OSError, ValueError) as exc:
|
||||||
|
raise ValueError("asset is not a valid raster image") from exc
|
||||||
|
if width < 1 or height < 1 or width * height > MAX_IMAGE_PIXELS:
|
||||||
|
raise ValueError("asset dimensions are outside safe limits")
|
||||||
|
mime = Image.MIME.get(image_format or "")
|
||||||
|
if mime not in {"image/jpeg", "image/png", "image/webp", "image/gif"}:
|
||||||
|
raise ValueError(f"unsupported image format: {image_format}")
|
||||||
|
return width, height, mime
|
||||||
|
|
||||||
|
|
||||||
|
def store_asset(root: Path, body: bytes, *, content_type: str, source_url: str) -> tuple[str, str, int, int, str]:
|
||||||
|
width, height, detected_mime = inspect_image(body)
|
||||||
|
declared_mime = content_type.split(";", 1)[0]
|
||||||
|
if declared_mime != detected_mime:
|
||||||
|
raise ValueError(f"image MIME mismatch: declared {declared_mime}, detected {detected_mime}")
|
||||||
|
digest = hashlib.sha256(body).hexdigest()
|
||||||
|
extension = mimetypes.guess_extension(detected_mime) or Path(urlsplit(source_url).path).suffix
|
||||||
|
extension = ".jpg" if extension == ".jpe" else extension
|
||||||
|
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)), width, height, detected_mime
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
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 .media_assets import extract_media_candidates, merge_manifest, reclassify_manifest, review_asset, 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)
|
||||||
|
attempted_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
try:
|
||||||
|
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, 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"
|
||||||
|
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
|
||||||
|
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}"
|
||||||
|
|
||||||
|
|
||||||
|
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("--review-url", help="Review an asset already present in the manifest")
|
||||||
|
parser.add_argument("--decision", choices=("approved", "rejected"))
|
||||||
|
parser.add_argument("--entity-type", choices=("fish", "waterbody", "tackle", "reference"))
|
||||||
|
parser.add_argument("--entity-key")
|
||||||
|
parser.add_argument("--note")
|
||||||
|
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.review_url:
|
||||||
|
if not args.decision:
|
||||||
|
parser.error("--decision is required with --review-url")
|
||||||
|
item = review_asset(args.root / "manifest.json", asset_url=args.review_url, decision=args.decision, entity_type=args.entity_type, entity_key=args.entity_key, note=args.note)
|
||||||
|
print(f"{item['status']} {item['asset_url']}")
|
||||||
|
return 0
|
||||||
|
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())
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from rf4_research.media_assets import extract_media_candidates, inspect_image, merge_manifest, review_asset, 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
|
||||||
|
image = io.BytesIO()
|
||||||
|
Image.new("RGB", (3, 2), "green").save(image, format="PNG")
|
||||||
|
digest, relative, width, height, mime = store_asset(tmp_path, image.getvalue(), content_type="image/png", source_url=item.asset_url)
|
||||||
|
assert len(digest) == 64
|
||||||
|
assert (tmp_path / relative).read_bytes() == image.getvalue()
|
||||||
|
assert (width, height, mime) == (3, 2, "image/png")
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_inspection_rejects_invalid_body_and_mime_mismatch(tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(ValueError, match="valid raster"):
|
||||||
|
inspect_image(b"not an image")
|
||||||
|
image = io.BytesIO()
|
||||||
|
Image.new("RGB", (1, 1)).save(image, format="PNG")
|
||||||
|
with pytest.raises(ValueError, match="MIME mismatch"):
|
||||||
|
store_asset(tmp_path, image.getvalue(), content_type="image/jpeg", source_url="https://example.test/a.jpg")
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_requires_stored_asset_and_canonical_mapping(tmp_path: Path) -> None:
|
||||||
|
item = extract_media_candidates('<img src="/fish/pike.png" alt="Щука">', source_page="https://example.test")[0]
|
||||||
|
path = tmp_path / "manifest.json"
|
||||||
|
manifest = merge_manifest(path, [item])
|
||||||
|
with pytest.raises(ValueError, match="stored"):
|
||||||
|
review_asset(path, asset_url=item.asset_url, decision="approved", entity_type="fish", entity_key="pike")
|
||||||
|
manifest["assets"][0]["status"] = "stored"
|
||||||
|
path.write_text(__import__("json").dumps(manifest), encoding="utf-8")
|
||||||
|
with pytest.raises(ValueError, match="canonical key"):
|
||||||
|
review_asset(path, asset_url=item.asset_url, decision="approved", entity_type="fish")
|
||||||
|
reviewed = review_asset(path, asset_url=item.asset_url, decision="approved", entity_type="fish", entity_key="pike", note="matched by name")
|
||||||
|
assert (reviewed["status"], reviewed["entity_key"]) == ("approved", "pike")
|
||||||
Reference in New Issue
Block a user