Создание изображений с помощью node.js и puppeteer
В наше время множество сайтов создают страницы которыми пользователи хотели бы делиться друг с другом. Если раньше это была просто ссылка, то сейчас, благодаря Open Graph тегам ссылки могут иметь красочное превью изображение которые еще больше привлекает к себе внимание, например, в социальных сетях.
Обычно многие сайты не сильно заморачиваются с превью изображения и просто добавляют одну картинку на большинство страниц или используют какую-либо стоковую фотографию, если же картинки нет, то парсеры пытаются автоматически найти первое доступное подходящие изображение и используют его.
Но представьте как бы было здорово иметь персонализированную картинку на странице, например, когда у вас есть профили ваших пользователей, или каких-то событий в которых они участвуют. Такая задача возникла и в нашем проекте, где мы хотели бы генерировать страницы с красочными изображениями которыми пользователи хотели бы делиться.
Например, посмотрите на картинку ниже которую мы генерируем для шаринга в социальных сетях. Тут есть кастомный шрифт, градиент, и даже локализация картинки на разные языки. Этой картинкой приятно делиться в социальных сетях, она явно привлекает внимание.
В нашем проекте, первые версии картинок генерировались на PHP и на первое время этого хватало - потому что картинки были достаточно простые и содержали только изображение пользователя. Но как только мы добавили имя пользователя - то сразу же начались проблемы с позиционированием текста. Например, вот небольшой пример создания простого изображения на php.
<?php
$width = 300;
$height = 150;
$image = imagecreatetruecolor($width, $height);
$backgroundColor = imagecolorallocate($image, 255, 255, 255);
imagefill($image, 0, 0, $backgroundColor);
$textColor = imagecolorallocate($image, 0, 0, 0);
$text = "Hello, PHP!";
$fontSize = 20;
$textX = ($width - imagefontwidth($fontSize) * strlen($text)) / 2;
$textY = ($height - imagefontheight($fontSize)) / 2;
imagestring($image, $fontSize, $textX, $textY, $text, $textColor);
header("Content-Type: image/png");
imagepng($image);
imagedestroy($image);
?>
Как видно из примера, если текст будет слишком длинный, то может уйти и за рамки изображения. А если сюда добавить градиенты, полупрозрачность, другие шрифты, эмоджи, то код очень усложнится и работать с ним будет не очень просто. Итого мы получаем вот такой список проблем:
- Длинные названия (текст на английском может умещаться в размер изображения, а на другом языке выходить за рамки. Это нужно учитывать и правильно позиционировать).
- Использование дополнительных шрифтов или emoji с iPhone. (текст с другим шрифтом тоже может выходить за рамки)
- Для создания 2х или 3х размера изображения скорее всего нужно менять размеры шрифта, положение элементов на странице.
Все это приводит к тому, что затраты на изменение изображения требуют достаточно большого количества времени разработки.
Решение, о котором я хотел бы рассказать позволяет в разы сократить время разработки и упростить поддержку таких изображений. Более того оно гибкое и очень легко масштабируется для других вещей.
Самым простым и удобным языком разметки является HTML. Вместе с CSS стилями можно сделать интерфейс который будет гибким и учитывать позиционирование любых элементов на странице - картинки, текст, таблицы, списки. А так же стилизовать это все любым образом.
В нашем проекте уже были страницы которые мы отображали с помощью серверного рендера на React и они содержали всю необходимую информацию. Все, что нужно было сделать - это только убрать лишнюю информацию со страницы и оставить только необходимые данные. После этого используя библиотеку puppeteer, которая запускает handless версию Google Chrome можно сделать скриншот страницы и получить нужное изображение.
Ниже пример кода который позволяет получить содержимое страницы с указанной шириной и высотой. Тут очень важно обрабатывать исключения и закрывать страницы браузера, если что-то пошло не так. Иначе можно получить утечку по памяти.
const browser = await puppeteer.launch({
headless: true,
ignoreHTTPSErrors: true,
executablePath: browserPath,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
dumpio: true,
});
const page: Page = await browser.newPage();
await page.setJavaScriptEnabled(false);
await page.setViewport({
width,
height,
deviceScaleFactor,
});
try {
const result = await page.goto(url, {
waitUntil: 'load',
});
if (result.status() !== 200) {
page.close();
throw new PageNotFoundError(`Incorrect status page ${result.status()}`);
}
} catch (error) {
page.close();
throw new PageFetchError(error as string);
}
const data = await page.screenshot({
type: imageType,
quality: imageQuality,
encoding: 'binary',
});
page.close();
В нашем случае мы не отключаем поддержку JS потому что отрисовкой занимается серверный рендер и экономим время загрузки страницы (нам нужны только картинки).
После того как изображение получено, его можно загрузить в какое-то S3 хранилище с определенным именнем и TTL временем жизни изображения, а потом уже обращаться к нему в OG тегах страницы.
В итоге получилось легкое node.js приложение, которое мы запаковали в докер контейнер и оно позволяет генерировать изображения для разных страниц сайта с разными форматами. Логика нашего сервиса получилась такая:
- При открытии страницы мы отдаем OG ссылку на изображение которая ведет на наш прокси сервер
- Когда делается превью страницы и идет HTTP запрос за картинкой, то мы сначала на прокси сервере проверяем есть ли такая картинка в S3 хранилище
- Если картинка уже была сгенерирована, то отдаем ее
- Если картинки нет, то делаем запрос в сервис, он генерирует изображение и отдает его и в фоне загружает в S3 хранилище.
Так же были небольшие техническое сложности с которыми мы столкнулись:
- Chromium не смог запуститься на Docker образе ubuntu. Это проблему мы решили прямым скачиванием Google Chrome.
- Не все шрифты одинаковы полезны, один из шрифтов который мы загрузили ломал нам шрифт Emoji. Решили проблему методом исключения, но пришлось повозиться.
Но все это мелочи по сравнению с тем сколько времени уходило раньше на поддержку PHP кода для создания картинок. В итоге меньше чем за неделю новый сервис был готов к production решению. Такое решение позволило не только в разы сократить время поддержки изображений, но и генерировать красочные изображения для пользователей.
Для удобства предоставляю команды для сборки докер контейнера
FROM ubuntu:20.04
RUN ln -s /usr/share/zoneinfo/Etc/UTC /etc/localtime \
&& apt -y update \
&& apt -y install \
git \
openssh-server \
gconf-service \
libasound2 \
libatk1.0-0 \
libc6 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libexpat1 \
libfontconfig1 \
libgcc1 \
libgconf-2-4 \
libgdk-pixbuf2.0-0 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libstdc++6 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxcursor1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxi6 \
libxrandr2 \
libxrender1 \
libxss1 \
libxtst6 \
ca-certificates \
fonts-liberation \
libappindicator1 \
libnss3 \
lsb-release \
xdg-utils \
wget \
curl \
libnss3-dev \
libgbm-dev \
libu2f-udev \
udev \
&& (curl -fsSL https://deb.nodesource.com/setup_14.x | bash -) \
&& apt-get install -y nodejs \
&& wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \
&& apt install ./google-chrome-stable_current_amd64.deb \
&& rm -rf ./google-chrome-stable_current_amd64.deb \
&& apt-get clean \
&& rm -rf /var/cache/apt/lists
# Add new fonts
COPY ./fonts /root/.fonts
RUN fc-cache -fv
# Build and run your node.js app
...
# Run node
CMD ["node", "app.js"]