Pull to refresh

Как мы используем Puppeteer для создания Open Graph изображений с Node.js

Level of difficultyEasy
Reading time7 min
Views2.7K

В наше время многочисленные сайты создают страницы, которыми пользователи хотели бы делиться в разных социальных сетях или мессенджерах. Благодаря тегам Open Graph ссылки могут иметь красочное превью изображение, которое привлекает еще больше внимания, например, с помощью тега og:image.

Обычно многие веб-сайты не прикладывают особых усилий к предварительному просмотру изображений и просто добавляют одно изображение на большинство страниц. Если изображения нет, парсеры пытаются автоматически найти первое доступное подходящее изображение и использовать его.

Но представьте как бы было здорово иметь персонализированную картинку на странице, например, когда у вас есть профили ваших пользователей, или каких-то событий в которых они участвуют. Такая задача возникла и в нашем проекте, где мы хотели бы генерировать страницы с красочными изображениями которыми пользователи хотели бы делиться.

Например, посмотрите на картинку ниже которую мы генерируем для шаринга в социальных сетях. Тут есть кастомный шрифт, градиент, и даже локализация картинки на разные языки.

Open Graph изображение профиля пользователя
Open Graph изображение профиля пользователя

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

Пример создания изображения на PHP
<?php

$width = 400;
$height = 200;
$image = imagecreatetruecolor($width, $height);

$backgroundColor = imagecolorallocate($image, 255, 255, 255);
imagefill($image, 0, 0, $backgroundColor);

$textColor = imagecolorallocate($image, 0, 0, 0);

$fontFile = 'path/to/your/font.ttf';

$language = isset($_GET['lang']) ? $_GET['lang'] : 'ru';
$text = getLocalizedText($language);

$fontSize = 24;

$textbox = imagettfbbox($fontSize, 0, $fontFile, $text);
$textX = ($width - ($textbox[2] - $textbox[0])) / 2;
$textY = ($height - ($textbox[5] - $textbox[3])) / 2 + ($textbox[5] - $textbox[3]);

imagettftext($image, $fontSize, 0, $textX, $textY, $textColor, $fontFile, $text);

header("Content-Type: image/png");
imagepng($image);
imagedestroy($image);

function getLocalizedText($language) {
    switch ($language) {
        case 'en':
            return 'Hello, PHP!';
        case 'fr':
            return 'Bonjour, PHP!';
        default:
            return 'Привет, PHP!';
    }
}

Как видно из примера, если текст будет слишком длинный, то может уйти и за рамки изображения. А если сюда добавить градиенты, полупрозрачность, тени или что-то еще интересное, то код усложнится и поддерживать его будет не очень просто.

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

Решение, о котором я хотел бы рассказать позволяет в разы сократить время разработки и упростить поддержку таких изображений. Более того - оно гибкое и очень легко масштабируется и для других вещей.

Для веб-разработчика HTML - это самый простой и удобный язык разметки. Вместе со стилями CSS вы можете создать гибкий интерфейс, учитывающий расположение любых элементов на странице — изображений, текста, таблиц, списков и т.д.

Давайте посмотрим и на примере ниже. Это простая HTML-страница со множеством стилей CSS, таких как градиент в тексте, тени, ограничение текстовых строк для длинного текста и другое.

Пример HTML кода для страницы профиля
html>
    <head>
        <style>
            html, body {
                margin: 0;
                padding: 0;
            }

            body {
                background-color: #0093E9;
                background-image: linear-gradient(160deg, #0093E9 0%, #80D0C7 100%);

                font-family: Helvetica, sans-serif;
                padding: 5%;
                text-align: center;
            }

            header {
                position: relative;
            }

            header .emoji {
                position: absolute;
                top: -10px;
                left: 0;
                transform: rotate(20deg);
                font-size: 3rem;
            }

            * {
                box-sizing: border-box;
            }

            h1 {
                text-transform: uppercase;
                font-size: 3rem;
                background: -webkit-linear-gradient(45deg, #85FFBD 0%, #FFFB7D 100%);
                -webkit-background-clip: text;
                -webkit-text-fill-color: transparent;
            }

            .wrapper {
                display: flex;
                padding-top: 2rem;
            }

            .avatar {
                display: flex;
                justify-content: center;
                align-items: center;
            }
            .avatar img {
                width: 140px;
                height: 140px;
                border-radius: 100px;
                border: 5px solid rgba(255,255,255, 0.5);
                box-shadow: 0 0 10px rgba(0,0,0,0.2);
                object-fit: cover;
            }

            .content {
                padding: 1rem 2rem;
            }

            .content .text {
                font-size: 1.5rem;
                display: -webkit-box;
                -webkit-line-clamp: 3; /* number of lines to show */
                        line-clamp: 3; 
                -webkit-box-orient: vertical;
                overflow: hidden;
                color: rgba(255,255,255, 0.8);
            }
        </style>
    </head>
    <body>
        <div>
            <header>
                <h1>Hello, Javascript</h1>
                <div class="emoji">?</div>
            <header>
            <div class="wrapper">
                <div class="avatar">
                    <img src="https://sun9-57.userapi.com/impg/O3egMIWPZjhcKSThZ2hn7ByaQmET8ySOq5e4ww/O_ngP3qqEd8.jpg?size=1178x1789&quality=95&sign=71fcbf49ffff80fad9f0ef39f598cf69&type=album" alt=""/>
                </div>
                <div class="content">
                    <div class="text"><strong>Lorem Ipsum</strong>  is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.</div>
                </div>
            </div>
        </div>
    </body>
</html>

Теперь все, что нам нужно, так это запустить веб-сервер, который может обслуживать этот HTML-код, и запустить приложение node.js с библиотекой Puppeteer, которая запускает headless версию Google Chrome, а затем вы можете сделать снимок экрана страницы и получить изображение.

Ниже приведен пример кода, который позволяет получить содержимое страницы с заданной шириной, высотой. Крайне важно обрабатывать исключения и закрывать страницы браузера, если что-то пойдет не так; в противном случае это может привести к утечке памяти.

import puppeteer, { Page } from 'puppeteer-core';

const width = 800;
const height = 600;
const deviceScaleFactor = 1;
const imageType = 'webp';
const imageQuality = 90;
const url = '<Путь к странице на веб сервере>';

// Путь к Chrome или Chromium
// На macOS путь до браузера будет таким "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
// на Linux Для Google Chrome будет "/usr/bin/google-chrome-stable"
const browserPath = '/usr/bin/chromium'; 
 
const browser = await puppeteer.launch({
    headless: true,
    ignoreHTTPSErrors: true,
    executablePath: browserPath,
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
});

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 Error(`Incorrect status page ${result?.status()}`);
    }
} catch (error) {
    page.close();
    throw new Error(error as string);
}

const data = await page.screenshot({
    type: imageType,
    quality: imageQuality,
    encoding: 'binary',
});

page.close();

В результате получается вот такое изображение, которое можно сохранить на диск или загрузить в свое S3 хранилище и предоставить его пользователям.

Изображение созданное с помощью Puppeteer
Изображение созданное с помощью Puppeteer

В нашем случае мы создали небольшое приложение node.js с Puppeteer, которое упаковано в контейнер Docker, и запускаем внутри него HTTP-сервер для управления запросами. HTML код мы генерируем с помощью серверного рендера React, в зависимости от переданных данных в запросе. В итоге приложение позволяет нам создавать изображения различного формата (например, png, jpeg или webp) для любой страницы веб-сайта. Также мы отключаем поддержку JS потому что отрисовкой занимается серверный рендер и экономим время загрузки страницы (нам нужны только картинки). С недавних пор мы полностью перешли на генерацию webp изображений и это позволило нам существенно сократить размер изображений и сделать градиенты (по сравнению с jpeg) более приятными.

В итоге, полная логика сервиса по генерации OG-изображений получилось такой:

  1. Когда пользователь шарит где-то ссылку, то в og:image отдается специальная ссылка (с идентификатором его профиля, необходимым размером и расширением изображения) на прокси-сервер nginx.

  2. Прокси-сервер проверяет, находится ли изображение уже в хранилище S3 или нет.

  3. Если изображение существует, оно отдается напрямую; в противном случае делается запрос к сервису node.js, который генерирует изображение, возвращает его и асинхронно загружает на S3.

Несмотря на незначительные технические трудности, новый сервис значительно сократил время на создание и поддержку изображений по сравнению с предыдущим подходом. Такое решение позволяет нам быстро обновлять изображения, просто меняя HTML-код, легко тестировать его в браузере и использовать все возможности CSS.

Само собой такой подход можно использовать и для создания любых других изображений как баннеры или даже, например, PDF файлов с описанием каких-либо товаров. Более того, для Puppeteer есть библиотека puppeteer-screen-recorder благодаря которой можно создавать видео ролики в которой анимация будет создана с помощью CSS. Это тоже может быть полезным потому что, например, iMessage умеет играть небольшие видео которые есть в метатеге og:video. Возможно в будущем мы увидим такую же возможность, например, и в Telegram.

import puppeteer from 'puppeteer';
import { PuppeteerScreenRecorder } from 'puppeteer-screen-recorder';

function delay(time: number) {
    return new Promise(function (resolve) {
        setTimeout(resolve, time);
    });
}

(async () => {
    const browser = await puppeteer.launch({ headless: true });
    const page = await browser.newPage();

    await page.setViewport({
        width: 640,
        height: 480,
    });
    await page.goto('http://localhost:10001');

    const recorder = new PuppeteerScreenRecorder(page, {
        fps: 30,
        videoCrf: 20,
        videoFrame: {
            width: 640,
            height: 480,
        },
    });
  
    await recorder.start('./simple.mp4');

    await delay(500);
    await recorder.stop();
    await page.close();
    await browser.close();
})();

В итоге получается вот такой ролик

Для удобства вот пример докер-контейнера, который мы используем со всеми необходимыми библиотеками для работы с изображениями.

Пример Docker контейнера
FROM node:18

RUN 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 \
  libvips \
  chromium

# Добавьте нужные вам шрифты
COPY ./fonts /root/.fonts
RUN fc-cache -fv

# Сборка вашего проекта
...

# Запуск приложения
CMD ["node", "app.js"]

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 4: ↑4 and ↓0+4
Comments0

Articles