feat: establish public SEO foundation
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-07 13:29:13 +07:00
parent 9275799ce7
commit 494a37ce8f
14 changed files with 160 additions and 12 deletions
+37 -2
View File
@@ -1,12 +1,47 @@
---
import "../styles/global.css";
import FishingIcon from "../components/FishingIcon.astro";
const { title = "RF4 Spotter" } = Astro.props;
const {
title = "RF4 Spotter — свежие точки клёва Russian Fishing 4",
description = "Свежие точки, уловы и честная оценка активности в Russian Fishing 4 с обязательным указанием источников.",
noindex = false,
image = "/lake-dawn.png",
structuredData = null,
} = Astro.props;
const path = Astro.url.pathname;
const siteUrl = import.meta.env.PUBLIC_SITE_URL || "https://rf4spotter.ru";
const canonical = new URL(path, siteUrl).toString();
const socialImage = new URL(image, siteUrl).toString();
const preventIndexing = noindex || path.startsWith("/admin/");
const websiteJsonLd = { "@type": "WebSite", name: "RF4 Spotter", url: siteUrl, inLanguage: "ru" };
const jsonLd = JSON.stringify({
"@context": "https://schema.org",
"@graph": structuredData ? [websiteJsonLd, structuredData] : [websiteJsonLd],
}).replaceAll("<", "\\u003c");
---
<!doctype html>
<html lang="ru">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width" /><meta name="description" content="Свежие точки и статистика клёва Russian Fishing 4" /><link rel="icon" href="/favicon.svg" /><title>{title}</title></head>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta name="description" content={description} />
<meta name="robots" content={preventIndexing ? "noindex, nofollow" : "index, follow, max-image-preview:large"} />
<link rel="canonical" href={canonical} />
<link rel="icon" href="/favicon.svg" />
<meta property="og:locale" content="ru_RU" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="RF4 Spotter" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={socialImage} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={socialImage} />
<script type="application/ld+json" set:html={jsonLd} is:inline />
<title>{title}</title>
</head>
<body>
<a class="skip-link" href="#main-content">Перейти к содержимому</a>
<header class="topbar">
+8
View File
@@ -0,0 +1,8 @@
---
import Layout from "../layouts/Layout.astro";
import FishingIcon from "../components/FishingIcon.astro";
Astro.response.status = 404;
---
<Layout title="Страница не найдена — RF4 Spotter" description="Такой страницы нет. Вернитесь к свежим точкам клёва Russian Fishing 4." noindex={true}>
<section class="not-found content-grid"><div class="not-found__float"><FishingIcon name="float" size={54}/><strong>404</strong></div><span class="overline">Сошла с крючка</span><h1>Эта страница<br/><em>не клюнула.</em></h1><p>Возможно, ссылка устарела или такой точки больше нет. Свежие наблюдения уже ждут на главной.</p><a href="/">Вернуться к горячим точкам <FishingIcon name="arrow" size={18}/></a></section>
</Layout>
+8 -1
View File
@@ -26,8 +26,15 @@ const selectedFish = fishes.find(item => item.slug === fish)?.name_ru ?? "Люб
const periodLabel = { "6": "6 часов", "12": "12 часов", "24": "24 часа", "72": "72 часа" }[hours] ?? hours;
const sortLabel = { activity: "Сначала активные", confidence: "Сначала надёжные", freshness: "Сначала свежие" }[sort] ?? sort;
const filtersChanged = Boolean(waterbody || fish || hours !== "24" || sort !== "activity");
const datasetJsonLd = {
"@context": "https://schema.org", "@type": "Dataset",
name: "RF4 Spotter — наблюдения об активности рыбы",
description: "Агрегированные и исходные наблюдения по точкам ловли в Russian Fishing 4 с указанием происхождения и полноты данных.",
url: "https://rf4spotter.ru/", inLanguage: "ru",
creator: { "@type": "Organization", name: "RF4 Spotter" }, isAccessibleForFree: true,
};
---
<Layout title="Что клюёт сейчас — RF4 Spotter">
<Layout title="Что клюёт сейчас в Russian Fishing 4 — RF4 Spotter" description="Свежие точки клёва RF4, рабочие приманки, вес уловов и прозрачная оценка данных с указанием каждого источника." structuredData={datasetJsonLd}>
<section class="intro content-grid"><div class="intro-copy"><span class="eyebrow"><b>RF4</b> Живая карта клёва</span><h1>Выбирай место,<br/><em>пока клюёт.</em></h1><p>Свежие точки, рабочие приманки и честная оценка данных от игроков.</p></div><div class="lake-card"><img src="/lake-dawn.png" alt="Туманное озеро на рассвете"/><div class="lake-overlay"><div><span class="overline">Пульс водоёмов</span><strong>{items.length} {plural(items.length, ["точка показывает", "точки показывают", "точек показывают"])} активность</strong></div><div class="pulse-orb"><span></span></div></div></div></section>
<section class="filters-wrap"><form class="filters content-grid" method="get" action="/#results">
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(x => <option value={x.slug} selected={waterbody === x.slug}>{x.name_ru}</option>)}</select></label>
+1 -1
View File
@@ -1,7 +1,7 @@
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="Конфиденциальность — RF4 Spotter">
<Layout title="Политика конфиденциальности — RF4 Spotter" description="Как RF4 Spotter обрабатывает и удаляет данные пользовательских наблюдений, ники и скриншоты.">
<article class="legal-page"><span class="eyebrow">Открытая альфа · 7 сентября 2026</span><h1>Конфиденциальность</h1>
<p>RF4 Spotter собирает только сведения, необходимые для публикации и проверки рыболовных наблюдений, защиты от злоупотреблений и работы сервиса.</p>
<h2>Что обрабатывается</h2><ul><li>рыба, водоём, координаты, вес и необязательные сведения об улове;</li><li>необязательные ник и скриншот;</li><li>HMAC-отпечаток адреса для ограничения частоты — исходный IP не хранится;</li><li>источник, внешний идентификатор и URL импортированных наблюдений.</li></ul>
+1 -1
View File
@@ -9,7 +9,7 @@ let records: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryIt
try { [records, runs, fishes, waterbodies] = await Promise.all([api<OfficialRecord[]>(`/api/v1/records?${new URLSearchParams({ fish, waterbody })}`), api<ImportRun[]>("/api/v1/imports?limit=1"), api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); } catch { unavailable = true; }
const last = runs[0];
---
<Layout title="Официальные рекорды — RF4 Spotter">
<Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник.">
<section class="records-hero"><div><span class="eyebrow">Публичные данные RF4</span><h1>Официальные<br/><em>рекорды</em></h1></div><div class="source-status"><span class:list={["status-dot", last?.status]}></span><strong>{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}</strong>{last?.finished_at && <small>{new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк</small>}</div></section>
<form class="record-filters" method="get"><label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><button>Фильтровать</button>{(fish || waterbody) && <a href="/records">Сбросить</a>}</form>
<div class="section-heading content-grid"><div><span class="overline">Официальный источник</span><h2>Последние записи</h2></div><span class="result-count">{records.length} показано</span></div>
+1 -1
View File
@@ -6,7 +6,7 @@ try { [fishes, waterbodies] = await Promise.all([api<DictionaryItem[]>("/api/v1/
const state = Astro.url.searchParams.get("state");
const reportId = Astro.url.searchParams.get("report_id");
---
<Layout title="Добавить улов — RF4 Spotter">
<Layout title="Добавить улов Russian Fishing 4 — RF4 Spotter" description="Отправьте собственное наблюдение об улове RF4 на проверку и помогите игрокам находить актуальные точки.">
<section class="form-hero"><div><span class="eyebrow"><b>+1</b> Помочь сообществу</span><h1>Добавить<br/><em>свой улов</em></h1></div><p>Около минуты — и рабочая точка появится в общей статистике после проверки модератором.</p></section>
{state === "sent" && <div class="notice success">Улов отправлен на модерацию. Спасибо!</div>}
{state === "screenshot_sent" && <div class="notice success">Скриншот добавлен к ранее созданной заявке.</div>}
+16
View File
@@ -0,0 +1,16 @@
import type { APIRoute } from "astro";
export const GET: APIRoute = ({ site }) => {
const origin = site?.origin ?? import.meta.env.PUBLIC_SITE_URL ?? "https://rf4spotter.ru";
const body = [
"User-agent: *",
"Allow: /",
"Disallow: /admin/",
"Disallow: /api/",
"Disallow: /health",
"Disallow: /ready",
`Sitemap: ${origin}/sitemap.xml`,
"",
].join("\n");
return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "public, max-age=3600" } });
};
+1 -1
View File
@@ -1,7 +1,7 @@
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="Правила открытой альфы — RF4 Spotter">
<Layout title="Правила открытой альфы — RF4 Spotter" description="Правила отправки, проверки, атрибуции и удаления пользовательских наблюдений в RF4 Spotter.">
<article class="legal-page"><span class="eyebrow">Открытая альфа · 7 сентября 2026</span><h1>Правила участия</h1>
<p>RF4 Spotter — неофициальный экспериментальный сервис сообщества. Данные могут быть неполными или устаревшими и не гарантируют результат в игре.</p>
<h2>Можно</h2><ul><li>отправлять собственные достоверные наблюдения из Russian Fishing 4;</li><li>указывать необязательный ник и прикладывать свой скриншот;</li><li>сообщать об ошибках, устаревших точках и злоупотреблениях.</li></ul>
+19
View File
@@ -0,0 +1,19 @@
import type { APIRoute } from "astro";
import { api, type Activity } from "../lib/api";
const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, character => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" })[character] ?? character);
export const GET: APIRoute = async ({ site }) => {
const origin = site?.origin ?? import.meta.env.PUBLIC_SITE_URL ?? "https://rf4spotter.ru";
const paths = new Set(["/", "/records", "/report", "/rules", "/privacy"]);
try {
const activity = await api<Activity[]>("/api/v1/activity?hours=72&limit=100");
activity.forEach(item => paths.add(`/spots/${item.spot_id}`));
} catch {
// A temporary API outage must not make the static part of the sitemap unavailable.
}
const urls = [...paths].map(path => `<url><loc>${escapeXml(new URL(path, origin).toString())}</loc></url>`).join("");
return new Response(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`, {
headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=1800" },
});
};
+6 -1
View File
@@ -9,8 +9,13 @@ try {
spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === 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}` },
] } : null;
---
<Layout title={spot ? `${spot.waterbody} ${spot.x}:${spot.y} — RF4 Spotter` : "Точка — RF4 Spotter"}>
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs}>
<a class="back" href="/">← Все активные точки</a>
{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></div><div class="pin">{spot.x}<span>:</span>{spot.y}</div></section>
+1
View File
@@ -34,3 +34,4 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
.signal-section{padding:0 0 100px}.signal-heading{display:flex;align-items:end;justify-content:space-between;gap:35px;padding-top:68px;border-top:1px solid #cbd6ce;margin-bottom:25px}.signal-heading h2{font:400 46px/.95 Georgia,serif;letter-spacing:-.045em;margin:8px 0 10px}.signal-heading p{max-width:700px;color:#4d605d;line-height:1.55;margin:0}.signal-count{padding:7px 12px;border:1px solid #c2cec5;border-radius:999px;color:#526662;font-size:12px;white-space:nowrap}.signal-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:13px}.signal-card{position:relative;overflow:hidden;padding:20px;border:1px solid #d6cda9;border-radius:15px;background:linear-gradient(145deg,#fffdf5,#f8f5e9);box-shadow:0 10px 25px #443d1a0a}.signal-card:after{content:"";position:absolute;width:80px;height:80px;right:-40px;bottom:-40px;border:1px solid #bdac6b55;border-radius:50%;box-shadow:0 0 0 13px #bdac6b14}.signal-card__top{display:flex;justify-content:space-between;align-items:center;gap:8px}.quality-chip{display:inline-flex;align-items:center;gap:5px;color:#76570c;font-size:10px;font-weight:750}.quality-chip i{width:18px;height:18px;display:grid;place-items:center;border-radius:50%;background:#f2d77e;font-style:normal}.signal-card h3{font:400 24px Georgia,serif;margin:17px 0 3px}.signal-card>p{margin:0;color:#576966;font-size:13px}.signal-card dl{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:18px 0}.signal-card dl div{padding-top:9px;border-top:1px solid #d9d3b9}.signal-card dt{color:#776f56;font-size:9px;text-transform:uppercase;letter-spacing:.08em}.signal-card dd{margin:4px 0 0;font:400 14px Georgia,serif}.signal-card .missing-note{position:relative;z-index:1;padding:9px 11px;border-radius:8px;background:#efe5bd;color:#6b550f;font-size:11px}.missing-note span{font-weight:800}
@media(max-width:1040px){.signal-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:720px){.signal-heading{display:block;padding-top:45px}.signal-count{display:inline-block;margin-top:14px}.signal-grid{grid-template-columns:1fr}.signal-card__top{align-items:flex-start}.source-strip{margin-bottom:4px}}
.not-found{min-height:calc(100vh - 204px);display:flex;flex-direction:column;align-items:flex-start;justify-content:center;padding-block:70px}.not-found__float{width:130px;height:130px;display:grid;place-items:center;position:relative;margin-bottom:28px;border:1px solid #b8c8be;border-radius:50%;color:#55751e;box-shadow:0 0 0 16px #dce7d722}.not-found__float strong{position:absolute;right:-18px;bottom:5px;padding:5px 9px;border-radius:20px;background:var(--deep);color:var(--lime);font:italic 14px Georgia,serif}.not-found h1{margin:14px 0 22px;font:400 clamp(58px,8vw,105px)/.88 Georgia,serif;letter-spacing:-.06em}.not-found h1 em{color:#497074;font-weight:400}.not-found p{max-width:600px;color:#4d605d;font-size:17px;line-height:1.6}.not-found>a{display:flex;align-items:center;gap:10px;margin-top:18px;padding:14px 18px;border-radius:10px;background:var(--deep);color:#fff;font-weight:750;text-decoration:none}.not-found>a .fish-icon{color:var(--lime)}