171 lines
7.5 KiB
JavaScript
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); }); |