From dbc1dfdb8ab0a03f91966a238e3c16c9205e9df3 Mon Sep 17 00:00:00 2001 From: sindoring Date: Tue, 20 Oct 2026 19:17:49 +0400 Subject: [PATCH] seo --- .env.example | 6 + README.md | 2 + app/Http/Controllers/LandingController.php | 2 +- app/Http/Controllers/NewsController.php | 2 + app/Http/Controllers/SeoController.php | 27 +++ app/Http/Middleware/HandleInertiaRequests.php | 3 +- app/Services/SeoService.php | 133 +++++++++++ bootstrap/app.php | 26 +- config/seo.php | 7 + docs/seo.md | 65 +++++ public/robots.txt | 2 - resources/js/Pages/Admin/Accounts.vue | 4 +- resources/js/Pages/Admin/Balances.vue | 4 +- resources/js/Pages/Admin/LoginScreenNews.vue | 3 - resources/js/Pages/Admin/News.vue | 3 - resources/js/Pages/Admin/Shop.vue | 4 +- resources/js/Pages/Auth/Login.vue | 7 +- resources/js/Pages/Cabinet/Index.vue | 7 +- .../js/Pages/Landing/Components/About.vue | 6 +- .../js/Pages/Landing/Components/Hero.vue | 2 +- .../js/Pages/Landing/Components/Realm.vue | 2 +- resources/js/Pages/Landing/Main.vue | 6 +- resources/js/Pages/Legal/Offer.vue | 6 - resources/js/Pages/Legal/Privacy.vue | 6 - resources/js/Pages/Legal/Rules.vue | 6 - resources/js/Pages/News/Index.vue | 8 +- resources/js/Pages/News/Show.vue | 13 +- resources/js/app.js | 1 + resources/views/app.blade.php | 18 +- resources/views/seo/content.blade.php | 99 ++++++++ resources/views/seo/head.blade.php | 29 +++ resources/views/seo/sitemap.blade.php | 17 ++ routes/web.php | 3 + tests/Feature/LandingPageTest.php | 5 +- tests/Feature/SeoFeatureTest.php | 222 ++++++++++++++++++ 35 files changed, 662 insertions(+), 94 deletions(-) create mode 100644 app/Http/Controllers/SeoController.php create mode 100644 app/Services/SeoService.php create mode 100644 config/seo.php create mode 100644 docs/seo.md delete mode 100644 public/robots.txt create mode 100644 resources/views/seo/content.blade.php create mode 100644 resources/views/seo/head.blade.php create mode 100644 resources/views/seo/sitemap.blade.php create mode 100644 tests/Feature/SeoFeatureTest.php diff --git a/.env.example b/.env.example index 72b981d..d9a2944 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,12 @@ APP_URL=http://localhost:8080 APP_PORT=8080 VITE_PORT=5173 +# Indexing is enabled by default only when APP_ENV=production. +# Canonical URLs and sitemap URLs use APP_URL (set the public HTTPS domain in production). +# MOONWELL_SEO_INDEXABLE=true +GOOGLE_SITE_VERIFICATION= +YANDEX_SITE_VERIFICATION= + APP_LOCALE=ru APP_FALLBACK_LOCALE=ru APP_FAKER_LOCALE=ru_RU diff --git a/README.md b/README.md index cb22ccb..77b7706 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Laravel 12 сайт для World of Warcraft 1.12.1 на vMaNGOS (`/home/sindo/core`). Игровые аккаунты и права хранятся в базе `realmd`, персонажи — в `characters`. Сайт хранит свои данные в отдельной базе Laravel. +Настройка индексации, подтверждение домена и отправка sitemap описаны в [инструкции SEO для Яндекса и Google](docs/seo.md). + ## Подключение к локальному vMaNGOS Локальный стенд ядра описан в `/home/sindo/core/deploy/local/README.md`. Сначала запустите его. Контейнер сайта подключается к сети `vmangos-local_default` и обращается к MariaDB как `db:3306`. Пароль пользователя `vmangos` находится в `/home/sindo/core/deploy/local/secrets/db_password`; скопируйте его в `VMANGOS_AUTH_DB_PASSWORD` и `VMANGOS_CHARACTERS_DB_PASSWORD` своего `.env`. Текущий локальный `.env` сайта уже настроен на этот стенд. diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index 93ed53d..524ad70 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -60,7 +60,7 @@ class LandingController extends Controller $bootstrap = [ 'realm' => [ 'name' => $realmName, - 'mode' => sprintf('PvE · %s rate · RU', $rates['experience'] ?? 'x1'), + 'mode' => sprintf('PvP · %s rate · RU', $rates['experience'] ?? 'x1'), 'realmlist' => $realmConfig['realmlist'], 'online' => $playersOnline !== null, 'players' => $playersOnline, diff --git a/app/Http/Controllers/NewsController.php b/app/Http/Controllers/NewsController.php index c19b26c..55d99b9 100644 --- a/app/Http/Controllers/NewsController.php +++ b/app/Http/Controllers/NewsController.php @@ -23,6 +23,8 @@ class NewsController extends Controller ->withQueryString() ->through(fn (News $item): array => $this->summary($item)); + abort_if($articles->currentPage() > 1 && $articles->isEmpty(), 404); + return Inertia::render('News/Index', [ ...$this->portalProps($request), 'articles' => $articles, diff --git a/app/Http/Controllers/SeoController.php b/app/Http/Controllers/SeoController.php new file mode 100644 index 0000000..e5b87d3 --- /dev/null +++ b/app/Http/Controllers/SeoController.php @@ -0,0 +1,27 @@ +routeUrl('seo.sitemap')."\n" + : "User-agent: *\nDisallow: /\n"; + + return response($rules, 200, ['Content-Type' => 'text/plain; charset=UTF-8']); + } + + public function sitemap(SeoService $seo): Response + { + return response()->view('seo.sitemap', [ + 'seo' => $seo, + 'articles' => News::query()->published()->select(['id', 'updated_at'])->orderBy('id')->cursor(), + ], 200, ['Content-Type' => 'application/xml; charset=UTF-8']); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index c19ce18..c0ef788 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Services\SeoService; use Illuminate\Http\Request; use Inertia\Middleware; @@ -37,7 +38,7 @@ class HandleInertiaRequests extends Middleware { return [ ...parent::share($request), - // + 'seo' => fn (): array => app(SeoService::class)->forRequest($request), ]; } } diff --git a/app/Services/SeoService.php b/app/Services/SeoService.php new file mode 100644 index 0000000..cf1ce69 --- /dev/null +++ b/app/Services/SeoService.php @@ -0,0 +1,133 @@ +absoluteUrl(route($name, $parameters, false)); + } + + public function absoluteUrl(string $path): string + { + if (Str::startsWith($path, ['https://', 'http://'])) { + return $path; + } + + return rtrim(config('app.url'), '/').'/'.ltrim($path, '/'); + } + + /** @return array */ + public function forRequest(Request $request): array + { + $name = config('moonwell.realm.server_name', 'MoonWell'); + $name = strcasecmp($name, 'MoonWell') === 0 ? 'MoonWell' : $name; + $version = config('moonwell.realm.client_version', '1.12.1'); + $rate = config('moonwell.realm.rates.experience', 'x1'); + $home = $this->routeUrl('landing.index'); + $route = $request->route()?->getName(); + $article = $request->route('news'); + $indexable = in_array($route, ['landing.index', 'news.index', 'news.show'], true); + $title = match ($route) { + 'landing.index' => "{$name} — PvP сервер WoW Vanilla {$version} {$rate}", + 'news.index' => "Новости сервера WoW {$version} — {$name}", + 'news.show' => $article instanceof News ? "{$article->title} — {$name}" : $name, + 'legal.offer' => "Публичная оферта — {$name}", + 'legal.privacy' => "Политика обработки персональных данных — {$name}", + 'legal.rules' => "Правила проекта — {$name}", + 'login' => "Вход в личный кабинет — {$name}", + 'cabinet.index' => "Личный кабинет — {$name}", + 'admin.accounts.index' => "Аккаунты и инвайты — {$name}", + 'admin.balances.index' => "Слёзы Элуны — {$name}", + 'admin.shop.index' => "Магазин — {$name}", + 'admin.news.index' => "Новости — {$name}", + 'admin.login-screen-news.index' => "Новости WoW — {$name}", + default => $name, + }; + $description = match ($route) { + 'landing.index' => "{$name} — русскоязычный PvP сервер World of Warcraft Vanilla {$version} с рейтами {$rate}. Классический Азерот, подземелья и рейды. Создай аккаунт и начни играть.", + 'news.index' => "Новости PvP сервера WoW Vanilla {$version} {$name}: обновления, события, патчноуты и объявления проекта.", + 'news.show' => $article instanceof News + ? Str::limit(Str::squish(html_entity_decode(strip_tags($article->body), ENT_QUOTES | ENT_HTML5, 'UTF-8')), 160) + : '', + 'legal.offer' => "Публичная оферта о заключении договора возмездного оказания цифровых услуг {$name}.", + 'legal.privacy' => "Политика обработки и защиты данных пользователей {$name}.", + 'legal.rules' => "Запрещённые действия, меры ответственности и порядок обжалования санкций в проекте {$name}.", + 'login' => "Вход в личный кабинет игрового сервера {$name}.", + 'cabinet.index' => "Персонажи, баланс и настройки игрового аккаунта {$name}.", + default => '', + }; + $canonical = match ($route) { + 'landing.index' => $home, + 'news.index' => $this->routeUrl('news.index', Paginator::resolveCurrentPage() > 1 ? ['page' => Paginator::resolveCurrentPage()] : []), + 'news.show' => $article instanceof News ? $this->routeUrl('news.show', $article) : null, + default => null, + }; + + if ($route === 'news.index' && Paginator::resolveCurrentPage() > 1) { + $title .= ' — страница '.Paginator::resolveCurrentPage(); + } + + $image = $this->absoluteUrl($article instanceof News && $article->image_url ? $article->image_url : '/landing.png'); + $graph = []; + + if ($indexable) { + $graph[] = [ + '@type' => 'WebSite', + '@id' => $home.'#website', + 'url' => $home, + 'name' => $name, + 'alternateName' => 'Лунный колодец', + 'inLanguage' => 'ru-RU', + ]; + $graph[] = [ + '@type' => $route === 'news.index' ? 'CollectionPage' : 'WebPage', + '@id' => $canonical.'#webpage', + 'url' => $canonical, + 'name' => $title, + 'description' => $description, + 'inLanguage' => 'ru-RU', + 'isPartOf' => ['@id' => $home.'#website'], + ]; + + if ($route === 'news.show' && $article instanceof News) { + $graph[] = array_filter([ + '@type' => 'NewsArticle', + 'headline' => $article->title, + 'description' => $description, + 'image' => $image, + 'datePublished' => $article->created_at?->toIso8601String(), + 'dateModified' => $article->updated_at?->toIso8601String(), + 'inLanguage' => 'ru-RU', + 'mainEntityOfPage' => ['@id' => $canonical.'#webpage'], + 'author' => ['@type' => 'Organization', 'name' => $name, 'url' => $home], + 'publisher' => ['@type' => 'Organization', 'name' => $name, 'url' => $home], + ], static fn (mixed $value): bool => $value !== null); + } + } + + $seo = [ + 'title' => $title, + 'description' => $description, + 'canonical' => $canonical, + 'robots' => $indexable && config('seo.indexable') ? 'index, follow, max-image-preview:large' : 'noindex, follow', + 'type' => $route === 'news.show' ? 'article' : 'website', + 'image' => $image, + 'site_name' => $name, + 'published_at' => $route === 'news.show' && $article instanceof News ? $article->created_at?->toIso8601String() : null, + 'modified_at' => $route === 'news.show' && $article instanceof News ? $article->updated_at?->toIso8601String() : null, + 'structured_data' => $graph ? ['@context' => 'https://schema.org', '@graph' => $graph] : null, + ]; + + // Inertia manages these same server-rendered tags on subsequent visits. + $seo['head'] = array_map('trim', preg_split('/\R(?=<)/', trim(view('seo.head', ['seo' => $seo])->render()))); + + return $seo; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index ffc819b..daefa63 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,23 +1,27 @@ withRouting( - web: __DIR__ . '/../routes/web.php', - api: __DIR__ . '/../routes/api.php', - commands: __DIR__ . '/../routes/console.php', + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', health: '/up', ) - ->withMiddleware(function (Middleware $middleware) { + ->withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [ HandleInertiaRequests::class, ]); - }) - ->withMiddleware(function (Middleware $middleware): void { + $middleware->trustProxies(at: env('TRUSTED_PROXIES', '*')); $middleware->validateCsrfTokens(except: [ @@ -26,14 +30,14 @@ return Application::configure(basePath: dirname(__DIR__)) ]); $middleware->api(prepend: [ - \Illuminate\Http\Middleware\SetCacheHeaders::class . ':no_store', - \App\Http\Middleware\ForceJsonResponse::class, + SetCacheHeaders::class.':no_store', + ForceJsonResponse::class, ]); $middleware->alias([ - 'gamemaster' => \App\Http\Middleware\EnsureGameMaster::class, - 'launcher' => \App\Http\Middleware\EnsureLauncherRequest::class, - 'game-server' => \App\Http\Middleware\EnsureGameServer::class, + 'gamemaster' => EnsureGameMaster::class, + 'launcher' => EnsureLauncherRequest::class, + 'game-server' => EnsureGameServer::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/config/seo.php b/config/seo.php new file mode 100644 index 0000000..e7192c2 --- /dev/null +++ b/config/seo.php @@ -0,0 +1,7 @@ + (bool) env('MOONWELL_SEO_INDEXABLE', env('APP_ENV', 'production') === 'production'), + 'google_verification' => env('GOOGLE_SITE_VERIFICATION'), + 'yandex_verification' => env('YANDEX_SITE_VERIFICATION'), +]; diff --git a/docs/seo.md b/docs/seo.md new file mode 100644 index 0000000..6ceb832 --- /dev/null +++ b/docs/seo.md @@ -0,0 +1,65 @@ +# SEO для Яндекса и Google + +Публичные страницы главной и новостей отдают метатеги и основной HTML до запуска +JavaScript. При переходах Inertia обновляет тот же набор тегов через `serverHead`. +Заголовок, описание и текст главной ориентированы на PvP сервер WoW Vanilla, +версию клиента и рейты из `config/moonwell.php`. Название проекта также берётся +из конфигурации. Позиции в поиске зависят и от содержания, ссылок и конкуренции; +SEO-настройки обеспечивают техническую готовность к индексации. + +## Настройка production + +```dotenv +APP_ENV=production +APP_URL=https://moon-well.online +MOONWELL_SEO_INDEXABLE=true +GOOGLE_SITE_VERIFICATION= +YANDEX_SITE_VERIFICATION= +``` + +`APP_URL` — единственный источник домена для canonical, sitemap и разметки. +Используйте основной HTTPS-адрес без `www`, если сайт работает именно на нём. +На уровне внешнего nginx настройте постоянный редирект HTTP и альтернативного +домена на этот адрес. Для стендов задайте `MOONWELL_SEO_INDEXABLE=false`: +robots.txt запретит обход, а HTML получит `noindex`. По умолчанию индексация +включается только при `APP_ENV=production`. + +В `GOOGLE_SITE_VERIFICATION` и `YANDEX_SITE_VERIFICATION` вставляются только +значения `content` из проверочных метатегов соответствующего сервиса. +Пустые значения не создают метатеги. После изменения `.env` пересоздайте +production-контейнер приложения, чтобы загрузить переменные, и обновите кеш: + +```bash +docker compose -f compose.prod.yaml up -d --no-deps --force-recreate app +docker compose -f compose.prod.yaml exec -T --user app app php artisan config:cache +``` + +## Подключение поисковых систем + +1. Добавьте сайт в [Google Search Console](https://search.google.com/search-console) + и [Яндекс Вебмастер](https://webmaster.yandex.ru/). Для проверки метатегом + выберите ресурс по URL в Google и заполните переменные выше. Можно также + подтвердить домен через DNS средствами самих сервисов. +2. Отправьте `https://moon-well.online/sitemap.xml` в оба сервиса. Карта + автоматически содержит главную, список новостей и опубликованные статьи + с датами последнего изменения. Черновики, личный кабинет, вход, юридические + документы и административные страницы в карту не попадают. +3. Проверьте главную и одну новость инструментом проверки URL Google и + инструментами проверки страниц Яндекса, затем запросите переобход главной. +4. Проверьте robots.txt: ресурсы `/build/`, изображения и `/storage/` доступны + роботам. Закрыты API, файлы лаунчера и техническая проверка состояния. + Страницы входа, кабинета и администрации имеют `noindex`; robots.txt + не запрещает их обход, чтобы робот мог прочитать запрет индексации. + +У страниц новостей `?page=2` и далее собственный canonical и номер страницы +в заголовке. Метки рекламы и другие параметры в canonical не включаются. +Несуществующие страницы пагинации и неопубликованные новости возвращают 404. + +Разметка JSON-LD описывает сайт и страницы, а для новостей — `NewsArticle` +с заголовком, изображением и датами. Open Graph и Twitter Card поддерживают +превью ссылок. Проверочные коды для поисковых систем остаются на всех HTML-страницах. + +Реализация учитывает рекомендации +[Google по JavaScript SEO](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics), +[Google по sitemap](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap) +и [Яндекса по robots.txt](https://yandex.ru/support/webmaster/ru/controlling-robot/robots-txt). diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index eb05362..0000000 --- a/public/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Disallow: diff --git a/resources/js/Pages/Admin/Accounts.vue b/resources/js/Pages/Admin/Accounts.vue index 6885af7..e4283c7 100644 --- a/resources/js/Pages/Admin/Accounts.vue +++ b/resources/js/Pages/Admin/Accounts.vue @@ -1,5 +1,5 @@