From 0f9c255596afba77ba560b6fbcd5542f28ed7efc Mon Sep 17 00:00:00 2001 From: IK Date: Mon, 7 Sep 2026 18:45:16 +0700 Subject: [PATCH] feat: group duplicate field signals --- README.md | 2 +- apps/web/src/components/SignalFeed.astro | 34 ++++++++++++++++++++++++ apps/web/src/pages/index.astro | 3 ++- docs/ROADMAP.md | 2 +- 4 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/SignalFeed.astro diff --git a/README.md b/README.md index f2d0503..39f0632 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ RF4 Spotter — неофициальный сервис свежих точек RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации. -На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. +На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. Совпадающие рыба, водоём, координаты и вес объединяются визуально, при этом карточка сохраняет все уникальные ссылки на исходные наблюдения. Базовый SEO-контур готов для `rf4spotter.ru`: страницы имеют уникальные метаданные, canonical, Open Graph/Twitter Card, фирменное изображение 1200×630 и JSON-LD; доступны динамические `/robots.txt` и `/sitemap.xml`, административные и ошибочные страницы закрыты от индексации, добавлена собственная страница 404. Индексируемые каталоги рыб и водоёмов, detail-страницы и сочетания водоём + рыба строятся из актуального разрешённого справочника и включаются в sitemap. Подключены фирменные favicon/app icons, web manifest и production-кэширование статических ресурсов. Карточки активности показывают единый паспорт данных: источники, свежесть, полноту и уровень доверия. На `/status` опубликована легенда цветов всех источников и статусов качества. diff --git a/apps/web/src/components/SignalFeed.astro b/apps/web/src/components/SignalFeed.astro new file mode 100644 index 0000000..bc75f11 --- /dev/null +++ b/apps/web/src/components/SignalFeed.astro @@ -0,0 +1,34 @@ +--- +import SourceBadge from "./SourceBadge.astro"; +import { ago, kg, type PublicObservation } from "../lib/api"; +const { signals } = Astro.props as { signals: PublicObservation[] }; +const groups = [...signals.reduce((map, signal) => { + const key = [signal.fish_name, signal.waterbody_name, signal.x, signal.y, signal.weight_g].join("|").toLocaleLowerCase("ru"); + const current = map.get(key); + if (!current) map.set(key, { ...signal, observations: [signal] }); + else { + current.observations.push(signal); + current.last_seen_at = new Date(signal.last_seen_at) > new Date(current.last_seen_at) ? signal.last_seen_at : current.last_seen_at; + current.missing_fields = [...new Set([...current.missing_fields, ...signal.missing_fields])]; + if (signal.quality === "incomplete") current.quality = "incomplete"; + } + return map; +}, new Map()).values()]; +--- +
+
Сырые данные источников

Полевые сигналы

Показываем сразу, даже если часть полей отсутствует. Эти карточки не участвуют в расчёте активности до полного подтверждения.

{signals.length} сигналов · {groups.length} сюжетов
+
{groups.map(signal => { + const provenance = [...new Map(signal.observations.map(item => [`${item.source_system}|${item.source_url}`, item])).values()]; + return
+
{provenance.map(item => )}
{signal.quality === "incomplete" ? "!" : "?"} {signal.quality === "incomplete" ? "Неполные данные" : "Ждёт проверки"}
+

{signal.fish_name}

{signal.waterbody_name}

+
Точка
{signal.x == null || signal.y == null ? "не указана" : `${signal.x}:${signal.y}`}
Вес
{signal.weight_g == null ? "не указан" : kg(signal.weight_g)}
Получено
{ago(signal.last_seen_at)}
+ {signal.observations.length > 1 &&

Совпали {signal.observations.length} наблюдения · все источники сохранены

} +

{signal.missing_fields.length ? "Не хватает:" : "Статус:"} {signal.missing_fields.join(", ") || "проверяется соответствие справочнику"}

+
; + })}
+
+ diff --git a/apps/web/src/pages/index.astro b/apps/web/src/pages/index.astro index f7ea059..50ede5e 100644 --- a/apps/web/src/pages/index.astro +++ b/apps/web/src/pages/index.astro @@ -3,6 +3,7 @@ import Layout from "../layouts/Layout.astro"; import ActivityCard from "../components/ActivityCard.astro"; import FishingIcon from "../components/FishingIcon.astro"; import SourceBadge from "../components/SourceBadge.astro"; +import SignalFeed from "../components/SignalFeed.astro"; import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PublicObservation } from "../lib/api"; const params = Astro.url.searchParams; @@ -58,7 +59,7 @@ const datasetJsonLd = {
За выбранный период

Горячие точки

{items.length} {plural(items.length, ["точка", "точки", "точек"])}
{filterError ?

Некорректные фильтры

Выберите период и сортировку из предложенных значений.

Сбросить фильтры
: unavailable ?

Источник временно недоступен

Не показываем устаревшие догадки. Попробуйте позже.

: items.length ?
{items.map(item => )}
:

Пока нет свежих данных

Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.

}
{items[0] && leaderLevel && }
- {signals.length > 0 &&
Сырые данные источников

Полевые сигналы

Показываем сразу, даже если часть полей отсутствует. Эти карточки не участвуют в расчёте активности до полного подтверждения.

{signals.length} новых
{signals.map(signal =>
{signal.quality === "incomplete" ? "!" : "?"} {signal.quality === "incomplete" ? "Неполные данные" : "Ждёт проверки"}

{signal.fish_name}

{signal.waterbody_name}

Точка
{signal.x == null || signal.y == null ? "не указана" : `${signal.x}:${signal.y}`}
Вес
{signal.weight_g == null ? "не указан" : kg(signal.weight_g)}
Получено
{ago(signal.last_seen_at)}

{signal.missing_fields.length ? "Не хватает:" : "Статус:"} {signal.missing_fields.join(", ") || "проверяется соответствие справочнику"}

)}
} + {signals.length > 0 && } {hasMoreSignals && signalLimit < 48 && Показать ещё {signalLimit} из доступных}
Как читать данные

Не обещаем рыбу.
Показываем факты.

01

Свежесть

Чем старше сообщение, тем меньше оно влияет на активность.

02

Разные игроки

Десять уловов одного человека не равны десяти подтверждениям.

03

Уверенность

Каждая оценка объясняет, сколько данных за ней стоит.