Comment nous avons traduit un site entier en une nuit avec un essaim de sous-agents Claude
816 fichiers sources, 4 langues cibles, 6 380 requêtes de traduction, 45 lots Claude, une nuit. L'architecture, la salle de contrôle 3D en three.js, et tout ce qui a cassé en chemin.
InSkillCoach
Comment nous avons traduit un site entier en une nuit avec un essaim de sous-agents Claude
Le soir du 16 octobre 2026, SkillverseHub existait dans une seule langue : le français. Huit cent seize fichiers sources, environ dix-neuf millions de caractères de cours, tutoriels, articles, gabarits de pages et composants d’interface. Le lendemain matin, le site entier existait en anglais, espagnol, portugais et arabe, avec 754 pages générées par langue, un sélecteur de langue fonctionnel, et une salle de contrôle 3D où l’on pouvait rejouer toute la nuit minute par minute.
Cet article explique comment. Pas la version marketing, la version ingénierie : le pipeline, les chiffres, le tableau de bord, la coordination entre agents, et les cinq choses qui ont cassé.
La contrainte qui a tout dicté
Nous disposions de crédits API Anthropic prépayés qui allaient expirer sous dix jours. La question n’était pas « faut-il traduire le site ? » mais « comment convertir des crédits périssables en la plus grande quantité possible de travail fini et déployable, sans surveillance, en une seule nuit ? »
Cette contrainte éliminait l’approche évidente : appeler l’API fichier par fichier dans une boucle. Une boucle séquentielle sur 6 000 requêtes aurait pris des jours et serait morte au premier incident réseau. Il fallait quelque chose qui se comporte comme une usine : tout soumettre d’un coup, laisser le fournisseur paralléliser, récupérer les résultats quand ils arrivent, et survivre aux plantages, à l’épuisement des crédits et aux erreurs d’opérateur sans perdre de travail déjà payé.
La réponse : l’API Message Batches d’Anthropic, enveloppée dans un petit orchestrateur reprenable que nous avons appelé le moteur de traduction, et observée par un tableau de bord temps réel baptisé l’Atelier.
Le pipeline, étape par étape
1. Inventaire
Le moteur parcourt src/ et classe chaque fichier source français dans l’une de sept familles : articles de blog, chapitres de cours (« catégories »), tutoriels, pages Astro, gabarits, composants et fichiers de données. Chaque famille a ses propres règles de traduction, car traduire un cours en Markdown et traduire un gabarit .astro ne sont pas le même métier.
| Famille | Fichiers | Caractères |
|---|---|---|
| Tutoriels | 322 | 8,0 M |
| Blog | 215 | 7,8 M |
| Catégories (cours) | 111 | 1,0 M |
| Pages | 103 | 1,4 M |
| Gabarits | 32 | 0,6 M |
| Composants | 29 | 0,2 M |
| Données | 2 | < 0,1 M |
Soit 18,9 millions de caractères par langue cible, quatre langues, donc environ 76 millions de caractères à produire et à vérifier.
2. Découpage
Certains chapitres font 60 000 caractères. Les envoyer entiers risque une sortie tronquée et rend chaque échec coûteux. Le moteur découpe le Markdown aux frontières de titres en morceaux d’environ 16 000 caractères, sans jamais couper dans un bloc de code ni dans le front-matter. Les blocs de code dépassant un seuil sont remplacés par un marqueur avant l’envoi et restaurés à l’identique ensuite : le modèle n’a jamais l’occasion « d’améliorer » un Dockerfile.
Chaque couple (morceau, langue) devient une requête. Son custom_id est le SHA-1 de langue + type de fichier + texte du morceau. C’est la décision de conception la plus importante du système : l’identifiant dérive du contenu, donc un morceau resoumis après un plantage retombe sur le même résultat, et les résultats peuvent être réassociés à leur morceau même si tous les fichiers d’état locaux ont disparu. On y reviendra.
3. Un prompt système par (langue, famille)
Le prompt d’un article dit : traduis la prose, conserve chaque structure Markdown, ne touche pas au code, garde les clés du front-matter mais traduis leurs valeurs, garde les liens internes en réécrivant /fr/ en /en/. Le prompt d’un composant Astro dit tout autre chose : traduis uniquement les chaînes visibles, ne touche jamais aux imports, props, expressions ou classes, et garde le fichier compilable. Les prompts arabes ajoutent des consignes droite-à-gauche pour la ponctuation et les termes latins mélangés.
4. Soumission à l’API Batch
Les requêtes sont groupées par lots de 200 maximum et soumises à /v1/messages/batches. Anthropic les exécute en parallèle de son côté, à moitié prix des appels synchrones, et laisse interroger l’avancement. Chaque lot est ce que le tableau de bord appelle un sous-bot : un travailleur autonome qui emporte des milliers de requêtes et revient avec les résultats.
Sur la nuit, 45 lots ont été soumis, totalisant 6 380 requêtes, dont 6 227 revenues avec succès. Le moteur alternait entre deux clés API avec bascule automatique et ajoutait l’en-tête d’espace de travail exigé par les clés à portée restreinte.
5. Valider, ne pas faire confiance
Chaque résultat est contrôlé avant d’être accepté :
- même nombre de titres, mêmes niveaux, dans le même ordre que la source ;
- même nombre de blocs de code, au contenu identique ;
- front-matter toujours analysable et contenant les mêmes clés ;
- pour les fichiers
.astroet.ts: mêmes imports, mêmes symboles exportés, plus de français dans les chaînes du gabarit ; - aucun bavardage de modèle (« Voici la traduction… »).
Un morceau qui échoue est resoumis, jusqu’à trois tentatives. Après trois échecs, l’original français sert de repli pour ce fichier, signalé dans l’état : le site se construit quand même et le trou est visible plutôt que silencieux. Au matin, 3 136 fichiers étaient traduits et 128 en repli ; un second agent a ensuite ramené les replis à 64.
6. Assemblage et réécriture du code
Les morceaux sont recollés dans l’ordre. Une passe de réécriture adapte ensuite le code à la place de la langue dans le projet :
getCollection('blog')devientgetCollection('blog_en'), chaque langue ayant sa propre collection de contenu Astro ;- les imports pointant vers
src/components/sont redirigés verssrc/l10n/en/components/; - les préfixes de liens
/fr/deviennent/en/,/es/,/pt/,/ar/; - diagrammes Mermaid, chemins d’images relatifs et blocs de code marqués sont restaurés.
7. Build auto-réparant
Le moteur lance ensuite un astro build complet. En cas d’échec, il analyse l’erreur, identifie le fichier coupable, le remplace par le repli français et reconstruit, jusqu’à trente fois. Quatre langues fois 754 pages ont fini par se construire proprement grâce à cette boucle.
8. Publication
Les langues traduites à au moins 90 % sont ajoutées à PUBLISHED_LOCALES, qui pilote à la fois les balises hreflang et le sélecteur de langue. Le résultat est commité et poussé sur une branche dédiée, où Vercel produit un aperçu à valider avant toute mise en production.
La machine à états qui a sauvé la nuit
Tout ce que le moteur sait vit dans un dossier .traduction/ : etat.json (chaque unité, son statut, son identifiant de lot), un fichier de résultat par custom_id, un journal, et historique.jsonl, un instantané de l’état complet pris toutes les 30 secondes.
Vers minuit, une erreur d’opérateur a supprimé ce dossier. Quarante-deux lots déjà payés dormaient sur les serveurs d’Anthropic sans aucune trace locale.
Comme les résultats sont conservés 29 jours et téléchargeables gratuitement, et comme chaque custom_id est une empreinte de contenu, le moteur a gagné une commande rattacher : lister tous les lots du compte des 29 derniers jours, télécharger leurs résultats, recalculer l’empreinte de chaque morceau local, et apparier. 6 478 résultats récupérés sans dépenser un centime. La nuit a continué.
Plus tard, les crédits se sont épuisés en pleine exécution. Le moteur a détecté l’erreur credit balance is too low sur les deux clés, marqué l’état crédits épuisés, cessé de soumettre, laissé finir les lots en vol, et enchaîné directement sur l’assemblage et le build avec repli pour ce qui manquait. Personne n’était réveillé pour prendre cette décision ; le moteur l’a prise.
La salle de contrôle 3D
Un journal qui défile à 3 h du matin ne dit rien. Nous voulions que n’importe qui, y compris sans bagage technique, ouvre une page et comprenne d’un coup d’œil ce qui se passait. Le tableau de bord, construit avec three.js, est donc une ville :

- Chaque fichier source est un immeuble. Sa hauteur est proportionnelle à sa taille en caractères. Des quartiers regroupent les familles : Blog, Tutoriels, Catégories, Pages, Gabarits, Composants.
- Chaque immeuble a quatre étages, un par langue, l’anglais en bas, l’arabe en haut. Un étage s’allume dans la couleur de sa langue dès que la traduction est validée. Un étage sombre est en attente ; un étage rouge est un repli.
- Au centre brille le cœur Anthropic. Quand un lot est soumis, un drone (le sous-bot) quitte le cœur avec une étiquette portant son identifiant, son nombre de requêtes et sa famille, orbite pendant l’exécution, et renvoie des particules vers les immeubles à mesure que les résultats arrivent.
- Quatre réservoirs se remplissent avec le pourcentage d’achèvement de chaque langue.
- Un panneau latéral explique les six étapes en langage simple, et un journal affiche les dernières décisions du moteur.
- Des boutons permettent de lancer une exécution (10 fichiers, 60 fichiers, ou tout le site), de mettre en pause, de reprendre, et surtout de rejouer la nuit à ×150, ×300 ou ×900 à partir des 1 210 instantanés enregistrés, avec une frise chronologique.
Le replay était le livrable du lendemain matin : un film de quatre minutes pour quatre heures et demie de travail, montrant 45 drones quittant le cœur, des immeubles s’allumant étage par étage, le compteur de crédits grimpant jusqu’à 309,95 $, et l’instant, à 01 h 37, où le dernier build est passé et où le moteur a poussé la branche.
Le tableau de bord est une page statique servie par le moteur lui-même (et recopiée sur le site sous /atelier-traduction/) ; elle lit etat.json, plan.json et historique.json en HTTP et accepte les commandes lancer, pause et reprendre avec une liste d’origines autorisées.
Deux agents, un fichier Markdown
Le moteur a été écrit et piloté par un agent de programmation IA (Claude, dans Cursor). Un second agent, Grok, était disponible en parallèle. Plutôt que de les laisser se télescoper dans le même dépôt, nous leur avons donné un protocole :
- un fichier unique,
grok-cursor-bridge.md, utilisé comme tableau d’affichage : chaque agent y ajoute des entrées datées, dit ce qu’il fait, pose ses questions ; - un dossier
grok-inbox/où Claude dépose des listes de tâches (par exemplereplis-a-traduire.tsv, les fichiers retombés en français) ; - un dossier
grok-outbox/<langue>/<chemin>où Grok dépose ses traductions finies, en reproduisant l’arborescencesrc/du projet ; - une commande
importerdans le moteur, qui valide tout le contenu de l’outbox avec les mêmes contrôles que les résultats de lots et l’intègre dans l’état.
Grok a traduit les fichiers les plus durs, ceux que les lots de Claude avaient rejetés trois fois, et les a livrés par vagues de 20 à 50 ; le moteur en a importé environ soixante-dix. Grok a aussi travaillé sur une branche séparée pour les dictionnaires d’interface, fusionnée normalement. Les deux agents n’ont jamais modifié le même fichier, et chaque question de Grok a reçu une réponse écrite dans le pont en quelques minutes.
Les chiffres
| Mesure | Valeur |
|---|---|
| Fichiers sources par langue | 816 |
| Caractères par langue | 18,9 M |
| Langues cibles | 4 (EN, ES, PT, AR) |
| Requêtes de traduction | 6 380 |
| Lots (sous-bots) | 45 |
| Jetons en entrée | 35,3 M |
| Jetons en sortie | 34,3 M |
| Coût | ≈ 310 $ |
| Pages générées | 754 par langue |
| Fichiers traduits / replis (au matin) | 3 136 / 128 |
| Résultats récupérés après perte d’état | 6 478 |
| Instantanés enregistrés pour le replay | 1 210 |
| Durée | 21 h 07 → 01 h 37, sans surveillance |
Effort humain équivalent, à un rythme professionnel d’environ 2 500 mots par jour et par traducteur : près de 12 millions de mots produits, soit de l’ordre de vingt années-traducteur.
Cinq choses qui ont cassé
- Le paramètre
temperature. Le modèle utilisé en lot ne l’accepte plus ; chaque requête du premier lot a échoué. Retiré, resoumis, dix minutes perdues. - Un port déjà pris. Le serveur HTTP du tableau de bord a été lancé deux fois (
EADDRINUSE). Le moteur tourne désormais détaché, en vrai processus d’arrière-plan. - La suppression de l’état, racontée plus haut, qui a donné naissance à la commande
rattacher. Les meilleures fonctionnalités naissent des pires nuits. - Il manquait 215 pages en arabe. Les pages générées contenaient encore
isLocaleEntry('ar'), un filtre sans objet dans une collection par langue, qui filtrait donc tout. Le réécriveur le remplace désormais par() => true. Cinquante-deux fichiers corrigés, reconstruction, 754 pages. - L’hébergeur. Un site en 5 langues embarque tout le magasin de contenu dans la fonction serverless Vercel : 391 Mo pour une limite dure de 250 Mo, et le conteneur de build de 8 Go a manqué de mémoire. La production sert aujourd’hui le français et l’anglais (180 Mo) ; l’espagnol, le portugais et l’arabe sont complets sur la branche et apparaissent grisés « Bientôt » dans le menu de langue, en attendant leur passage en sortie purement statique. Traduire le site s’est révélé la partie facile. Le déployer, la partie difficile.
Ce que nous garderions
- Des identifiants adressés par contenu. Hacher le morceau dans le
custom_idest ce qui a rendu le système insensible aux plantages et l’état récupérable. - Des lots plutôt que des boucles. 6 380 requêtes en 45 soumissions, parallélisées par le fournisseur, à moitié prix. Tout travail de masse ponctuel au-delà de quelques centaines d’éléments devrait être un lot.
- Tout valider, ne rien croire. Les contrôles structurels ont attrapé des sorties tronquées, des blocs de code avalés et des préambules bavards qui seraient partis en production.
- Replier, jamais bloquer. Un fichier intraduisible après trois tentatives retombe sur la source et est signalé ; le build n’attend jamais la perfection.
- Rendre l’état visible. La ville 3D n’était pas une décoration. C’est ainsi qu’une personne non technique a vérifié le lendemain, en quatre minutes, que le travail était réellement fait ; et c’est ainsi que nous avons trouvé le bogue arabe : un quartier entier avec le dernier étage éteint.
Démarrer la salle de contrôle vous-même
Tout ce qui suit tourne sur un portable. Le moteur est un seul fichier Node.js sans autre dépendance que Node 20 ; le tableau de bord, trois fichiers statiques.
1. Ce qu’il faut
- Node.js 20 ou plus récent (
fetchetnode:cryptosont intégrés). - Le projet, avec les sources françaises sous
src/. - Une clé API Anthropic avec des crédits. Mettez-la dans un fichier
.envà la racine du projet, jamais dans le code :
# .env (ignoré par git)
ANTHROPIC_API_KEY=sk-ant-...
# Seulement pour les clés limitées à un espace de travail d'organisation :
# ANTHROPIC_WORKSPACE_ID=wrkspc_...
Le moteur accepte aussi une seconde clé (key_anthropic_2) et bascule dessus automatiquement quand la première n’a plus de crédits.
2. Les fichiers
scripts/traduction-batch/traduire.mjs le moteur (annexe A, 752 lignes)
public/atelier-traduction/index.html la page du tableau de bord
public/atelier-traduction/app.js la scène three.js et les commandes
public/atelier-traduction/style.css l'habillage
.traduction/ créé au premier lancement : état, résultats, journal, historique
Le dossier .traduction/ figure dans .gitignore : c’est la mémoire du moteur, pas du code source.
3. Regarder avant de dépenser
node scripts/traduction-batch/traduire.mjs plan
Aucun appel API. Le moteur inventorie les sources, les découpe en morceaux et affiche le nombre de fichiers, de requêtes et de caractères. Multipliez les caractères par votre nombre de langues et par le prix du lot : vous connaissez votre budget avant de lancer quoi que ce soit.
4. Démarrer le tableau de bord
node scripts/traduction-batch/traduire.mjs serve
Puis ouvrez http://localhost:4599/. Au démarrage, le moteur :
- construit le plan de travail (chaque fichier, chaque langue, chaque morceau, chacun avec son empreinte de contenu) ;
- ouvre le serveur HTTP sur le port 4599 (
--port 4600pour changer) ; - appelle Anthropic une fois pour rattacher tout lot des 29 derniers jours dont les résultats correspondent à vos morceaux : le travail d’une nuit précédente est reconnu immédiatement et gratuitement ;
- prend un instantané de l’état toutes les 10 secondes, et en ajoute un à
historique.jsonltoutes les 30 secondes.
La page se connecte à http://localhost:4599/etat.json. Si elle affiche « le moteur de traduction ne répond pas », le moteur n’est simplement pas lancé.
5. Piloter depuis le navigateur
- Cochez les langues, choisissez une taille (10 fichiers pour un premier essai, 60, ou le site complet) et appuyez sur Lancer. Un drone quitte le cœur Anthropic pour chaque lot soumis.
- Pause arrête la soumission de nouveaux lots ; ceux déjà chez Anthropic continuent et leurs résultats sont quand même récupérés.
- Reprendre lève la pause et réinitialise l’indicateur « crédits épuisés » : vous pouvez recharger votre compte et continuer sans redémarrer.
- Rejouer la nuit lit
historique.jsonlet joue chaque instantané à ×150, ×300 ou ×900, avec une frise chronologique. Retour au direct revient à l’état réel.
Les mêmes actions existent en ligne de commande :
node scripts/traduction-batch/traduire.mjs run --langs en --limit 10 # essai : 10 fichiers, anglais seulement
node scripts/traduction-batch/traduire.mjs run # tout, puis build, puis push
node scripts/traduction-batch/traduire.mjs run --no-build --no-push # traduction seule
node scripts/traduction-batch/traduire.mjs importer ./grok-outbox # intégrer des fichiers traduits ailleurs
node scripts/traduction-batch/traduire.mjs replis # lister les fichiers restés en français
6. Le laisser tourner toute la nuit
Lancez-le détaché de votre terminal, pour que fermer la fenêtre ne le tue pas :
# Windows PowerShell
Start-Process node -ArgumentList 'scripts/traduction-batch/traduire.mjs','run' `
-RedirectStandardOutput .traduction-console.log -RedirectStandardError .traduction-console.err
# macOS / Linux
nohup node scripts/traduction-batch/traduire.mjs run > .traduction-console.log 2>&1 &
Si quelque chose plante, si le portable redémarre, ou si vous l’arrêtez exprès, relancez simplement la même commande : le fichier d’état sait quels morceaux sont faits, lesquels sont en vol et lesquels ont échoué combien de fois. Rien de payé n’est jamais refait.
7. Ce que le moteur expose
| Point d’entrée | Méthode | Contenu |
|---|---|---|
/etat.json | GET | Instantané courant : phase, avancement par langue, lots, jetons, coût, build, dernières lignes du journal |
/plan.json | GET | L’inventaire : chaque fichier source avec sa famille et sa taille (les immeubles de la ville) |
/historique.json | GET | Tous les instantanés enregistrés, pour le replay |
/api/commande | POST | { "action": "lancer" | "pause" | "reprendre", "langs": ["en"], "limite": 10 } |
/ | GET | Le tableau de bord lui-même |
Les commandes ne sont acceptées que depuis une liste d’origines autorisées (localhost, le domaine de production et ses aperçus) : une page web quelconque ne peut pas se mettre à dépenser vos crédits.
Construire votre propre système de sous-agents, de zéro
Oubliez la ville 3D une minute. Dessous, un « système de sous-agents » tient en un tout petit nombre d’idées, et vous pouvez en avoir un qui fonctionne en une centaine de lignes. Voici le modèle mental, puis le code.
Ce qu’est un sous-agent, et ce qu’il n’est pas
Dans ce système, un sous-agent n’est pas un chatbot doté d’une personnalité à qui l’on parle. C’est un ouvrier : un paquet de requêtes partageant le même contrat (un prompt système qui dit exactement quoi renvoyer), expédié pour s’exécuter en parallèle et revenir avec des résultats. L’ouvrier porte l’intelligence. L’orchestrateur, votre script, porte la discipline : ce qui reste à faire, ce qui est revenu, si c’est acceptable, quoi faire sinon. L’orchestrateur ne « réfléchit » jamais ; c’est du code ennuyeux et déterministe, et c’est précisément pour cela que l’ensemble peut tourner sans surveillance.
Les sept principes
- Définir l’unité de travail. Assez petite pour qu’un échec soit bon marché, assez grande pour que le modèle ait du contexte : un fichier, ou un morceau de 16 000 caractères. Tout le reste se compte en unités.
- Nommer chaque unité par son contenu.
id = hash(langue + texte). Deux exécutions sur le même texte donnent le même identifiant ; un texte modifié en donne un nouveau. C’est ce qui rend les reprises, plantages et récupérations triviaux : un résultat reste valide tant que son identifiant existe dans le plan. - Ouvriers sans état, orchestrateur avec état. Un ouvrier ne connaît que ses requêtes. L’orchestrateur écrit un petit fichier JSON après chaque décision. La mémoire sur disque, pas en RAM.
- Écrire le contrat et le vérifier mécaniquement. Le prompt dit « renvoie seulement le fichier, garde chaque bloc de code ». Le validateur compte les blocs de code. La promesse d’un modèle n’est pas une vérification.
- Donner un budget aux échecs, puis un repli. Trois tentatives. Ensuite l’original, clairement signalé. Le pipeline n’attend jamais une unité qui ne viendra pas.
- Rendre la boucle idempotente. Un « tour » = récolter les ouvriers finis → calculer ce qui reste → lancer un ouvrier pour cela → sauver → dormir. Tuez le processus à n’importe quelle ligne et relancez : le tour suivant recalcule tout à partir du fichier d’état et des résultats présents.
- Exposer l’état. Servez le JSON. N’importe quelle interface, d’un
curlà une ville three.js, peut alors montrer ce qui se passe, et un humain peut intervenir avec un bouton plutôt qu’un terminal.
La boucle, en mots
pour toujours :
pour chaque ouvrier pas encore récolté :
s'il a fini : télécharger les résultats, valider chacun, garder les bons, compter une faute aux mauvais
restant = unités sans résultat, pas en vol, avec moins de 3 fautes
si restant non vide : lancer un ouvrier avec
si rien de restant et rien en vol : arrêter
sauver l'état, dormir une minute
assembler : pour chaque unité, le résultat s'il existe, sinon l'original marqué comme repli
Un moteur complet et minimal (un fichier, une centaine de lignes)
Ce script traduit chaque fichier .md d’un dossier vers une langue avec des ouvriers Anthropic en lot. Il est reprenable, il valide, il réessaie, il se replie. Enregistrez-le sous mini-essaim.mjs, définissez ANTHROPIC_API_KEY, et lancez node mini-essaim.mjs ./docs ./docs-en English.
#!/usr/bin/env node
// mini-essaim.mjs - traduit chaque fichier .md d'un dossier avec des ouvriers Anthropic en lot.
// Reprenable : relancez-le après n'importe quel plantage, il continue là où il s'est arrêté.
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const [SOURCE = './docs', CIBLE = './docs-en', LANGUE = 'English'] = process.argv.slice(2);
const CLE = process.env.ANTHROPIC_API_KEY;
const MODELE = 'claude-sonnet-5-5';
const FICHIER_ETAT = join(CIBLE, '.etat.json');
const MAX_TENTATIVES = 3;
mkdirSync(CIBLE, { recursive: true });
const etat = existsSync(FICHIER_ETAT)
? JSON.parse(readFileSync(FICHIER_ETAT, 'utf8'))
: { ouvriers: [], tentatives: {}, resultats: {} };
const sauver = () => writeFileSync(FICHIER_ETAT, JSON.stringify(etat, null, 1));
const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
// 1. Unités de travail : une par fichier, nommée par l'empreinte de son contenu (principe 2).
const unites = new Map();
for (const nom of readdirSync(SOURCE).filter((n) => n.endsWith('.md'))) {
const texte = readFileSync(join(SOURCE, nom), 'utf8');
const id = createHash('sha1').update(LANGUE + texte).digest('hex').slice(0, 40);
unites.set(id, { id, nom, texte });
}
// 2. Un appel à l'API, avec les en-têtes qu'Anthropic attend.
async function api(chemin, init = {}) {
const r = await fetch(`https://api.anthropic.com${chemin}`, {
...init,
headers: { 'x-api-key': CLE, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
});
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
return init.brut ? r.text() : r.json();
}
// 3. Le contrat que chaque résultat doit honorer avant d'être accepté (principe 4).
function valider(unite, sortie, raisonArret) {
if (raisonArret === 'max_tokens') return 'tronqué';
const ratio = sortie.length / unite.texte.length;
if (ratio < 0.5 || ratio > 2.5) return `longueur suspecte (${ratio.toFixed(2)})`;
const clotures = (t) => (t.match(/^```/gm) ?? []).length;
if (clotures(sortie) !== clotures(unite.texte)) return 'blocs de code différents';
return null;
}
// 4. Lancer un ouvrier (un lot) portant chaque unité qui reste à faire.
async function lancer(ids) {
const requests = ids.map((id) => ({
custom_id: id,
params: {
model: MODELE,
max_tokens: 16000,
system: `Translate the Markdown file from French into ${LANGUE}. Keep headings, lists and code blocks exactly as they are. Output only the translated file, nothing else.`,
messages: [{ role: 'user', content: unites.get(id).texte }],
},
}));
const lot = await api('/v1/messages/batches', { method: 'POST', body: JSON.stringify({ requests }) });
etat.ouvriers.push({ id: lot.id, ids, recolte: false });
sauver();
console.log(`ouvrier ${lot.id} lancé avec ${ids.length} fichiers`);
}
// 5. Récolter les ouvriers finis : valider, garder, ou compter une faute (principe 5).
async function recolter() {
for (const ouvrier of etat.ouvriers.filter((o) => !o.recolte)) {
const info = await api(`/v1/messages/batches/${ouvrier.id}`);
if (info.processing_status !== 'ended') continue;
const jsonl = await api(`/v1/messages/batches/${ouvrier.id}/results`, { brut: true });
for (const ligne of jsonl.split('\n').filter(Boolean)) {
const r = JSON.parse(ligne);
const unite = unites.get(r.custom_id);
if (!unite) continue; // résultat d'un fichier modifié depuis : ignoré
const message = r.result?.message;
const sortie = message ? message.content.filter((c) => c.type === 'text').map((c) => c.text).join('') : '';
const erreur = r.result?.type === 'succeeded'
? valider(unite, sortie, message.stop_reason)
: (r.result?.error?.error?.message ?? r.result?.type ?? 'erreur inconnue');
if (!erreur) { etat.resultats[unite.id] = sortie; continue; }
etat.tentatives[unite.id] = (etat.tentatives[unite.id] ?? 0) + 1;
console.log(`rejeté ${unite.nom} : ${erreur} (tentative ${etat.tentatives[unite.id]})`);
}
ouvrier.recolte = true;
sauver();
}
}
// 6. La boucle. Chaque tour est idempotent : plantez n'importe où, relancez, rien n'est perdu (principe 6).
for (;;) {
await recolter();
const enVol = new Set(etat.ouvriers.filter((o) => !o.recolte).flatMap((o) => o.ids));
const restant = [...unites.keys()].filter(
(id) => !etat.resultats[id] && !enVol.has(id) && (etat.tentatives[id] ?? 0) < MAX_TENTATIVES,
);
if (restant.length) await lancer(restant);
if (!restant.length && !enVol.size) break;
console.log(`${Object.keys(etat.resultats).length}/${unites.size} faits, ${enVol.size + restant.length} en vol`);
await dormir(60_000);
}
// 7. Assembler : la traduction, ou l'original clairement signalé comme repli.
let replis = 0;
for (const unite of unites.values()) {
const traduit = etat.resultats[unite.id];
if (!traduit) replis++;
writeFileSync(join(CIBLE, unite.nom), traduit ?? `<!-- REPLI : non traduit après ${MAX_TENTATIVES} tentatives -->\n${unite.texte}`);
}
console.log(`terminé : ${unites.size - replis} traduits, ${replis} replis`);
Lisez-le une fois de haut en bas et vous avez lu le moteur de production en miniature : les mêmes sept étapes, le même fichier d’état, les mêmes identifiants par empreinte. Tout ce que la version de 752 lignes ajoute relève de l’échelle (découpage, sept familles de fichiers avec leurs prompts, lots de 200 requêtes, deux clés), de la sûreté (réécriture des chemins d’import, build-et-réparation, rattachement des lots perdus) ou de la visibilité (le serveur HTTP et les instantanés).
Le faire grandir jusqu’au vrai
- Découpage. Quand un fichier peut dépasser la sortie confortable du modèle, coupez aux frontières de titres, jamais dans un bloc de code, et faites de chaque morceau une unité. Recollez dans l’ordre. Voir
decouperMarkdownen annexe A. - Un prompt par type de fichier. Prose Markdown, gabarits Astro et fichiers de données TypeScript ont besoin de contrats différents. Classez à l’inventaire ; choisissez le prompt selon le type. Voir
systeme. - Un post-traitement prouvable. Tout ce qui peut se faire après coup avec une expression régulière (préfixes de liens, chemins d’import, noms de collections) ne doit pas être demandé au modèle. Les réécritures déterministes sont gratuites et ne se trompent jamais deux fois. Voir
reecrireCodeetreecrireMarkdown. - Le build comme validateur final. Si la sortie est du code, compilez-la. Si le compilateur nomme un fichier, remplacez-le par son repli et recompilez. Voir
construire. - Une fonction d’instantané plutôt qu’un journal.
photographiercalcule un objet JSON décrivant tout l’état. Servez-le. Ajoutez-le à un fichier d’historique. Le tableau de bord, le replay et le rapport final lisent tous ce même objet. - Plusieurs agents. Si une seconde IA, ou un humain, peut aider, donnez-lui une boîte de réception (une liste d’unités), une boîte d’envoi (les fichiers finis dans une arborescence miroir) et passez le même validateur sur ce qui revient. Ne laissez jamais deux rédacteurs toucher le même fichier ; coordonnez-vous par un fichier Markdown daté, en ajout seulement.
Les erreurs que nous avons faites pour que vous ne les fassiez pas
- Envoyer un paramètre que le modèle avait déprécié (
temperature) : tout le premier lot a échoué. Soumettez un lot de 5 requêtes avant un lot de 5 000. - Supprimer le dossier d’état à la main. Les identifiants par empreinte et la commande de rattachement nous ont sauvés ; sans eux, 42 lots payés étaient perdus.
- Croire qu’un filtre par langue (
isLocaleEntry('ar')) serait inoffensif dans une collection par langue. Il filtrait tout. Comptez vos pages produites par langue et comparez. - Ne pas vérifier les limites de l’hébergeur avant de traduire. Traduire est bon marché ; une limite de fonction à 250 Mo ne se négocie pas.
Annexe A : le moteur complet (traduire.mjs)
Le fichier exact qui a tourné cette nuit-là, 752 lignes, sans retouche. Il est aussi disponible en téléchargement brut : traduire.mjs. Les commentaires de section marquent les huit étapes décrites plus haut.
#!/usr/bin/env node
/**
* Traduction complete du site par l'API Message Batches d'Anthropic.
*
* node scripts/traduction-batch/traduire.mjs plan estimation, rien n'est envoye
* node scripts/traduction-batch/traduire.mjs run boucle complete (reprend la ou elle s'est arretee)
* node scripts/traduction-batch/traduire.mjs run --langs en --limit 20 essai reduit
*
* Etapes de `run`, toutes reprenables (etat dans .traduction/) :
* 1. inventaire des sources francaises (contenus Markdown, pages, composants, layouts, donnees) ;
* 2. decoupage des gros Markdown en morceaux, une requete par morceau et par langue ;
* 3. envoi en lots (Message Batches), suivi toutes les minutes, telechargement des resultats ;
* 4. validation de chaque morceau (structure, blocs de code, imports) ; renvoi en cas d'echec ;
* 5. assemblage des fichiers cibles et reecritures deterministes (imports, collections, liens) ;
* 6. build Astro, remplacement par la version francaise des fichiers qui le cassent ;
* 7. commit et push de la branche, rapport final.
*
* Le tableau de bord (public/atelier-traduction/) lit http://localhost:4599/etat.json.
*/
import { createHash } from 'node:crypto';
import { createServer } from 'node:http';
import { spawn } from 'node:child_process';
import {
existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, appendFileSync, rmSync, copyFileSync
} from 'node:fs';
import { dirname, join, relative, resolve, extname, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const RACINE = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
const ETAT_DIR = join(RACINE, '.traduction');
const RES_DIR = join(ETAT_DIR, 'resultats');
const FICHIER_ETAT = join(ETAT_DIR, 'etat.json');
const JOURNAL = join(ETAT_DIR, 'journal.log');
const PUBLIC_TABLEAU = join(RACINE, 'public', 'atelier-traduction');
const LANGUES = {
en: { nom: 'English (United States)', bcp47: 'en-US' },
es: { nom: 'Spanish (Spain)', bcp47: 'es-ES' },
pt: { nom: 'Portuguese (Brazil)', bcp47: 'pt-BR' },
ar: { nom: 'Modern Standard Arabic', bcp47: 'ar' }
};
const COLLECTIONS = ['blog', 'tutoriels', 'category'];
const PRIX = { in: 1.5, out: 7.5 }; // $ par million de jetons, tarif Batch (moitie du tarif standard)
const TAILLE_MORCEAU = 16000;
const MAX_CODE = 120000;
const MAX_TENTATIVES = 3;
const REQUETES_PAR_LOT = 200; // petits lots : progression fine et un sous-bot par lot dans la vue 3D
/* ------------------------------------------------------------------ options */
function options(argv) {
const o = { cmd: argv[0] && !argv[0].startsWith('--') ? argv[0] : 'run', langs: Object.keys(LANGUES), model: 'claude-sonnet-5-5',
port: 4599, limit: 0, build: true, push: true, intervalle: 60 };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--langs') o.langs = argv[++i].split(',');
else if (a === '--model') o.model = argv[++i];
else if (a === '--port') o.port = Number(argv[++i]);
else if (a === '--limit') o.limit = Number(argv[++i]);
else if (a === '--no-build') o.build = false;
else if (a === '--no-push') o.push = false;
else if (a === '--intervalle') o.intervalle = Number(argv[++i]);
}
return o;
}
const O = options(process.argv.slice(2));
function lireEnv() {
const candidats = [join(RACINE, '.env'), resolve(RACINE, '..', '07-contactFormApp', '.env')];
const vals = {};
for (const f of candidats) {
if (!existsSync(f)) continue;
for (const l of readFileSync(f, 'utf8').split(/\r?\n/)) {
const m = l.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.+?)\s*$/);
if (m && !(m[1].toLowerCase() in vals)) vals[m[1].toLowerCase()] = m[2];
}
}
return vals;
}
const ENV = lireEnv();
const CLEFS = [...new Set([process.env.ANTHROPIC_API_KEY, ENV.key_anthropic, ENV.key_anthropic_to_use, ENV.key_anthropic_2, ENV.other_key_anthropic].filter(Boolean))];
const WORKSPACE = process.env.ANTHROPIC_WORKSPACE_ID ?? ENV.key_id_workspace ?? ENV.id_1_claude_key;
let clefActive = 0;
/* ------------------------------------------------------------------ etat */
mkdirSync(RES_DIR, { recursive: true });
const etat = existsSync(FICHIER_ETAT)
? JSON.parse(readFileSync(FICHIER_ETAT, 'utf8'))
: { debut: new Date().toISOString(), lots: [], tentatives: {}, usage: { in: 0, out: 0 }, fichiers: {}, build: null, phase: 'demarrage', git: null };
const journalRecent = [];
function log(msg) {
const ligne = `[${new Date().toISOString().slice(11, 19)}] ${msg}`;
console.log(ligne);
appendFileSync(JOURNAL, ligne + '\n');
journalRecent.push(ligne);
if (journalRecent.length > 60) journalRecent.shift();
}
function sauver() { writeFileSync(FICHIER_ETAT, JSON.stringify(etat, null, 1)); }
/* ------------------------------------------------------------------ inventaire */
function parcourir(dossier, filtre, acc = []) {
if (!existsSync(dossier)) return acc;
for (const n of readdirSync(dossier)) {
const f = join(dossier, n);
if (statSync(f).isDirectory()) parcourir(f, filtre, acc);
else if (filtre(f)) acc.push(f);
}
return acc;
}
const posix = (p) => p.split(sep).join('/');
/** Fichiers de code mirroirs par langue : composants, layouts, donnees, et les lib qui lisent une collection. */
function ensembleMiroir() {
const src = join(RACINE, 'src');
const res = [
...parcourir(join(src, 'components'), (f) => f.endsWith('.astro')),
...parcourir(join(src, 'layouts'), (f) => f.endsWith('.astro')),
...parcourir(join(src, 'data'), (f) => /\.(ts|js)$/.test(f)),
...parcourir(join(src, 'lib'), (f) => /\.(ts|js)$/.test(f) && /getCollection\(|getEntry\(/.test(readFileSync(f, 'utf8')))
];
return new Set(res);
}
const MIROIR = ensembleMiroir();
function destination(source, lang) {
const rel = posix(relative(join(RACINE, 'src'), source));
const [tete, ...reste] = rel.split('/');
if (tete === 'content') return join(RACINE, 'src', 'content', reste[0], lang, ...reste.slice(1));
if (tete === 'pages') return join(RACINE, 'src', 'pages', lang, ...reste.slice(1));
return join(RACINE, 'src', 'l10n', lang, tete, ...reste);
}
function inventaire() {
const src = join(RACINE, 'src');
const fichiers = [];
for (const c of COLLECTIONS) {
const base = join(src, 'content', c);
for (const f of parcourir(base, (f) => f.endsWith('.md'))) {
const premier = posix(relative(base, f)).split('/')[0];
if (premier in LANGUES || premier === 'fr') continue;
fichiers.push({ source: f, type: 'md', groupe: c });
}
}
for (const f of parcourir(join(src, 'pages', 'fr'), (f) => /\.(astro|ts|js)$/.test(f))) {
fichiers.push({ source: f, type: 'code', groupe: 'pages' });
}
for (const f of MIROIR) fichiers.push({ source: f, type: 'code', groupe: posix(relative(src, f)).split('/')[0] });
return fichiers;
}
/* ------------------------------------------------------------------ decoupage */
function decouperMarkdown(texte) {
if (texte.length <= TAILLE_MORCEAU) return [texte];
const lignes = texte.split(/(?<=\n)/);
const morceaux = [];
let courant = '', dansBloc = false, frontmatter = texte.startsWith('---'), finFm = false, nFm = 0;
let coupure = -1; // longueur de `courant` au dernier point de coupure sur
for (const l of lignes) {
if (frontmatter && !finFm) { if (/^---\s*$/.test(l)) { nFm++; if (nFm === 2) finFm = true; } courant += l; continue; }
if (/^\s*(```|~~~)/.test(l)) dansBloc = !dansBloc;
if (!dansBloc && (/^#{1,3}\s/.test(l) || /^\s*$/.test(l))) coupure = courant.length;
courant += l;
if (courant.length > TAILLE_MORCEAU && coupure > 0 && !dansBloc) {
morceaux.push(courant.slice(0, coupure));
courant = courant.slice(coupure);
coupure = -1;
}
}
if (courant) morceaux.push(courant);
return morceaux;
}
/* ------------------------------------------------------------------ consignes */
function systeme(lang, type) {
const L = LANGUES[lang];
const commun = [
`You are a senior technical translator for SkillverseHub, a French e-learning platform (software development, DevOps, cloud, AI, cybersecurity).`,
`Translate from French into ${L.nom}. The result is published as-is on the ${lang} version of the site.`,
`Output ONLY the translated file content. No preamble, no explanation, no surrounding code fence.`,
`Natural, fluent, professional ${L.nom}; keep technical terms, product names, commands and acronyms in their usual form.`,
`Internal links: replace the /fr/ language prefix with /${lang}/ (for example /fr/blog/x/ becomes /${lang}/blog/x/). Never translate URL paths or slugs themselves.`
];
if (lang === 'ar') commun.push(`Arabic text uses Arabic punctuation; Latin technical identifiers stay in Latin script, unaltered.`);
if (type === 'md') {
commun.push(
`The input is a Markdown file (or a consecutive part of one). Preserve its exact structure: headings, lists, tables, HTML tags and attributes, blank lines, admonition markers such as ":::note".`,
`YAML frontmatter, if present: keep every key and every value unchanged EXCEPT the values of title, description, sidebarTitle, subtitle, prevTitle, nextTitle, which you translate. Keep quoting valid YAML.`,
`Code blocks: keep code, commands, outputs, file names and identifiers byte-for-byte; translate only human comments inside them. Mermaid blocks stay strictly unchanged.`,
`Keep image paths, link targets, anchors and inline code unchanged.`
);
} else {
commun.push(
`The input is a source file of an Astro 5 site (.astro, .ts or .js). Translate ONLY human-visible French text: text between tags, title/description strings, alt, aria-label, placeholder, meta content, button labels, user-facing strings in scripts and data objects.`,
`Do not change imports, identifiers, variable or function names, props, object keys, CSS, class names, slugs, API URLs, collection names, logic or formatting. Leave code comments unchanged.`,
`Where the code states the page language as French (lang="fr", inLanguage "fr", og:locale fr_FR, toLocaleDateString('fr-FR'), Intl formats 'fr-FR'), use ${L.bcp47} instead.`,
`Return the complete file, every line, in the same order.`
);
}
return commun.join('\n');
}
/* ------------------------------------------------------------------ unites */
const hash = (s) => createHash('sha1').update(s).digest('hex');
/** Toutes les langues sont planifiees d'emblee : un lot lance plus tot reste exploitable quel que soit le lancement en cours. */
function construireUnites() {
const fichiers = inventaire();
const unites = new Map(); // custom_id -> requete
const plans = []; // un par fichier et langue
const sources = []; // un par fichier, pour la vue 3D
for (const f of fichiers) {
const texte = readFileSync(f.source, 'utf8');
f.index = sources.length;
sources.push({ g: f.groupe, n: posix(relative(join(RACINE, 'src'), f.source)), c: texte.length, t: f.type });
if (f.type === 'code' && texte.length > MAX_CODE) { for (const lang of Object.keys(LANGUES)) plans.push({ ...f, lang, ids: [], tropGros: true }); continue; }
const morceaux = f.type === 'md' ? decouperMarkdown(texte) : [texte];
for (const lang of Object.keys(LANGUES)) {
const ids = morceaux.map((m, i) => {
const id = `${lang}_${hash(lang + f.type + m).slice(0, 40)}`;
if (!unites.has(id)) {
const partie = morceaux.length > 1 ? `\n\n(Part ${i + 1} of ${morceaux.length} of the same document${i > 0 ? '; this part has no frontmatter' : ''}.)` : '';
unites.set(id, {
id, lang, type: f.type, source: m, groupe: f.groupe,
max: Math.min(64000, Math.max(4000, Math.ceil(m.length / 2) + 2000)),
system: systeme(lang, f.type), user: `File: ${posix(relative(RACINE, f.source))}${partie}\n\n${m}`
});
}
return id;
});
plans.push({ ...f, lang, ids, morceaux });
}
}
return { unites, plans, sources };
}
/* ------------------------------------------------------------------ API */
async function api(chemin, init = {}) {
for (let essai = 1; essai <= 6; essai++) {
const headers = { 'x-api-key': CLEFS[clefActive], 'anthropic-version': '2023-06-01', 'content-type': 'application/json' };
if (WORKSPACE) headers['anthropic-workspace-id'] = WORKSPACE;
let r;
try { r = await fetch(`https://api.anthropic.com${chemin}`, { ...init, headers }); }
catch (e) { log(`reseau : ${e.message}, nouvel essai`); await pause(10000 * essai); continue; }
if (r.status === 429 || r.status >= 500) { await pause(10000 * essai); continue; }
if (!r.ok) {
const t = await r.text();
if (/credit|billing|balance/i.test(t)) {
if (clefActive < CLEFS.length - 1) { clefActive++; log(`credits epuises sur la clef ${clefActive}, bascule sur la suivante`); continue; }
etat.credits = 'epuises';
const e = new Error('credits Anthropic epuises sur toutes les clefs'); e.credits = true; throw e;
}
throw new Error(`${r.status} ${t.slice(0, 300)}`);
}
return init.brut ? r.text() : r.json();
}
throw new Error(`API injoignable : ${chemin}`);
}
const pause = (ms) => new Promise((ok) => setTimeout(ok, ms));
async function soumettre(ids, unites) {
const parLangue = {};
for (const id of ids) (parLangue[unites.get(id).lang] ??= []).push(id);
for (const [lang, liste] of Object.entries(parLangue)) {
for (let i = 0; i < liste.length; i += REQUETES_PAR_LOT) {
const tranche = liste.slice(i, i + REQUETES_PAR_LOT);
const requests = tranche.map((id) => {
const u = unites.get(id);
const tent = etat.tentatives[id] ?? 0;
return { custom_id: id, params: { model: O.model, max_tokens: tent > 0 ? Math.min(u.max, 32000) : u.max,
system: u.system, messages: [{ role: 'user', content: u.user }] } };
});
const lot = await api('/v1/messages/batches', { method: 'POST', body: JSON.stringify({ requests }) });
etat.lots.push({ id: lot.id, lang, ids: tranche, statut: lot.processing_status, cree: new Date().toISOString(),
compte: lot.request_counts, recupere: false, types: compterTypes(tranche, unites) });
log(`lot ${lot.id} envoye : ${tranche.length} requetes (${lang})`);
sauver();
}
}
}
function compterTypes(ids, unites) {
const c = {};
for (const id of ids) { const g = unites.get(id)?.groupe ?? '?'; c[g] = (c[g] ?? 0) + 1; }
return c;
}
/* ------------------------------------------------------------------ validation */
const fences = (t) => (t.match(/^\s*(```|~~~)/gm) ?? []).length;
const imports = (t) => (t.match(/^\s*import\s/gm) ?? []).length;
function nettoyer(sortie, source) {
let t = sortie;
if (!/^\s*```/.test(source) && /^\s*```[a-z]*\n/.test(t) && /\n```\s*$/.test(t)) t = t.replace(/^\s*```[a-z]*\n/, '').replace(/\n```\s*$/, '\n');
if (source.endsWith('\n') && !t.endsWith('\n')) t += '\n';
return t;
}
function valider(u, t, stop) {
if (stop === 'max_tokens') return 'tronque';
const ratio = t.length / Math.max(1, u.source.length);
if (ratio < 0.45 || ratio > 2.6) return `longueur ${ratio.toFixed(2)}`;
if (u.type === 'md') {
if (fences(t) !== fences(u.source)) return 'blocs de code';
if (u.source.startsWith('---') && !t.startsWith('---')) return 'frontmatter';
} else {
if (imports(t) !== imports(u.source)) return 'imports';
if (u.source.startsWith('---') && !t.startsWith('---')) return 'frontmatter astro';
}
return null;
}
/**
* Rattache les lots deja soumis chez Anthropic (etat local perdu, ou relance depuis une autre
* machine). Les custom_id sont des empreintes du texte source : un resultat reste exploitable
* tant que la source n'a pas change, et le telechargement des resultats ne coute aucun credit.
*/
async function rattacher(unites) {
const connus = new Set(etat.lots.map((l) => l.id));
const depuis = new Date(Date.now() - 29 * 86400000).toISOString();
let apres = '', ajoutes = 0;
for (;;) {
const page = await api(`/v1/messages/batches?limit=100${apres ? `&after_id=${apres}` : ''}`);
for (const b of page.data ?? []) {
if (connus.has(b.id) || b.created_at < depuis) continue;
etat.lots.push({ id: b.id, lang: '?', ids: [], statut: b.processing_status, cree: b.created_at, compte: b.request_counts, recupere: false, types: {}, rattache: true });
ajoutes++;
}
if (!page.has_more || !page.last_id) break;
apres = page.last_id;
}
if (ajoutes) { log(`${ajoutes} lots existants rattaches depuis le compte Anthropic`); sauver(); }
await suivreLots(unites);
// Les lots rattaches sans aucune requete connue (essais d'un autre projet) sont ecartes.
etat.lots = etat.lots.filter((l) => !(l.rattache && l.recupere && !l.ids.length));
sauver();
}
async function suivreLots(unites) {
for (const lot of etat.lots.filter((l) => !l.recupere)) {
const info = await api(`/v1/messages/batches/${lot.id}`);
lot.statut = info.processing_status;
lot.compte = info.request_counts;
if (info.processing_status !== 'ended') continue;
const jsonl = await api(`/v1/messages/batches/${lot.id}/results`, { brut: true });
let ok = 0, ko = 0;
const rattache = lot.rattache && !lot.ids.length;
for (const ligne of jsonl.split('\n')) {
if (!ligne.trim()) continue;
const r = JSON.parse(ligne);
const u = unites.get(r.custom_id);
if (rattache) {
if (!u) continue; // resultat d'un autre projet ou d'une source modifiee depuis
lot.ids.push(r.custom_id);
lot.lang = u.lang;
lot.types[u.groupe] = (lot.types[u.groupe] ?? 0) + 1;
}
if (r.result?.type === 'succeeded') {
const m = r.result.message;
etat.usage.in += m.usage?.input_tokens ?? 0;
etat.usage.out += m.usage?.output_tokens ?? 0;
if (!u) continue;
const texte = nettoyer((m.content ?? []).filter((c) => c.type === 'text').map((c) => c.text).join(''), u.source);
const erreur = valider(u, texte, m.stop_reason);
if (!erreur) { writeFileSync(join(RES_DIR, `${r.custom_id}.txt`), texte); ok++; continue; }
log(`rejet ${r.custom_id} (${u.groupe}) : ${erreur}`);
} else {
const msg = r.result?.error?.error?.message ?? r.result?.type ?? 'inconnu';
if (!ko) log(`erreur API sur ${r.custom_id} : ${msg}`);
etat.erreursApi = (etat.erreursApi ?? 0) + 1;
}
etat.tentatives[r.custom_id] = (etat.tentatives[r.custom_id] ?? 0) + 1;
ko++;
}
lot.recupere = true;
lot.ok = ok; lot.ko = ko;
log(`lot ${lot.id} termine : ${ok} acceptes, ${ko} a refaire`);
sauver();
}
}
/* ------------------------------------------------------------------ assemblage */
function versSpec(depuis, cible, specOrigine) {
let r = posix(relative(dirname(depuis), cible));
if (!r.startsWith('.')) r = './' + r;
if (!extname(specOrigine) && extname(r)) r = r.slice(0, -extname(r).length);
return r;
}
function resoudre(base, spec) {
const abs = resolve(dirname(base), spec);
for (const c of [abs, abs + '.ts', abs + '.js', abs + '.astro', join(abs, 'index.ts'), join(abs, 'index.js')]) {
if (existsSync(c) && statSync(c).isFile()) return c;
}
return abs;
}
function reecrireCode(texte, source, dest, lang) {
let t = texte.replace(/(from\s+|import\s+|import\(\s*)(['"])(\.{1,2}\/[^'"]+)\2/g, (tout, avant, q, spec) => {
const cible = resoudre(source, spec);
const finale = MIROIR.has(cible) ? destination(cible, lang) : cible;
return `${avant}${q}${versSpec(dest, finale, spec)}${q}`;
});
t = t.replace(/(getCollection|getEntry|getEntries)\(\s*(['"])(blog|tutoriels|category)\2/g, `$1($2$3_${lang}$2`);
t = t.replace(/CollectionEntry<\s*(['"])(blog|tutoriels|category)\1\s*>/g, `CollectionEntry<$1$2_${lang}$1>`);
// Dans une collection par langue, les identifiants n'ont plus de prefixe de langue : le filtre
// de langue de la collection francaise ne doit plus rien exclure.
t = t.replace(/isLocaleEntry\(\s*['"][a-z]{2}['"]\s*\)/g, '() => true');
t = t.replace(/entriesForLocale\(([^,()]+),\s*['"][a-z]{2}['"]\s*\)/g, '$1');
t = t.replace(/(["'`(=]\s*)\/fr(?=[/"'`])/g, `$1/${lang}`);
t = t.replace(/skillversehub\.com\/fr\//g, `skillversehub.com/${lang}/`);
return t;
}
function reecrireMarkdown(texte, original, lang) {
let t = texte;
const mOrig = original.match(/```mermaid[\s\S]*?```/g) ?? [];
const mTrad = t.match(/```mermaid[\s\S]*?```/g) ?? [];
if (mOrig.length && mOrig.length === mTrad.length) { let i = 0; t = t.replace(/```mermaid[\s\S]*?```/g, () => mOrig[i++]); }
t = t.replace(/(\]\(|href=["']|src=["'])\/fr\//g, `$1/${lang}/`);
t = t.replace(/skillversehub\.com\/fr\//g, `skillversehub.com/${lang}/`);
t = t.replace(/(\]\(|src=["'])(\.{1,2}\/)/g, '$1../$2');
return t;
}
function assembler(plans, forcer = false) {
let ecrits = 0;
for (const p of plans) {
const dest = destination(p.source, p.lang);
const cle = posix(relative(RACINE, dest));
const deja = etat.fichiers[cle];
if (deja && (deja.statut === 'traduit' || deja.statut === 'conserve')) continue;
const original = readFileSync(p.source, 'utf8');
// Fichier deja present sans etat connu (ecrit par un lancement precedent, ou a la main) : il
// est conserve seulement si on ne dispose d'aucun resultat et qu'il differe du repli francais.
if (!deja && existsSync(dest) && !p.tropGros && !p.ids.every((id) => existsSync(join(RES_DIR, `${id}.txt`)))) {
const actuel = readFileSync(dest, 'utf8');
const repliFr = p.type === 'md' ? reecrireMarkdown(original, original, p.lang) : reecrireCode(original, p.source, dest, p.lang);
if (actuel !== repliFr && actuel !== original) { etat.fichiers[cle] = { statut: 'conserve', groupe: p.groupe, lang: p.lang, source: posix(relative(RACINE, p.source)), type: p.type }; continue; }
}
let texte = null, statut = null;
if (p.tropGros) { texte = original; statut = 'repli'; }
else {
const morceaux = p.ids.map((id) => { const f = join(RES_DIR, `${id}.txt`); return existsSync(f) ? readFileSync(f, 'utf8') : null; });
if (morceaux.every((m) => m !== null)) { texte = morceaux.join(''); statut = 'traduit'; }
else if (!forcer && p.ids.some((id, i) => morceaux[i] === null && (etat.tentatives[id] ?? 0) < MAX_TENTATIVES)) continue;
else { texte = original; statut = 'repli'; }
}
if (deja?.statut === 'repli' && statut === 'repli') continue;
texte = p.type === 'md' ? reecrireMarkdown(texte, original, p.lang) : reecrireCode(texte, p.source, dest, p.lang);
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, texte);
etat.fichiers[cle] = { statut, groupe: p.groupe, lang: p.lang, source: posix(relative(RACINE, p.source)), type: p.type };
ecrits++;
}
if (ecrits) { log(`${ecrits} fichiers assembles`); sauver(); }
}
/* ------------------------------------------------------------------ build */
function executer(cmd, args, cwd = RACINE) {
return new Promise((ok) => {
// Cinq langues de contenu : la synchronisation Astro depasse le tas Node par defaut (4 Go).
const p = spawn(cmd, args, { cwd, shell: true, env: { ...process.env, NODE_OPTIONS: '--max-old-space-size=16384' } });
let sortie = '';
p.stdout.on('data', (d) => { sortie += d; });
p.stderr.on('data', (d) => { sortie += d; });
p.on('close', (code) => ok({ code, sortie }));
});
}
async function construire(plans) {
etat.phase = 'build';
etat.build = { statut: 'en cours', essais: 0, replis: [] };
sauver();
for (let essai = 1; essai <= 30; essai++) {
etat.build.essais = essai;
log(`build Astro, essai ${essai}`);
const r = await executer('npm', ['run', 'build']);
writeFileSync(join(ETAT_DIR, `build-${essai}.log`), r.sortie);
if (r.code === 0) { etat.build.statut = 'reussi'; log('build reussi'); sauver(); return true; }
const fautif = trouverFautif(r.sortie);
if (!fautif) { etat.build.statut = 'echec'; etat.build.erreur = r.sortie.slice(-1500); log('build en echec sans fichier traduit identifiable'); sauver(); return false; }
const info = etat.fichiers[fautif];
const dest = join(RACINE, fautif);
if (info?.statut === 'traduit') {
const p = plans.find((x) => posix(relative(RACINE, destination(x.source, x.lang))) === fautif);
const original = readFileSync(join(RACINE, info.source), 'utf8');
writeFileSync(dest, p.type === 'md' ? reecrireMarkdown(original, original, p.lang) : reecrireCode(original, p.source, dest, p.lang));
info.statut = 'repli';
log(`build : ${fautif} remplace par sa version francaise`);
} else if (info && info.type === 'md') {
rmSync(dest, { force: true }); info.statut = 'retire'; log(`build : ${fautif} retire`);
} else if (info && info.groupe === 'pages') {
rmSync(dest, { force: true }); info.statut = 'retire'; log(`build : page ${fautif} retiree`);
} else { etat.build.statut = 'echec'; etat.build.erreur = r.sortie.slice(-1500); log(`build : ${fautif} casse meme en repli`); sauver(); return false; }
etat.build.replis.push(fautif);
sauver();
}
etat.build.statut = 'echec';
sauver();
return false;
}
function trouverFautif(sortie) {
const connus = Object.keys(etat.fichiers);
const norm = sortie.replace(/\\/g, '/');
return connus.find((cle) => norm.includes(cle)) ?? null;
}
async function publier(langs) {
etat.phase = 'git';
sauver();
const config = join(RACINE, 'src', 'i18n', 'config.ts');
const prets = Object.keys(LANGUES).filter((l) => {
const f = Object.values(etat.fichiers).filter((x) => x.lang === l);
return f.length && f.filter((x) => x.statut === 'traduit' || x.statut === 'conserve').length / f.length >= 0.9;
});
const c = readFileSync(config, 'utf8').replace(/PUBLISHED_LOCALES: readonly Locale\[\] = \[[^\]]*\]/, `PUBLISHED_LOCALES: readonly Locale[] = ['fr'${prets.map((l) => `, '${l}'`).join('')}]`);
writeFileSync(config, c);
const etapes = [['git', ['add', '-A', 'src', 'public', 'scripts', '.gitignore']],
['git', ['commit', '-q', '-m', `"i18n - traduction complete (${langs.join(', ')}) par l'API Batch Anthropic"`]]];
if (O.push) etapes.push(['git', ['push', '-q', '-u', 'origin', 'i18n-batch']]);
for (const [cmd, args] of etapes) {
const r = await executer(cmd, args);
log(`${cmd} ${args[0]} : ${r.code === 0 ? 'ok' : r.sortie.slice(0, 300)}`);
}
etat.git = { branche: 'i18n-batch', publiees: prets, at: new Date().toISOString() };
}
/* ------------------------------------------------------------------ tableau de bord */
const HISTORIQUE = join(ETAT_DIR, 'historique.jsonl');
const ORIGINES = [/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/, /^https:\/\/(www\.)?skillversehub\.com$/, /^https:\/\/[a-z0-9-]+\.vercel\.app$/];
let U = null; // { unites, plans, sources }
let travailEnCours = null;
const controle = { pause: false, langs: [], limite: 0, demarre: null };
let instantane = {};
let dernierInstantane = 0;
let derniereEcritureHisto = 0;
const cleDest = (p) => posix(relative(RACINE, destination(p.source, p.lang)));
function photographier() {
if (!U) return instantane;
const { unites, plans, sources } = U;
const vol = new Set(etat.lots.filter((l) => !l.recupere).flatMap((l) => l.ids));
const fait = (id) => existsSync(join(RES_DIR, `${id}.txt`));
const langues = {};
for (const lang of Object.keys(LANGUES)) {
const p = plans.filter((x) => x.lang === lang);
const statuts = new Array(sources.length).fill('.');
let caracteres = 0, caracteresFaits = 0, requetes = 0, faites = 0, traduits = 0, replis = 0, enVol = 0;
for (const x of p) {
const s = etat.fichiers[cleDest(x)]?.statut;
const taille = sources[x.index].c;
caracteres += taille;
const okIds = x.ids.filter(fait).length;
requetes += x.ids.length; faites += okIds;
if (s === 'traduit' || s === 'conserve') { statuts[x.index] = 't'; traduits++; caracteresFaits += taille; }
else if (s === 'repli' || s === 'retire') { statuts[x.index] = 'r'; replis++; caracteresFaits += taille; }
else if (x.ids.some((id) => vol.has(id))) { statuts[x.index] = 'v'; enVol++; caracteresFaits += x.ids.length ? taille * okIds / x.ids.length : 0; }
}
langues[lang] = { active: controle.langs.includes(lang), fichiers: p.length, traduits, replis, enVol, requetes, faites, caracteres, caracteresFaits, statuts: statuts.join('') };
}
const anthropicFaites = etat.lots.reduce((n, l) => n + (l.compte?.succeeded ?? 0) + (l.compte?.errored ?? 0), 0);
instantane = {
maj: new Date().toISOString(), debut: etat.debut, phase: etat.phase, modele: O.model, pause: controle.pause,
actif: !!travailEnCours, langsActives: controle.langs, langues, anthropicFaites, credits: controle.creditsEpuises ? 'epuises' : 'ok',
lots: etat.lots.map(({ id, lang, statut, compte, cree, ok, ko, types }) => ({ id, lang, statut, compte, cree, ok, ko, types, taille: Object.values(types ?? {}).reduce((a, b) => a + b, 0) })),
usage: etat.usage, cout: +((etat.usage.in * PRIX.in + etat.usage.out * PRIX.out) / 1e6).toFixed(2), erreursApi: etat.erreursApi ?? 0,
build: etat.build ? { statut: etat.build.statut, essais: etat.build.essais, replis: etat.build.replis?.length ?? 0 } : null,
git: etat.git, journal: journalRecent.slice(-25)
};
dernierInstantane = Date.now();
if (Date.now() - derniereEcritureHisto > 30000) {
derniereEcritureHisto = Date.now();
const { journal, ...reste } = instantane;
appendFileSync(HISTORIQUE, JSON.stringify({ ...reste, journal: journal.slice(-6) }) + '\n');
}
return instantane;
}
function lireCorps(req) {
return new Promise((ok) => { let d = ''; req.on('data', (c) => { d += c; if (d.length > 1e5) req.destroy(); }); req.on('end', () => { try { ok(JSON.parse(d || '{}')); } catch { ok({}); } }); });
}
async function commande(c) {
const langs = (c.langs ?? []).filter((l) => l in LANGUES);
switch (c.action) {
case 'lancer':
if (travailEnCours) return { ok: false, message: 'Un travail tourne deja.' };
if (!langs.length) return { ok: false, message: 'Choisis au moins une langue.' };
travailEnCours = travail(langs, Number(c.limite) || 0, c.build !== false).finally(() => { travailEnCours = null; photographier(); });
return { ok: true, message: `Lancement : ${langs.join(', ')}${c.limite ? `, ${c.limite} fichiers` : ', site complet'}` };
case 'pause': controle.pause = true; log('pause demandee : plus aucun nouveau lot, les lots en cours continuent chez Anthropic'); return { ok: true };
case 'reprendre': controle.pause = false; controle.creditsEpuises = false; etat.credits = 'ok'; log('reprise (les clefs seront reessayees)'); return { ok: true };
default: return { ok: false, message: 'Action inconnue.' };
}
}
function serveur() {
const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css', '.json': 'application/json' };
createServer(async (req, res) => {
const origine = req.headers.origin;
const origineOk = !origine || ORIGINES.some((re) => re.test(origine));
if (origine && origineOk) res.setHeader('Access-Control-Allow-Origin', origine);
res.setHeader('Access-Control-Allow-Private-Network', 'true');
res.setHeader('Access-Control-Allow-Headers', 'content-type');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
const url = new URL(req.url, 'http://x');
const json = (code, o) => { res.writeHead(code, { 'content-type': types['.json'], 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
if (url.pathname === '/etat.json') return json(200, Date.now() - dernierInstantane > 2000 ? photographier() : instantane);
if (url.pathname === '/plan.json') return json(200, { sources: U?.sources ?? [], langues: Object.fromEntries(Object.entries(LANGUES).map(([k, v]) => [k, v.nom])) });
if (url.pathname === '/historique.json') {
const lignes = existsSync(HISTORIQUE) ? readFileSync(HISTORIQUE, 'utf8').trim().split('\n').filter(Boolean) : [];
return json(200, lignes.map((l) => JSON.parse(l)));
}
if (url.pathname === '/api/commande' && req.method === 'POST') {
if (!origineOk) return json(403, { ok: false, message: 'Origine refusee.' });
return json(200, await commande(await lireCorps(req)));
}
const f = join(PUBLIC_TABLEAU, url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, ''));
if (!f.startsWith(PUBLIC_TABLEAU) || !existsSync(f)) { res.writeHead(404); return res.end(); }
res.writeHead(200, { 'content-type': types[extname(f)] ?? 'application/octet-stream', 'cache-control': 'no-store' });
res.end(readFileSync(f));
}).listen(O.port, () => log(`tableau de bord : http://localhost:${O.port}/`));
}
/* ------------------------------------------------------------------ principal */
function selection(langs, limite) {
let plans = U.plans.filter((p) => langs.includes(p.lang));
if (limite) {
const indices = (type, n) => U.sources.map((s, i) => [s.t, i]).filter(([t]) => t === type).slice(0, n).map(([, i]) => i);
const garde = new Set([...indices('md', limite), ...indices('code', Math.ceil(limite / 4))]);
plans = plans.filter((p) => garde.has(p.index));
}
return plans;
}
async function travail(langs, limite, build) {
const { unites } = U;
const plans = selection(langs, limite);
const ids = new Set(plans.flatMap((p) => p.ids));
controle.langs = langs; controle.limite = limite; controle.demarre = new Date().toISOString();
const caracteres = [...ids].reduce((n, id) => n + unites.get(id).source.length, 0);
log(`lancement : ${plans.length} fichiers, ${ids.size} requetes (${langs.join(', ')}), ${(caracteres / 1e6).toFixed(1)} M caracteres, environ ${((caracteres / 3.3 * PRIX.in + caracteres / 3 * PRIX.out) / 1e6).toFixed(0)} $`);
etat.phase = 'traduction';
const enVol = () => new Set(etat.lots.filter((l) => !l.recupere).flatMap((l) => l.ids));
for (;;) {
let aEnvoyer = [];
try {
await suivreLots(unites);
const vol = enVol();
aEnvoyer = [...ids].filter((id) => !existsSync(join(RES_DIR, `${id}.txt`)) && !vol.has(id) && (etat.tentatives[id] ?? 0) < MAX_TENTATIVES);
if (aEnvoyer.length && !controle.pause && !controle.creditsEpuises) await soumettre(aEnvoyer, unites);
assembler(plans);
sauver();
const resteEnVol = [...enVol()].some((id) => ids.has(id));
if (!aEnvoyer.length && !resteEnVol) break;
// Plus de credits et plus rien en vol : on termine avec ce qu'on a, le reste reste en francais.
if (controle.creditsEpuises && !resteEnVol) { log(`${aEnvoyer.length} requetes restent sans credit : repli francais pour ces fichiers, on passe au build`); break; }
} catch (e) {
if (e.credits && !controle.creditsEpuises) { controle.creditsEpuises = true; log('credits Anthropic epuises sur toutes les clefs : plus aucun envoi'); }
else log(`erreur : ${e.message}`);
}
photographier();
await pause(controle.creditsEpuises ? 5000 : O.intervalle * 1000);
}
assembler(plans, controle.creditsEpuises);
log('traduction terminee');
if (build) {
photographier();
const ok = await construire(plans);
if (ok) await publier(langs);
}
etat.phase = 'termine';
photographier();
sauver();
ecrireRapport();
log('travail termine');
}
/**
* Importe des traductions faites a la main ou par un autre agent (Grok Bot) : le dossier
* contient `<lang>/<chemin relatif a src/>` de la source francaise, par exemple
* `en/content/blog/linux/mon-article.md`. Le fichier est valide puis ecrit a sa destination,
* et le fichier source est marque traduit.
* node scripts/traduction-batch/traduire.mjs importer C:\...\grok-outbox
*/
function importer(dossier) {
const plansParCle = new Map(U.plans.map((p) => [`${p.lang}/${posix(relative(join(RACINE, 'src'), p.source))}`, p]));
let ok = 0, ko = 0;
for (const f of parcourir(dossier, () => true)) {
const cle = posix(relative(dossier, f));
const p = plansParCle.get(cle);
if (!p) { log(`import : ${cle} ne correspond a aucune source, ignore`); ko++; continue; }
const texte = readFileSync(f, 'utf8');
const original = readFileSync(p.source, 'utf8');
const erreur = valider({ type: p.type, source: original }, texte, null);
if (erreur) { log(`import : ${cle} refuse (${erreur})`); ko++; continue; }
const dest = destination(p.source, p.lang);
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, p.type === 'md' ? reecrireMarkdown(texte, original, p.lang) : reecrireCode(texte, p.source, dest, p.lang));
etat.fichiers[posix(relative(RACINE, dest))] = { statut: 'traduit', groupe: p.groupe, lang: p.lang, source: posix(relative(RACINE, p.source)), type: p.type, origine: 'import' };
ok++;
}
sauver();
log(`import : ${ok} fichiers integres, ${ko} ignores`);
}
/** Liste des fichiers restes en francais (repli), pour les confier a un autre agent. */
function listerReplis() {
return Object.entries(etat.fichiers)
.filter(([, v]) => v.statut === 'repli' || v.statut === 'retire')
.map(([dest, v]) => ({ lang: v.lang, source: v.source, dest, groupe: v.groupe }));
}
async function main() {
if (!CLEFS.length) throw new Error('Aucune clef Anthropic (key_anthropic dans .env).');
U = construireUnites();
if (O.cmd === 'importer') return importer(resolve(process.argv[3]));
if (O.cmd === 'replis') { const l = listerReplis(); console.log(l.map((r) => `${r.lang}\t${r.source}`).join('\n')); console.log(`${l.length} fichiers`); return; }
const caracteres = [...U.unites.values()].reduce((n, u) => n + u.source.length, 0);
log(`${U.sources.length} fichiers sources, ${U.unites.size} requetes pour toutes les langues, ${(caracteres / 1e6).toFixed(1)} M caracteres`);
if (O.cmd === 'plan') return;
serveur();
photographier();
try { await rattacher(U.unites); } catch (e) { log(`rattachement : ${e.message}`); }
if (etat.credits === 'epuises') controle.creditsEpuises = true;
// Le suivi des lots tourne meme sans travail actif, pour que la vue reste vivante.
setInterval(() => { if (!travailEnCours) photographier(); }, 10000);
if (O.cmd === 'run') await commande({ action: 'lancer', langs: O.langs, limite: O.limit, build: O.build });
else log('en attente d\'un lancement depuis le tableau de bord');
}
function ecrireRapport() {
const l = Object.entries(instantane.langues ?? {}).map(([k, v]) => `| ${k} | ${v.fichiers} | ${v.traduits} | ${v.replis} |`).join('\n');
writeFileSync(join(ETAT_DIR, 'RAPPORT.md'), `# Rapport de traduction\n\nDebut ${etat.debut}, fin ${new Date().toISOString()}.\n\n| Langue | Fichiers | Traduits | Repli FR / retires |\n|---|---|---|---|\n${l}\n\nJetons : ${etat.usage.in} en entree, ${etat.usage.out} en sortie, environ ${instantane.cout} $.\n\nBuild : ${etat.build?.statut ?? 'non lance'} (${etat.build?.essais ?? 0} essais, ${etat.build?.replis?.length ?? 0} fichiers remplaces).\n\nGit : ${etat.git ? `branche ${etat.git.branche}, langues publiees ${etat.git.publiees.join(', ')}` : 'aucun commit'}.\n`);
}
main().catch((e) => { log(`arret : ${e.stack ?? e.message}`); process.exitCode = 1; });
Annexe B : le tableau de bord
Trois fichiers statiques, servis par le moteur lui-même et recopiés sur ce site :
- index.html : la mise en page : compteurs d’en-tête, panneau « comment ça marche », le canevas 3D, l’avancement par langue, le journal et le panneau de commande.
- app.js : la scène three.js (530 lignes). Les immeubles sont un seul
InstancedMeshcontenant quatre étages par fichier, hauteurs tirées deplan.json; la couleur d’instance de chaque étage suit le statut de la langue dansetat.json:ttraduit,ven vol,rrepli,.en attente. Les drones sont de petits maillages avec étiquettes CSS2D, créés à partir du tableaulotset animés en orbite tant questatut !== 'ended'. Le replay se contente de passer les instantanés enregistrés à la même fonction de rendu, à la vitesse choisie. - style.css : thème sombre, panneaux, boutons.
La page en direct est à /atelier-traduction/. Elle lit le moteur sur localhost:4599, donc elle n’affiche des données que sur une machine où le moteur tourne ; les captures de cet article proviennent du replay de la nuit.
À propos de InSkillCoach
Expert en formation et technologies
Coach spécialisé dans les technologies avancées et l'IA, porté par GNeurone Inc.
Certifications:
- AWS Certified Solutions Architect – Professional
- Certifications Google Cloud
- Microsoft Certified: DevOps Engineer Expert
- Certified Kubernetes Administrator (CKA)
- CompTIA Security+
Commentaires
Les commentaires sont alimentés par GitHub Discussions
Connectez-vous avec GitHub pour participer à la discussion