feat: add unique waterbody fingerprints
This commit is contained in:
@@ -34,7 +34,7 @@ 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` опубликована легенда цветов всех источников и статусов качества.
|
||||
|
||||
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Каждый улов показывает источник, относительную свежесть и точное время UTC; время получения явно отделено от времени улова. Каталоги оформлены как полевой атлас: тёмный seal показывает объём справочника, карточки рыб используют SVG-силуэты, карточки водоёмов — абстрактные береговые контуры и точку наблюдения. Пустые состояния используют статичную CSS-иллюстрацию поплавка; смысловые анимации полностью учитывают системное ограничение движения.
|
||||
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Каждый улов показывает источник, относительную свежесть и точное время UTC; время получения явно отделено от времени улова. Каталоги оформлены как полевой атлас: тёмный seal показывает объём справочника, карточки рыб используют смысловые SVG-силуэты, а каждый водоём — собственный детерминированный абстрактный отпечаток берега, волн, точки и индекса. Это не карта и не игровая география. Пустые состояния используют статичную 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 задаются переменными окружения.
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
import FishingIcon from "./FishingIcon.astro";
|
||||
import FishSilhouette from "./FishSilhouette.astro";
|
||||
const { eyebrow, title, description, variant, count } = Astro.props as { eyebrow: string; title: string; description?: string; variant?: "water" | "fish"; count?: number };
|
||||
import WaterbodyMark from "./WaterbodyMark.astro";
|
||||
const { eyebrow, title, description, variant, count, identity = title } = Astro.props as { eyebrow: string; title: string; description?: string; variant?: "water" | "fish"; count?: number; identity?: string };
|
||||
---
|
||||
<section class:list={["catalog-hero content-grid", { "catalog-hero--illustrated": variant }]}>
|
||||
<div class="catalog-hero__copy"><span class="overline">{eyebrow}</span><h1>{title}</h1>{description && <p>{description}</p>}</div>
|
||||
{variant && <div class="catalog-hero__seal" aria-hidden="true"><span>{variant === "water" ? <FishingIcon name="ripple" size={54} /> : <FishSilhouette name={title} size={92} />}</span>{typeof count === "number" && <><strong>{String(count).padStart(2,"0")}</strong><small>{variant === "water" ? "водоёмов" : "видов рыб"}</small></>}</div>}
|
||||
{variant && <div class="catalog-hero__seal" aria-hidden="true"><span>{variant === "water" ? <WaterbodyMark identity={identity} size={94} /> : <FishSilhouette name={identity} size={92} />}</span>{typeof count === "number" && <><strong>{String(count).padStart(2,"0")}</strong><small>{variant === "water" ? "водоёмов" : "видов рыб"}</small></>}</div>}
|
||||
</section>
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
---
|
||||
const { name, size = 112 } = Astro.props as { name: string; size?: number };
|
||||
const variant = [...name].reduce((sum, char) => sum + (char.codePointAt(0) ?? 0), 0) % 3;
|
||||
import { waterbodyVisual } from "../lib/waterbody-visuals";
|
||||
const { identity, size = 112 } = Astro.props as { identity: string; size?: number };
|
||||
const visual = waterbodyVisual(identity);
|
||||
const shores = [
|
||||
"M8 57C22 42 34 48 47 34S75 20 91 34s16 7 25-3",
|
||||
"M7 48c17-20 34 5 50-10s28-15 38 1 13 14 22 9",
|
||||
"M5 54c14-4 20-24 39-18s27-9 40-14 20 11 33 10",
|
||||
"M7 42c14 8 25-17 42-8s23 16 35 2 20-13 34-6",
|
||||
"M6 51c16-24 31 7 47-8s32-17 41 3 14 11 24 4",
|
||||
"M8 38c19-8 26 19 46 8s30-25 42-8 14 11 22 7",
|
||||
"M5 56c11-19 25-20 39-5s25-1 37-16 25-10 37 2",
|
||||
"M6 47c18 15 31-18 50-5s27 9 37-5 17-15 27-5",
|
||||
];
|
||||
---
|
||||
<svg class="waterbody-mark" width={size} height={Math.round(size * .62)} viewBox="0 0 124 72" aria-hidden="true">
|
||||
<path class="waterbody-mark__shore" d={shores[variant]} />
|
||||
<path d="M12 61c18-7 31 6 49-2s31-6 51 0" />
|
||||
<path d="M24 68c13-4 25 3 39-2s25-3 37 0" />
|
||||
<circle cx={variant === 1 ? 82 : 51} cy={variant === 2 ? 25 : 30} r="3" />
|
||||
<path class="waterbody-mark__shore" d={shores[visual.shore]} />
|
||||
{visual.waves >= 1 && <path d="M12 59c18-7 31 6 49-2s31-6 51 0" />}
|
||||
{visual.waves >= 2 && <path d="M20 65c14-5 27 4 42-2s27-4 43 0" />}
|
||||
{visual.waves >= 3 && <path d="M31 70c11-3 21 2 32-1s20-2 30 0" />}
|
||||
<circle cx={visual.markerX} cy={visual.markerY} r="3" />
|
||||
<text x="111" y="15" text-anchor="end">{visual.code}</text>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
text { fill: currentColor; stroke: none; font: 700 8px Inter, sans-serif; letter-spacing: .08em; }
|
||||
:global(.catalog-hero__seal) .waterbody-mark { position: static; right: auto; bottom: auto; opacity: 1; color: var(--lime); stroke: currentColor; }
|
||||
:global(.catalog-hero__seal) text { fill: var(--white); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export type WaterbodyVisual = {
|
||||
shore: number;
|
||||
waves: number;
|
||||
markerX: number;
|
||||
markerY: number;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export function stableVisualHash(identity: string): number {
|
||||
return [...identity.trim().toLocaleLowerCase("ru")]
|
||||
.reduce((hash, char) => Math.imul(hash ^ (char.codePointAt(0) ?? 0), 16777619) >>> 0, 2166136261);
|
||||
}
|
||||
|
||||
export function waterbodyVisual(identity: string): WaterbodyVisual {
|
||||
const hash = stableVisualHash(identity || "waterbody");
|
||||
return {
|
||||
shore: hash % 8,
|
||||
waves: 1 + ((hash >>> 4) % 3),
|
||||
markerX: 32 + ((hash >>> 8) % 59),
|
||||
markerY: 18 + ((hash >>> 15) % 23),
|
||||
code: hash.toString(36).toUpperCase().padStart(2, "0").slice(-2),
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,6 @@ const schema = fish ? { "@context":"https://schema.org", "@type":"CollectionPage
|
||||
---
|
||||
<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>
|
||||
<PageHero eyebrow="Свежие данные за 72 часа" title={fish?.name_ru ?? "Рыба не найдена"} description={fish ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} на ${waters.length} ${plural(waters.length,["водоёме","водоёмах","водоёмах"])}.` : undefined} />
|
||||
<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>}
|
||||
</Layout>
|
||||
|
||||
@@ -22,6 +22,6 @@ const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPag
|
||||
---
|
||||
<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>
|
||||
<PageHero eyebrow="Свежие данные за 72 часа" title={water?.name_ru ?? "Водоём не найден"} description={water ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} для ${fishes.length} ${plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.` : undefined} />
|
||||
<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>}
|
||||
</Layout>
|
||||
|
||||
@@ -14,5 +14,5 @@ if (unavailable) {
|
||||
---
|
||||
<Layout title="Все водоёмы Russian Fishing 4 — RF4 Spotter" description="Каталог водоёмов RF4 со свежими точками, рыбами, приманками и прозрачными источниками данных.">
|
||||
<PageHero eyebrow="Карта водоёмов RF4" title="Водоёмы" description="Откройте водоём, чтобы увидеть активные виды рыб и последние подтверждённые точки." variant="water" count={waters.length} />
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid catalog-grid--waters content-grid" aria-label="Водоёмы">{waters.map((water,index) => <a href={`/waterbodies/${water.slug}`}><span>Водоём · {String(index + 1).padStart(2,"0")}</span><strong>{water.name_ru}</strong><i>Открыть <b>→</b></i><WaterbodyMark name={water.name_ru}/></a>)}</nav>}
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid catalog-grid--waters content-grid" aria-label="Водоёмы">{waters.map((water,index) => <a href={`/waterbodies/${water.slug}`}><span>Водоём · {String(index + 1).padStart(2,"0")}</span><strong>{water.name_ru}</strong><i>Открыть <b>→</b></i><WaterbodyMark identity={water.slug}/></a>)}</nav>}
|
||||
</Layout>
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
import { activityLevel, plural } from "../../src/lib/presentation.ts";
|
||||
import { fishVisualFamily } from "../../src/lib/fish-visuals.ts";
|
||||
import { tackleVisualKind, tackleVisualTone } from "../../src/lib/tackle-visuals.ts";
|
||||
import { waterbodyVisual } from "../../src/lib/waterbody-visuals.ts";
|
||||
|
||||
test("activity levels share one complete 0-100 scale", () => {
|
||||
assert.deepEqual(
|
||||
@@ -34,3 +35,14 @@ test("tackle glyphs only infer a type from explicit name terms", () => {
|
||||
);
|
||||
assert.equal(tackleVisualTone("Spiker #2"), tackleVisualTone("spiker #2"));
|
||||
});
|
||||
|
||||
test("waterbody fingerprints are stable, bounded and entity-specific", () => {
|
||||
const kuori = waterbodyVisual("kuori");
|
||||
assert.deepEqual(kuori, waterbodyVisual(" KUORI "));
|
||||
assert.notDeepEqual(kuori, waterbodyVisual("vyunok"));
|
||||
assert.ok(kuori.shore >= 0 && kuori.shore < 8);
|
||||
assert.ok(kuori.waves >= 1 && kuori.waves <= 3);
|
||||
assert.ok(kuori.markerX >= 32 && kuori.markerX <= 90);
|
||||
assert.ok(kuori.markerY >= 18 && kuori.markerY <= 40);
|
||||
assert.match(kuori.code, /^[0-9A-Z]{2}$/);
|
||||
});
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
- [x] **B07 · Аудит официальных изображений.** Текущий records-parser получает названия рыб/водоёмов и `title` приманки, но не image URL; официальные gallery/userguide содержат тематические медиа без устойчивого полного соответствия каноническим сущностям. Изображения не хотлинкать и не считать разрешение на данные автоматическим разрешением на медиапубликацию.
|
||||
- [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, лидере, уловах, рекордах и списке лучших приманок, не выдавая категорию за точную модель.
|
||||
- [ ] **B10 · Визуальные отпечатки водоёмов.** Расширить текущие три береговых контура до детерминированного набора по slug: форма берега, число волн, положение точки и индексный код. Явно обозначать их как абстрактный знак, не карту и не игровую географию.
|
||||
- [x] **B10 · Визуальные отпечатки водоёмов.** Для каждого slug воспроизводимо выбираются один из восьми береговых контуров, число волн, положение точки и двухсимвольный индекс. Знак используется в каталоге и detail-hero; это явно абстрактный отпечаток, а не карта или игровая география.
|
||||
- [ ] **B11 · Опциональный media pipeline.** После отдельного письменного разрешения на изображения добавить allowlist официальных asset URL, локальное сохранение без hotlink, content hash, MIME/dimensions, attribution/source URL, дату получения и fallback на SVG. Изменение/удаление оригинала должно отправлять media asset на повторную проверку, а не молча менять публичную карточку.
|
||||
|
||||
- [ ] **Q01 · Документы источников.** Приложить или дать устойчивые ссылки на первичные разрешения RF4DB, RF4-STAT, RF4MAP и RF4 Posts; для каждого зафиксировать атрибуцию, точный production-лимит, срок хранения и процедуру удаления.
|
||||
|
||||
@@ -58,6 +58,8 @@ CTA задаются смыслом через `data-action`: `primary` — ос
|
||||
|
||||
Приманки различаются типовым контуром только при явном указании категории в названии: блесна, воблер, мягкая приманка, бойл, естественная наживка или оснастка. Неизвестное название получает нейтральную каплевидную форму. Один из пяти спокойных акцентных цветов детерминирован normalized name и помогает повторно узнавать строку, но не кодирует эффективность, качество или игровую характеристику.
|
||||
|
||||
Водоём получает воспроизводимый абстрактный отпечаток по каноническому slug: один из восьми контуров берега, от одной до трёх линий воды, положение точки и двухсимвольный индекс. Он помогает узнавать сущность между каталогом и detail-страницей, но не изображает карту, реальные координаты или форму игрового водоёма. Текстовое название всегда остаётся главным идентификатором.
|
||||
|
||||
## 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, светлая поверхность сайта и тёмные бренд-носители.
|
||||
|
||||
Reference in New Issue
Block a user