В стандартном пагинаторе (PHPLengthAwarePaginator
) есть пара досадных косяков:
- При выводе пагинатора на страницах >= 2 — ссылка на первую страницу идет в виде example.com/?page=1 и это создает «зеркало» страницы для поисковика — тот же самый контент находится и на странице example.com/.
- Если зайти на страницу 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
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
и:
- Сделал редирект с example.com/?page=1 на example.com/
- Сделал вывод 404 ошибки, если каким-то образом у нас хотят получить страницу, которой не существует (аля example.com/?page=999999 или example.com/?page=0)
<?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)
К слову, эти же огрехи применимы и к текущей версии Habravel:
https://laravel.ru/?page=999999
https://laravel.ru/?page=0
https://laravel.ru/?page=-1000
Потому что Habravel (внезапно) написан на Laravel, а автор описывает общую «проблему» Laravel. Не знаю, насколько это именно проблема в SEO, ИМХО, поведение логичное и поисковики его должны учитывать.
Да, я в курсе, что он на Laravel, поэтому и добавил этот комментарий.
Поисковики учитывают это по-разному, поэтому некоторые плохие люди могут натолкать кучу ссылок со сторонних ресурсов на такие «зеркала» или «пустышки», этим самым пошатнув позиции сайта в серпе.
ИМХО, это не может быть так — «зеркала» можно создать на любом сайте вообще без усилий, добавив любой параметр к URL (...?foo=bar); с «пустышками» сложнее, но тоже реально. Если такие проблемы и были, то когда-то давно, в то же время, когда URL были «обязаны» с точки зрения SEO иметь расширение .html, а JS-страницы не индексировались вообще.
С зеркалами сейчас ситуация такая (сужу по гуглу) — они учитываются, если ссылка стоит с сайта, на который и ссылаются. То есть если на сайте есть ссылки на главную и на первую страницу одновременно (а так и есть в настоящий момент), то это считается зеркалом.
PS: лучше код гляньте, скажите — правильная ли реализация или я что-то не учел или мог бы сделать лучше?
Явных проблем не вижу, но есть два момента, из-за которых вы делаете два запроса вместо одного.
if (count($this->query) > 0) {
Это может вызывать повторный запрос, т.е. внутри paginator будет делаться два запроса (нужно проверить).
$items = Article::filtered();
$total = $items->count();
...
$items = $items->skip(($page - 1) * $per_page)->take($per_page)->get();
Здесь вы вначале делаете запрос с COUNT, дальше без, но с LIMIT. А зачем первый запрос вообще нужен? Ведь вы после второй выборки уже имеете массив; если он пустой — значит,
PHP$page
слишком велика и нужно выдавать 404 (по вашей логике). Нет смысла дополнительно вначале проверять общее число записей в таблице, это можно определить из второго запроса.Спасибо за замечания)
и
Тут тоже на самом деле не всё так просто. Возможно, что у человека, кто потом заберет этот код себе — не будут созданы индексы по фильтрации, которая у меня заложена в методе
PHPfiltered()
, поэтомуsqlSELECT
может оказаться в разы медленнее, чемsqlCOUNT
. Но это спорно, конечно.Это из оригинального кода пагинатора) И это не sql query, это get query parameters
Смотрел через debugbar — делается всего 1 count-запрос, не 2.
Первый запрос для того, чтобы выдать 404 ошибку. Как уже выше писал — это из-за скорости (в любом случае COUNT будет быстрее SELECT), поэтому для проверки несуществующих страниц, имхо, лучше использовать его — чтобы не «насиловать» сервер в случае DDOS. Но если переписать код, используя вашу мысль — можно в перспективе сделать кеширование гораздо красивее, чем ещё более сильнее обезопасится от тупого DDOS.
О, ну это просто отговорка — в любом веб-приложении тонна мест, где можно получить DDoS: та же авторизация по определению требует тяжёлых вычислений хэша, или фильтр каких-нибудь товаров, который не может покрыть все поля индексами. А некоторые типы атаки (на канал) вообще не требуют никаких действий от сервера, ими можно завалить даже отдачу статики, безо всяких PHP. Конечно, специально создавать бутылочные горлышки не нужно, но именно защищаться от DDoS нужно на другом уровне (сетевом).
Это спорно и зависит от типа запроса. Например, если в запросе нет ORDER B Y, то SELECT от SELECT с COUNT отличается только тем, что первый передаёт данные по сети, а второй нет. Если БД стоит на том же сервере, что и PHP — накладные расходы на копирование данных через память минимальны, зато сложный WHERE выполняется в обоих запросах дважды, что в зависимости от настроек БД и доступной памяти может не кэшироваться.
ИМХО, в первую очередь код должен быть кратким и «красивым» (последнее субъективно). Остальное это предоптимизация, которая обычно приносит больше проблем, чем пользы.
Добрый вечер.
Это конечно не относится к фреймворку, но раз вопрос возник, решил ответить. Да, вопрос с зеркалом страниц нужно решать обязательно, даже адрес test и test/ — это разные страницы. Проверять весь массив $_GET не лучшая идея в данном случае. Решается это очень просто — мета тегом canonical. Это единственный правильный путь решения данного вопроса.
Однозначно плюсую. Это единственный и идеальный способ решения всех проблем с «зеркалами».
Я тоже столкнулся с этой проблемой в ларавел при изучении темы пагинации для сео. Сделал все практически так же, как и автор. Унаследовал LengthAwarePaginator, добавил условие $page>1.
Единственное отличие — логику редиректов я реализовал не прямо в контроллере, а изолировал ее в Middleware:
public function handle($request, Closure $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 на первую страницу (причем с каким-то гео-параметром), хотя это вроде бы неправильно. Другие вообще закрывают ссылки на пагинацию, короче понимай как хочешь...
Если именно про сео... Обычно вся пагинация скрывается в robots.txt кроме основной странице. Так что сео-френдли пагинация не нужна.
Какие из современных поисковиков учитывают robots.txt? Google — его игнорирует уже давно.
Простите, шта ? первый раз об этом слышу.
Всё верно. Гугл зачастую индексирует страницы, которые закрыты в robots.txt, но на них есть ссылки на сайте. Но эти страницы не показывает в результатах поиска, а пишет что-то в духе «содержимое страницы закрыто robots.txt» (это можно встретить при поиске через «site:domain.tld»)
Google замечательно показывает «закрытые» страницы даже в обычном поиске без site: — а вот вместо вырезки со страницы (под заголовком и URL) он действительно пишет «содержимое страницы закрыто robots.txt».
Очень часто натыкаюсь на подобные результаты, по-моему со Stack Overflow.