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

171 lines
7.5 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 el editor de títulos de un fansub de manga que traduce del japonés/inglés al español latinoamericano.
Recibís el título de un manga/doujinshi JUNTO CON su sinopsis y tags como contexto.
Tu trabajo es formular un título NATURAL en español para ese manga, como lo titularía una editorial real.
REGLAS:
- No traduzcas el título literalmente. Usá el contexto (tags + sinopsis) para entender DE QUÉ TRATA el manga y titulalo en español de forma natural y atractiva.
- Si el título original ya es claro en español, adaptalo levemente. Si es confuso, reformulalo basándote en la sinopsis.
- Mantené nombres propios (personajes, artistas, marcas, franquicias) sin traducir.
- Mantené números de capítulo/volumen/año.
- Si hay un término japonés de uso común en el fandom (ecchi, harem, netorare, yuri, etc.) podés dejarlo o traducirlo según suene mejor.
- El resultado debe leerse como un título de manga en español, no como una traducción de Google.
- Respondé SOLO con el título final, UNA línea, sin comillas, sin explicaciones, sin corchetes de autor.`;
// Arma el mensaje de usuario con título + contexto (tags + sinopsis).
function buildUserPrompt(post) {
let tags = post.tags || [];
if (typeof tags === "string") { try { tags = JSON.parse(tags); } catch { tags = []; } }
const lines = [`Título original: ${post.title}`];
if (Array.isArray(tags) && tags.length) lines.push(`Tags: ${tags.slice(0, 25).join(", ")}`);
if (post.summary && post.summary.length > 20) lines.push(`Sinopsis: ${post.summary.slice(0, 600)}`);
return lines.join("\n");
}
// Modelos en orden de preferencia (benchmark + tests reales 2026-08):
// nemotron-3-ultra-550b y nemotron-3-nano-omni traducen BIEN con max_tokens>=600.
// Los demas son fallbacks (algunos dan basura, el filtro la descarta).
const MODEL_CANDIDATES = [
"nvidia/nemotron-3-ultra-550b-a55b:free", // ⭐ traduce bien (verificado: "Crónicas de Derrota...")
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", // traduce bien
"nemotron-3-ultra-free", // alias zen del 550b
"openrouter/free",
"deepseek-v4-flash-free", // bueno cuando no esta cageado
"big-pickle",
"tencent/hy3:free",
"mimo-v2.5-free",
"cohere/north-mini-code:free",
"inclusionai/ling-3.0-flash:free",
"nvidia/nemotron-3-super-120b-a12b: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: 600, // >=500: los reasoning models gastan los primeros tokens en razonamiento
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(post) {
const clean = (post.title || "").trim();
if (!clean) return null;
if (isProbablySpanish(clean)) return clean;
const userMsg = buildUserPrompt(post);
// 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, userMsg);
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 retake = process.env.RETAKE === "1";
const posts = db.prepare("SELECT id, gid, title, title_es, tags, summary FROM posts").all();
let toTranslate = posts.filter(p => !p.title_es);
if (retake) toTranslate = posts.filter(p => p.title_es); // re-traducir los existentes
if (limit > 0) toTranslate = toTranslate.slice(0, limit);
console.log(`Total: ${posts.length} | a procesar: ${toTranslate.length}${retake ? " (RETAKE: re-traduciendo existentes)" : ""}${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);
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); });