Может войдёшь?
Черновики Написать статью Профиль

SEOFriendlyPaginator — пагинатор без зеркала первой страницы (?page=1) и несуществующих страниц (?page=999999)

версия 5.4 404 ошибка зеркала версия 5.x пагинатор seo paginator

В стандартном пагинаторе (PHPLengthAwarePaginator) есть пара досадных косяков:

  1. При выводе пагинатора на страницах >= 2 — ссылка на первую страницу идет в виде example.com/?page=1 и это создает «зеркало» страницы для поисковика — тот же самый контент находится и на странице example.com/.
  2. Если зайти на страницу example.com/?page=999999, то мы не увидим 404 страницу ошибки, а просто увидим пустую страницу с пагинатором.

Я написал решение этих двух проблем и выкладываю его, чтобы вы покритиковали и сказали как можно было сделать проще и изящнее.

Изначально хотел просто подменить view (PHPresources/views/vendor/pagination/default.blade.php), но уткнулся в то, что в PHPLengthAwarePaginator нет метода для получения предыдущей страницы, есть только метод для получения url предыдущей страницы (PHP$paginator->previousPageUrl()). Если пробовать разбирать через него, то это будет плохим способом, т.к. изначально пагинатор может выводиться на разных страницах, соответственно «хардкодить» там все пути — не кошерно.

Потом наткнулся на комментарий @FreeWebber и понял, что это можно сделать через наследования, придется только чуть-чуть поколдовать.

Пронаследовал PHPIlluminate\Pagination\LengthAwarePaginator и изменил метод PHPurl() — сделал, чтобы при визите на любую страницу с ?page=X, где X > 1 — у первой страницы (page=1) не добавлялся GET-параметр page=1:

PHP
<?php

namespace App\Utils;

use 
Illuminate\Support\Str;
use 
Illuminate\Pagination\LengthAwarePaginator;


class 
SEOFriendlyPaginator extends LengthAwarePaginator {

    public function 
url($page)
    {
        if (
$page <= 0) {
            
$page 1;
        }

        
$parameters = array();
        if (
$page 1) {
            
$parameters = [$this->pageName => $page];
        }

        if (
count($this->query) > 0) {
            
$parameters array_merge($this->query$parameters);
        }

        
$url $this->path;

        
$params_delimiter = (Str::contains($this->path'?') ? '&' '?');
        
$params_query http_build_query($parameters'''&');
        
$fragment $this->buildFragment();

        if (!empty(
$params_query)) {
            
$url .= $params_delimiter $params_query;
        }
        if (!empty(
$fragment)) {
            
$url .= $fragment;
        }

        return 
$url;
    }
}

Положил это добро соответственно в PHPApp\Utils\SEOFriendlyPaginator.php.

Затем в своем Controller использовал этот PHPSEOFriendlyPaginator и:

  1. Сделал редирект с example.com/?page=1 на example.com/
  2. Сделал вывод 404 ошибки, если каким-то образом у нас хотят получить страницу, которой не существует (аля example.com/?page=999999 или example.com/?page=0)
PHP
<?php

namespace App\Http\Controllers;

use 
Illuminate\Http\Request;

use 
App\Models\Article;

use 
App\Utils\SEOFriendlyPaginator;

class 
SectionController extends Controller
{
    public function 
home(Request $request) {
        
$per_page config('project.per_page');  // добавил соответствующий конфиг
        
$page $request->input('page');
        
$items Article::filtered();  // здесь применяется мой собственный scope - filtered, это что-то вроде published, но с нужной мне сортировкой
        
$total $items->count();
        if (!
is_null($page)) {
            if (
$page == 1) {
                return return 
redirect($request->getPathInfo(), 301);
            }
            if ((
$page ceil($total $per_page)) || ($page 1)) {  // предотвращаем отображение страниц, которые выходит за пределы текущих данных
                
abort(404);
            }
        } else {
            
$page 1;
        }

        
$items $items->skip(($page 1) * $per_page)->take($per_page)->get(); // берем только нужные элементы
        
$items = new SEOFriendlyPaginator($items$total$per_page$page, ['path' => $request->getPathInfo()]);

        return 
view('index', [
            
'items' => $items
        
]);
    }
}

Вывод элементов и пагинатора во view не изменился:

@foreach ($items as $item)
    {{-- вывод каждого элемента --}}
@endforeach


{{-- Вывод пагинатора --}}
{{ $items->links() }}

Всё. Как уже и писал выше — предложения, критика и советы — приветствуются! :-)

Как вы считаете, полезен ли этот материал? Да Нет

Комментарии (16)

Lord_Alfred

К слову, эти же огрехи применимы и к текущей версии Habravel:

https://laravel.ru/?page=999999

https://laravel.ru/?page=0

https://laravel.ru/?page=-1000

Proger_XP
  1. К слову, эти же огрехи применимы и к текущей версии Habravel:

Потому что Habravel (внезапно) написан на Laravel, а автор описывает общую «проблему» Laravel. Не знаю, насколько это именно проблема в SEO, ИМХО, поведение логичное и поисковики его должны учитывать.

Lord_Alfred

Да, я в курсе, что он на Laravel, поэтому и добавил этот комментарий.

Поисковики учитывают это по-разному, поэтому некоторые плохие люди могут натолкать кучу ссылок со сторонних ресурсов на такие «зеркала» или «пустышки», этим самым пошатнув позиции сайта в серпе.

Proger_XP

ИМХО, это не может быть так — «зеркала» можно создать на любом сайте вообще без усилий, добавив любой параметр к URL (...?foo=bar); с «пустышками» сложнее, но тоже реально. Если такие проблемы и были, то когда-то давно, в то же время, когда URL были «обязаны» с точки зрения SEO иметь расширение .html, а JS-страницы не индексировались вообще.

Lord_Alfred

С зеркалами сейчас ситуация такая (сужу по гуглу) — они учитываются, если ссылка стоит с сайта, на который и ссылаются. То есть если на сайте есть ссылки на главную и на первую страницу одновременно (а так и есть в настоящий момент), то это считается зеркалом.

PS: лучше код гляньте, скажите — правильная ли реализация или я что-то не учел или мог бы сделать лучше?

Proger_XP
  1. PS: лучше код гляньте, скажите — правильная ли реализация или я что-то не учел или мог бы сделать лучше?

Явных проблем не вижу, но есть два момента, из-за которых вы делаете два запроса вместо одного.

PHP
if (count($this->query) > 0) {

Это может вызывать повторный запрос, т.е. внутри paginator будет делаться два запроса (нужно проверить).

PHP
$items Article::filtered();
$total $items->count();
...
$items $items->skip(($page 1) * $per_page)->take($per_page)->get();

Здесь вы вначале делаете запрос с COUNT, дальше без, но с LIMIT. А зачем первый запрос вообще нужен? Ведь вы после второй выборки уже имеете массив; если он пустой — значит, PHP$page слишком велика и нужно выдавать 404 (по вашей логике). Нет смысла дополнительно вначале проверять общее число записей в таблице, это можно определить из второго запроса.

Lord_Alfred

Спасибо за замечания)

  1. но есть два момента, из-за которых вы делаете два запроса вместо одного

и

  1. Нет смысла дополнительно вначале проверять общее число записей в таблице, это можно определить из второго запроса.

Тут тоже на самом деле не всё так просто. Возможно, что у человека, кто потом заберет этот код себе — не будут созданы индексы по фильтрации, которая у меня заложена в методе PHPfiltered(), поэтому sqlSELECT может оказаться в разы медленнее, чем sqlCOUNT. Но это спорно, конечно.

  1. PHPif (count($this->query) > 0) {
  2. Это может вызывать повторный запрос, т.е.

Это из оригинального кода пагинатора) И это не sql query, это get query parameters

  1. внутри paginator будет делаться два запроса (нужно проверить).

Смотрел через debugbar — делается всего 1 count-запрос, не 2.

  1. Здесь вы вначале делаете запрос с COUNT, дальше без, но с LIMIT. А зачем первый запрос вообще нужен?

Первый запрос для того, чтобы выдать 404 ошибку. Как уже выше писал — это из-за скорости (в любом случае COUNT будет быстрее SELECT), поэтому для проверки несуществующих страниц, имхо, лучше использовать его — чтобы не «насиловать» сервер в случае DDOS. Но если переписать код, используя вашу мысль — можно в перспективе сделать кеширование гораздо красивее, чем ещё более сильнее обезопасится от тупого DDOS.

Proger_XP
  1. чтобы не «насиловать» сервер в случае DDOS

О, ну это просто отговорка — в любом веб-приложении тонна мест, где можно получить DDoS: та же авторизация по определению требует тяжёлых вычислений хэша, или фильтр каких-нибудь товаров, который не может покрыть все поля индексами. А некоторые типы атаки (на канал) вообще не требуют никаких действий от сервера, ими можно завалить даже отдачу статики, безо всяких PHP. Конечно, специально создавать бутылочные горлышки не нужно, но именно защищаться от DDoS нужно на другом уровне (сетевом).

  1. в любом случае COUNT будет быстрее SELECT

Это спорно и зависит от типа запроса. Например, если в запросе нет ORDER B Y, то SELECT от SELECT с COUNT отличается только тем, что первый передаёт данные по сети, а второй нет. Если БД стоит на том же сервере, что и PHP — накладные расходы на копирование данных через память минимальны, зато сложный WHERE выполняется в обоих запросах дважды, что в зависимости от настроек БД и доступной памяти может не кэшироваться.

ИМХО, в первую очередь код должен быть кратким и «красивым» (последнее субъективно). Остальное это предоптимизация, которая обычно приносит больше проблем, чем пользы.

Pavel87

Добрый вечер.
Это конечно не относится к фреймворку, но раз вопрос возник, решил ответить. Да, вопрос с зеркалом страниц нужно решать обязательно, даже адрес test и test/ — это разные страницы. Проверять весь массив $_GET не лучшая идея в данном случае. Решается это очень просто — мета тегом canonical. Это единственный правильный путь решения данного вопроса.

Proger_XP
  1. Решается это очень просто — мета тегом canonical. Это единственный правильный путь решения данного вопроса.

Однозначно плюсую. Это единственный и идеальный способ решения всех проблем с «зеркалами».

antoha75

Я тоже столкнулся с этой проблемой в ларавел при изучении темы пагинации для сео. Сделал все практически так же, как и автор. Унаследовал LengthAwarePaginator, добавил условие $page>1.

Единственное отличие — логику редиректов я реализовал не прямо в контроллере, а изолировал ее в Middleware:

PHP
public function handle($requestClosure $next)
    {
        
// сразу определим есть ли пагинация на странице
        
$page $request->input('page') ?? false;

        
// пагинации нет, отправляем запрос дальше
        
if ($page === false) {
            return 
$next($request);
        }

        
// здесь логика редиректов, если с номером страницы что-то не так
        
.....

        return 
$next($request);
    }
}

Дальше добавил этот посредник в группу web (т.е. для всех запросов).
Плюс в том, что это может быть применимо не только для одного контроллера. А в самом контроллере меньше кода.

Продолжая разбираться с темой пагинации, также решил передавать объект пагинатора в основной layout. И там реализовал для гугла rel='prev' и rel='next', а также rel='canonical' при необходимости, задействовав для этого методы LengthAwarePaginator (чтобы понимать на какой странице нахожусь).

P/S Изучив кучу материала на эту тему, так до конца и не понял, как в итоге лучше всего поступать с метатегами для яндекса и гугла одновременно :( Авито делает canonical на первую страницу (причем с каким-то гео-параметром), хотя это вроде бы неправильно. Другие вообще закрывают ссылки на пагинацию, короче понимай как хочешь...

sugoj

Если именно про сео... Обычно вся пагинация скрывается в robots.txt кроме основной странице. Так что сео-френдли пагинация не нужна.

Proger_XP

Какие из современных поисковиков учитывают robots.txt? Google — его игнорирует уже давно.

SlowDream

Простите, шта ? первый раз об этом слышу.

Lord_Alfred
  1. Простите, шта ? первый раз об этом слышу.

Всё верно. Гугл зачастую индексирует страницы, которые закрыты в robots.txt, но на них есть ссылки на сайте. Но эти страницы не показывает в результатах поиска, а пишет что-то в духе «содержимое страницы закрыто robots.txt» (это можно встретить при поиске через «site:domain.tld»)

Proger_XP

Google замечательно показывает «закрытые» страницы даже в обычном поиске без site: — а вот вместо вырезки со страницы (под заголовком и URL) он действительно пишет «содержимое страницы закрыто robots.txt».

Очень часто натыкаюсь на подобные результаты, по-моему со Stack Overflow.

Написать комментарий

Разметка: ? ?

Авторизуйся, чтобы прокомментировать.