La génération de SEO automatisée grâce à l'IA

20 min de lecture

Découvrez comment automatiser le SEO avec l'IA, épatant plus qu'un poème de Baudelaire, mais sans le spleen.

Pourquoi automatiser la génération de SEO avec l'IA ?

Avant de vous défoncer le crâne avec du code technique comme un pervers narcissique avec son ego, posons-nous la question fondamentale : pourquoi diable vouloir confier la génération des métadonnées SEO à une intelligence artificielle ?

  1. Parce que c'est chiant comme la mort
  2. Parce que l'IA peut analyser le contenu plus vite qu'un comptable anémique
  3. Parce que ça impressionne les clients/investisseurs/votre belle-mère qui vous demande toujours quand vous trouverez un "vrai travail, bordel"

Et surtout, parce que ça marche ! L'IA moderne, particulièrement ces gros modèles de langage comme GPT, peut générer des titres clickbait, des descriptions à la con et des mots-clés qui rivalisent avec ceux produits par des humains. Pas tous les humains, hein. Certains continuent de produire des merveilles d'éloquence. Mais pour le SEO basique, l'IA fait le taf, et elle le fait sans se plaindre ni demander d'augmentation.

L'architecture de notre système : une symphonie en PHP majeur

Notre système de génération automatisée de SEO est construit sur Symfony, ce framework PHP que j'aime d'amour.

L'architecture repose sur plusieurs composants clés qui s'orchestrent ensemble comme un gang de politiciens corrompus :

  1. php-llm/llm-chain-bundle : L'intégration Symfony qui nous permet de communiquer avec les modèles de langage
  2. Un système d'événements : Pour déclencher la génération SEO au bon moment
  3. Un système de suivi de coûts : Parce que les API d'IA, c'est comme dans un taxi - ça chiffre vite
  4. Un traitement synchrone : Pour générer le SEO de manière directe et fiable

Maintenant, explorons chaque composant comme si nous étions des archéologues pervers découvrant un cadavre particulièrement bien conservé.

La configuration du bundle LLM Chain

Commençons par le commencement : la configuration du bundle qui nous permet de dialoguer avec les grands modèles de langage. Voici comment j'ai configuré mon llm_chain.yaml :

services:
    _defaults:
        autowire: true
        autoconfigure: true

    PhpLlm\LlmChain\Chain\Toolbox\Tool\Clock: null

llm_chain:
    platform:
        openai:
            api_key: '%env(OPENAI_API_KEY)%'
    chain:
        default:
            platform: 'llm_chain.platform.openai'
            model:
                name: 'GPT'
            system_prompt: >
                Tu es un expert SEO qui rédige des titres, des meta descriptions et des mots-clés accrocheurs pour des articles de blog techniques avec une touche d'humour acide et ironique.

Cette configuration est aussi simple qu'un député qui détourne des fonds publics : nous définissons la plateforme (OpenAI), nous précisons le modèle, et nous donnons un prompt système qui indique au modèle qu'il doit jouer le rôle d'un expert SEO avec un sens de l'humour acéré. Un peu comme moi, mais sans les injures et les menaces de mort.

Le cœur du système : ContentSeoGenerator

Le générateur de SEO est le cœur battant de notre système. C'est lui qui dialogue avec l'IA et transforme ses réponses en métadonnées structurées. Analysons cette classe comme un flic analyserait un cadavre dans une série B :

class ContentSeoGenerator
{
    private const int MAX_TITLE_LENGTH = 60;
    private const int MAX_DESCRIPTION_LENGTH = 160;
    private const int MAX_KEYWORDS = 5;
    
    // Estimation approximative du nombre de tokens pour le calcul du coût
    private const float ESTIMATED_INPUT_TOKENS_PER_CHAR = 0.25;
    private const int ESTIMATED_OUTPUT_TOKENS = 200;

    public function __construct(
        private readonly ChainInterface $chain,
        private readonly LoggerInterface $logger,
        private readonly LlmApiLimitService $apiLimitService,
    ) {
    }
    
    public function generateSeo(AbstractContent $content, bool $forceRegeneration = false): void
    {
        $apiUsage = null;

        try {
            $contentText = $content->getContent();
            $contentTitle = $content->getTitle();
            $seo = $content->getSeo();

            // Vérifications préliminaires : contenu vide, SEO déjà généré, etc.
            // [Code de vérification omis pour clarté]

            // Vérifier si on peut faire un appel API (limite budgétaire mensuelle)
            if (!$this->apiLimitService->canMakeApiCall()) {
                $this->logger->warning('Monthly LLM API budget limit reached, skipping SEO generation');
                return;
            }

            // Commencer à tracer l'appel API
            $apiUsage = $this->apiLimitService->startApiCall($content);

            $truncatedContent = $this->truncateContent($contentText);

            // Construction du prompt
            $promptText = "Voici le titre d'un article de blog: \"{$contentTitle}\"\n\n";
            $promptText .= "Et voici le contenu de l'article:\n";
            $promptText .= "\"\"\"\n{$truncatedContent}\n\"\"\"\n\n";
            $promptText .= "En tant qu'expert SEO, génère pour cet article:\n";
            $promptText .= '1. Un titre SEO optimisé (max '.self::MAX_TITLE_LENGTH." caractères)\n";
            $promptText .= '2. Une meta description accrocheuse (max '.self::MAX_DESCRIPTION_LENGTH." caractères)\n";
            $promptText .= '3. Une liste de '.self::MAX_KEYWORDS." mots-clés pertinents, séparés par des virgules\n\n";
            $promptText .= 'Réponds uniquement au format JSON avec les champs title, description et keywords (tableau de strings).';

            // Préparation et envoi du message
            $messages = new MessageBag();
            $messages->add(new UserMessage(new Text($promptText)));

            // Appel à l'API LLM via le service Chain
            $response = $this->chain->call($messages);
            $result = $response->getContent();

            // Traitement de la réponse
            $cleanedResult = preg_replace('/```json\s*|\s*```/', '', trim($result));
            $seoData = json_decode((string) $cleanedResult, true, 512, JSON_THROW_ON_ERROR);

            // Validation et traitement des données reçues
            // [Code de validation omis pour clarté]

            // Mise à jour des métadonnées SEO
            $seo->setTitle($seoData['title']);
            $seo->setDescription($seoData['description']);
            $seo->setKeywords($seoData['keywords']);
            
            // Enregistrement de l'utilisation de l'API
            // [Code d'enregistrement omis pour clarté]
            
        } catch (\Throwable $throwable) {
            // Gestion des erreurs
            // [Code de gestion d'erreur omis pour clarté]
        }
    }

    private function truncateContent(string $content): string
    {
        // Limite pour ne pas dépasser le nombre de tokens
        $maxChars = 5000;
        if (mb_strlen($content) > $maxChars) {
            return mb_substr($content, 0, $maxChars).'...';
        }

        return $content;
    }
}

Ce code est structuré comme une stratégie d'évasion fiscale bien rodée :

  1. On commence par définir les constantes qui limitent la taille des métadonnées générées (60 caractères pour le titre, 160 pour la description, 5 mots-clés maximum).
  2. On vérifie ensuite si on a le droit de faire un appel API, car contrairement aux promesses électorales, le budget a une limite réelle, bordel.
  3. On prépare le prompt avec le titre et le contenu de l'article, en demandant spécifiquement un format JSON en retour.
  4. On fait l'appel à l'API via le service Chain fourni par le bundle.
  5. On nettoie et traite la réponse pour extraire les données SEO.
  6. On met à jour l'entité de contenu avec les nouvelles métadonnées.
  7. On enregistre l'utilisation de l'API pour le suivi des coûts.

Tout cela est emballé dans un joli try/catch, car comme pour une partouze avec des inconnus, il vaut mieux prévoir des protections.

Le système de gestion des limites d'API : LlmApiLimitService

Parlons maintenant d'un élément crucial mais souvent négligé : la gestion des coûts. Car oui, appeler une API d'IA, ce n'est pas gratuit. Sauf si vous êtes le PDG d'OpenAI, mais dans ce cas, vous êtes probablement trop occupé à compter vos milliards pour lire ce billet.

class LlmApiLimitService
{
    private const float COST_PER_1K_TOKENS_INPUT = 0.0015;
    private const float COST_PER_1K_TOKENS_OUTPUT = 0.002;

    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly LlmApiUsageRepository $apiUsageRepository,
        private readonly LoggerInterface $logger,
        #[Autowire('%llm_monthly_budget%')]
        private readonly float $monthlyBudget,
    ) {
    }

    public function canMakeApiCall(): bool
    {
        return !$this->apiUsageRepository->isMonthlyLimitReached($this->monthlyBudget);
    }

    public function getRemainingBudget(): float
    {
        return $this->apiUsageRepository->getRemainingBudget($this->monthlyBudget);
    }

    public function startApiCall(AbstractContent $content, string $action = 'generate_seo'): LlmApiUsage
    {
        $apiUsage = new LlmApiUsage();
        $apiUsage->setEntityType($content::class);
        $apiUsage->setEntityId($content->getId() ?? 0);
        $apiUsage->setAction($action);

        $this->entityManager->persist($apiUsage);
        $this->entityManager->flush();

        return $apiUsage;
    }

    public function recordSuccessfulApiCall(
        LlmApiUsage $apiUsage,
        int $inputTokenCount,
        int $outputTokenCount,
        ?array $metadata = null,
    ): void {
        $cost = $this->calculateCost($inputTokenCount, $outputTokenCount);

        $apiUsage->setSuccess(true);
        $apiUsage->setCost((string) $cost);
        $apiUsage->setTokenCount($inputTokenCount + $outputTokenCount);
        $apiUsage->setMetadata($metadata);

        $this->entityManager->flush();

        $this->logger->info('LLM API call recorded successfully', [
            'cost' => $cost,
            'entity_id' => $apiUsage->getEntityId(),
            'entity_type' => $apiUsage->getEntityType(),
            'remaining_budget' => $this->getRemainingBudget(),
        ]);
    }

    public function recordFailedApiCall(
        LlmApiUsage $apiUsage,
        string $errorMessage,
        ?array $metadata = null,
    ): void {
        // [Code d'enregistrement d'échec omis pour clarté]
    }

    private function calculateCost(int $inputTokenCount, int $outputTokenCount): float
    {
        $inputCost = ($inputTokenCount / 1000) * self::COST_PER_1K_TOKENS_INPUT;
        $outputCost = ($outputTokenCount / 1000) * self::COST_PER_1K_TOKENS_OUTPUT;

        return round($inputCost + $outputCost, 4);
    }
    
    public function getMonthlyBudget(): float
    {
        return $this->monthlyBudget;
    }
}

Ce service est le ministre des finances de notre système. Il veille à ce que nous ne dépassions pas notre budget mensuel alloué aux appels d'API. Il enregistre chaque utilisation, calcule les coûts associés, et nous permet de suivre nos dépenses avec la précision d'un avare comptant ses pièces.

Le calcul des coûts est basé sur le nombre de tokens utilisés, avec un tarif différent pour les tokens d'entrée (0,0015 $ par 1000 tokens) et les tokens de sortie (0,002 $ par 1000 tokens). C'est comme les toilettes payantes : tu payes pour y entrer, mais tu payes encore plus pour en sortir.

L'entité LlmApiUsage : tracer chaque putain de centime

Pour suivre nos coûts avec précision, nous avons créé une entité dédiée :

#[ORM\Entity(repositoryClass: LlmApiUsageRepository::class)]
#[ORM\Table(name: 'llm_api_usage')]
#[ORM\Index(columns: ['created_at'], name: 'llm_api_usage_created_at_idx')]
class LlmApiUsage
{
    use TimestampableEntity;

    #[ORM\Id]
    #[ORM\GeneratedValue(strategy: 'IDENTITY')]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private string $entityType;

    #[ORM\Column]
    private int $entityId;

    #[ORM\Column(length: 50)]
    private string $action = 'generate_seo';

    #[ORM\Column(nullable: true)]
    private ?bool $success = null;

    #[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 4)]
    private string $cost = '0.0000';

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $metadata = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $errorMessage = null;

    #[ORM\Column(type: Types::SMALLINT)]
    private int $tokenCount = 0;

    // [Getters et setters omis pour clarté]
}

Cette entité enregistre tout ce que nous devons savoir sur chaque appel d'API :

  • L'entité concernée (type et ID)
  • L'action effectuée (généralement "generate_seo")
  • Le succès ou l'échec de l'appel
  • Le coût calculé
  • Le nombre de tokens utilisés
  • Des métadonnées supplémentaires pour le débogage
  • Un message d'erreur en cas d'échec

C'est comme un détective privé qui suit tous vos mouvements bancaires. Flippant, mais efficace.

Le repository LlmApiUsageRepository : des statistiques dignes du KGB

Pour interroger notre historique d'utilisation, nous avons créé un repository spécialisé :

class LlmApiUsageRepository extends ServiceEntityRepository
{
    public function getCurrentMonthTotalCost(): float
    {
        $startOfMonth = new \DateTimeImmutable('first day of this month midnight');

        $qb = $this->createQueryBuilder('u');
        $qb->select('SUM(u.cost) as total_cost')
            ->where('u.createdAt >= :start_date')
            ->andWhere('u.success = :success')
            ->setParameter('start_date', $startOfMonth)
            ->setParameter('success', true);

        $result = $qb->getQuery()->getSingleScalarResult();

        return (float) ($result ?? 0);
    }

    public function isMonthlyLimitReached(float $monthlyLimit = 5.0): bool
    {
        $currentUsage = $this->getCurrentMonthTotalCost();
        return $currentUsage >= $monthlyLimit;
    }

    public function getRemainingBudget(float $monthlyLimit = 5.0): float
    {
        $currentUsage = $this->getCurrentMonthTotalCost();
        $remaining = $monthlyLimit - $currentUsage;
        return max(0, $remaining);
    }

    public function findByMonth(\DateTimeInterface $date): array
    {
        // [Code de recherche par mois omis pour clarté]
    }

    public function getDailyUsageStats(\DateTimeInterface $date): array
    {
        // [Code de statistiques quotidiennes omis pour clarté]
    }
}

Ce repository nous permet de répondre à des questions cruciales comme :

  • Combien avons-nous dépensé ce mois-ci ?
  • Avons-nous atteint notre limite mensuelle ?
  • Combien reste-t-il dans notre budget ?
  • Quels sont nos coûts quotidiens pour un mois donné ?

C'est comme avoir un contrôleur fiscal personnel pour notre consommation d'IA, mais sans la menace de prison pour fraude.

Le système de messagerie : GenerateSeoMessage et son handler

Pour traiter les demandes de génération de SEO de manière asynchrone dans certains cas (comme lors des mises à jour automatiques de contenu), nous utilisons le composant Messenger de Symfony. Commençons par le message :

class GenerateSeoMessage
{
    /**
     * @param array<string, mixed> $options Options like ['force' => bool]
     */
    public function __construct(
        private readonly int $contentId,
        private readonly string $contentClass,
        private readonly array $options = [],
    ) {
    }

    // [Getters omis pour clarté]
}

Ce message est aussi simple qu'un tweet présidentiel : il contient l'ID du contenu, sa classe (pour pouvoir le retrouver), et quelques options. Rien de révolutionnaire, mais efficace.

Le handler, lui, est un peu plus sophistiqué :

#[AsMessageHandler]
class GenerateSeoHandler
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly ContentSeoGenerator $seoGenerator,
        private readonly LoggerInterface $logger,
    ) {
    }

    public function __invoke(GenerateSeoMessage $message): void
    {
        try {
            $contentId = $message->getContentId();
            $contentClass = $message->getContentClass();

            // Valider de la classe
            if (!class_exists($contentClass) || !is_subclass_of($contentClass, AbstractContent::class)) {
                $this->logger->error('Invalid content class in GenerateSeoMessage');
                return;
            }

            // Trouver l'entité du contenu
            $content = $this->entityManager->find($contentClass, $contentId);
            if ($content === null) {
                $this->logger->error('Content not found for SEO generation');
                return;
            }

            // Vérifier si le paramètre doit être forcer
            $forceRegeneration = false;
            if (isset($message->getOptions()['force']) && $message->getOptions()['force'] === true) {
                $forceRegeneration = true;
            }

            // Générer les metadonnées (seulement si les champs sont vides et que force est à true)
            $this->seoGenerator->generateSeo($content, $forceRegeneration);

            // Sauver l'entité mise à jour
            $this->entityManager->flush();

            $this->logger->info('SEO metadata generated and saved');
        } catch (\Throwable $throwable) {
            $this->logger->error('Error in GenerateSeoHandler');
        }
    }
}

Ce handler est comme un videur efficace dans une boîte de nuit :

  1. Il vérifie d'abord que le message est valide (classe existante et héritant d'AbstractContent).
  2. Il recherche ensuite l'entité de contenu correspondante.
  3. Il détermine si une régénération forcée est demandée.
  4. Il appelle notre générateur de SEO.
  5. Il enregistre les modifications en base de données.
  6. Il gère les erreurs éventuelles sans faire de scandale.

Le traitement synchrone : une évolution récente

Dans la version récente du système, nous avons migré le traitement manuel vers une approche synchrone, directement depuis la commande console. Voici un extrait de la commande qui montre comment nous avons implémenté ce changement :

protected function execute(InputInterface $input, OutputInterface $output): int
{
    $io = new SymfonyStyle($input, $output);
    $io->title('Génération des métadonnées SEO pour le contenu existant');

    // Afficher la version et le mode de traitement
    $io->section('🚀 Configuration');
    $io->writeln([
        '• <info>Mode de traitement</info>: <comment>Synchrone (direct)</comment>',
        '• <info>Version</info>: <comment>1.0</comment>',
    ]);
    
    // [Code omis pour clarté]
    
    // Si un ID spécifique est fourni, traiter uniquement ce contenu
    if ($contentId !== null) {
        $io->section('🔍 Traitement d\'un contenu spécifique');
        $content = $this->contentRepository->find((int) $contentId);

        if (!$dryRun) {
            $io->writeln("\n<options=bold>Génération en cours...</options=bold>");
            $startTime = microtime(true);

            // Exécuter directement le traitement au lieu d'utiliser le message bus
            $result = $this->processSeoGeneration($content, $force, $io);

            $endTime = microtime(true);
            $duration = round($endTime - $startTime, 2);
            
            // [Code d'affichage des résultats omis pour clarté]
        }
    }
    
    // [Code omis pour clarté]
}

/**
 * Traite directement la génération SEO sans passer par le message bus.
 */
private function processSeoGeneration(AbstractContent $content, bool $force = false, ?SymfonyStyle $io = null): bool
{
    try {
        $contentClass = $content::class;
        $contentId = $content->getId();

        if ($io instanceof SymfonyStyle) {
            $io->writeln('<fg=blue>• Préparation de la génération SEO...</>');
        }

        // Créer le message comme il aurait été envoyé par le message bus
        $message = new GenerateSeoMessage(
            contentId: $contentId,
            contentClass: $contentClass,
            options: ['force' => $force],
        );

        if ($io instanceof SymfonyStyle) {
            $io->writeln('<fg=blue>• Appel du traitement de génération SEO...</>');
        }

        // Créer une instance du handler avec nos services injectés
        $handler = new \App\MessageHandler\Seo\GenerateSeoHandler(
            $this->entityManager,
            $this->seoGenerator,
            $this->logger,
        );

        // Invoquer directement le handler
        $handler->__invoke($message);

        if ($io instanceof SymfonyStyle) {
            $io->writeln('<fg=green>• Génération terminée</>');
        }

        return true;
    } catch (\Throwable $throwable) {
        // [...gestion des erreurs...]
        return false;
    }
}

Ce passage à un traitement synchrone pour la commande console offre plusieurs avantages :

  1. Retour d'information immédiat sur le résultat de la génération
  2. Simplification du débogage et des tests
  3. Accès direct aux métadonnées générées pour les afficher à l'utilisateur
  4. Réduction des dépendances à la configuration du message bus

C'est comme passer de la livraison par La Poste à la remise en main propre : plus rapide, plus fiable, mais toujours aussi chiant à implémenter.

Le subscriber ContentSeoSubscriber : détecter les changements

Pour déclencher la génération de SEO au bon moment, nous utilisons un subscriber qui écoute les événements Doctrine et gère les mises à jour de contenu :

#[AsDoctrineListener(event: Events::postUpdate)]
#[AsDoctrineListener(event: Events::postPersist)]
#[WhenNot(env: 'dev')]
readonly class ContentSeoSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private MessageBusInterface $messageBus,
        private EventDispatcherInterface $eventDispatcher,
    ) {
    }

    public static function getSubscribedEvents(): array
    {
        return [
            ContentUpdatedEvent::class => 'onContentUpdated',
        ];
    }

    public function postUpdate(AbstractContent $content, PostUpdateEventArgs $args): void
    {
        if ($this->shouldProcessEntity($content)) {
            $this->eventDispatcher->dispatch(new ContentUpdatedEvent($content, false));
        }
    }

    public function postPersist(AbstractContent $content, PostPersistEventArgs $args): void
    {
        if ($this->shouldProcessEntity($content)) {
            $this->eventDispatcher->dispatch(new ContentUpdatedEvent($content, true));
        }
    }

    public function onContentUpdated(ContentUpdatedEvent $event): void
    {
        $content = $event->getContent();

        // Générer le SEO uniquement pour le contenu en ligne ou sur le point d'être publié
        if ($content->isOnline() !== true) {
            return;
        }

        // Pour les nouveaux contenus, on force la génération SEO
        // Pour les mises à jour, on ne régénère que si les champs SEO sont vides
        $forceRegeneration = $event->isNewContent();

        // Soumettre le message pour un traitement asynchrone
        $this->messageBus->dispatch(
            new GenerateSeoMessage(
                contentId: $content->getId(),
                contentClass: $content::class,
                options: ['force' => $forceRegeneration],
            ),
        );
    }

    private function shouldProcessEntity(object $entity): bool
    {
        return $entity instanceof AbstractContent;
    }
}

Ce subscriber est stratégiquement positionné pour observer les changements de contenu comme un voyeur devant la fenêtre d'une jolie fille :

  1. Il écoute les événements postUpdate et postPersist de Doctrine.
  2. Quand un contenu est créé ou mis à jour, il dispatche un événement ContentUpdatedEvent.
  3. En réponse à cet événement, il vérifie si le contenu est en ligne.
  4. Si c'est le cas, il envoie un message GenerateSeoMessage dans la file d'attente.
  5. Il est également intelligent : il force la régénération pour les nouveaux contenus, mais pas pour les mises à jour simples.

Notez l'attribut #[WhenNot(env: 'dev')] qui désactive ce subscriber en environnement de développement.

La commande LlmApiUsageCommand : surveiller vos dépenses

Pour suivre nos coûts d'utilisation de l'API, nous avons créé une commande console pratique :

#[AsCommand(
    name: 'app:seo:llm-usage',
    description: 'Affiche les statistiques d\'utilisation de l\'API LLM pour la génération SEO',
)]
class LlmApiUsageCommand extends Command
{
    public function __construct(
        private readonly LlmApiUsageRepository $apiUsageRepository,
        private readonly LlmApiLimitService $apiLimitService,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addOption(
                'month',
                'm',
                InputOption::VALUE_OPTIONAL,
                'Le mois à consulter (format: YYYY-MM)',
            )
            ->addOption(
                'detailed',
                'd',
                InputOption::VALUE_NONE,
                'Afficher les données détaillées',
            );
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $io->title("Statistiques d'utilisation de l'API LLM");

        // [Code d'affichage des statistiques omis pour clarté]

        return Command::SUCCESS;
    }
}

Cette commande est comme un relevé de compte bancaire pour nos appels d'API. Elle affiche :

  • Le budget total alloué
  • Le montant utilisé ce mois-ci
  • Le budget restant
  • Des statistiques quotidiennes d'utilisation
  • Des détails sur chaque appel d'API (avec l'option --detailed)

Pratique pour savoir si vous pouvez encore vous permettre de générer du SEO pour vos 200 articles de blog sur "Comment baiser sa belle-sœur" ou si vous devez rationner vos appels d'API comme des cigarettes en prison.

L'embeddable Seo : stocker les métadonnées

Pour stocker les métadonnées SEO générées, nous utilisons une classe embeddable :

#[ORM\Embeddable]
class Seo
{
    #[ORM\Column(length: 255, nullable: true)]
    private ?string $title = null;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $description = null;

    /**
     * @var string[]|null
     */
    #[ORM\Column(nullable: true)]
    #[Assert\All([
        new Assert\Type(type: 'string'),
        new Assert\Length(max: 100),
    ])]
    private ?array $keywords = null;

    /**
     * Valid values: index, noindex, follow, nofollow, none, noarchive, etc.
     */
    #[ORM\Column(length: 255, nullable: true)]
    #[Assert\Regex(
        pattern: '/^(index|noindex|follow|nofollow|none|noarchive|nosnippet|notranslate|noimageindex)(\s*,\s*(index|noindex|follow|nofollow|none|noarchive|nosnippet|notranslate|noimageindex))*$/',
        message: 'La valeur robots doit être une combinaison valide de directives séparées par des virgules',
    )]
    private ?string $robots = 'index, follow';

    // [Getters et setters omis pour clarté]
}

Cette classe embeddable est incorporée dans nos entités de contenu pour stocker les métadonnées SEO. Elle contient :

  • Un titre optimisé pour le SEO
  • Une meta description accrocheuse
  • Une liste de mots-clés pertinents
  • Des directives robots avec une validation regex

C'est comme un petit coffre-fort à l'intérieur de nos entités de contenu, où les métadonnées SEO peuvent se cacher des regards indiscrets de Google.

Le mot de la fin

Nous avons parcouru ensemble l'implémentation d'un système de génération automatisée de métadonnées SEO utilisant l'intelligence artificielle. Ce système :

  1. Détecte automatiquement les nouveaux contenus et les mises à jour
  2. Génère des titres, descriptions et mots-clés optimisés
  3. Traite les demandes de manière synchrone ou asynchrone selon le contexte
  4. Surveille et limite les coûts d'utilisation de l'API
  5. Enregistre chaque utilisation pour analyse et débogage

L'évolution la plus récente du système a été la migration du traitement manuel via la commande console vers un mode synchrone, offrant ainsi une meilleure expérience utilisateur et des retours d'information immédiats. Cette approche hybride nous permet de profiter du meilleur des deux mondes, comme un politicien qui promet tout à tout le monde.

Ce système nous permet de gagner un temps considérable tout en maintenant une qualité de SEO élevée. Bien sûr, il ne remplace pas complètement un expert SEO humain (rassurez-vous, connards de consultants au prix horaire exorbitant), mais il automatise efficacement une tâche répétitive et chronophage.

Pour aller plus loin

Si vous souhaitez implémenter un système similaire, voici quelques conseils :

  1. Commencez par installer le bundle php-llm/llm-chain-bundle : composer require php-llm/llm-chain-bundle
  2. Configurez votre accès à l'API OpenAI dans vos variables d'environnement
  3. Créez les entités nécessaires pour stocker les métadonnées SEO et suivre l'utilisation de l'API
  4. Implémentez le générateur de SEO et le service de gestion des limites
  5. Mettez en place un système hybride avec traitement synchrone pour les opérations manuelles et asynchrone pour les opérations automatiques
  6. Configurez les subscribers pour déclencher la génération au bon moment

Si un jour les IA prennent le contrôle du monde, rappelez-vous que c'est probablement parce que quelqu'un leur a demandé d'optimiser le taux de conversion "à tout prix". Comme quoi, même les robots peuvent devenir des capitalistes sans scrupules.

Liens utiles

Confidentialité

Ce site utilise Umami pour analyser le trafic de manière anonyme. Acceptez-vous la collecte de données anonymes ?