Meta image openGraph via un service de capture
Le 1/27/2023

Hello World !

La dernière fois on a setup Nuxt3 en SSR avec du rendu sur des fonctions firebase, aujourd’hui on va voir comment ajouter un service de capture pour les metas openGraph (en gros quand vous partagez le lien sur un réseau social ça va prendre un screenshot de la page)

PS: N’hésitez pas à donner votre avis sur le discord, si vous voyez une coquille n’hésitez pas à la remonter 😋

# Service de capture

J’utilise FastAPI + Playwright pour faire le service mais vous pouvez utiliser ce que vous voulez.

On fait un dossier capture et on y met 3 fichiers :

  • Dockerfile
  • requirements.txt
  • main.py

# Dockerfile

# On commence de l'image officiel de playwright
FROM mcr.microsoft.com/playwright/python:v1.30.0-focal

# Pour avoir les logs en temps réel dans GCP
ENV PYTHONUNBUFFERED True

# On récupère le fichier qui décrit les dépendances et on les installes
COPY requirements.txt ./
RUN python3 -m pip install --no-cache-dir -r requirements.txt

# on install un binaire chromium grace à Playwright
RUN python3 -m playwright install chromium

# On copie les sources
COPY main.py ./

# enfin on donne la commande à lancer
CMD [ "uvicorn", "main:app", "--port", "8000", "--host", "0.0.0.0" ]
capture/Dockerfile

# requirements.txt

J’utilise Poetry pour gérer les dépendances et surtout pour générer le fichier de requirements

[tool.poetry]
name = "capture"
version = "0.1.0"
description = ""
authors = ["Benoit Deveaux <contact.flapili@gmail.com>"]

[tool.poetry.dependencies]
python = "^3.10"
fastapi = "^0.89.1"
uvicorn = "^0.20.0"
Pillow = "^9.4.0"
playwright = "^1.30.0"

[tool.poetry.dev-dependencies]
black = "^22.12.0"
flake8 = "^6.0.0"

[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
pyproject.toml
poetry export -f requirements.txt -o requirements.txt --without-hashes

ce qui donne le fichier suivant :

anyio==3.6.2; python_full_version >= "3.6.2" and python_version >= "3.7"
click==8.1.3; python_version >= "3.7"
colorama==0.4.6; python_version >= "3.7" and python_full_version < "3.0.0" and platform_system == "Windows" or platform_system == "Windows" and python_version >= "3.7" and python_full_version >= "3.7.0"
fastapi==0.89.1; python_version >= "3.7"
greenlet==2.0.1; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.7"
h11==0.14.0; python_version >= "3.7"
idna==3.4; python_full_version >= "3.6.2" and python_version >= "3.7"
pillow==9.4.0; python_version >= "3.7"
playwright==1.30.0; python_version >= "3.7"
pydantic==1.10.4; python_version >= "3.7"
pyee==9.0.4; python_version >= "3.7"
sniffio==1.3.0; python_full_version >= "3.6.2" and python_version >= "3.7"
starlette==0.22.0; python_version >= "3.7"
typing-extensions==4.4.0; python_version >= "3.7"
uvicorn==0.20.0; python_version >= "3.7"
capture/requirements.txt

# le service de capture, main.py

# coding: utf-8
import io
from typing import Union

from PIL import Image
from playwright.async_api import async_playwright
from fastapi import FastAPI, Response, Header, HTTPException

app = FastAPI(openapi_url=None)

# /{path:path} est un catch-all
@app.get("/{path:path}")
async def get_capture(
    path: str,
    host: Union[str, None] = Header(default=None, alias="x-forwarded-host"),
):
    # si c'est pas apellé depuis un domain personalisé
    # (qui est transmis à l'application via le header x-forwarded-host)
    if host is None:
        raise HTTPException(status_code=400, detail="bad host")

    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()
        await page.set_viewport_size({"width": 1920, "height": 1080})
        await page.goto(f"https://{host}")

        # on enlève les scrollbar
        await page.evaluate("document.documentElement.style.overflow = 'hidden';")

        result = await page.screenshot()

        await page.close()
        await browser.close()

        # on redimensionne l'image
        with io.BytesIO(result) as f:
            with Image.open(f) as img:
                resized_img = img.convert("RGB").resize((960, 540))

        # on transforme tout ça en jpeg avec une qualité de 80%
        with io.BytesIO() as output_img:
            resized_img.save(output_img, "JPEG", quality=80)

            # on retourne l'image
            # de plus on utilise le header cache-control pour dire au CDN de google
            # de garder en cache l'image pendant 600s
            return Response(
                content=output_img.getvalue(),
                media_type="image/jpeg",
                headers={"Cache-Control": "public, max-age=0, s-maxage=600"},
            )
capture/main.py

# Deployement du container

Attention
Prérequis: il faut avoir installé gcloud.
# Login sur la cli de GCP
gcloud auth login

# On selectionne le bon projet
gcloud config set project test-nuxt3-faas

# ça va demander quelques infos, comme le nom du service, la region, etc
gcloud run deploy

Et normalement le service Cloud Run est déployé

# Utilisation du service

Je me suis fait un composable pour avoir le host coté Nuxt:

export const useHost = () => {
  const headers = useState('headers', () => useRequestHeaders()).value
  const host = headers['x-forwarded-host']
  return host
}
composables/useHost.ts

et dans le app.vue il suffit de rajouter les metas openGraph, de dire que l’image est sur l’url {host}/_api/capture

<script setup>
const host = useHost()
useSeoMeta({
  ogImage: () => `https://${host}/_api/capture`,
  ogImageHeight: 540,
  ogImageWidth: 960,
})
</script>

<template>
  <div>
    <NuxtPage />
  </div>
</template>
app.vue

Il n’y a plus qu’à faire une règle de redirection

{
  "functions": {
    "source": ".output/server"
  },
  "hosting": [
    {
      "site": "test-nuxt3-faas",
      "public": ".output/public",
      "cleanUrls": true,
      "rewrites": [
        {
          "source": "/_api/capture",
          "run": {
            "serviceId": "capture",
            "region": "europe-west1"
          }
        },
        {
          "source": "**",
          "function": "server"
        }
      ]
    }
  ]
}
firebase.json

Un coup de firebase deploy and voilà !

si je vais sur https://test-nuxt3-faas.flapili.fr/_api/capture j’ai ça comme rendu :

capture

Et si je partage le lien sur discord TADAAAA !

previsu-discord

# EDIT

L’image faisait quasiment 2go, j’ai donc fait une alternative plus legère:

FROM joyzoursky/python-chromedriver:3.9-alpine-selenium

RUN apk add --no-cache jpeg-dev zlib-dev
RUN apk add --no-cache --virtual .build-deps build-base linux-headers \
    && pip install Pillow uvicorn fastapi

WORKDIR /app
COPY main.py .


CMD [ "uvicorn", "main:app", "--port", "8000", "--host", "0.0.0.0" ]
Dockerfile
# coding: utf-8
import io
from typing import Union

from PIL import Image
from selenium import webdriver
from fastapi import FastAPI, Response, Header, HTTPException


app = FastAPI(openapi_url=None)


@app.get("/{path:path}")
def get_capture(
    path: str,
    host: Union[str, None] = Header(default=None, alias="x-forwarded-host"),
):
    if host is None:
        raise HTTPException(status_code=400, detail="bad host")

    chrome_options = webdriver.ChromeOptions()
    chrome_options.add_argument("--no-sandbox")
    chrome_options.add_argument("--headless")
    chrome_options.add_argument("--disable-gpu")
    chrome_options.add_argument("--disable-dev-shm-usage")
    chrome_options.add_argument("--window-size=1920,1080")
    driver = webdriver.Chrome(options=chrome_options)
    driver.get(f"https://{host}")
    result = driver.get_screenshot_as_png()

    driver.close()

    with io.BytesIO(result) as f:
        with Image.open(f) as img:
            resized_img = img.convert("RGB").resize((960, 540))

    with io.BytesIO() as output_img:
        resized_img.save(output_img, "JPEG", quality=80)
        return Response(
            content=output_img.getvalue(),
            media_type="image/jpeg",
            headers={"Cache-Control": "public, max-age=0, s-maxage=600"},
        )
main.py

et maintenant TADA !

docker image ls
REPOSITORY  TAG     IMAGE ID      CREATED            SIZE
test        latest  2aba5691207f  About an hour ago  630MB