feat: traduccion de titulos al espanol (title_es) + card limpia (fecha en vez de tags/min) + poller traduce nuevos

This commit is contained in:
Renato
2026-08-05 05:35:41 +08:00
parent fcb418101e
commit d3cd79f50d
10 changed files with 235 additions and 38 deletions
+95
View File
@@ -0,0 +1,95 @@
// Traducción masiva de títulos existentes al español.
// Usa el proxy free-ide local (deepseek-v4-flash-free con fallback).
// Corre standalone: node translate_existing.js
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 {} // ya existe
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.`;
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 translate(title) {
const clean = (title || "").trim();
if (!clean) return null;
if (isProbablySpanish(clean)) return clean;
for (const model of ["deepseek-v4-flash-free", "big-pickle"]) {
try {
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: clean },
],
max_tokens: 200,
temperature: 0.3,
}),
signal: AbortSignal.timeout(90000),
});
if (!res.ok) continue;
const data = await res.json();
const content = data?.choices?.[0]?.message?.content;
if (!content || !content.trim()) continue;
return content.trim().replace(/^["']|["']$/g, "");
} catch {
continue;
}
}
return null;
}
async function main() {
const limit = parseInt(process.env.LIMIT || "0", 10);
const posts = db.prepare("SELECT id, gid, title, title_es FROM posts WHERE published=1 OR published=0").all();
let toTranslate = posts.filter(p => !p.title_es);
if (limit > 0) toTranslate = toTranslate.slice(0, limit);
console.log(`Total posts: ${posts.length} | sin traducción: ${toTranslate.length}${limit ? ` (probando ${limit})` : ""}`);
let ok = 0, fail = 0, skip = 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}] ✓ ${p.title.slice(0, 40)} -> ${t.slice(0, 50)}`);
} else {
fail++;
console.log(`[${i + 1}/${toTranslate.length}] ✗ ${p.title.slice(0, 40)} (sin traducción)`);
}
// Delay pequeño entre requests para no saturar el proxy
await new Promise((r) => setTimeout(r, 300));
}
console.log(`\n=== RESULTADO: ${ok} traducidos, ${fail} fallaron, ${skip} omitidos ===`);
db.close();
}
main().catch((e) => { console.error("ERROR:", e.message); process.exit(1); });