feat: establish public SEO foundation
This commit is contained in:
@@ -2,6 +2,7 @@ import { defineConfig } from "astro/config";
|
||||
import node from "@astrojs/node";
|
||||
|
||||
export default defineConfig({
|
||||
site: process.env.PUBLIC_SITE_URL || "https://rf4spotter.ru",
|
||||
output: "server",
|
||||
adapter: node({ mode: "standalone" }),
|
||||
server: { host: true, port: 4321 },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { api, type Activity } from "../lib/api";
|
||||
|
||||
const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, character => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ })[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" },
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user