Megaverse #1: Ingestion de flux RSS
Les forums de jeu de rôle communautaire fonctionnent sur un principe simple : chaque message est un fragment de narration collective, souvent organisé en chapitres numérotés et rattaché à un ou plusieurs personnages. Sur des communautés actives, ce flux narratif devient rapidement difficile à suivre manuellement, des dizaines de sujets sont publiés chaque semaine, répartis sur des quartiers fictionnels distincts, avec des histoires qui s'étendent sur plusieurs chapitres non consécutifs dans le temps.
Le forum étudié ici, et dans lequel je suis inscrit en tant que membre, expose un flux RSS standard (spécification RSS 2.0, avec extension Dublin Core pour l'attribution d'auteur). Ce flux est aujourd'hui relayé tel quel vers un canal Discord personnel et via un service tiers (Monitorss), ce qui pose deux limites structurelles : (1) aucune donnée n'est conservée au-delà de la fenêtre glissante du flux, et (2) aucune structuration n'est appliquée aux données brutes.
Ce projet vise à combler cet écart en construisant, au-dessus du flux existant, une couche de valeur ajoutée : ingestion persistante, structuration narrative, restitution enrichie. Cet article documente le premier jalon de ce projet, le composant d'ingestion, dont la robustesse conditionne la fiabilité de tous les modules ultérieurs.
Trois éléments techniques préexistants encadrent la conception du système :
- RSS 2.0 définit un format
<item>avec des champs standards (title,link,pubDate) et un champguidexplicitement prévu comme identifiant stable d'une entrée, y compris lorsque celle-ci est éditée. - L'extension Dublin Core (
dc:creator) est utilisée par le forum pour exposer l'auteur du sujet, c'est-à-dire, dans ce contexte, le pseudonyme du personnage à l'origine du message. - Ruby on Rails, via ActiveRecord, offre un mécanisme de contrainte d'unicité applicable à la fois au niveau applicatif (validation) et au niveau du schéma de base de données (index unique), ce qui permet de garantir l'idempotence à deux niveaux indépendants, une redondance volontaire plutôt qu'une simplification hasardeuse.
Le pipeline d'ingestion suit un flux linéaire à quatre étapes :
[Flux RSS du forum]
│ (requête HTTP toutes les 10 min, planifiée via whenever)
▼
[RssIngestor#call]
│ parsing tolérant (RSS::Parser.parse(xml, false))
▼
[Déduplication par guid]
│ Topic.find_or_initialize_by(guid: ...)
▼
[Persistance ActiveRecord / SQLite]
│
▼
[Result { created, skipped, errors }]
Chaque étape est isolée dans une méthode unique, ce qui permet de tester indépendamment le comportement de parsing, de déduplication, et de gestion d'erreur.
Le schéma repose sur une table unique à ce stade, topics, avec une contrainte d'unicité explicite sur guid :
create_table :topics do |t|
t.string :guid, null: false
t.string :title
t.string :link
t.string :creator
t.string :category
t.text :description
t.datetime :pub_date
t.timestamps
end
add_index :topics, :guid, unique: true
La contrainte est doublée au niveau applicatif :
class Topic < ApplicationRecord
validates :guid, presence: true, uniqueness: true
end
Ce doublement n'est pas redondant : la validation ActiveRecord protège contre les erreurs de logique applicative avant écriture, tandis que l'index unique protège contre les conditions de concurrence (deux processus d'ingestion qui s'exécuteraient simultanément, par exemple lors d'un futur passage à plusieurs workers).
Le cœur du système repose sur un design volontairement défensif : aucune exception ne doit jamais remonter à l'appelant. Toute erreur, réseau, parsing, persistance, est capturée et convertie en donnée structurée via un objet Result :
Result = Struct.new(:created, :skipped, :errors, keyword_init: true)
def call
feed = RSS::Parser.parse(fetch_feed, false)
created = 0
skipped = 0
feed.items.each do |item|
guid = item.guid.content.strip
topic = Topic.find_or_initialize_by(guid: guid)
if topic.persisted?
skipped += 1
next
end
topic.assign_attributes(
title: item.title&.strip,
link: item.link&.strip,
creator: item.dc_creator,
category: item.categories.first&.content&.strip,
description: item.description,
pub_date: item.pubDate
)
created += 1 if topic.save
end
Result.new(created: created, skipped: skipped, errors: [])
rescue => e
Rails.logger.error("[RssIngestor] échec d'ingestion: #{e.class} #{e.message}")
Result.new(created: 0, skipped: 0, errors: [e.message])
end
Ce choix architectural, que l'on peut rapprocher du pattern Either/Result courant en programmation fonctionnelle, déplace la gestion d'erreur du niveau « exception non gérée » vers le niveau « donnée de sortie explicite ». La conséquence directe est qu'un appelant (tâche cron, futur worker, futur bot Discord) n'a jamais besoin d'envelopper l'appel dans un begin/rescue : il consulte simplement result.errors.
Le second choix notable est l'appel RSS::Parser.parse(xml, false). Le second argument désactive la validation stricte du parseur, ce qui est indispensable face à un flux produit par un forum grand public, dont la conformité totale à la spécification RSS n'est pas garantie dans le temps.
La suite de tests RSpec repose sur une fixture unique, une capture réelle du flux du forum, contenant vingt entrées hétérogènes, combinée à WebMock pour intercepter la requête HTTP sortante sans dépendance réseau réelle pendant les tests.
Quatre familles d'assertions ont été définies :
- Complétude: chaque entrée du flux produit un enregistrement (
change(Topic, :count).by(20)). - Idempotence: une seconde exécution sur le même flux ne produit aucune création supplémentaire.
- Exactitude des champs: les champs
creatoretcategoryextraits correspondent aux valeurs attendues sur une entrée de référence. - Résilience: une réponse HTTP 500 simulée ne lève aucune exception et ne modifie pas l'état de la base.
Cette approche, tester le comportement observable plutôt que l'implémentation interne, rend la suite robuste aux refactorings ultérieurs du service, tant que le contrat d'entrée/sortie (Result) reste stable.
L'exécution de la suite complète produit sept assertions passantes sans échec. En conditions réelles, deux exécutions consécutives de la tâche planifiée (rss:ingest) confirment l'absence de doublon, y compris sur des exécutions rapprochées. Une simulation d'indisponibilité réseau confirme que le service se dégrade silencieusement (retour d'un Result vide avec erreur consignée) plutôt que d'interrompre le processus planifié.
La planification via la gem whenever, à intervalle de dix minutes, aligné sur le ttl annoncé par le flux, a été validée par inspection de la crontab générée.
Ce composant repose sur une hypothèse forte : la stabilité du guid fourni par le forum comme identifiant pérenne. Cette hypothèse est conforme à la spécification RSS 2.0, mais reste dépendante de l'implémentation du moteur de forum sous-jacent, hors du contrôle du projet.
Une seconde limite est la nature mono-processus de l'ingestion actuelle : en l'état, une seule exécution cron est supposée active à un instant donné. La contrainte d'unicité au niveau base de données constitue néanmoins un filet de sécurité si cette hypothèse venait à être violée (exécutions concurrentes accidentelles).
Enfin, le champ description est stocké tel quel, sans structuration. C'est précisément l'objet de la suite de ce projet : l'extraction du numéro de chapitre, des mentions de partenaires, et des champs narratifs (date in-univers, lieu, avertissements de contenu).
Ce travail établit la fondation de ce nouveau projet sur trois garanties vérifiées empiriquement : idempotence, résilience aux pannes, et traçabilité des erreurs. En déplaçant la gestion d'erreur d'un mécanisme d'exception vers un objet de résultat structuré, et en doublant la contrainte d'unicité entre couche applicative et couche base de données, le système démontre une robustesse suffisante pour servir de socle aux modules narratifs plus avancés prévus dans les sprints suivants.