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 portfolio bardy.michael.occitaweb.fr, 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';

const env = process.env.NODE_ENV

export async function GET(req: Request) {
    if (!(env === 'development') && req.headers.get('Authorization') !== `Bearer ${process.env.CRON_SECRET}`) {
        return NextResponse.json({ status: 'Unauthorized', code: 401 });
    }
    // 1. RécupÚre les articles publiés dans les derniÚres 24h
    const articles = await getPosts({ limit: 1 });

    // 2. Pour chaque article, publier sur les médias sociaux
    for (const article of articles) {
        if (article.metadata.publishedAt && isLessThan24HoursOld(article.metadata.publishedAt)) {
            const postData = {
                title: article.metadata.title as string,
                description: article.metadata.description as string,
                url: `${baseURL}/blog/${article.slug}`
            };
            await Promise.all([
                // postToLinkedIn(postData),
                postToFacebook(postData)
            ]);
            console.log(`Partage de l'article ${article.slug} sur les média sociaux.`);
            return NextResponse.json({ status: 'done', count: articles.length });
        } else {
            console.log(`L'article ${article.slug} a plus de 24 heures il ne sera pas diffusé !.`);
            return NextResponse.json({ status: 'skipped', message: articles.length + ' article(s) ils ont plus de 24h et ont déjà était diffusés.' });
        }
    }

}

function isLessThan24HoursOld(dateString: string): boolean {
    const publishedDate = new Date(dateString);
    const now = new Date();
    const diffMs = now.getTime() - publishedDate.getTime();
    const diffHours = diffMs / (1000 * 60 * 60);
    return diffHours < 24;
}




typeScript /src/app/api/cron/social share/postToLinkedIn.ts
import { get } from '@vercel/edge-config';
import ogs from 'open-graph-scraper';

export async function postToLinkedIn(article: any) {
    const accessToken = (await get('linkedin_token')) as string;
    const personURN = process.env.LINKEDIN_AUTHOR_URN!; // ex: urn:li:person:abc123

    // 1ïžâƒŁ RĂ©cupĂ©rer les donnĂ©es OpenGraph
    const ogResult = await ogs({ url: article.url });
    const ogData = ogResult.result;

    // console.log('OpenGraph Data:', ogData);

    // fallback en cas de champs manquants
    const ogTitle = ogData.ogTitle || article.title;
    const ogDescription = ogData.ogDescription || article.description;
    let ogImage: string | undefined;
    if (ogData.ogImage && Array.isArray(ogData.ogImage) && typeof ogData.ogImage[0] === 'object' && ogData.ogImage[0] !== null && 'url' in ogData.ogImage[0]) {
        ogImage = ogData.ogImage[0].url;
    }

    // 2ïžâƒŁ Construire le post UGC avec OG
    const postBody = {
        author: personURN,
        lifecycleState: 'DRAFT',
        specificContent: {
            'com.linkedin.ugc.ShareContent': {
                shareCommentary: {
                    text: ogTitle
                },
                shareMediaCategory: 'ARTICLE',
                media: [
                    {
                        status: 'READY',
                        description: {
                            text: ogDescription
                        },
                        originalUrl: article.url,
                        title: {
                            text: ogTitle
                        },
                        ...(ogImage && {
                            thumbnails: [
                                { resolvedUrl: ogImage }
                            ]
                        })
                    }
                ]
            }
        },
        visibility: {
            'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC'
        }
    };

    // 3ïžâƒŁ Envoi vers 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)
    });

    if (!res.ok) {
        const error = await res.text();
        console.error(`LinkedIn post failed: ${res.status}`, error);
        throw new Error(`LinkedIn post failed: ${res.status} - ${error}`);
    }

    console.log('LinkedIn post succeeded!');
}

typeScript /src/app/api/cron/social share/postToFacebook.ts
import { get } from '@vercel/edge-config';

export async function postToFacebook(article: any) {
    const accessToken = await get('facebook_token') as string;
    const pageId = process.env.FACEBOOK_PAGE_ID!;

    const url = `https://graph.facebook.com/${pageId}/feed`;

    const params = new URLSearchParams({
        message:
            `${article.title}

${article.description}`,
        link: article.url,
        access_token: accessToken
    });
    const res = await fetch(`${url}?${params.toString()}`, {
        method: 'POST'
    });

    if (!res.ok) {
        const error = await res.text();
        console.error(`Facebook post failed: ${res.status}`, error);
        throw new Error(`Facebook post failed: ${res.status} - ${error}`);
    }
}

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/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
Sources :
Commentaires
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 !