diff --git a/scripts/translate_existing.js b/scripts/translate_existing.js new file mode 100644 index 0000000..08195dd --- /dev/null +++ b/scripts/translate_existing.js @@ -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); }); \ No newline at end of file diff --git a/src/app/(public)/p/[slug]/page.tsx b/src/app/(public)/p/[slug]/page.tsx index 10121d7..d1c2f5e 100644 --- a/src/app/(public)/p/[slug]/page.tsx +++ b/src/app/(public)/p/[slug]/page.tsx @@ -4,6 +4,7 @@ import type { Metadata } from "next" import { getPostBySlug, getAllPosts, getAuthorSlugByName } from "@/lib/db" import { PostCard } from "@/components/post-card" import { TagBadge } from "@/components/tag-badge" +import { displayTitle } from "@/lib/title" import { ArrowLeft, BookOpen, ExternalLink } from "lucide-react" export const dynamic = "force-dynamic" @@ -19,7 +20,7 @@ export async function generateMetadata({ const post = getPostBySlug(slug) if (!post) return { title: "No encontrado" } - const title = post.title + const title = displayTitle(post) const description = post.summary && !post.summary.startsWith("**") && post.summary.length > 40 ? post.summary.slice(0, 160) @@ -81,6 +82,9 @@ export default async function PostDetailPage({ // Enlaces cruzados: autor -> página de autor, parodia -> franquicia. const authorSlug = post.artist ? getAuthorSlugByName(post.artist) : null + // Título a mostrar (traducción al español si existe). + const title = displayTitle(post) + return (
{post.title_jpn}
)} diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index 8781087..073d56c 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -3,6 +3,7 @@ import { getPostsPage, getAllPosts } from "@/lib/db" import { InfinitePostGrid } from "@/components/infinite-grid" import { TagBadge } from "@/components/tag-badge" import { EmptyState } from "@/components/empty-state" +import { displayTitle } from "@/lib/title" import { BookOpen, Sparkles, ArrowRight } from "lucide-react" export const dynamic = "force-dynamic" @@ -60,7 +61,7 @@ export default async function PublicHomePage() { )}por {latest.artist}
diff --git a/src/app/rss.xml/route.ts b/src/app/rss.xml/route.ts index ac33b61..8a89390 100644 --- a/src/app/rss.xml/route.ts +++ b/src/app/rss.xml/route.ts @@ -1,4 +1,5 @@ import { getAllPosts } from "@/lib/db" +import { displayTitle } from "@/lib/title" export const dynamic = "force-dynamic" @@ -15,8 +16,9 @@ export async function GET() { (p.summary && !p.summary.startsWith("**") && p.summary.length > 30 ? p.summary.slice(0, 300) : `${p.num_pages} páginas`) || `${p.num_pages} páginas` + const title = displayTitle(p) return `{post.artist}
- )}{formatDate(post.published_at)}
)}