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
# 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 :
Et si je partage le lien sur discord TADAAAA !
# 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