Генерация 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). Эндпоинта отрисовки по запросу нет (см. Ограничения).
Требования
Браузерный драйвер — необязательная зависимость, поэтому бесплатное ядро устанавливается без него. Чтобы включить возможность, установите в приложении:
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), и включите возможность:
// config/seo.php
'og_image' => [
'enabled' => true, // requires spatie/browsershot + Chrome
],Затем заранее создайте карточки — до этого ничего не отрисовывается:
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
Заранее создаёт карточки, чтобы резолвер мог их отдавать.
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.
Расписание
Готовьте карточки по расписанию, чтобы они соответствовали контенту, и удаляйте осиротевшие файлы, оставшиеся после изменения заголовков:
// 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 или сопоставьте шаблоны типам моделей, чтобы статья и товар автоматически получали разные карточки:
// 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, поэтому браузеру не нужна сеть. Изменить шаблон можно двумя способами:
Опубликовать и отредактировать встроенное представление:
php artisan vendor:publish --tag=seo-viewsЗатем отредактируйте resources/views/vendor/seo/og/default.blade.php.
Или указать собственное представление:
// config/seo.php
'og_image' => [
'template' => 'og.my-card', // resources/views/og/my-card.blade.php
],Шаблон получает следующие переменные:
| Переменная | Тип | Примечания |
|---|---|---|
$title | string | OG-заголовок, если задан, иначе заголовок страницы. |
$siteName | ?string | Итоговое og:site_name. |
$fontDataUri | string | Встроенный жирный шрифт в виде URI data:; если недоступен — пустая строка, и браузер использует собственный шрифт без засечек. |
$gradientFrom | string | seo.og_image.gradient_from. |
$gradientTo | string | seo.og_image.gradient_to. |
$width | int | Ширина результата (по умолчанию 1200). |
$height | int | Высота результата (по умолчанию 630). |
$locale | ?string | Итоговая локаль страницы для атрибута <html lang>. |
$author | ?string | Автор статьи (используется в seo::og.article). |
$publishedDate | ?string | Дата публикации для seo::og.article: средний формат ICU в локали страницы, если доступен; иначе Carbon переводит месяц, сохраняя порядок M j, Y. Null, если дата не задана. |
$section | ?string | Раздел / категория контента: рубрика над заголовком статьи или метка товара. |
$description | ?string | OG-описание, иначе описание страницы (используется в seo::og.product). |
Имя шаблона входит в ключ кеша
И имя шаблона, и цвета градиента входят в хеш контента. Поэтому смена шаблона или цветов автоматически делает существующие карточки устаревшими. Правка шаблона на месте этого не делает: имя остаётся прежним. После правки увеличьте cache_version или запустите команду с --force.
Конфигурация
// 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. Это отключает изоляцию браузера. Используйте только если при развёртывании вы осознанно принимаете такой компромисс:
// 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:
'browsershot_args' => ['disable-dev-shm-usage'],Собственные драйверы
browsershot — единственный встроенный драйвер, но отрисовка скрыта за контрактом (Rankbeam\Seo\Contracts\OgImageRenderer). Зарегистрируйте собственный драйвер, например на основе canvas или сервиса, и выберите его через seo.og_image.driver:
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):
Цепочка
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. Цепочка входит в ключ кеша, поэтому её изменение требует повторной отрисовки всех карточек.Предварительная проверка в
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. Именно поэтому и нужно предупреждение.
Образец глифов каждой письменности в реальном дымовом тесте. При
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:
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. Он соответствует методу BrowsershotsetNodeModulePath(), который добавляет к команде префикс POSIXNODE_PATH=…, игнорируемый Windows. В Windows устанавливайтеpuppeteerв корне приложения, чтобы Node находил его, поднимаясь по каталогам. В Linux/macOS настройка работает как ожидается.- Нелатинским письменностям нужен шрифт на хосте. См. Шрифты и нелатинские письменности: встроенный шрифт покрывает латиницу, кириллицу и греческий; остальные берутся из шрифтов образа развёртывания, и команда сообщает об отсутствии нужного шрифта.
- Сбой не блокирует страницу. Если отрисовка не удалась — отсутствует пакет, браузер аварийно завершился или истёк тайм-аут, — команда сообщает об этом, а страница сохраняет статическое
default_og_image. Сломанный браузер не приводит к ошибке 500 на странице.