158 lines
6.4 KiB
JavaScript
158 lines
6.4 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 + 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(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); }); |