# SEO

Мета-теги страницы считаются из шаблонов, а шаблоны наследуются по дереву
разделов. Владелец пишет правило один раз в корне ветки, а не заголовок
в каждом из пятисот разделов.

## Наследование — вверх, а не копированием вниз

`section_seo(site_id, section_id, target, field, value, inherit)`. Значение
ищется **вверх** по цепочке предков до первого установленного: сам раздел,
ближайший предок, дальше к корню, и последним звеном — умолчание сайта
из настроек.

Разница с копированием видна в тот момент, когда владелец правит шаблон
в корне каталога:

| | Копирование вниз (легаси) | Поиск вверх (здесь) |
|---|---|---|
| Правка в корне | переписать 500 строк | изменить одну |
| Раздел с переопределением | затрётся или выпадет из обхода | не тронут |
| Новый подраздел | останется без SEO до следующего прогона | получает всё сразу |

Легаси хранил SEO колонками в `catalogue` и копировал их скриптом — отсюда
и вечное «в подразделах старый заголовок».

### Тумблер «и для подразделов»

У каждого значения есть флаг `inherit`. Снят — значение действует только
на своём разделе; так делается заголовок для одной страницы, не трогая ветку.
Поднят — значение подхватывают потомки, которые его не переопределили.

**На своём разделе тумблер не действует**: он управляет потомками, а не собой.

### Пустое поле — это «наследовать»

Пустое значение не хранится, а удаляет строку. Хранить пустую строку значило бы
оборвать цепочку: раздел перестал бы видеть заголовок предка, и вернуть
наследование стало бы нечем.

### Форма показывает только собственные значения

Унаследованное показывается **подсказкой** (`placeholder`), а не подставляется
в поле. Подставь его — и первое же сохранение превратило бы значение в копию,
отвязанную от источника. Ровно так легаси-SEO и становилось неуправляемым.

## Две цели: список и карточка

`target` = `listing` (страница раздела) или `object` (карточка объекта в нём).
У каталога это заголовки разной природы:

```
listing: {name} — купить в {city_in}, цены и наличие
object:  {name} — характеристики и цена | {site}
```

Один набор на оба случая заставил бы владельца выбирать, какую из двух страниц
оставить без заголовка.

## Подстановки

| Подстановка | Что | Пример |
|---|---|---|
| `{name}` | название раздела или объекта | Насосы |
| `{site}` | название сайта | Медтехника |
| `{section}` | раздел объекта (на карточке) | Насосы |
| `{city}` | город запроса, именительный | Тверь |
| `{city_in}` | предложный | Твери |
| `{city_to}` | винительный | Тверь |
| `{page}` | номер страницы, пусто на первой | 3 |

Падежи города берутся из справочника — ради них они там и лежат: «купить
в {city_in}» без падежа даёт «купить в Тверь».

Подстановка без значения оставляет висящий разделитель: «Насосы — купить в ».
Двойные пробелы схлопываются, хвостовой разделитель срезается.

### Страницы пагинации

Одинаковый `<title>` на двадцати страницах списка — дубли в выдаче. Если
шаблон про номер не сказал, ядро дописывает « — страница N» само. Шаблон
с `{page}` этого не получает: владелец сформулировал сам.

SEO-текст под списком печатается **только на первой странице**: на двадцатой
он был бы тем же дублем.

## Умолчания сайта — последнее звено

Настройки → **SEO**. Без них верхним разделам наследовать не от кого:
в дереве KORZILLA главная страница — такой же раздел с `parent_id = NULL`,
как «Каталог» и «Новости», а не их родитель.

Из коробки стоит `{name} — {site}`: владелец, который ничего не настраивал,
получает осмысленные заголовки, а не голое название раздела.

⚠️ **Умолчания подмешиваются ПОСЛЕ кэша.** У настроек свой счётчик версий;
положи их внутрь ключа, помеченного версией дерева, — и правка умолчания
не доехала бы до страниц, пока кто-нибудь не тронет раздел.

## Свои мета-теги объекта — самое сильное звено

Цепочка целиком: **умолчания сайта → шаблоны разделов вверх по дереву →
значения самого объекта**. Шаблон `{name} — купить в {city_in}` покрывает сто
тысяч товаров разом, но одному товару бывает нужен свой заголовок, и записать
его до сих пор было негде.

Вкладка «SEO» в форме объекта, четыре поля: заголовок, описание, H1 и «скрыть
от поисковиков». Текста под списком там нет — он свойство списка, а не карточки.

```
object_seo (site_id, component_key, object_id, field, value)
```

- **Ключ объекта — пара «компонент + идентификатор»**, а не внешний ключ:
  у каждого компонента своя таблица объектов, и одной ссылки на них
  не существует. Так же устроены `mediables` и поисковый индекс.
- **Строки удаляет писатель объектов**, потому что внешнего ключа нет: иначе
  мета-теги достались бы следующему объекту с тем же идентификатором.
- **Пустое поле = «наследовать»**, строка удаляется — то же правило, что
  у разделов. Пустая строка оборвала бы цепочку.
- **Форма показывает только свои значения**, унаследованное не подставляется:
  подставь — и первое же сохранение сделало бы копию, отвязанную от шаблона.
- **Кэша нет намеренно.** У раздела значения читают на каждой странице ветки,
  и цепочка предков стоит запроса; у объекта — один раз на его собственной
  странице, и кэш обошёлся бы дороже запроса по первичному ключу.
- H1 объекта — это «Альтернативное название» из действующей KORZILLA: в списке
  нужно короткое «Насос», а на странице — «Насос для скважины с доставкой».

## Что печатается на странице

`<title>`, `description`, `canonical`, `robots` и OG-теги печатает
платформенный partial `platform::head`, а не тема. Тема — это вёрстка клиента,
и правило «корзину в поиск не пускать» слишком дорого стоит, чтобы зависеть
от того, вспомнил ли верстальщик про `noindex`.

- **Канонический адрес** строится от пути раздела или объекта и содержит номер
  страницы. Параметры запроса в него не попадают — это и есть главный источник
  дублей (utm, сортировки).
- **На `noindex` канонического адреса нет**: «не индексируй, но вот тебе
  адрес» — противоречивый сигнал.
- **`noindex, follow`, а не `nofollow`**: страницу прятать надо, а ссылки
  с неё на разделы каталога пусть работают.
- **Корзина, оформление, кабинет и выдача поиска закрыты от индексации
  всегда** — это делает `PageShell`, а не каждый модуль по отдельности.

### Заголовок H1 — отдельное поле

В меню нужно короткое «Насосы», на странице — «Насосы для скважин с доставкой».
Шаблоны компонентов печатают `$heading` (с откатом на название), а ядро
подставляет туда поле `heading`.

⚠️ Раньше раздел без компонента отдавал **целый HTML-документ** внутрь блока:
`<!doctype>`, `<head>`, второй `<title>` и `<body>` посреди готовой страницы
темы. Поисковик видел заголовок «Каталог» там, где владелец писал «Каталог
насосов — купить в Твери». Нашлось живым прогоном.

## Кэш

Ключ `kz:{site}:seo:{section}:{target}:v{tv}`, счётчик — версия дерева
(`CacheVersion::Tree`). Собственного счётчика нет намеренно: SEO живёт
разделами, и правка любого из них всё равно сбрасывает дерево, а лишний
счётчик — это ещё один MGET на каждый запрос ради поля, которое меняют раз
в год.

Писатель сбрасывает **весь** счётчик дерева, а не ключ своего раздела:
поднятый тумблер меняет заголовки всей ветки, и вычислять, какие именно ключи
протухли, дороже, чем пересчитать их лениво.

## Карта сайта

`/sitemap.xml` — оглавление, `/sitemap-{ключ}-{страница}.xml` — сами адреса.
Оглавление, а не один файл: протокол ограничивает файл 50 000 адресами,
и каталог на сотню тысяч товаров перешагивает ограничение на первом же клиенте.

Собирается на лету и отдаётся **потоком**, мимо моделей и без файлов на диске.
Файлы пришлось бы пересобирать по расписанию для трёхсот сайтов, то есть
держать вечно устаревающую копию того, что и так есть в базе. Замер на живом
сайте: 20 000 адресов, 3,1 МБ, 2,6 с.

В карту не попадают: неопубликованные объекты и разделы, разделы с внешней
ссылкой (приглашать робота в чужой домен — не наше дело), закрытые `noindex`
вместе со всей веткой, и компоненты без карточки объекта — у них собственных
адресов нет, а раздел уже перечислен.

## robots.txt

Отдаётся кодом, а не файлом: сайтов на установке сотни, а корень `public`
ровно один и достался бы им всем разом.

⚠️ **`public/robots.txt` из скелета Laravel удалён.** Веб-сервер отдаёт файл
раньше маршрута, поэтому маршрут был, а отвечал не он — и отвечал `Disallow:`
(то есть «индексируй всё») на всех сайтах установки. **Нашлось живым прогоном.**

- **Домен разработки закрыт целиком, и это не настройка.** Копия сайта
  на `{login}.kzla.ru` — точная копия боевого; попав в индекс, она конкурирует
  с оригиналом его же текстами.
- **Сайт-черновик закрыт целиком** по той же причине. Отключённый и архивный
  до маршрута не доходят: им middleware арендатора отдаёт заглушку раньше.
- **Ссылка на карту дописывается всегда**, даже если владелец написал robots
  сам: забыть её — самая частая ошибка, а лишней она не бывает.
- `Clean-param` для меток кампаний: они плодят дубли каждой страницы сайта.

## Разметка schema.org

JSON-LD одним блоком `@graph`, а не тремя тегами `<script>`: так организация,
хлебные крошки и сам товар связываются в одно описание страницы, а не в три
независимых.

**Микроразметки в HTML (`itemprop`) нет намеренно.** Она размазывается
по вёрстке клиента, и любая правка шаблона ломает её молча. JSON-LD живёт
отдельно от разметки — в этом и смысл.

**Что описывать, знает компонент**, а не ядро: товар — это `Product` с ценой
и наличием, новость — `NewsArticle` с датой публикации. Компонент реализует
`ProvidesStructuredData`; не реализует — в разметку не попадает, потому что
пустой `Thing` роботу бесполезен.

- **Хлебные крошки считаются из дерева, а не из блока**: блока на странице
  может не быть вовсе, а роботу цепочка нужна всегда.
- **Последняя крошка без адреса**: это текущая страница, ссылка на саму себя
  роботу ничего не сообщает.
- **Товар без цены не получает `offers`**: `price: 0` робот прочитает
  как «бесплатно», а не как «цена по запросу».
- **Персональная цена покупателя в разметку не идёт** — робот видит страницу
  как аноним, и обещать выдаче скидку клиента значило бы её обманывать.
- **На `noindex` разметки нет**: описывать роботу то, что ему же и запрещено,
  — противоречие.

## Чего ещё нет

| Что | Когда |
|---|---|
| og:image из медиатеки | вместе с schema.org |
| Мета-ключевые слова | не планируются: поисковики их не учитывают. В действующей KORZILLA поле есть — при переносе решили не тащить |
