feat: add canonical spot URLs

This commit is contained in:
ik
2026-09-07 16:43:12 +07:00
parent 233c5887eb
commit 4e037eb7e8
8 changed files with 33 additions and 8 deletions
+2
View File
@@ -14,6 +14,8 @@ 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.
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL.
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены. Изолированные проверки `deploy/test-production-bootstrap.sh` и `deploy/test-backup-restore.sh` подтверждают старт с пустых volumes и восстановление данных.
+13
View File
@@ -135,6 +135,19 @@ def _spot_or_404(db: Session, spot_id: UUID) -> Spot:
return spot
@app.get("/api/v1/spots/resolve", response_model=SpotOut)
def resolve_spot(
db: Db, waterbody: str, x: int = Query(ge=-10_000, le=10_000),
y: int = Query(ge=-10_000, le=10_000),
) -> SpotOut:
spot = db.scalar(select(Spot).options(joinedload(Spot.waterbody)).join(Spot.waterbody).where(
Waterbody.slug == waterbody, Spot.x == x, Spot.y == y,
))
if spot is None:
raise HTTPException(status_code=404, detail="spot not found")
return spot_detail(spot.id, db)
@app.get("/api/v1/spots/{spot_id}", response_model=SpotOut)
def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
spot = _spot_or_404(db, spot_id)
+3
View File
@@ -83,6 +83,9 @@ def test_spot_detail_and_catches() -> None:
assert catches.status_code == 200
assert len(catches.json()) == 3
assert catches.json()[0]["source_system"] == "manual-import"
resolved = client.get("/api/v1/spots/resolve?waterbody=test-lake&x=10&y=20")
assert resolved.status_code == 200
assert resolved.json()["id"] == spot_id
def test_records_list_is_empty_before_import() -> None:
+2 -2
View File
@@ -1,13 +1,13 @@
---
import type { Activity } from "../lib/api";
import { activityLevel, ago, kg, plural } from "../lib/api";
import { activityLevel, ago, kg, plural, spotPath } from "../lib/api";
import FishingIcon from "./FishingIcon.astro";
import SourceBadge from "./SourceBadge.astro";
const { item } = Astro.props as { item: Activity };
const level = activityLevel(item.activity_score);
const limited = item.catches < 3;
---
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={`/spots/${item.spot_id}`}>
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={spotPath(item)}>
<span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
<div class="spot-main"><div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill" data-activity-level={level.short}><i></i>{level.short}</span>{limited && <span class="data-quality">Данных мало</span>}</div><h3>{item.fish}</h3><div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div><div class="source-strip" aria-label="Источники данных">{item.sources.map(source => <SourceBadge source={source}/>)}</div><p class="data-note">{item.explanation}</p><div class="bait-line"><FishingIcon name="lure" size={25}/><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div></div>
<div class="spot-stats"><div><strong>{item.catches}</strong><span>{plural(item.catches, ["улов", "улова", "уловов"])}</span></div><div><strong>{item.unique_players}</strong><span>{plural(item.unique_players, ["игрок", "игрока", "игроков"])}</span></div><div><strong>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{item.confidence_score}%</strong><span>уверенность</span></div></div><span class="card-arrow"><FishingIcon name="arrow" size={22}/></span>
+2
View File
@@ -13,6 +13,8 @@ export type OfficialRecord = { id: string; fish: string; weight_g: number; water
export type PublicObservation = { id: string; source_system: string; source_name: string; source_url: string; fish_name: string; waterbody_name: string; x: number | null; y: number | null; weight_g: number | null; last_seen_at: string; missing_fields: string[]; quality: "incomplete" | "unverified" };
export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null };
export const spotPath = (item: Pick<Activity, "waterbody_slug" | "x" | "y">) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`;
export { activityLevel, ago, kg, plural } from "./presentation";
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
+2 -2
View File
@@ -1,5 +1,5 @@
import type { APIRoute } from "astro";
import { api, type Activity, type DictionaryItem } from "../lib/api";
import { api, spotPath, type Activity, type DictionaryItem } from "../lib/api";
const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, character => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" })[character] ?? character);
@@ -11,7 +11,7 @@ export const GET: APIRoute = async ({ site }) => {
paths.add("/fish"); paths.add("/waterbodies");
fishes.forEach(item => paths.add(`/fish/${item.slug}`));
waters.forEach(item => paths.add(`/waterbodies/${item.slug}`));
activity.forEach(item => paths.add(`/spots/${item.spot_id}`));
activity.forEach(item => paths.add(spotPath(item)));
activity.forEach(item => paths.add(`/waterbodies/${item.waterbody_slug}/${item.fish_slug}`));
} catch {
// A temporary API outage must not make the static part of the sitemap unavailable.
+8 -3
View File
@@ -5,14 +5,19 @@ import { activityLevel, api, kg, plural, type Activity, type Catch, type Spot }
const { id } = Astro.params;
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
try {
const [spotResult, catchResult, activityRows] = await Promise.all([api<Spot>(`/api/v1/spots/${id}`), api<Catch[]>(`/api/v1/spots/${id}/catches`), api<Activity[]>("/api/v1/activity?hours=24&limit=100")]);
spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === id) ?? null;
const readable = id?.match(/^(.+)-(-?\d+)x(-?\d+)$/);
const spotResult = readable
? await api<Spot>(`/api/v1/spots/resolve?waterbody=${encodeURIComponent(readable[1])}&x=${readable[2]}&y=${readable[3]}`)
: await api<Spot>(`/api/v1/spots/${id}`);
if (!readable) return Astro.redirect(`/spots/${spotResult.waterbody_slug}-${spotResult.x}x${spotResult.y}`, 301);
const [catchResult, activityRows] = await Promise.all([api<Catch[]>(`/api/v1/spots/${spotResult.id}/catches`), api<Activity[]>("/api/v1/activity?hours=24&limit=100")]);
spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === spotResult.id) ?? null;
} catch { unavailable = true; }
const level = activity ? activityLevel(activity.activity_score) : null;
const spotDescription = spot ? `Свежие уловы и активность на точке ${spot.x}:${spot.y}, ${spot.waterbody}: рыба, вес, приманки и источники данных.` : "Данные точки ловли Russian Fishing 4.";
const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [
{ "@type": "ListItem", position: 1, name: "Сейчас клюёт", item: "https://rf4spotter.ru/" },
{ "@type": "ListItem", position: 2, name: `${spot.waterbody} ${spot.x}:${spot.y}`, item: `https://rf4spotter.ru/spots/${id}` },
{ "@type": "ListItem", position: 2, name: `${spot.waterbody} ${spot.x}:${spot.y}`, item: `https://rf4spotter.ru/spots/${spot.waterbody_slug}-${spot.x}x${spot.y}` },
] } : null;
---
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs}>
+1 -1
View File
@@ -118,7 +118,7 @@
- [x] Добавить JSON-LD: `WebSite`, `Dataset` и `BreadcrumbList` на соответствующих страницах (7 сентября 2026).
- [x] Сделать собственную полезную страницу 404 с возвратом к свежим точкам (7 сентября 2026).
- [x] Явно отдавать `noindex` на административных страницах и состояниях ошибок (7 сентября 2026).
- [ ] Добавить человекочитаемые постоянные URL точек без поломки существующих UUID-ссылок.
- [x] Добавить постоянные URL точек `/spots/{waterbody}-{x}x{y}`, перевести карточки и sitemap, а старые UUID-ссылки сохранить через 301 redirect (7 сентября 2026).
- [x] Создать индексируемые каталоги и страницы `/fish/{slug}`, `/waterbodies/{slug}` и сочетания водоём + рыба с уникальными метаданными и включением в sitemap (7 сентября 2026).
### Графические элементы и объяснение данных