Articles similaires dynamiques en PHP sans base de données

La section "articles similaires" en bas d'un article de blog, c'est le genre de chose qu'on met en dur au départ — deux liens choisis à la main — et qu'on oublie de maintenir. Résultat : des suggestions obsolètes qui pointent vers des articles sans rapport, ou pire, vers des articles qui n'existent plus.

Sur ce blog, tout le contenu est décrit dans un seul fichier posts.json avec pour chaque article son slug, sa catégorie et ses tags. De quoi calculer automatiquement des suggestions pertinentes sans base de données.

Le problème avec les liens en dur

La version initiale passait un tableau de liens à blog_footer() :

<?php blog_footer([
    ['url' => '/blog/commentaires-sans-bdd-php', 'title' => 'Ajouter des commentaires...'],
    ['url' => '/blog/creer-un-blog-avec-claude-code', 'title' => 'Créer un blog...'],
]); ?>

Fonctionnel, mais à maintenir à la main dans chaque fichier article. Avec 10 articles ça passe, avec 50 ça devient ingérable — et ça ne s'améliore jamais automatiquement quand on publie un nouvel article qui serait plus pertinent.

La source de vérité : posts.json

Chaque article est décrit dans posts.json avec ses métadonnées :

{
  "slug": "analytics-php-sans-cookies-rgpd",
  "title": "Analytics PHP sans cookies ni base de données",
  "date": "2026-02-25",
  "category": "Retour d'expérience",
  "tags": ["php", "analytics", "rgpd", "sécurité", "no-database"]
}

C'est déjà utilisé pour le listing du blog et le sitemap. Autant s'en servir pour les suggestions.

L'algorithme de scoring

Le principe est simple : pour chaque article candidat (tous sauf l'article courant), on calcule un score de pertinence basé sur deux critères :

  • +2 par tag en commun — les tags sont le signal le plus précis
  • +1 si même catégorie — signal de contexte plus large

On trie par score décroissant, à score égal on prend le plus récent, et on garde les 3 premiers. Les articles avec un score de 0 (aucun point commun) sont exclus.

function find_related_posts(string $slug, int $limit = 3): array {
    $all = json_decode(file_get_contents(__DIR__ . '/posts.json'), true) ?? [];

    // Trouver l'article courant
    $current = null;
    foreach ($all as $p) {
        if ($p['slug'] === $slug) { $current = $p; break; }
    }
    if (!$current) return [];

    $current_tags = $current['tags'] ?? [];
    $current_cat  = $current['category'] ?? '';

    // Scorer les candidats
    $scored = [];
    foreach ($all as $p) {
        if ($p['slug'] === $slug) continue;

        $score = count(array_intersect($current_tags, $p['tags'] ?? [])) * 2
               + ($p['category'] === $current_cat ? 1 : 0);

        if ($score > 0) {
            $scored[] = ['score' => $score, 'post' => $p];
        }
    }

    // Trier par score desc, puis date desc à égalité
    usort($scored, fn($a, $b) =>
        $b['score'] <=> $a['score'] ?: strcmp($b['post']['date'], $a['post']['date'])
    );

    return array_map(fn($s) => [
        'url'   => '/blog/' . $s['post']['slug'],
        'title' => $s['post']['title'],
    ], array_slice($scored, 0, $limit));
}

Intégration dans blog_footer()

blog_footer() acceptait déjà un $slug en paramètre (utilisé pour charger les commentaires). Il suffit d'auto-déclencher le calcul quand aucune liste n'est passée explicitement :

function blog_footer($related_posts = [], $slug = null) {
    if (empty($related_posts) && $slug !== null) {
        $related_posts = find_related_posts($slug);
    }
    // ...
}

Côté article, l'appel devient :

<?php blog_footer([], 'mon-slug-article'); ?>

C'est tout. Les articles existants qui passaient déjà leur slug n'ont pas eu besoin d'être modifiés — ils héritent du comportement automatiquement.

Pourquoi pondérer les tags x2

La catégorie regroupe des articles de contexte similaire mais pas forcément de sujet proche — "Retour d'expérience" peut couvrir du PHP comme du Bash. Les tags sont plus précis : deux articles qui partagent les tags php et no-database parlent probablement du même problème. La pondération x2 sur les tags garantit que la proximité thématique prime sur la proximité catégorielle.

Limites

Le fichier posts.json est relu à chaque affichage d'article. Sur un blog à faible trafic avec une dizaine d'articles, c'est négligeable. Si le volume augmente, un opcache ou un cache fichier simple suffirait — mais ce n'est pas le problème aujourd'hui.

L'algorithme ne tient pas compte du contenu réel des articles, seulement des métadonnées. La qualité des suggestions dépend donc directement de la qualité du tagging. Ce qui est une bonne raison de soigner ses tags plutôt que de les mettre à la volée.

Commentaires (0)