Come ho tradotto 1.200 pagine in 11 lingue con Ollama spendendo €0
Una pipeline di traduzione locale — JSON → Python → Ollama (qwen2.5:7b su Apple Silicon) → Next.js — che genera 1.243 pagine SEO multilingua per FitMesh Sync. Costo API: zero. Codice, scelte e limiti reali.
FitMesh Sync vive in undici mercati. Ognuno vuole leggere la stessa cosa nella propria lingua: la landing, le pagine dei dispositivi supportati, gli articoli. Tradurre tutto con un'API commerciale era la strada ovvia — ed è quella che ho scartato. Questo è il sistema che ho costruito al suo posto: gira interamente in locale sul mio MacBook, il costo delle API di traduzione è zero, e a build time produce 1.243 pagine indicizzabili.
Il problema: SEO multilingua con budget zero
Per un'app indie, "tradurre il sito" non significa cambiare le stringhe nell'interfaccia. Significa generare pagine vere, una per lingua, con il testo dentro l'HTML al momento del build — non un'etichetta caricata via JavaScript nel browser. È questa la differenza tra una pagina che Google indicizza e una che resta invisibile.
Moltiplica tutto per undici locali e la superficie del sito si moltiplica per undici. Il testo da tradurre non è poco: copy delle landing, schede dei dispositivi, articoli del blog. La via comoda — DeepL API o GPT-4o — funziona benissimo, ma ha un prezzo a carattere, e quel prezzo si ripaga a ogni rigenerazione del sito. Per un progetto che mantengo da solo, il vincolo era netto: costo marginale per pagina pari a zero, perché altrimenti smetto di rigenerare e il contenuto invecchia.
Da qui in poi, ogni decisione tecnica nasce da questo vincolo.
La scelta del modello: perché qwen2.5:7b
Ollama è un runtime che fa girare modelli linguistici in locale ed espone una CLI e un'API HTTP: nessuna chiamata esce dalla macchina. Sopra ci ho montato qwen2.5:7b, il modello da 7 miliardi di parametri di Alibaba, quantizzato Q4_K_M: 4,7 GB su disco, gira in memoria su un MacBook Apple Silicon senza GPU dedicata.
Non è il modello migliore in assoluto. È il migliore per questo vincolo. Le alternative che ho valutato:
- gpt-4o — qualità di traduzione superiore, ma a pagamento. Su 1.243 pagine, moltiplicate per ogni aggiornamento del copy, il conto non è simbolico.
- DeepL API — la qualità più alta del gruppo sulle lingue europee, ma a pagamento e con limiti di caratteri sul piano gratuito. Stesso problema di gpt-4o: ottimo finché non rigeneri spesso.
- gemma (2B / 7B) — gira in locale come qwen, ma nei miei test perdeva colpi sul lessico tecnico: nomi di sensori, unità di misura, standard. Tendeva a "tradurre" termini che vanno lasciati in inglese.
- qwen2.5:7b — il miglior compromesso tra qualità, tenuta sul lessico tecnico e velocità che gira sul mio laptop. Vince perché è l'unico che soddisfa il vincolo "€0 a pagina" senza crollare sul contenuto health-tech.
La regola che ne ho ricavato: quando il vincolo è il costo marginale zero, la domanda non è "qual è il modello migliore" ma "qual è il modello migliore tra quelli che girano gratis sulla mia macchina".
L'architettura della pipeline
La sorgente unica di verità è il contenuto inglese in JSON. Tutto il resto è derivato. Lo schema, per una landing page:
post_data/<slug>-lp.json sorgente EN (~38 stringhe)
│
▼
translate_landing.py batch da 18 stringhe → Ollama → JSON
│ qwen2.5:7b · Q4_K_M · 4,7 GB · in locale, Apple Silicon
▼
LandingPage oggetto TypeScript tipizzato
│
▼
lib/landing/data.ts 11 locali per ogni slug
│
▼
Next.js generateStaticParams → 11 pagine SSG per slug
Il punto importante: l'inglese è l'input, il TypeScript tipizzato è l'output. Lo script Python sta nel mezzo e non sa nulla di Next.js — produce un oggetto LandingPage che il sito consuma come se l'avessi scritto a mano. Le pagine sono statiche (SSG): il testo tradotto è già nell'HTML al deploy, esattamente ciò che serve perché un motore di ricerca lo legga.
Il cuore: batch, retry, fallback
Il modello non traduce una stringa alla volta — sarebbe lentissimo — né tutte insieme, perché un 7B perde coerenza su input lunghi. Il compromesso è il batch da 18 stringhe: abbastanza contesto perché le traduzioni siano coerenti tra loro, abbastanza corto perché il modello non dimentichi le regole a metà.
# translate_landing.py — traduce una landing EN in N locali.
# In Docker: docker compose run --rm translate python -u translate_landing.py
import json, subprocess
BATCH_SIZE = 18 # compromesso contesto/velocità su un 7B
MAX_RETRIES = 3
MODEL = "qwen2.5:7b"
def ollama(prompt):
r = subprocess.run(
["ollama", "run", MODEL],
input=prompt.encode(),
capture_output=True,
)
return r.stdout.decode().strip()
def translate_batch(strings, target_lang):
"""Ritorna la lista tradotta, o None se il modello non produce JSON valido."""
prompt = build_prompt(strings, target_lang)
for _ in range(MAX_RETRIES):
try:
out = json.loads(ollama(prompt))
if isinstance(out, list) and len(out) == len(strings):
return out
except json.JSONDecodeError:
pass # JSON malformato → ritenta
return None # retry esauriti → il chiamante terrà l'inglese
def translate_landing(strings, target_lang):
result = []
for i in range(0, len(strings), BATCH_SIZE):
batch = strings[i:i + BATCH_SIZE]
out = translate_batch(batch, target_lang)
if out is None:
result.extend(batch) # fallback: tieni l'EN originale
print("!", end="", flush=True) # un "!" = un batch non tradotto
else:
result.extend(out)
print(".", end="", flush=True) # un "." = un batch ok
print()
return result
Tre dettagli che sembrano minori e non lo sono:
- Fallback all'inglese. Se dopo 3 tentativi il modello non restituisce un array JSON della lunghezza giusta, non invento niente: tengo la stringa inglese originale. Una pagina con qualche frase in inglese è meglio di una pagina rotta o di una traduzione allucinata.
print(".", flush=True)su ogni batch. Senzaflush, lo stdout resta nel buffer e non vedi niente finché lo script non finisce. Con il flush, ogni.è un batch andato a buon fine e ogni!è un fallback: leggo la qualità di una run a colpo d'occhio, mentre gira.- Il flag
-udi Python in Docker. Dentro un container lo stdout è bufferizzato in modo ancora più aggressivo.python -ulo forza in modalità unbuffered: senza, i.e i!arrivano tutti in blocco alla fine, e il monitoraggio in tempo reale non esiste.
Il prompt: cosa non tradurre
Metà della qualità è nel prompt, e la parte più importante del prompt è la lista delle cose da non tradurre. "Health Connect" non è una frase, è un nome di API. "SpO2" è uno standard. Se il modello li traduce, il testo diventa subito sbagliato per chi lo legge.
# Nomi tecnici, standard e marchi: vanno lasciati in inglese.
BRANDS = [
"Health Connect", "Google Fit", "Samsung Health", "Apple Health",
"Wear OS", "HRV", "SpO2", "VO2 Max", "Garmin", "Fitbit",
"FitMesh Sync", "Bluetooth", "GPX", "BPM",
]
def build_prompt(strings, target_lang):
numbered = "\n".join(f"{i}. {s}" for i, s in enumerate(strings))
keep = ", ".join(BRANDS)
return f"""Sei un traduttore tecnico per un'app di salute e fitness.
Traduci le stringhe seguenti dall'inglese a {target_lang}.
Regole:
- NON tradurre questi nomi tecnici e marchi: {keep}
- Tono conciso e tecnico, non promozionale.
- NON usare em-dash (—) né virgolette tipografiche: usa trattini normali e virgolette dritte (").
- Conserva i placeholder come {{count}} o %s esattamente come sono.
Rispondi SOLO con un array JSON di {len(strings)} stringhe, nello stesso ordine.
Nessun testo prima o dopo.
Stringhe:
{numbered}"""
La regola sull'em-dash e sulle virgolette tipografiche sembra una pignoleria: non lo è. I modelli amano i caratteri tipografici "belli" (— invece di -, " " invece di "), che poi rompono il JSON, l'incolonnamento nei file .ts e gli snippet nei meta tag. Dirlo esplicitamente nel prompt costa una riga e mi ha tolto un'intera classe di errori a valle.
I numeri reali
Niente vanity metric. Questo è ciò che la pipeline produce a build time:
- 47 articoli del blog × 11 locali = 517 URL
- 12 landing page × 11 locali = 132 URL
- 24 pagine dispositivo × 11 locali = 264 URL (generate da
translate_provider_models.py, stessa pipeline) - Subtotale dei tre generatori: 913 URL
- + ~330 URL da home, pagine di sezione, categorie e pagine legali, una versione per ciascun locale
- Totale indicizzabile: 1.243 URL
Tempo per tradurre una landing page (~38 stringhe × 10 locali, l'undicesimo è l'inglese sorgente): circa 8 minuti sul portatile. Costo delle API di traduzione: €0, perché il modello gira in locale e Ollama è gratuito. L'unico costo è l'energia del laptop e i minuti che ci mette.
Limiti onesti
qwen2.5:7b non è GPT-4, e fingere il contrario sarebbe disonesto.
- Qualità da health-tech, non letteraria. Per copy tecnico conciso — nomi di feature, descrizioni di dispositivi, istruzioni — il risultato regge. Per prosa con sfumature o ritmo, no. Per fortuna il contenuto di FitMesh Sync sta nella prima categoria.
- Giapponese e coreano fanno più fallback. Nelle run, JA e KO collezionano più
!delle lingue europee: il modello produce JSON malformato più spesso, e più batch finiscono a fallback inglese. La qualità su quelle lingue è la più debole del lotto. - I caratteri tipografici restano un fronte aperto. Anche con la regola esplicita nel prompt, ogni tanto un em-dash passa. Per ora lo accetto; la sanitizzazione sistematica è sulla lista.
Cosa farei diversamente
Tre cose, in ordine di ritorno sull'investimento:
- Un modello più grande per JA/KO. Per le due lingue problematiche userei
qwen3:14b: più lento, ma quasi certamente meno fallback dove la qualità è più debole. Le lingue europee restano sul 7B, che lì basta e avanza. - Una cache delle traduzioni. Oggi un aggiornamento del copy ritraduce tutto. Con un hash della stringa sorgente come chiave, ritradurrei solo ciò che è cambiato — gli aggiornamenti passerebbero da minuti a secondi.
- Output direttamente nel volume Docker. Adesso seguo l'avanzamento via stdout, comodo per il monitoraggio dal vivo ma fragile come trasporto. Scrivere i file tradotti direttamente nel volume montato toglie un passaggio e un punto di rottura.
La traduzione è solo metà del lavoro
Avere 1.243 pagine tradotte e statiche non significa avere 1.243 pagine che si posizionano. Tradurre il testo è la metà visibile. L'altra metà è dire ai motori di ricerca come tenere insieme undici versioni della stessa pagina senza che si cannibalizzino: hreflang corretti, canonical per locale, una sitemap multilingua, e structured data che descriva ogni pagina nella sua lingua. È un problema a sé, con le sue trappole, e lo riprenderò in un articolo dedicato.
Un modello da 7B sul tuo laptop non batte GPT-4. Ma 1.243 pagine a costo zero, riproducibili e versionate nel repo, battono le 1.243 pagine che non hai mai pubblicato perché l'API costava troppo.