Suivre un fichier public servi sans Last-Modified
L'administration fiscale publie des classeurs Excel qui changent sans que rien ne l'indique : pas d'en-tête Last-Modified, pas d'archive, pas d'annonce. La seule histoire possible de ces fichiers est celle qu'on collecte soi-même — chaque jour sans relevé est perdu pour toujours. Voici la mécanique complète du relevé : un téléchargement, un lecteur de .xlsx en soixante lignes sans dépendance, et git comme archive. Dix-neuf jours plus tard, elle avait déjà attrapé trois corrections silencieuses.
Le cas concret : la DGFiP publie la liste des plateformes agréées pour la
facturation électronique sous forme de deux classeurs .xlsx, mis à jour à
un rythme inconnu. Ce qui suit n’a rien de propre au domaine fiscal — le
problème se pose pour n’importe quel fichier public qu’on veut suivre dans le
temps.
Le problème : un fichier sans passé
Premier réflexe avant d’écrire du code : demander au serveur de quand date le fichier.
$ curl -sI https://www.impots.gouv.fr/sites/default/files/media/.../liste_pa_attente_rapport_audit.xlsx | grep -i -E 'last-modified|etag'
$
Rien. Ni Last-Modified, ni ETag. Le CDN sert content-length et c’est à
peu près tout. Les conséquences en cascade :
- impossible de savoir si le fichier a changé sans le télécharger et le comparer ;
- impossible de dater un changement détecté — on sait qu’il a eu lieu entre deux téléchargements, pas quand ;
- surtout : aucun état passé n’est reconstituable. Personne ne peut dire combien de lignes contenait le fichier le 1er juillet, sauf à l’avoir téléchargé le 1er juillet.
La page HTML qui héberge les fichiers porte bien une date de mise à jour, mais elle date la page, pas les fichiers — les deux vivent des vies séparées.
Il découle de tout cela une règle simple et un peu brutale : l’historique d’un tel fichier commence au premier relevé, et chaque jour sans relevé est définitivement perdu. C’est la seule vraie raison de lancer la collecte tout de suite, même avec un script imparfait, plutôt que « proprement, plus tard ».
Lire un .xlsx sans rien installer
Un .xlsx est un ZIP de fichiers XML. Pour deux classeurs d’une feuille
chacun, sans formule ni cellule fusionnée porteuse de donnée, un parseur
complet (SheetJS, exceljs…) est une dépendance de plus pour aucun gain.
unzip -p extrait un membre du ZIP vers stdout, et deux fichiers suffisent :
const lis = (membre) =>
execFileSync("unzip", ["-p", cheminZip, membre], {
maxBuffer: 64 * 1024 * 1024,
}).toString("utf8");
Le premier membre utile est xl/sharedStrings.xml. Excel n’écrit pas les
chaînes dans les cellules : il les range dans ce magasin global, et les
cellules de type t="s" n’en portent que l’index. Une chaîne peut être
éclatée en plusieurs runs <t> (quand la cellule mélange des mises en
forme), d’où la concaténation :
const chaines = [];
for (const si of brut.match(/<si>[\s\S]*?<\/si>/g) ?? []) {
const morceaux = [...si.matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((m) => m[1]);
chaines.push(decode(morceaux.join("")));
}
Le second est xl/worksheets/sheet1.xml, où chaque cellule est un <c> dont
le type dicte la lecture : t="s" renvoie vers le magasin, t="inlineStr"
porte ses runs <t> en ligne, et le reste est une valeur brute dans <v>.
Oui, c’est du XML parsé aux regex, et non, ce n’est pas un conseil général — c’est un choix borné à des fichiers dont la structure est connue, vérifiée à chaque exécution par les totaux de sortie, et dont le producteur est unique. Le jour où la DGFiP change la structure, le script casse bruyamment, ce qui est exactement le comportement voulu.
Le piège des dates Excel
Les dates des classeurs ne sont pas des chaînes mais des numéros de série : le nombre de jours écoulés depuis une époque. Et cette époque n’est pas le 31 décembre 1899 qu’on croit déduire, mais le 30 :
const EPOQUE = Date.UTC(1899, 11, 30);
function dateExcel(serie) {
const n = Number(serie);
if (!Number.isFinite(n) || n <= 0) return null;
return new Date(EPOQUE + Math.trunc(n) * 86400000).toISOString().slice(0, 10);
}
Le décalage d’un jour absorbe un bogue historique : Lotus 1-2-3 traitait 1900 comme bissextile, Excel a reproduit l’erreur pour rester compatible, et le numéro de série 60 correspond au 29 février 1900 — un jour qui n’existe pas. Prendre le 30 décembre comme époque rend tous les numéros postérieurs au 28 février 1900 corrects. « Corriger » l’époque en 1900-01-01 décale toutes les dates de deux jours ; c’est le genre d’erreur qui passe une revue de code et fausse un jeu de données entier.
Détail utile du même registre : les en-têtes HTTP sont des ByteStrings. Un
User-Agent contenant un tiret cadratin lève une TypeError avant même que
la requête parte — l’ASCII pur est de rigueur.
Git comme archive
Chaque exécution écrit un instantané JSON daté — comptes, opérateurs, dates,
anomalies, en-têtes HTTP reçus — et cet instantané est committé. Le dépôt
devient l’archive que le serveur ne fournit pas : git log sur le dossier
des relevés est l’historique du fichier public, avec des dates de collecte
signées par les commits.
L’instantané conserve aussi, volontairement, le champ last_modified_servi
même quand il vaut null : l’absence de cet en-tête est précisément le fait
qui justifie le dispositif. Si le serveur se met un jour à le servir, le
changement apparaîtra dans un diff.
Ce que dix-neuf jours de relevés ont attrapé
La méthode a payé plus vite que prévu. Entre trois relevés (25 août, 4 septembre, 13 septembre), le fichier des opérateurs immatriculés est passé de 148 à 150 puis à 149 lignes — sans qu’aucune de ces variations soit une immatriculation nouvelle :
- deux lignes qui portaient « à venir » en texte libre à la place de la date ont reçu une date valide ;
- une date postérieure au jour du relevé — une coquille d’année, selon toute vraisemblance — a été corrigée ;
- une ligne en doublon (même éditeur présent sous deux entités juridiques, même site web) a été retirée.
Aucun de ces mouvements n’a été annoncé où que ce soit, et aucun n’est prouvable sans détenir les fichiers des deux dates. Trois décomptes différents du même fichier officiel sont donc en circulation selon le jour où chacun l’a recopié — et sans relevé daté, le débat entre eux est indécidable.
C’est une propriété générale qui mérite d’être retenue : un fichier public
servi sans métadonnées de version n’est pas une source, c’est un flux. Le
transformer en source — datée, diffable, citable — coûte une soixantaine de
lignes et un git commit par relevé.