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.num_pages} páginas )}
-

{post.title}

+

{title}

{post.title_jpn && (

{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() { )}

- {latest.title} + {displayTitle(latest)}

{latest.artist && (

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 ` - <![CDATA[${p.title}${p.title_jpn ? ` (${p.title_jpn})` : ""}]]> + <![CDATA[${title}${p.title_jpn ? ` (${p.title_jpn})` : ""}]]> https://worstscan.xyz/p/${p.slug} worstscan-${p.gid} ${pubDate} diff --git a/src/components/post-card.tsx b/src/components/post-card.tsx index 07c4cc7..95ae860 100644 --- a/src/components/post-card.tsx +++ b/src/components/post-card.tsx @@ -1,19 +1,18 @@ import Link from "next/link" import type { Post } from "@/lib/db" import { formatDate } from "@/lib/utils" -import { TagBadge } from "@/components/tag-badge" import { BookOpen, Clock } from "lucide-react" +import { displayTitle } from "@/lib/title" -// Card estilo "pin" (Pinterest/manga sites): portada dominante, hover con zoom -// y overlay de lectura, metadata compacta. Image-first para retención visual. +// Card limpia: portada dominante, título (traducido al español si existe), +// fecha de publicación. Sin tags ni artista (ya están en el panel lateral). export function PostCard({ post, compact = false }: { post: Post; compact?: boolean }) { const isAnthology = post.tags?.some((t) => ["anthology", "compilation", "tankoubon"].some((k) => t.toLowerCase().includes(k)) ) || post.num_pages >= 100 - // Duraciones de lectura aproximadas para dar contexto (hook de curiosidad). - const readMinutes = post.num_pages > 0 ? Math.max(1, Math.round(post.num_pages / 30)) : null + const title = displayTitle(post) if (compact) { return ( @@ -23,24 +22,18 @@ export function PostCard({ post, compact = false }: { post: Post; compact?: bool > {post.title}

- {post.title} + {title}

- {post.artist && ( -

{post.artist}

- )}
{isAnthology && ( Tomo )} - {post.source && ( - {post.source} - )} {post.published_at && ( {formatDate(post.published_at)} )} @@ -59,7 +52,7 @@ export function PostCard({ post, compact = false }: { post: Post; compact?: bool
{post.title} @@ -87,23 +80,13 @@ export function PostCard({ post, compact = false }: { post: Post; compact?: bool )}
- {/* Metadatos */} + {/* Metadatos: solo título (traducido) + fecha */}

- {post.title} + {title}

-
- {post.artist || post.parody || post.source || "worst-scan"} - {readMinutes && ( - {readMinutes} min - )} -
- {post.tags && post.tags.length > 0 && ( -
- {post.tags.slice(0, 2).map((tag) => ( - - ))} -
+ {post.published_at && ( +

{formatDate(post.published_at)}

)}
diff --git a/src/lib/db.ts b/src/lib/db.ts index ef8de90..3e159c2 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -112,12 +112,18 @@ function migrate(db: Database.Database) { db.exec("ALTER TABLE posts ADD COLUMN series_id INTEGER REFERENCES series(id)") db.exec("CREATE INDEX IF NOT EXISTS idx_posts_series ON posts(series_id)") } catch {} + + // title_es: traducción del título al español (para la web en español). + try { + db.exec("ALTER TABLE posts ADD COLUMN title_es TEXT") + } catch {} } export interface Post { id: number gid: string title: string + title_es: string | null title_jpn: string | null artist: string | null parody: string | null @@ -137,6 +143,7 @@ export interface Post { export interface PostInput { gid: string title: string + title_es?: string title_jpn?: string artist?: string parody?: string @@ -200,12 +207,13 @@ export function createPost(input: PostInput): Post { } const stmt = d.prepare(` - INSERT INTO posts (gid, title, title_jpn, artist, parody, tags, num_pages, source, cover_url, url, summary, slug, published, published_at) - VALUES (@gid, @title, @title_jpn, @artist, @parody, @tags, @num_pages, @source, @cover_url, @url, @summary, @slug, @published, ${publishedAt}) + INSERT INTO posts (gid, title, title_es, title_jpn, artist, parody, tags, num_pages, source, cover_url, url, summary, slug, published, published_at) + VALUES (@gid, @title, @title_es, @title_jpn, @artist, @parody, @tags, @num_pages, @source, @cover_url, @url, @summary, @slug, @published, ${publishedAt}) `) const result = stmt.run({ gid: input.gid, title: input.title, + title_es: input.title_es || null, title_jpn: input.title_jpn || null, artist: input.artist || null, parody: input.parody || null, @@ -225,6 +233,7 @@ export function createPost(input: PostInput): Post { // por nombres de columna y el bypass de `published`/columnas internas. const UPDATE_FIELDS = new Set([ "title", + "title_es", "title_jpn", "artist", "parody", diff --git a/src/lib/discord.ts b/src/lib/discord.ts index 4cee5c6..6ab6fd7 100644 --- a/src/lib/discord.ts +++ b/src/lib/discord.ts @@ -1,4 +1,5 @@ import type { Post } from "./db" +import { displayTitle } from "./title" // Notificación a Discord vía Webhook. // Si DISCORD_WEBHOOK_URL no está configurada, no hace nada (silencioso). @@ -7,15 +8,16 @@ const WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL || "" export async function notifyNewPost(post: Post): Promise { if (!WEBHOOK_URL) return false + const title = displayTitle(post) const description = post.summary && !post.summary.startsWith("**") && post.summary.length > 40 ? post.summary.slice(0, 200) : `${post.num_pages} páginas${post.artist ? ` por ${post.artist}` : ""}` const payload = { - content: `📚 **Nuevo manga disponible:** ${post.title}`, + content: `📚 **Nuevo manga disponible:** ${title}`, embeds: [ { - title: post.title, + title, url: `https://worstscan.xyz/p/${post.slug}`, description, color: 0x6366f1, // indigo, acorde al tema diff --git a/src/lib/poller.ts b/src/lib/poller.ts index 9c8d581..e92d772 100644 --- a/src/lib/poller.ts +++ b/src/lib/poller.ts @@ -6,6 +6,7 @@ import { cacheCover } from "./cover-cache" import { notifyNewPost } from "./discord" import { moderateTags, moderationReasonText } from "./moderation" import { notifyQuarantine } from "./discord-moderation" +import { translateTitle } from "./translate" let intervalId: ReturnType | null = null const POLL_INTERVAL_MS = 60_000 @@ -67,9 +68,17 @@ export async function pollOnce(): Promise<{ newPosts: number }> { if (!existing) { // Los posts moderados se crean en cuarentena (published=0) const isPublished = moderated ? 0 : 1 + + // Traducir el título al español (si no está ya en español). + let titleEs: string | null = null + try { + titleEs = await translateTitle(cleanT) + } catch {} + await createPost({ gid, title: cleanT, + title_es: titleEs || undefined, title_jpn: summary.title_jpn, artist: summary.artist, parody: summary.parody, @@ -96,11 +105,19 @@ export async function pollOnce(): Promise<{ newPosts: number }> { await notifyNewPost(created).catch(() => {}) } } else { - // Post existente: si se actualizó y ahora tiene tags dudosos, - // pasarlo a cuarentena (despublicar). + // Post existente: si no tiene traducción, intentar traducirlo ahora. + let titleEs = existing.title_es + if (!titleEs) { + try { + titleEs = await translateTitle(cleanT) + } catch {} + } + + // Si se actualizó y ahora tiene tags dudosos, pasarlo a cuarentena. if (moderated) { updatePost(existing.id, { title: cleanT, + title_es: titleEs || undefined, title_jpn: summary.title_jpn || undefined, artist: summary.artist || undefined, parody: summary.parody || undefined, @@ -116,6 +133,7 @@ export async function pollOnce(): Promise<{ newPosts: number }> { } else { updatePost(existing.id, { title: cleanT, + title_es: titleEs || undefined, title_jpn: summary.title_jpn || undefined, artist: summary.artist || undefined, parody: summary.parody || undefined, diff --git a/src/lib/title.ts b/src/lib/title.ts new file mode 100644 index 0000000..6ad3d7e --- /dev/null +++ b/src/lib/title.ts @@ -0,0 +1,9 @@ +import type { Post } from "./db" + +// Devuelve el título a mostrar: la traducción al español si existe, +// o el título original como fallback. +export function displayTitle( + post: Pick, +): string { + return (post.title_es && post.title_es.trim()) || post.title +} \ No newline at end of file diff --git a/src/lib/translate.ts b/src/lib/translate.ts new file mode 100644 index 0000000..cc415ab --- /dev/null +++ b/src/lib/translate.ts @@ -0,0 +1,74 @@ +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 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.` + +// Llama al proxy y devuelve el texto traducido (o null si falla). +export async function translateTitle(title: string): Promise { + 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 + + const models = ["deepseek-v4-flash-free", "big-pickle"] + + 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: 200, + 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 + if (!content || !content.trim()) continue + return content.trim().replace(/^["']|["']$/g, "") + } catch { + continue + } + } + return null +} + +// 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 +} \ No newline at end of file