Files
worst-scan-web/scripts/translate_existing.js
T

158 lines
6.3 KiB
JavaScript

// Traducción masiva de títulos existentes al español.
// CON ROTACIÓN DE MODELOS: usa TODOS los modelos free del proxy y salta
// los que dan 429/vacío (rate limit). Con reintentos y progreso.
// Corre standalone: node translate_existing.js [LIMIT]
const Database = require("better-sqlite3");
const db = new Database("./data/worst-scan.db");
// Asegurar columna title_es (misma migracion que src/lib/db.ts)
try { db.exec("ALTER TABLE posts ADD COLUMN title_es TEXT"); } catch {}
const PROXY_URL = "http://127.0.0.1:6446/v1";
const PROXY_KEY = "tw0rxo314kvga9lfbp2nj5sde7hiq6uc";
const SYSTEM_PROMPT = `Sos un traductor de títulos de manga al español latinoamericano.
Recibís UN título de manga (en inglés, japonés romanizado, katakana, kanji o chino).
Devolvés SOLO la traducción al español, sin comillas, sin corchetes de autor,
sin explicaciones, sin "Traducción:" ni puntos finales.
Mantené el tono del título original.
Normas:
- No traduzcas nombres propios (personajes, artistas, marcas).
- Si el título ya está en español o es un nombre propio, devolvelo tal cual.
- Traducí términos como "Haha no Hi" -> "Día de la Madre".
- Si hay números de capítulo/volumen/año, mantenelos.
- Respondé en UNA línea.`;
// Modelos en orden de preferencia (benchmark real 2026-08):
// nemotron-3-nano-omni y openrouter/free traducen BIEN (no meta-pensamiento).
// Los demas son fallbacks que a veces dan basura (el filtro la descarta).
const MODEL_CANDIDATES = [
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", // ⭐ traduce bien, 5.5s
"openrouter/free", // ⭐ traduce bien, 2.7s
"deepseek-v4-flash-free", // bueno cuando no esta cageado
"big-pickle",
"tencent/hy3:free",
"nvidia/nemotron-3-ultra-550b-a55b:free",
"mimo-v2.5-free",
"cohere/north-mini-code:free",
"inclusionai/ling-3.0-flash:free",
"nvidia/nemotron-3-super-120b-a12b:free",
"nemotron-3-ultra-free",
"north-mini-code-free",
"poolside/laguna-s-2.1:free",
"poolside/laguna-xs-2.1:free",
"kilo-auto/free",
"forever_free",
];
// Modelos que fallaron (rate limit / vacío) -> no reintentar por un rato
const deadModels = new Map(); // modelo -> timestamp cuando se puede reintentar
function isProbablySpanish(t) {
if (/[\u3040-\u30ff\u4e00-\u9fff]/.test(t)) return false;
const spanishWords = ["el ", "la ", "los ", "las ", " de ", " y ", " un ", " una ", " para ", "con ", "por "];
let count = 0;
for (const w of spanishWords) if (t.toLowerCase().includes(w)) count++;
return count >= 3;
}
async function tryModel(model, title) {
const res = await fetch(`${PROXY_URL}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${PROXY_KEY}` },
body: JSON.stringify({
model,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: title },
],
max_tokens: 200,
temperature: 0.3,
}),
signal: AbortSignal.timeout(60000),
});
if (!res.ok) return { ok: false, rateLimited: res.status === 429 };
const data = await res.json();
const content = data?.choices?.[0]?.message?.content;
const text = (content || "").trim().replace(/^["']|["']$/g, "");
if (!text) return { ok: false, rateLimited: true }; // vacío = agotado
// Rechazar respuestas que NO son una traducción (repiten el prompt o razonan).
if (!isValidTranslation(text)) return { ok: false, rateLimited: false };
return { ok: true, text };
}
// Filtra respuestas basura: si el modelo repite el prompt o razona en voz alta
// ("The user wants...", "I need to translate...", "Esta es una traducción...")
// no es una traducción válida.
function isValidTranslation(text) {
const t = text.toLowerCase();
const junk = [
"the user wants", "the task", "i need to", "i'll", "the manga title",
"esta es una traducción", "aquí tienes", "la traducción es", "claro que",
"por supuesto", "the translation", "here is", "the title", "sure",
"voy a traducir", "the user asked", "the user is",
];
// Si es muy larga (> 120 chars) probablemente es razonamiento, no título.
if (text.length > 120) return false;
for (const j of junk) if (t.includes(j)) return false;
return true;
}
async function translate(title) {
const clean = (title || "").trim();
if (!clean) return null;
if (isProbablySpanish(clean)) return clean;
// Bucle sobre modelos con backtracking: si todos fallan, reintentar tras pausa
for (let attempt = 0; attempt < 3; attempt++) {
for (const model of MODEL_CANDIDATES) {
const retryAt = deadModels.get(model) || 0;
if (Date.now() < retryAt) continue;
try {
const r = await tryModel(model, clean);
if (r.ok) return r.text;
if (r.rateLimited) {
// Marcar como muerto por 5 min
deadModels.set(model, Date.now() + 5 * 60 * 1000);
console.log(` [rate-limit] ${model} -> pausado 5 min`);
}
} catch {
deadModels.set(model, Date.now() + 60 * 1000);
}
}
// Todos fallaron: esperar 30s antes del siguiente intento
if (attempt < 2) {
await new Promise((r) => setTimeout(r, 30000));
}
}
return null;
}
async function main() {
const limit = parseInt(process.argv[2] || "0", 10);
const posts = db.prepare("SELECT id, gid, title, title_es FROM posts").all();
let toTranslate = posts.filter(p => !p.title_es);
if (limit > 0) toTranslate = toTranslate.slice(0, limit);
console.log(`Total: ${posts.length} | sin traducción: ${toTranslate.length}${limit ? ` (lote ${limit})` : ""}`);
let ok = 0, fail = 0;
for (let i = 0; i < toTranslate.length; i++) {
const p = toTranslate[i];
const t = await translate(p.title);
if (t) {
db.prepare("UPDATE posts SET title_es = ?, updated_at = datetime('now') WHERE id = ?").run(t, p.id);
ok++;
console.log(`[${i + 1}/${toTranslate.length}] ✓ ${String(p.title).slice(0, 40)} -> ${t.slice(0, 50)}`);
} else {
fail++;
console.log(`[${i + 1}/${toTranslate.length}] ✗ ${String(p.title).slice(0, 40)}`);
}
// Pausa corta entre títulos para no saturar
await new Promise((r) => setTimeout(r, 1000));
}
console.log(`\n=== RESULTADO: ${ok} traducidos, ${fail} fallaron ===`);
db.close();
}
main().catch((e) => { console.error("ERROR:", e.message); process.exit(1); });