Aller au contenu

Note d'ingénierie

Publier sur Facebook et Instagram avec mon propre outil

Utilisateur système Meta, calendrier éditorial lisible par la machine, limites de l'API Instagram et validation humaine : les garde-fous de mon outil de publication quotidienne.

Illustration de l'article « Publier sur Facebook et Instagram avec mon propre outil »
Par Alexandre Kaczor7 min de lecture
  • NestJS
  • Architecture
  • IA
  • Sécurité

Chaque jour, mes cinq produits en production publient un post sur leur Page Facebook, et une partie d'entre eux aussi sur Instagram. Je n'utilise pas d'outil de planification du marché : la publication passe par KCockpit, le cockpit privé que j'ai écrit pour superviser le portefeuille. Il génère un brouillon par produit à 9 h, je le relis et je l'approuve, et il publie par l'API Graph officielle de Meta.

Écrire son propre outil de publication n'a d'intérêt que si l'on y met des garde-fous qu'un outil générique n'a aucune raison d'avoir. Voici ceux que j'ai codés, et les contraintes de l'API qui les ont imposés.

Un utilisateur système, pas un compte personnel

La publication ne repose pas sur mon compte Facebook. Une application Meta dédiée au cockpit, et un jeton d'utilisateur système créé dans le Business Manager. Ses autorisations sont listées dans le plan du projet : pages_manage_posts, pages_read_engagement, read_insights, ads_read, puis instagram_basic et instagram_content_publish pour Instagram. L'utilisateur système reçoit sur chaque Page les tâches Contenu et Statistiques, rien de plus.

Premier piège : publier sur une Page avec le jeton système échoue, même quand l'autorisation est accordée. Il faut un jeton de Page, que le client Graph dérive à la demande et garde en cache une heure :

async pageToken(pageId: string): Promise<string> {
  const cached = this.pageTokenCache.get(pageId);
  if (cached && cached.expiresAt > Date.now()) return cached.token;

  const query = new URLSearchParams({
    fields: 'access_token',
    access_token: this.requireToken(),
  });
  const page = await this.request<{ access_token?: string }>(`/${pageId}?${query.toString()}`);
  if (!page.access_token) {
    throw new BadGatewayException(
      `Aucun token de page pour ${pageId} : vérifier que l'utilisateur système a la tâche « Contenu » sur cette Page.`,
    );
  }
  // ... mise en cache 1 h
}

Ce client est le seul endroit qui parle à Graph (version v23.0). Il porte une politique d'erreur unique : on journalise le message de Graph et le chemin sans sa query string, puisque le jeton y figure pour les requêtes GET. Pour les POST, les paramètres partent dans le corps, en x-www-form-urlencoded, pour que le jeton n'apparaisse jamais dans une URL. Aucun identifiant de Page n'est écrit dans le code : le registre ne connaît que le nom de la variable d'environnement qui le contient.

Le calendrier éditorial est un fichier

Le générateur (un modèle Claude Sonnet) ne choisit pas ce qu'il raconte. Il suit à la lettre un fichier versionné, docs/EDITORIAL.md, qui fixe les règles absolues, la cadence et, pour chaque produit, une liste de faits autorisés vérifiés à la source. Aucun chiffre, prix, date ou témoignage hors de cette liste ne peut apparaître.

Cinq posts par jour, c'est le risque du remplissage. Chaque post est donc tiré d'un angle : astuce, pédagogie, piège, cas d'usage, coulisses, question, outil gratuit, comparaison… Les contraintes dures passent avant tout :

  • pas deux fois le même angle le même jour ;
  • pas d'angle déjà vu dans les 10 derniers posts de la Page ;
  • chiffre-produit seulement si le cockpit dispose d'une métrique fraîche de moins de 24 h, collectée sur le produit lui-même ;
  • preuve jamais en automatique : il faut un vrai artefact.

Le 17 septembre, plus aucun brouillon ne sortait : un pool de 8 ou 9 angles s'épuise forcément sur une fenêtre de 10 posts. Le générateur reprend désormais l'angle utilisé il y a le plus longtemps. Et si rien ne tient debout pour un produit un jour donné, il le dit et ne produit rien : un jour sans post vaut mieux qu'un post creux.

Le tirage est ensuite incliné par les performances mesurées de la Page, avec deux bornes : 30 % des tirages sont de l'exploration pure, et le meilleur angle n'est jamais plus de 3 fois plus probable qu'un angle inconnu. Un angle relevé sur moins de 3 posts ne pèse rien, aussi flatteur soit-il.

Instagram n'est pas Facebook avec un autre identifiant

J'ai d'abord cru qu'Instagram serait une variante de Facebook. Trois limites de l'API ont imposé un module séparé.

Une image est obligatoire. L'API Instagram ne publie pas de texte seul. Sur Facebook, le repli naturel est un post texte avec carte de lien ; sur Instagram, il n'existe aucun repli. Un post sans visuel généré et validé est refusé, avec un message explicite.

L'API exige une URL publique de l'image. Elle n'accepte pas d'envoi binaire. Or les visuels sont des PNG sur mon disque, et Meta ne peut pas venir les chercher sur localhost. Le détour retenu se fait en quatre appels : envoyer la photo sur la Page avec published=false, lire son URL de CDN publique, créer puis publier le média Instagram, et enfin supprimer la photo intermédiaire dans un finally, en cas de succès comme d'échec.

Les liens ne sont pas cliquables dans une légende. Le lien est donc retiré du texte, la légende se termine par « 👉 Lien en bio », et le cockpit fournit pour chaque produit le lien de bio à copier. La légende est plafonnée à 2 200 caractères : c'est le corps qui est raboté, jamais l'appel à l'action ni les hashtags, limités à 5.

La publication Instagram se fait en deux temps : le conteneur est sondé au plus 15 fois, toutes les 2 secondes, et l'épuisement des essais produit un échec explicite plutôt qu'une boucle infinie. La limite de Meta, 50 publications par 24 heures et par compte, est reconnue et reformulée quand elle tombe.

Les garde-fous qui comptent

Aucune image ne part sans validation humaine. Aucune image n'est générée automatiquement. Une image n'est publiable qu'une fois validée à la main, et toute modification du texte ou nouvelle génération annule cette validation. La décision est une fonction pure, testée cas par cas :

export function decideInstagramPublication(
  post: Pick<ContentPost, 'imagePath' | 'imageApprovedAt'>,
  featureEnabled: boolean,
): InstagramDecision {
  // ...
  if (!featureEnabled) return refuse('feature-off');
  if (!post.imagePath) return refuse('no-image');
  if (!post.imageApprovedAt) return refuse('not-approved');
  return { publishable: true };
}

La règle de 18 h, avec un frein. Un brouillon texte non traité à 18 h est publié tel quel : c'est un choix assumé, le texte ne contenant que des faits autorisés. Mais un post en retard de plus de 24 heures, par exemple parce que le Mac dormait, n'est pas publié : il repasse en brouillon, à valider de nouveau. Les Reels vidéo, eux, ne bénéficient jamais de cette règle :

for (const post of due) {
  // Un Reel (vidéo UGC) ne part JAMAIS sans validation humaine.
  if (post.ugcVideoId && post.status === ContentPostStatus.DRAFT) continue;
  if (this.isTooLate(post, now)) {
    await this.contentRepository.update(post.id, {
      status: ContentPostStatus.DRAFT,
      scheduledFor: null,
      error: 'Échéance dépassée de plus de 24h — repassé en brouillon, à re-valider',
    });
    // ...
  }
}

Un réseau en échec n'annule jamais l'autre. Chaque réseau ciblé est tenté indépendamment, avec son propre identifiant et sa propre erreur. Le post passe en publié dès qu'un réseau a réussi, et chaque échec déclenche une alerte avec le message brut de Meta. Un interrupteur permet enfin de mettre toute l'auto-publication en pause.

Ce qui manque, honnêtement : les statistiques Instagram ne sont pas encore relevées (la colonne qui doit les recevoir existe, le collecteur reste à écrire).

Ce que j'en retiens

  • Publier avec un utilisateur système, jamais avec un compte personnel. Des autorisations limitées, des tâches attribuées Page par Page, et un jeton qui n'apparaît jamais dans une URL ni dans un log.
  • Le calendrier éditorial doit être lisible par la machine. Des faits autorisés et des angles écrits dans un fichier valent mieux qu'une consigne vague donnée à un modèle.
  • Lire les limites de l'API avant de concevoir. Image obligatoire, URL publique, liens non cliquables : chacune a changé l'architecture.
  • L'automatisation a besoin d'un frein. Validation humaine des visuels, retard maximal de 24 h et Reels exclus de la publication automatique.

Vous voulez automatiser la publication de vos contenus sans perdre la main sur ce qui part ? Parlons de votre cas.