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); });
+6 -2
View File
@@ -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 (
<div className="pb-16 md:pb-0">
<Link
@@ -113,7 +117,7 @@ export default async function PostDetailPage({
<span className="text-xs text-[var(--muted)]">{post.num_pages} páginas</span>
)}
</div>
<h1 className="text-lg sm:text-xl font-bold leading-tight">{post.title}</h1>
<h1 className="text-lg sm:text-xl font-bold leading-tight">{title}</h1>
{post.title_jpn && (
<p className="mt-1 text-xs sm:text-sm text-[var(--muted)]">{post.title_jpn}</p>
)}
+2 -1
View File
@@ -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() {
)}
</div>
<h1 className="text-2xl sm:text-4xl font-bold tracking-tight leading-tight line-clamp-3">
{latest.title}
{displayTitle(latest)}
</h1>
{latest.artist && (
<p className="text-sm text-[var(--muted)]">por {latest.artist}</p>
+3 -1
View File
@@ -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 ` <item>
<title><![CDATA[${p.title}${p.title_jpn ? ` (${p.title_jpn})` : ""}]]></title>
<title><![CDATA[${title}${p.title_jpn ? ` (${p.title_jpn})` : ""}]]></title>
<link>https://worstscan.xyz/p/${p.slug}</link>
<guid isPermaLink="false">worstscan-${p.gid}</guid>
<pubDate>${pubDate}</pubDate>
+11 -28
View File
@@ -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
>
<img
src={`/api/cover/${post.gid}`}
alt={post.title}
alt={title}
className="h-32 w-24 flex-shrink-0 object-cover"
loading="lazy"
/>
<div className="flex flex-1 flex-col gap-1.5 py-3 pr-4">
<h3 className="line-clamp-2 text-sm font-semibold leading-snug group-hover:text-[var(--accent)] transition-colors">
{post.title}
{title}
</h3>
{post.artist && (
<p className="text-xs text-[var(--muted)]">{post.artist}</p>
)}
<div className="flex flex-1 items-end gap-2">
{isAnthology && (
<span className="text-[9px] font-semibold text-[var(--accent)] uppercase">Tomo</span>
)}
{post.source && (
<span className="text-[10px] uppercase text-[var(--muted)]">{post.source}</span>
)}
{post.published_at && (
<span className="text-[10px] text-[var(--muted)]">{formatDate(post.published_at)}</span>
)}
@@ -59,7 +52,7 @@ export function PostCard({ post, compact = false }: { post: Post; compact?: bool
<div className="relative aspect-[3/4] overflow-hidden bg-[var(--surface)]">
<img
src={`/api/cover/${post.gid}`}
alt={post.title}
alt={title}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.06]"
loading="lazy"
/>
@@ -87,23 +80,13 @@ export function PostCard({ post, compact = false }: { post: Post; compact?: bool
)}
</div>
{/* Metadatos */}
{/* Metadatos: solo título (traducido) + fecha */}
<div className="flex flex-col gap-1.5 p-3">
<h3 className="line-clamp-2 min-h-[2.6rem] text-sm font-semibold leading-snug group-hover:text-[var(--accent)] transition-colors">
{post.title}
{title}
</h3>
<div className="flex items-center justify-between text-[10px] text-[var(--muted)]">
<span className="truncate">{post.artist || post.parody || post.source || "worst-scan"}</span>
{readMinutes && (
<span className="flex-shrink-0">{readMinutes} min</span>
)}
</div>
{post.tags && post.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{post.tags.slice(0, 2).map((tag) => (
<TagBadge key={tag} tag={tag} href={`/tag/${encodeURIComponent(tag)}`} />
))}
</div>
{post.published_at && (
<p className="text-[10px] text-[var(--muted)]">{formatDate(post.published_at)}</p>
)}
</div>
</Link>
+11 -2
View File
@@ -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",
+4 -2
View File
@@ -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<boolean> {
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
+20 -2
View File
@@ -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<typeof setInterval> | 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,
+9
View File
@@ -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<Post, "title" | "title_es">,
): string {
return (post.title_es && post.title_es.trim()) || post.title
}
+74
View File
@@ -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<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
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
}