Перейти к содержимому

Генерация OG-изображений

Начиная с ядра 3.20, при отрисовке в Chrome JavaScript отключён, а запросы ресурсов по HTTP(S), FTP и WebSocket заблокированы. Собственные шаблоны должны использовать статические HTML/CSS и встроенные ресурсы, как и шаблоны из пакета.

Страница без карточки для соцсетей использует общее default_og_image — одну и ту же картинку при каждой публикации ссылки. Эта возможность даёт каждой странице собственную карточку Open Graph / Twitter размером 1200×630. Её отрисовывает из шаблона Blade настоящий браузер без графического интерфейса (через spatie/browsershot): заголовок переносится по строкам, диакритика отображается, для CJK подбирается нужный резервный шрифт, а слишком длинные заголовки аккуратно сокращаются. Самодельной библиотеке изображений всё это пришлось бы реализовывать отдельно.

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

Предварительная статическая генерация — осознанный выбор

Карточки создаются заранее командой artisan, а не во время веб-запроса. Страница ссылается только на карточку, которая уже есть на диске: запрос посетителя никогда не запускает браузер и не создаёт ссылку на отсутствующее изображение (404). Эндпоинта отрисовки по запросу нет (см. Ограничения).

Требования

Браузерный драйвер — необязательная зависимость, поэтому бесплатное ядро устанавливается без него. Чтобы включить возможность, установите в приложении:

bash
composer require spatie/browsershot

Также нужна среда выполнения, которой управляет Browsershot:

  • Node.js на хосте.
  • Puppeteer, установленный в корне приложения, чтобы Node мог его найти:
    bash
    npm install puppeteer
  • Chrome / Chromium — по умолчанию Puppeteer загружает собственный Chromium; в продакшене обычно указывают системный Chrome (см. chrome_path).

В Windows устанавливайте puppeteer в корне приложения

В Windows установите puppeteer в корне приложения и не полагайтесь на npm_module_path. Этот ключ конфигурации соответствует методу Browsershot setNodeModulePath(), который добавляет префикс POSIX NODE_PATH=… и не действует в Windows. Здесь Node ищет модули, поднимаясь по каталогам от приложения, поэтому работает именно установка в корне. См. Ограничения.

Включение

Опубликуйте конфигурацию, если ещё не сделали этого (php artisan vendor:publish --tag=seo-config), и включите возможность:

php
// config/seo.php
'og_image' => [
    'enabled' => true,   // requires spatie/browsershot + Chrome
],

Затем заранее создайте карточки — до этого ничего не отрисовывается:

bash
php artisan seo:og-images

Как определяется итоговое изображение

Генерация никогда не переопределяет заданное вами изображение. Когда возможность включена, резолвер заполняет og:image только если у страницы нет собственного изображения: итоговое og:image пусто либо всё ещё равно общему статическому default_og_image сайта. Явное изображение модели (из getSEOImage(), строки seo_meta, поля контента и т. п.) всегда имеет приоритет перед сгенерированной карточкой.

Чтобы определить значение, резолвер вызывает поиск генератора с проверкой существования файла: вычисляет путь карточки в хранилище и возвращает её публичный URL только если файл уже существует на настроенном диске. Сам поиск ничего не отрисовывает. На этом основаны гарантии безопасности:

  • Веб-запрос никогда не запускает браузер — в худшем случае он ссылается на статическое default_og_image, как и до появления этой возможности.
  • Страница никогда не ссылается на ещё не созданное изображение, поэтому нет промежутка, когда опубликованные ссылки ведут на 404.

Промежуток между изменением контента и появлением карточки закрывает команда seo:og-images: запускайте её при развёртывании и/или по расписанию.

Команда seo:og-images

Заранее создаёт карточки, чтобы резолвер мог их отдавать.

bash
php artisan seo:og-images                         # warm the configured models
php artisan seo:og-images --model="App\Models\Post"
php artisan seo:og-images --force                 # re-render even existing cards
php artisan seo:og-images --prune                 # + delete orphaned cards
  • --model=* — один или несколько классов моделей, для которых нужно подготовить карточки. Параметр можно повторять. Без него команда использует seo.og_image.models, а если список пуст — модели карты сайта (seo.sitemap.models), как и seo:llms-txt, использующая общие источники с картой сайта.
  • --force — повторно отрисовать уже существующие карточки. Используйте после изменения шаблона или фирменных цветов без увеличения cache_version.
  • --prune — после подготовки удалить карточки по настроенному пути, которые больше не соответствуют текущему контенту ни одной модели (см. ниже). Для безопасности удаляются только файлы с именами в виде сгенерированных хешей контента, а не ваши остальные ресурсы в том же каталоге. Параметр игнорируется при запуске с ограничением --model, поскольку список сохраняемых файлов не охватывал бы другие модели. Запускайте его без --model.

Каждая модель должна использовать трейт HasSEO. Запись без заголовка пропускается: на карточке нечего показать. Команда сообщает числа generated, skipped, failed и, с --prune, pruned.

Расписание

Готовьте карточки по расписанию, чтобы они соответствовали контенту, и удаляйте осиротевшие файлы, оставшиеся после изменения заголовков:

php
// routes/console.php
Schedule::command('seo:og-images --prune')->daily();

Модель инвалидации

Имя файла карточки — хеш всего, что влияет на её пиксели: заголовка, названия сайта, имени шаблона, драйвера, размеров, цветов фирменного градиента, числа cache_version и установленной версии пакета.

Этот хеш служит ключом кеша, из чего следуют два важных последствия:

  • Изменили заголовок → новый хеш → новый файл. Старая карточка становится осиротевшей на диске, а страница возвращается к статическому изображению по умолчанию, пока вы не подготовите новую. Команда создаёт новую карточку; --prune удаляет осиротевшую. Так работает инвалидация: отдельный шаг «сбросить кеш одной страницы» не нужен.
  • Увеличили cache_version или обновили пакет → меняются все хеши. Используйте cache_version после правки шаблона или фирменных цветов, чтобы разом сделать все карточки устаревшими. Версия пакета учитывается автоматически: новый выпуск, изменивший встроенный шаблон, не будет отдавать устаревшие карточки.

Встроенные шаблоны

В пакет входят три шаблона размером 1200×630 с одним фирменным градиентом:

ШаблонДля чего подходитЧто показывает
seo::og.defaultЛюбой контентЗаголовок + название сайта
seo::og.articleСтатьи блога, новостиРубрика над заголовком + заголовок + подпись «автор · дата»
seo::og.productТовары, объявленияФирменный блок + метка категории + заголовок + описание

Выберите общий шаблон через seo.og_image.template или сопоставьте шаблоны типам моделей, чтобы статья и товар автоматически получали разные карточки:

php
// config/seo.php
'og_image' => [
    'templates' => [
        App\Models\Post::class    => 'seo::og.article',
        App\Models\Product::class => 'seo::og.product',
    ],
],

Модель также может переопределять свой шаблон во время работы, задав getOgImageTemplate(): ?string. Верните имя представления либо null для перехода к сопоставлению или значению по умолчанию. Приоритет: хук модели, затем сопоставление templates, затем общий template.

Настройка шаблона

Карточка — это представление Blade (по умолчанию seo::og.default), отрисованное в самодостаточный HTML-документ. Встроенный шрифт включён как data URI, поэтому браузеру не нужна сеть. Изменить шаблон можно двумя способами:

Опубликовать и отредактировать встроенное представление:

bash
php artisan vendor:publish --tag=seo-views

Затем отредактируйте resources/views/vendor/seo/og/default.blade.php.

Или указать собственное представление:

php
// config/seo.php
'og_image' => [
    'template' => 'og.my-card',   // resources/views/og/my-card.blade.php
],

Шаблон получает следующие переменные:

ПеременнаяТипПримечания
$titlestringOG-заголовок, если задан, иначе заголовок страницы.
$siteName?stringИтоговое og:site_name.
$fontDataUristringВстроенный жирный шрифт в виде URI data:; если недоступен — пустая строка, и браузер использует собственный шрифт без засечек.
$gradientFromstringseo.og_image.gradient_from.
$gradientTostringseo.og_image.gradient_to.
$widthintШирина результата (по умолчанию 1200).
$heightintВысота результата (по умолчанию 630).
$locale?stringИтоговая локаль страницы для атрибута <html lang>.
$author?stringАвтор статьи (используется в seo::og.article).
$publishedDate?stringДата публикации для seo::og.article: средний формат ICU в локали страницы, если доступен; иначе Carbon переводит месяц, сохраняя порядок M j, Y. Null, если дата не задана.
$section?stringРаздел / категория контента: рубрика над заголовком статьи или метка товара.
$description?stringOG-описание, иначе описание страницы (используется в seo::og.product).

Имя шаблона входит в ключ кеша

И имя шаблона, и цвета градиента входят в хеш контента. Поэтому смена шаблона или цветов автоматически делает существующие карточки устаревшими. Правка шаблона на месте этого не делает: имя остаётся прежним. После правки увеличьте cache_version или запустите команду с --force.

Конфигурация

php
// config/seo.php
'og_image' => [
    'enabled' => false,             // master switch (off by default)
    'driver'  => 'browsershot',     // the render driver; register your own via OgImageManager::extend()
    'template' => 'seo::og.default', // the default Blade view rendered as the card
    'templates' => [],              // per-model-class template overrides (see "Bundled templates")
    'strip_title_suffix' => true,   // trim seo.title_suffix off the card title (the card shows the site name itself)

    'width'  => 1200,               // social-card standard
    'height' => 630,

    'disk' => 'public',             // must be publicly served — its url() becomes the og:image
    'path' => 'og-images',          // path prefix on that disk

    // Models seo:og-images warms. Empty → falls back to seo.sitemap.models.
    // Accepts a list [Post::class] or a map [Post::class => [...]].
    'models' => [],

    // Bump to invalidate every card after editing a template/colors in place.
    // The installed package version is folded in too, so an upgrade busts them.
    'cache_version' => 1,

    // Brand gradient (diagonal) for the bundled default template.
    'gradient_from' => '#1e2a5a',
    'gradient_to'   => '#3D5AFE',

    // Browsershot binary paths. null = its defaults (node/npx on PATH,
    // puppeteer's bundled Chromium). Set explicitly in production.
    'chrome_path'     => null,      // path to a system Chrome/Chromium
    'node_binary'     => null,      // path to the node binary
    'npm_module_path' => null,      // node_modules dir (no-op on Windows — see Caveats)

    'timeout' => 60,                // hard per-render timeout, seconds

    // Launch Chrome with --no-sandbox; weakens browser isolation.
    // Prefer configuring the host to support Chrome's sandbox (see below).
    'no_sandbox' => false,

    // Extra Chromium CLI flags, e.g. ['disable-dev-shm-usage', 'disable-gpu']
    // on a low-/dev-shm container. Leading "--" optional; map form for
    // value-bearing flags: ['proxy-server' => 'http://…'].
    'browsershot_args' => [],

    // Fallback font families for glyphs the bundled face lacks (CJK, Thai,
    // Arabic, …). null = the built-in Noto list; see "Fonts and non-Latin
    // scripts" below.
    'font_stack' => null,
],

У большинства скалярных значений есть соответствующая переменная окружения (SEO_OG_IMAGE_ENABLED, SEO_OG_IMAGE_DISK, SEO_OG_IMAGE_CHROME_PATH, SEO_OG_IMAGE_NO_SANDBOX и т. д.). Полный список — в файле конфигурации. Ключи-массивы (templates, models, browsershot_args, font_stack) редактируются непосредственно в нём.

Диск должен быть доступен публично, поскольку резолвер использует его url() как значение og:image. Для диска public один раз выполните php artisan storage:link, чтобы public/storage указывал на него.

Запуск в Linux: песочница

На хостах, ограничивающих механизмы песочницы Chrome, php artisan seo:og-images может завершиться ошибкой:

No usable sandbox! Update your OS ... or see
https://chromium.googlesource.com/.../linux/suid_sandbox_development.md

Одна из возможных причин — ограничения пространств имён пользователей в Ubuntu 23.10+. Сверьтесь с руководством по устранению проблем Puppeteer и фактической ошибкой запуска браузера. Предпочтительно исправить конфигурацию хоста, чтобы Chrome мог сохранить песочницу.

1. Явный запасной вариант: запустить Chrome с --no-sandbox. Это отключает изоляцию браузера. Используйте только если при развёртывании вы осознанно принимаете такой компромисс:

php
// config/seo.php
'og_image' => [
    'no_sandbox' => true,   // or set SEO_OG_IMAGE_NO_SANDBOX=true
],

Rankbeam отрисовывает статический сгенерированный HTML и блокирует запросы удалённых ресурсов, но эти меры не заменяют песочницу Chrome. Запускайте процесс отрисовки без привилегий и изолируйте его от посторонних задач и секретов.

2. Сохранить песочницу. Оставьте no_sandbox выключенным. Если причина в AppArmor, адаптируйте профиль под конкретный исполняемый файл Chrome; см. рекомендации Chromium. Например:

# /etc/apparmor.d/chrome-og
abi <abi/4.0>,
include <tunables/global>
profile chrome-og /path/to/chrome flags=(unconfined) {
  userns,
  include if exists <local/chrome-og>
}

Затем загрузите профиль с помощью sudo apparmor_parser -r /etc/apparmor.d/chrome-og и убедитесь, что Chrome запускается с включённой песочницей.

Другие флаги

Для контейнера с нехваткой разделяемой памяти — ещё одна частая причина сбоев Chrome в Linux во время отрисовки — добавьте флаги через browsershot_args:

php
'browsershot_args' => ['disable-dev-shm-usage'],

Собственные драйверы

browsershot — единственный встроенный драйвер, но отрисовка скрыта за контрактом (Rankbeam\Seo\Contracts\OgImageRenderer). Зарегистрируйте собственный драйвер, например на основе canvas или сервиса, и выберите его через seo.og_image.driver:

php
use Rankbeam\Seo\Services\OgImage\OgImageManager;

app(OgImageManager::class)->extend('my-driver', fn ($app) => new MyRenderer());

Драйвер только преобразует самодостаточную HTML-строку в байты PNG заданного размера; компоновкой и шаблонами он не управляет.

Шрифты и нелатинские письменности

Встроенный шрифт карточек (Noto Sans Bold, OFL) покрывает латиницу, кириллицу и греческий. Остальные письменности — китайская, японская, корейская, тайская, арабская, иврит, деванагари — и эмодзи берутся из шрифтов, установленных на машине, где запускается seo:og-images. Другие шрифты намеренно не включены: один шрифт CJK занимает от 16 МБ. Встроенный в Chrome подбор резервного шрифта для каждого символа работает, как только на хосте есть подходящий шрифт.

Надёжность обеспечивают три механизма (3.15):

  1. Цепочка font-family для разных письменностей в каждом встроенном шаблоне. В body сначала указан 'OGBrand' (встроенный шрифт), затем seo.og_image.font_stack — по умолчанию Noto Sans, четыре семейства Noto Sans CJK, Noto Sans Thai, Noto Sans Arabic, Noto Sans Hebrew, Noto Sans Devanagari, Noto Color Emoji — и наконец sans-serif. Для каждого символа Chrome выбирает первое установленное подходящее семейство; отсутствующие пропускаются, поэтому список только помогает. Семейство CJK языка страницы перемещается в начало (ja → JP, zh-Hans → SC, zh-Hant / zh-TW / zh-HK → TC, ko → KR), поскольку одна кодовая точка Han отображается по-разному в национальных шрифтах (унификация Han). Атрибут <html lang> содержит локаль страницы в форме BCP 47. Цепочка входит в ключ кеша, поэтому её изменение требует повторной отрисовки всех карточек.

  2. Предварительная проверка в seo:og-images. Перед отрисовкой команда запрашивает у fontconfig (fc-list :lang=ja, th, ar и т. д.), есть ли шрифт для письменностей заголовка, названия сайта и описания, включая письменность, занимающую малую долю смешанного текста. Она предупреждает один раз для каждой письменности и указывает пакет для установки:

    No installed font covers cjk text — its cards may render as boxes. Install one: apt-get install fonts-noto-cjk

    Если fontconfig отсутствует (Windows, macOS, минимальный контейнер), команда не гадает и не выдаёт предупреждение. Сама отрисовка никогда не завершается ошибкой из-за отсутствующего шрифта: Chrome рисует прямоугольники .notdef. Именно поэтому и нужно предупреждение.

  3. Образец глифов каждой письменности в реальном дымовом тесте. При SEO_OG_IMAGE_LIVE_TEST=1 тест tests/Feature/OgImage/BrowsershotSmokeTest.php отрисовывает заголовок на ja, zh-Hans, zh-Hant, ko, el, ru, tr, th, ar, he и hi рядом с контрольной строкой той же длины из неназначенной кодовой точки, которая гарантированно даёт прямоугольники. Если два PNG побайтово одинаковы, тест завершается ошибкой с указанием письменности и пакета. Это дымовая проверка, а не подтверждение каждого глифа: примесь латиницы или другие переносы могут дать разные изображения, даже если часть глифов отсутствует. Проверяйте фактическую отрисовку и шрифты на хосте развёртывания. Предупреждение FontProbe для языка — тоже предварительная проверка, а не полное подтверждение покрытия шрифтом. В ядре нет команды seo:doctor; для этой предварительной проверки используйте seo:og-images.

В Debian/Ubuntu:

bash
apt-get install fonts-noto-cjk fonts-noto-core fonts-noto-color-emoji
fc-cache -f

Собственные шаблоны, опубликованные через --tag=seo-views до версии 3.15, продолжают работать: они получают новые переменные $fontFamily и $lang и могут их игнорировать.

Ограничения

О них важно знать до развёртывания:

  • Только предварительная генерация — без эндпоинта отрисовки по запросу (v1). Маршрута, создающего карточку по запросу, нет. Поскольку веб-запрос ничего не отрисовывает, нет связанной с таким эндпоинтом поверхности атак через подписанные URL / SSRF / DoS, которую нужно настраивать или защищать. Обратная сторона: для появления карточек нужно запускать seo:og-images при развёртывании и/или по расписанию.
  • npm_module_path не действует в Windows. Он соответствует методу Browsershot setNodeModulePath(), который добавляет к команде префикс POSIX NODE_PATH=…, игнорируемый Windows. В Windows устанавливайте puppeteer в корне приложения, чтобы Node находил его, поднимаясь по каталогам. В Linux/macOS настройка работает как ожидается.
  • Нелатинским письменностям нужен шрифт на хосте. См. Шрифты и нелатинские письменности: встроенный шрифт покрывает латиницу, кириллицу и греческий; остальные берутся из шрифтов образа развёртывания, и команда сообщает об отсутствии нужного шрифта.
  • Сбой не блокирует страницу. Если отрисовка не удалась — отсутствует пакет, браузер аварийно завершился или истёк тайм-аут, — команда сообщает об этом, а страница сохраняет статическое default_og_image. Сломанный браузер не приводит к ошибке 500 на странице.

rankbeam/laravel-seo распространяется под лицензией MIT.