đ ProblĂ©matique
đŻ Objectifs
- Renouveler automatiquement le token Facebook, sans intervention manuelle, en le stockant dans Vercel Edge Config.
- Publier automatiquement les nouveaux articles enrichis avec les métadonnées OpenGraph (titre, description, image).
- Centraliser ces opérations dans des API routes Next.js.
- Piloter lâexĂ©cution avec des tĂąches planifiĂ©es via Vercel Cron.
đ Gestion automatique du token Facebook
-
Une API route
/api/cron/refresh-facebook-token
est appelée chaque jour par une tùche cron Vercel. - Elle vérifie si un token est présent dans Edge Config.
- Si oui, elle appelle lâAPI Graph de Facebook pour renouveler le token.
- Le nouveau token est stocké dans Edge Config.
- Les erreurs éventuelles sont loguées pour suivi.
typeScript /src/app/api/cron/refresh facebook token/route.ts
// /app/api/refresh-facebook-token/route.ts
import { NextResponse } from "next/server";
import { getAll, has, get } from '@vercel/edge-config';
// On récupÚre les identifiants de l'app Facebook
const facebook_app_id = process.env.FACEBOOK_APP_ID!;
const facebook_app_secret = process.env.FACEBOOK_APP_SECRET!;
// Route GET appelée par une tùche cron Vercel
export async function GET() {
try {
// Vérifie si un token existe déjà dans l'Edge Config
const hasFacebookToken = await has('facebook_token');
if (hasFacebookToken) {
const oldToken = await get('facebook_token') as string;
// On tente de renouveler le token
const result = await updateFacebookToken(oldToken);
if (result?.success) {
return NextResponse.json({
facebookToken: result.newToken,
status: 'success',
message: 'Token Facebook renouvelé.'
});
} else {
return NextResponse.json({
status: 'error',
message: 'Ăchec du renouvellement du token Facebook.'
}, { status: 500 });
}
}
// Aucun token Facebook trouvĂ© â retour d'Ă©tat
const configItems = await getAll();
return NextResponse.json({
configItems,
status: 'ok',
message: 'Aucun token Facebook Ă rafraĂźchir.'
});
} catch (err: any) {
console.error('Erreur lors du rafraĂźchissement du token :', err);
return NextResponse.json({
status: 'error',
message: 'Erreur serveur lors de la tentative de rafraĂźchissement.'
}, { status: 500 });
}
}
// Fonction qui appelle l'API Facebook pour renouveler le token
async function updateFacebookToken(oldToken: string): Promise<{ success: boolean, newToken?: string }> {
try {
// Appel à Facebook pour échanger le token
const res = await fetch(`https://graph.facebook.com/v21.0/oauth/access_token?grant_type=fb_exchange_token&client_id=${facebook_app_id}&client_secret=${facebook_app_secret}&fb_exchange_token=${oldToken}`);
const data = await res.json();
if (!data.access_token) {
console.error('Réponse invalide de Facebook :', data);
return { success: false };
}
// Mise Ă jour dans lâEdge Config
const updateRes = await fetch(
`https://api.vercel.com/v1/edge-config/${process.env.VERCEL_EDGE_ID}/items?teamId=${process.env.VERCEL_TEAM_ID}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.VERCEL_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
items: [
{
operation: 'update',
key: 'facebook_token',
value: data.access_token,
},
],
}),
}
);
const result = await updateRes.json();
if (updateRes.ok) {
return { success: true, newToken: data.access_token };
} else {
console.error('Ăchec de la mise Ă jour Vercel Edge:', result);
return { success: false };
}
} catch (error) {
console.error('Erreur dans updateFacebookToken:', error);
return { success: false };
}
}
đ° Publication automatique des articles rĂ©cents
/api/cron/social-share
, qui automatise la diffusion des articles récents.
Son fonctionnement est le suivant :
- Récupérer les articles publiés dans les derniÚres 24 heures.
-
Extraire les métadonnées OpenGraph via
open-graph-scraper
. - Générer les publications enrichies (titre, description, image).
-
Diffuser automatiquement :
- Sur LinkedIn (via API UGC v2).
- Sur Facebook (via Graph API Feed).
typeScript /src/app/api/cron/social share/route.ts
import { baseURL } from '@/app/resources';
import { getPosts } from '@/app/utils/serverActions';
import { NextResponse } from 'next/server';
import { postToFacebook } from './postToFacebook';
import { postToLinkedIn } from './postToLinkedIn';
import { get } from '@vercel/edge-config';
export const revalidate = 0;
// Environnement d'exécution
const env = process.env.NODE_ENV;
// Clé Edge Config pour les slugs partagés
const SHARED_ARTICLES_KEY = 'sharedArticlesSlugs';
const STORED_SLUG_LENGTH = 40
/**
* GĂšre la requĂȘte GET pour la publication automatique d'articles.
* Appelé par une tùche planifiée (cron job) via Vercel Cron Jobs.
*/
export async function GET(req: Request) {
// Vérification de sécurité (jeton secret requis en production)
if (!(env === 'development') && req.headers.get('Authorization') !== `Bearer ${process.env.CRON_SECRET}`) {
console.error("đ« AccĂšs non autorisĂ©.");
return NextResponse.json({ status: 'Unauthorized', code: 401, message: 'AccÚs non autorisé.' });
}
// Vérification du jour de la semaine (désactivé le week-end et mercredi)
const today = new Date();
const dayOfWeek = today.getDay();
if (dayOfWeek === 0 || dayOfWeek === 6 || dayOfWeek === 3) {
const dayName = today.toLocaleDateString('fr-FR', { weekday: 'long' });
console.log(`â±ïž Partage dĂ©sactivĂ© aujourd'hui (${dayName}).`);
return NextResponse.json({ status: 'skipped', message: `Partage désactivé le week-end et le mercredi. Actuellement, c'est ${dayName}.` });
}
// RécupÚre les 10 derniers articles publiés
const articles = await getPosts({ limit: 10 });
// RécupÚre les slugs des articles déjà partagés depuis Edge Config
let sharedArticlesSlugs: string[] = (await get(SHARED_ARTICLES_KEY)) || [];
// Filtre les articles déjà partagés
const unsharedArticles = articles.filter(post => !sharedArticlesSlugs.includes(post.slug));
// Trie les articles non partagés par date de publication (du plus ancien au plus récent)
unsharedArticles.sort((a, b) => {
const dateA = a.metadata.publishedAt ? new Date(a.metadata.publishedAt).getTime() : 0;
const dateB = b.metadata.publishedAt ? new Date(b.metadata.publishedAt).getTime() : 0;
return dateA - dateB;
});
// Traite et partage le premier article éligible (le plus ancien non partagé)
if (unsharedArticles.length > 0) {
const articleToShare = unsharedArticles[0]!; // Prend le plus ancien article non partagé
if (articleToShare.metadata.publishedAt) {
// Prépare les données pour le partage
const postData = {
title: articleToShare.metadata.title as string,
description: articleToShare.metadata.description as string,
url: `${baseURL}/blog/${articleToShare.slug}`
};
try {
// Publie l'article sur les plateformes sociales en parallĂšle
console.log("publie ! ", articleToShare.slug)
await Promise.all([
postToFacebook(postData),
postToLinkedIn(postData)
]);
console.log(`â
Article "${articleToShare.slug}" partagé.`);
// Met à jour la liste locale des slugs partagés
const arrayLength = sharedArticlesSlugs.unshift(articleToShare.slug);
// Garde les 10 derniers slugs partagés
if (arrayLength > STORED_SLUG_LENGTH) {
sharedArticlesSlugs = sharedArticlesSlugs.slice(0, STORED_SLUG_LENGTH);
}
// Met Ă jour Edge Config avec la nouvelle liste
const updateRes = await fetch(
`https://api.vercel.com/v1/edge-config/${process.env.VERCEL_EDGE_ID}/items?teamId=${process.env.VERCEL_TEAM_ID}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.VERCEL_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
items: [
{
operation: 'update',
key: SHARED_ARTICLES_KEY,
value: sharedArticlesSlugs,
},
],
}),
}
);
// GÚre l'échec de la mise à jour Edge Config
if (!updateRes.ok) {
const errorResult = await updateRes.json();
console.error("â Ăchec mise Ă jour Edge Config:", errorResult);
return NextResponse.json({ status: 'error', message: "Ăchec mise Ă jour Edge Config aprĂšs partage." });
}
console.log(updateRes)
return NextResponse.json({ status: 'done', message: `Article "${articleToShare.slug}" partagé et Edge Config mis à jour.`, sharedCount: 1, unsharedArticles });
} catch (socialError) {
// GĂšre les erreurs de partage social
console.error(`â Ăchec partage article "${articleToShare.slug}":`, socialError);
return NextResponse.json({ status: 'error', message: `Erreur lors du partage social pour l'article "${articleToShare.slug}".` });
}
} else {
console.log(`âïž L'article "${articleToShare.slug}" n'est pas publiĂ©.`);
return NextResponse.json({
status: 'skipped',
message: `L'article "${articleToShare.slug}" n'est pas éligible au partage (non publié).`
});
}
}
// Aucun article éligible trouvé
console.log('âčïž Aucun article Ă©ligible trouvĂ© Ă partager.');
return NextResponse.json({ status: 'no_articles', message: 'Aucun article éligible trouvé à partager.' });
}
Exemple LinkedIn
typeScript /src/app/api/cron/social share/postToLinkedIn.ts
import { get } from '@vercel/edge-config';
import ogs from 'open-graph-scraper'; // Importation pour scraper les métadonnées Open Graph
/**
* Publie un article sur LinkedIn en tant que post UGC.
* Utilise les métadonnées Open Graph de l'URL de l'article pour un aperçu riche.
*
* @param article Un objet contenant les détails de l'article (titre, description, URL).
* @returns {Promise<void>} Une promesse qui se résout en cas de succÚs, ou rejette une erreur détaillée.
*/
export async function postToLinkedIn(article: { title: string; description: string; url: string }): Promise<void> {
// Récupération du token d'accÚs LinkedIn.
const accessToken = (await get('linkedin_token')) as string;
if (!accessToken) {
throw new Error('Erreur critique: Le token d\'accĂšs LinkedIn est manquant.');
}
// Récupération de l'URN de l'auteur.
const personURN = process.env.LINKEDIN_AUTHOR_URN;
if (!personURN) {
throw new Error('Erreur critique: L\'URN de l\'auteur LinkedIn est manquant.');
}
try {
// Extraction des métadonnées Open Graph.
const ogResult = await ogs({ url: article.url });
const ogData = ogResult.result;
console.log('Données OpenGraph brutes récupérées :', ogData); // Pour débogage
const ogTitle = ogData.ogTitle || article.title;
const ogDescription = ogData.ogDescription || article.description;
let ogImage: string | undefined;
// Logique d'extraction de l'URL de l'image Open Graph plus robuste
if (ogData.ogImage) {
if (Array.isArray(ogData.ogImage)) {
// Cherche la premiĂšre image valide dans le tableau
const firstImage = ogData.ogImage.find(img => typeof img === 'object' && img !== null && 'url' in img);
if (firstImage) {
ogImage = (firstImage as { url: string }).url;
}
} else if (typeof ogData.ogImage === 'object' && ogData.ogImage !== null && 'url' in ogData.ogImage) {
// GĂšre un objet image unique
ogImage = (ogData.ogImage as { url: string }).url;
} else if (typeof ogData.ogImage === 'string') {
// GĂšre une URL d'image directe
ogImage = ogData.ogImage;
}
}
console.log('URL de l\'image OpenGraph extraite :', ogImage); // Pour débogage
// Construction du payload JSON pour le post LinkedIn.
const postBody: any = {
author: personURN,
lifecycleState: 'PUBLISHED',
specificContent: {
'com.linkedin.ugc.ShareContent': {
shareCommentary: {
text: ogTitle
},
shareMediaCategory: 'ARTICLE',
media: [
{
status: 'READY',
description: {
text: ogDescription
},
originalUrl: article.url,
title: {
text: ogTitle
},
}
]
}
},
visibility: {
'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC'
}
};
// Ajoute la miniature si une URL d'image valide a été trouvée
if (ogImage && ogImage.startsWith('http')) { // Simple validation pour s'assurer que c'est une URL HTTP(S)
(postBody.specificContent['com.linkedin.ugc.ShareContent'].media[0] as any).thumbnails = [
{ resolvedUrl: ogImage }
];
} else {
console.warn('â ïž Aucune URL d\'image OpenGraph valide trouvĂ©e ou extraite. Le post LinkedIn n\'aura pas de miniature.');
}
// Envoi de la requĂȘte HTTP POST Ă l'API LinkedIn.
const res = await fetch('https://api.linkedin.com/v2/ugcPosts', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json'
},
body: JSON.stringify(postBody)
});
// Gestion de la réponse de l'API.
if (!res.ok) {
const errorText = await res.text();
const errorMessage = `â Ăchec publication LinkedIn: Statut ${res.status} - ${res.statusText}. DĂ©tails: ${errorText}`;
console.error(errorMessage);
throw new Error(errorMessage);
}
console.log('đ Article postĂ© sur LinkedIn avec succĂšs !');
} catch (error) {
console.error('â ïž Erreur lors de la publication sur LinkedIn:', error);
throw error;
}
}
Exemple Facebook
typeScript /src/app/api/cron/social share/postToFacebook.ts
import { get } from '@vercel/edge-config';
/**
* Publie un article donné sur une page Facebook configurée.
* RécupÚre le token d'accÚs de la page et l'ID de la page depuis les sources sécurisées (Edge Config et variables d'environnement).
* Interagit avec l'API Graph de Facebook pour créer un nouveau post.
*
* @param article Un objet contenant les informations essentielles de l'article Ă partager.
* Ex: { title: "Mon Super Article", description: "Une description captivante", url: "https://monblog.com/article-slug" }
* @returns {Promise<void>} Une promesse qui se résout sans valeur en cas de succÚs, ou rejette une erreur détaillée en cas d'échec.
*/
export async function postToFacebook(article: { title: string; description: string; url: string }): Promise<void> {
// === Section 1 : Récupération des Informations d'Authentification et de Configuration ===
// 1. Récupération du token d'accÚs Facebook depuis Vercel Edge Config.
// C'est la mĂ©thode recommandĂ©e pour les secrets qui peuvent changer ou ĂȘtre gĂ©rĂ©s dynamiquement.
const accessToken = await get('facebook_token') as string;
// Vérification critique : S'assurer que le token d'accÚs est disponible.
// Sans ce token, aucune requĂȘte API ne peut ĂȘtre authentifiĂ©e.
if (!accessToken) {
const errorMessage = 'Erreur critique: Le token d\'accĂšs Facebook est manquant dans Edge Config. Impossible de publier.';
console.error(errorMessage);
throw new Error(errorMessage); // Lance une erreur pour stopper l'exécution et signaler le problÚme.
}
// 2. Récupération de l'ID de la page Facebook depuis les variables d'environnement.
// Cet ID est spĂ©cifique Ă la page oĂč le post doit ĂȘtre publiĂ©.
const pageId = process.env.FACEBOOK_PAGE_ID;
// Vérification critique : S'assurer que l'ID de la page est défini.
if (!pageId) {
const errorMessage = 'Erreur critique: L\'ID de la page Facebook (FACEBOOK_PAGE_ID) est manquant dans les variables d\'environnement. Impossible de publier.';
console.error(errorMessage);
throw new Error(errorMessage);
}
// URL de base pour l'API Graph de Facebook pour la publication sur le fil d'actualité d'une page.
const apiUrl = `https://graph.facebook.com/${pageId}/feed`;
// === Section 2 : Préparation des Données du Post ===
// PrĂ©paration des paramĂštres de la requĂȘte.
// URLSearchParams est utilisĂ© pour construire une chaĂźne de requĂȘte URL sĂ©curisĂ©e et correctement encodĂ©e.
const params = new URLSearchParams({
// Le message principal du post, combinant le titre et la description de l'article avec un double saut de ligne pour la lisibilité.
message: `${article.title}\n\n${article.description}`,
// L'URL de l'article qui sera partagée. Facebook va souvent extraire les métadonnées Open Graph de cette URL
// pour générer un aperçu riche (titre, image, description) directement dans le post.
link: article.url,
// Le token d'accĂšs est inclus en tant que paramĂštre de requĂȘte pour l'authentification.
access_token: accessToken
});
// === Section 3 : ExĂ©cution de la RequĂȘte API et Gestion des Erreurs ===
try {
// Envoi de la requĂȘte HTTP POST Ă l'API Graph de Facebook.
// Les paramÚtres sont ajoutés directement à l'URL.
const res = await fetch(`${apiUrl}?${params.toString()}`, {
method: 'POST',
// SpĂ©cifie le type de contenu de la requĂȘte. Important pour que l'API interprĂšte correctement les donnĂ©es.
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
// 5. Gestion de la réponse de l'API.
// Si la réponse HTTP n'est pas dans la plage 2xx (e.g., 400 Bad Request, 500 Internal Server Error),
// cela indique une erreur de l'API Facebook.
if (!res.ok) {
const errorText = await res.text(); // Tente de récupérer le corps de la réponse d'erreur pour des détails.
const errorMessage = `â Ăchec de la publication Facebook: Statut ${res.status} - ${res.statusText}. DĂ©tails: ${errorText}`;
console.error(errorMessage);
throw new Error(errorMessage); // Relance une erreur avec des informations complĂštes.
}
// Si la publication est réussie, l'API retourne un objet JSON contenant souvent l'ID du post.
const result = await res.json();
console.log(`đ Article postĂ© sur Facebook avec succĂšs. ID du post: ${result.id}`);
// `void` signifie que la fonction n'a pas besoin de retourner une valeur en cas de succĂšs.
} catch (error) {
// Capture les erreurs qui peuvent survenir pendant l'appel `fetch` (ex: problÚmes réseau, CORS).
console.error('â ïž Erreur inattendue lors de l\'envoi de la requĂȘte Ă Facebook:', error);
throw error; // Relance l'erreur pour qu'elle puisse ĂȘtre gĂ©rĂ©e par la fonction appelante (dans `route.ts`).
}
}
âïž Orchestration avec Vercel Cron
-
/api/cron/refresh-facebook-token
â tous les jours Ă 4h (renouvellement du token Facebook). -
/api/cron/social-share
â tous les jours Ă 5h (publication des articles).
JSON /vercel.json
{
"crons": [
{
"path": "/api/cron/indexNow",
"schedule": "0 0 * * *"
},
{
"path": "/api/cron/social-share",
"schedule": "0 7 * * *"
},
{
"path": "/api/cron/refresh-facebook-token",
"schedule": "0 4 20 * *"
}
]
}
đ RĂ©sultats obtenus
- Le token Facebook reste toujours valide.
- Les articles récents sont automatiquement publiés sur les réseaux.
- Les publications sont enrichies avec des métadonnées OpenGraph.
- Le systĂšme fonctionne de maniĂšre autonome, fiable et invisible.
đ Perspectives dâĂ©volution
- ImplĂ©menter un fallback pour les erreurs dâAPI.
- Ătendre le systĂšme Ă dâautres plateformes (Mastodon, X...).
- Ajouter la publication rĂ©troactive dâanciens articles.
En rĂ©sumĂ© : un systĂšme automatisĂ©, robuste et lĂ©ger, qui simplifie la gestion des publications tout en amĂ©liorant la visibilitĂ© SEO du portfolio đ.
Ă quoi sert le systĂšme dâautomatisation prĂ©sentĂ© ?
Comment le token Facebook est-il renouvelé automatiquement ?
Comment les articles sont-ils partagés sur les réseaux sociaux ?
Comment Vercel Cron est-il configuré ?
Et si le token expire ou quâil y a une erreur ?
Peut-on Ă©tendre ce systĂšme Ă dâautres plateformes ?
Quâapporte ce dispositif en termes de SEO ?
Powered by wisp