Albi, France
Publications

📱 Optimiser le SEO de son portfolio avec l'automatisation de la publication sur Facebook et LinkedIn (Next.js + Vercel Cron)

Partager :
Dans le cadre de l’amĂ©lioration continue de mon site, j’ai conçu un systĂšme automatisĂ© pour diffuser les nouveaux articles de blog sur les rĂ©seaux sociaux (Facebook et LinkedIn). L'objectif est avant tout d'amĂ©liorer le SEO du site en gĂ©nĂ©rant des backlinks et en augmentant la visibilitĂ© des contenus. Pour qu’un portfolio technique soit efficace sur le long terme, il ne suffit pas de produire du contenu de qualité : il faut aussi le promouvoir de maniĂšre rĂ©guliĂšre. Cela permet d’optimiser son rĂ©fĂ©rencement naturel (SEO), d’attirer un public ciblĂ© et de renforcer sa crĂ©dibilitĂ© professionnelle. Un point particuliĂšrement dĂ©licat est la gestion des tokens d’accĂšs aux APIs des rĂ©seaux sociaux. En particulier, le token Facebook a une durĂ©e de vie limitĂ©e Ă  environ 60 jours, ce qui complique l’automatisation. Un token expirĂ© empĂȘche toute nouvelle publication. Il est donc essentiel de mettre en place un systĂšme fiable qui renouvelle automatiquement le token avant expiration (idĂ©alement tous les 50 jours), afin d’éviter toute rupture de service. Voici une synthĂšse de cette architecture serverless, pensĂ©e pour ĂȘtre robuste, Ă©volutive et facilement intĂ©grable dans un workflow DevOps moderne.
Publier automatiquement une URL sur les rĂ©seaux sociaux via une API est relativement simple d’un point de vue technique. Le principal obstacle vient de la gestion des tokens d’authentification, dont la durĂ©e de validitĂ© est limitĂ©e. Facebook permet de gĂ©nĂ©rer des tokens dits "long-lived", mais ces derniers expirent malgrĂ© tout (≈ 60 jours). Pour LinkedIn, la documentation API est particuliĂšrement dense et la gestion des mĂ©dias (images notamment) reste complexe Ă  mettre en Ɠuvre correctement. C’est encore en cours d’optimisation.
Les objectifs du projet sont les suivants :
  • 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.
Ce dispositif assure un gain de temps conséquent et garantit une présence réguliÚre et optimisée sur les réseaux sociaux.
Le token Facebook "long-lived" doit ĂȘtre rafraĂźchi environ tous les 50 jours pour garantir la continuitĂ© des publications. Le mĂ©canisme mis en place est le suivant :
  1. Une API route /api/cron/refresh-facebook-token est appelée chaque jour par une tùche cron Vercel.
  2. Elle vérifie si un token est présent dans Edge Config.
  3. Si oui, elle appelle l’API Graph de Facebook pour renouveler le token.
  4. Le nouveau token est stocké dans Edge Config.
  5. Les erreurs éventuelles sont loguées pour suivi.
Ce processus garantit que le token Facebook reste valide en permanence, évitant les interruptions de service.
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 };
    }
}

DeuxiÚme volet du systÚme : une API route /api/cron/social-share, qui automatise la diffusion des articles récents. Son fonctionnement est le suivant :
  1. Récupérer les articles publiés dans les derniÚres 24 heures.
  2. Extraire les métadonnées OpenGraph via open-graph-scraper.
  3. Générer les publications enrichies (titre, description, image).
  4. 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.' });
}

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;
    }
}
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`).
    }
}

Deux tĂąches planifiĂ©es orchestrent l’automatisation :
  • /api/cron/refresh-facebook-token → tous les jours Ă  4h (renouvellement du token Facebook).
  • /api/cron/social-share → tous les jours Ă  5h (publication des articles).
Exemple de configuration :
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 * *"
        }
    ]
}
Le systĂšme publie sur les rĂ©seaux sociaux chaque jour Ă  7h UTC (Vercel ne gĂ©rant pas les fuseaux horaires ni les heures d’étĂ©, soyez vigilants) et rĂ©gĂ©nĂšre un token Facebook le 20 du mois Ă  4h. Ce modĂšle serverless offre une architecture performante, scalable, et totalement compatible avec un processus d’intĂ©gration continue (CI/CD) dĂ©ployĂ© sur Vercel.
  • 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.

  • 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 🚀.

Powered by wisp
Commentaires
Sources :
Navigation

Prendre rendez-vous

Je suis disponible pour des consultations, des collaborations ou simplement pour discuter de vos projets. N'hésitez pas à me contacter !