110 lines
4.6 KiB
TypeScript
110 lines
4.6 KiB
TypeScript
import "server-only"
|
|
|
|
// Traducción de títulos de manga al español usando el proxy free-ide local.
|
|
// Prefiere deepseek-v4-flash-free (probado, devuelve contenido limpio) con
|
|
// fallback a big-pickle. No depende de ninguna API externa de pago.
|
|
|
|
const PROXY_URL = process.env.FREE_IDE_PROXY_URL || "http://127.0.0.1:6446/v1"
|
|
const PROXY_KEY = process.env.FREE_IDE_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. Tu trabajo es ADAPTARLO a un título natural en español, como lo titularía una editorial de manga real.
|
|
REGLAS:
|
|
- NO traduzcas palabra por palabra. Reescribí el título para que suene natural y atractivo en español, manteniendo el significado.
|
|
- 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.`
|
|
|
|
// Llama al proxy y devuelve el texto traducido (o null si falla).
|
|
export async function translateTitle(title: string): Promise<string | null> {
|
|
const clean = (title || "").trim()
|
|
if (!clean) return null
|
|
|
|
// Si ya parece estar en español, no traducir (heurística ligera).
|
|
if (isProbablySpanish(clean)) return clean
|
|
|
|
// Modelos en orden de preferencia (benchmark + tests reales 2026-08):
|
|
// nemotron-3-ultra-550b traduce bien con max_tokens>=600.
|
|
const models = [
|
|
"nvidia/nemotron-3-ultra-550b-a55b:free",
|
|
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
|
|
"nemotron-3-ultra-free",
|
|
"openrouter/free",
|
|
"deepseek-v4-flash-free",
|
|
"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",
|
|
"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",
|
|
]
|
|
|
|
for (const model of models) {
|
|
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: 600, // >=500: los reasoning models gastan los primeros tokens en razonamiento
|
|
temperature: 0.3,
|
|
}),
|
|
signal: AbortSignal.timeout(90000),
|
|
})
|
|
if (!res.ok) continue
|
|
const data = await res.json()
|
|
const content: string | undefined = data?.choices?.[0]?.message?.content
|
|
const text = (content || "").trim().replace(/^["']|["']$/g, "")
|
|
if (!text) continue
|
|
// Rechazar respuestas que NO son traducción (repiten el prompt).
|
|
if (!isValidTranslation(text)) continue
|
|
return text
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
// Filtra respuestas basura: si el modelo razona en voz alta ("The user
|
|
// wants...") o entrega explicaciones, no es una traducción válida.
|
|
function isValidTranslation(text: string): boolean {
|
|
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",
|
|
]
|
|
if (text.length > 120) return false
|
|
for (const j of junk) if (t.includes(j)) return false
|
|
return true
|
|
}
|
|
|
|
// Heurística: detecta si el título ya está mayormente en español (para no
|
|
// re-traducir nombres propios o títulos ya en español).
|
|
function isProbablySpanish(t: string): boolean {
|
|
// Si tiene caracteres CJK (japonés/chino) claramente no está en español.
|
|
if (/[\u3040-\u30ff\u4e00-\u9fff]/.test(t)) return false
|
|
// Palabras "muy en español" que indican que no necesita traducción.
|
|
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++
|
|
// Si tiene 3+ marcadores de español, asumimos que ya está en español.
|
|
return count >= 3
|
|
} |