Ошибка в canonical, которая прячет ваши переводы
agentready.md выходит на десяти языках с префиксом в пути. Каждая страница существует как /about, /es/about, /ja/about и ещё семь адресов. Месяцами все девять переводов отдавали в <head> одну и ту же строку:
<link rel="canonical" href="https://agentready.md/about">
/es/about сообщал поисковикам: я дубль английской страницы, индексируйте её. То же самое говорил /ja/about. И остальные семь. Десять языков, из них индексируется один.
Что canonical заявляет на самом деле
rel="canonical" — это не подсказка о том, какой адрес выглядит опрятнее. Это утверждение: эта страница и та, которую я называю, — одна и та же, и в индекс должна попасть названная. Ссылки, сигналы ранжирования и сама запись в индексе достаются цели. Эта страница выбывает из борьбы.
Для /product?color=blue, указывающего на /product, поведение верное. Для перевода — прямо противоположное тому, что нужно. Испанская страница не дубль английской: это другая страница для другого читателя, и ранжироваться по испанским запросам она должна сама.
Почему баг прожил так долго
Наш роутер срезает языковой префикс до маршрутизации, поэтому запрос к /es/about доходит до обработчика как /about, а язык едет отдельно. Решение разумное: маршруты объявляются один раз и работают на всех языках. Но request.url остаётся без префикса, а шаблон <head> собирал canonical именно из этого голого пути.
На английском баг даёт правильный результат, потому что у английского префикса нет. Все страницы, которые вы смотрите, разрабатывая на своём языке, выглядят верно. Ломается только в тех девяти, которые вы не читаете.
Исправление — один вызов, возвращающий префикс перед выводом адреса:
<%# request.url уже потерял префикс /es/ — localizedUrl возвращает его. %>
<% const canonicalHref = localizedUrl(canonicalPath); %>
<link rel="canonical" href="<%= baseUrl %><%= canonicalHref %>">
Правило: каждая языковая версия канонична сама себе
Самоссылающийся canonical, всегда. /es/about объявляет /es/about. /ja/about объявляет /ja/about. Для переводов исключений нет. Если две версии почти совпадают — та же карточка товара с тремя изменёнными словами, — это ничего не меняет: язык не дублирование.
hreflang: каждая версия перечисляет все версии
Canonical говорит, какую страницу индексировать. hreflang говорит, какую из проиндексированных показать какому читателю. Нужны оба, и у hreflang есть два правила, которые нарушают чаще всего.
Каждая версия перечисляет все версии, включая себя. Десять языков — это десять ссылок alternate на всех десяти страницах, вместе с самоссылкой. Пропущенная самоссылка — самый частый дефект в остальном корректного набора.
Ссылки должны быть взаимными. Если английская страница называет испанскую, а испанская не называет английскую в ответ, поисковик отбрасывает связь, а нередко и весь набор, потому что проверить её нечем. Объявить себя чьим-то переводом может кто угодно. Подтвердить это может только вторая страница.
Генерируйте их из одного списка, а не поддерживайте десять:
<% ['en','es','fr','de','pt','it','ja','zh','ko','ru'].forEach(l => { %>
<link rel="alternate" hreflang="<%= l %>" href="<%= baseUrl %><%= localizedUrl(canonicalPath, l) %>">
<% }) %>
<link rel="alternate" hreflang="x-default" href="<%= baseUrl %><%= canonicalPath %>">
Берите чистый код языка (es), когда одна версия обслуживает всех говорящих на нём. Регион (es-ES, pt-BR) добавляйте, только если вы действительно публикуете отдельные версии по рынкам: выдуманные регионы дробят набор без всякой пользы.
x-default — не ещё один перевод
x-default указывает версию для читателя, чей язык вы не публикуете. Кто-то ищет по-нидерландски, нидерландского у вас нет — он получает то, на что указывает x-default.
Это не «самый важный язык» и не ещё одна запись в общем списке. У нас x-default остаётся на английском адресе без префикса, к которому весь сайт и так откатывается. Направьте его на страницу с конкретным языком, вроде /es/about, — и каждый читатель без перевода попадёт на испанскую страницу, что хуже поведения по умолчанию.
Проверка в одну строку
Вся поломка видна из терминала. Пройдитесь по своим языкам и напечатайте, что заявляет каждая страница:
for p in "" es/ fr/ de/ pt/ it/ ja/ zh/ ko/ ru/; do
printf '%-4s ' "${p:-en}"
curl -s "https://example.com/${p}about" | grep -o '<link rel="canonical"[^>]*>'
done
В правильном выводе каждая строка называет свой собственный путь:
en <link rel="canonical" href="https://example.com/about">
es <link rel="canonical" href="https://example.com/es/about">
fr <link rel="canonical" href="https://example.com/fr/about">
Десять строк с одним адресом — это баг из этой статьи. Прогоните проверку по всем шаблонам, которые трогали, а не только по главной: canonical обычно собирается на уровне макета, и сайт с тремя макетами может быть прав в двух.
Как это ломается ещё
Метки отслеживания внутри canonical. Если собирать canonical из сырого URL запроса, посетитель, пришедший по ?utm_source=newsletter, получит canonical, указывающий на ?utm_source=newsletter. Каждый репост рождает новый адрес, объявляющий каноничным себя, — неограниченное множество URL на одну страницу. Стройте canonical из пути и возвращайте только те параметры, которые действительно задают другую страницу: отфильтрованный список, номер страницы. И в фиксированном порядке, чтобы одна страница не могла записать своё имя двумя способами.
Canonical, указывающий на редирект. Если /es/about объявляет /es/about/, а тот отдаёт 301 обратно на /es/about, вы вручили краулеру петлю и повод не доверять сигналу. Указывайте на адрес, который отвечает 200.
Два canonical, противоречащих друг другу. <link> в HTML и HTTP-заголовок Link: <...>; rel="canonical" одинаково допустимы, и их охотно добавляют CDN, плагины и обратные прокси. Когда они расходятся, результат не определён и обычно не тот, которого вы ждали. Смотрите и заголовки:
curl -sI https://example.com/es/about | grep -i "^link:"
Пустой вывод здесь — хороший ответ, если только вы не поставили заголовок намеренно.
Проверить сайт · Генератор мета-тегов · Чек-лист готовности к ИИ