diff --git a/README.md b/README.md index 6013973..65971bb 100644 --- a/README.md +++ b/README.md @@ -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 и восстановление данных. diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 9d9406b..0da4a98 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -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) diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 0d3ce04..65119e4 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -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: diff --git a/apps/web/src/components/ActivityCard.astro b/apps/web/src/components/ActivityCard.astro index 8c74183..91cce12 100644 --- a/apps/web/src/components/ActivityCard.astro +++ b/apps/web/src/components/ActivityCard.astro @@ -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; --- - + {String(item.activity_score).padStart(2,"0")} {item.waterbody}{level.short}{limited && Данных мало}{item.fish} {item.x}:{item.y} {ago(item.last_confirmed_at)}{item.sources.map(source => )}{item.explanation}Работает сейчас{item.best_bait ?? "не указана"} {item.catches}{plural(item.catches, ["улов", "улова", "уловов"])}{item.unique_players}{plural(item.unique_players, ["игрок", "игрока", "игроков"])}{kg(item.average_weight_g)}средний вес{item.confidence_score}%уверенность diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 5be2b72..a21f7d9 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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) => `/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"; diff --git a/apps/web/src/pages/sitemap.xml.ts b/apps/web/src/pages/sitemap.xml.ts index ba1ea85..53befdc 100644 --- a/apps/web/src/pages/sitemap.xml.ts +++ b/apps/web/src/pages/sitemap.xml.ts @@ -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 => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ })[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. diff --git a/apps/web/src/pages/spots/[id].astro b/apps/web/src/pages/spots/[id].astro index 9670f35..888a02b 100644 --- a/apps/web/src/pages/spots/[id].astro +++ b/apps/web/src/pages/spots/[id].astro @@ -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(`/api/v1/spots/${id}`), api(`/api/v1/spots/${id}/catches`), api("/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(`/api/v1/spots/resolve?waterbody=${encodeURIComponent(readable[1])}&x=${readable[2]}&y=${readable[3]}`) + : await api(`/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(`/api/v1/spots/${spotResult.id}/catches`), api("/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; --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0003427..5979e4d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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). ### Графические элементы и объяснение данных
{item.explanation}