feat: traduccion de titulos al espanol (title_es) + card limpia (fecha en vez de tags/min) + poller traduce nuevos
This commit is contained in:
@@ -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); });
|
||||||
@@ -4,6 +4,7 @@ import type { Metadata } from "next"
|
|||||||
import { getPostBySlug, getAllPosts, getAuthorSlugByName } from "@/lib/db"
|
import { getPostBySlug, getAllPosts, getAuthorSlugByName } from "@/lib/db"
|
||||||
import { PostCard } from "@/components/post-card"
|
import { PostCard } from "@/components/post-card"
|
||||||
import { TagBadge } from "@/components/tag-badge"
|
import { TagBadge } from "@/components/tag-badge"
|
||||||
|
import { displayTitle } from "@/lib/title"
|
||||||
import { ArrowLeft, BookOpen, ExternalLink } from "lucide-react"
|
import { ArrowLeft, BookOpen, ExternalLink } from "lucide-react"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
@@ -19,7 +20,7 @@ export async function generateMetadata({
|
|||||||
const post = getPostBySlug(slug)
|
const post = getPostBySlug(slug)
|
||||||
if (!post) return { title: "No encontrado" }
|
if (!post) return { title: "No encontrado" }
|
||||||
|
|
||||||
const title = post.title
|
const title = displayTitle(post)
|
||||||
const description =
|
const description =
|
||||||
post.summary && !post.summary.startsWith("**") && post.summary.length > 40
|
post.summary && !post.summary.startsWith("**") && post.summary.length > 40
|
||||||
? post.summary.slice(0, 160)
|
? post.summary.slice(0, 160)
|
||||||
@@ -81,6 +82,9 @@ export default async function PostDetailPage({
|
|||||||
// Enlaces cruzados: autor -> página de autor, parodia -> franquicia.
|
// Enlaces cruzados: autor -> página de autor, parodia -> franquicia.
|
||||||
const authorSlug = post.artist ? getAuthorSlugByName(post.artist) : null
|
const authorSlug = post.artist ? getAuthorSlugByName(post.artist) : null
|
||||||
|
|
||||||
|
// Título a mostrar (traducción al español si existe).
|
||||||
|
const title = displayTitle(post)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-16 md:pb-0">
|
<div className="pb-16 md:pb-0">
|
||||||
<Link
|
<Link
|
||||||
@@ -113,7 +117,7 @@ export default async function PostDetailPage({
|
|||||||
<span className="text-xs text-[var(--muted)]">{post.num_pages} páginas</span>
|
<span className="text-xs text-[var(--muted)]">{post.num_pages} páginas</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{post.title_jpn && (
|
||||||
<p className="mt-1 text-xs sm:text-sm text-[var(--muted)]">{post.title_jpn}</p>
|
<p className="mt-1 text-xs sm:text-sm text-[var(--muted)]">{post.title_jpn}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getPostsPage, getAllPosts } from "@/lib/db"
|
|||||||
import { InfinitePostGrid } from "@/components/infinite-grid"
|
import { InfinitePostGrid } from "@/components/infinite-grid"
|
||||||
import { TagBadge } from "@/components/tag-badge"
|
import { TagBadge } from "@/components/tag-badge"
|
||||||
import { EmptyState } from "@/components/empty-state"
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { displayTitle } from "@/lib/title"
|
||||||
import { BookOpen, Sparkles, ArrowRight } from "lucide-react"
|
import { BookOpen, Sparkles, ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
@@ -60,7 +61,7 @@ export default async function PublicHomePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl sm:text-4xl font-bold tracking-tight leading-tight line-clamp-3">
|
<h1 className="text-2xl sm:text-4xl font-bold tracking-tight leading-tight line-clamp-3">
|
||||||
{latest.title}
|
{displayTitle(latest)}
|
||||||
</h1>
|
</h1>
|
||||||
{latest.artist && (
|
{latest.artist && (
|
||||||
<p className="text-sm text-[var(--muted)]">por {latest.artist}</p>
|
<p className="text-sm text-[var(--muted)]">por {latest.artist}</p>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getAllPosts } from "@/lib/db"
|
import { getAllPosts } from "@/lib/db"
|
||||||
|
import { displayTitle } from "@/lib/title"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -15,8 +16,9 @@ export async function GET() {
|
|||||||
(p.summary && !p.summary.startsWith("**") && p.summary.length > 30
|
(p.summary && !p.summary.startsWith("**") && p.summary.length > 30
|
||||||
? p.summary.slice(0, 300)
|
? p.summary.slice(0, 300)
|
||||||
: `${p.num_pages} páginas`) || `${p.num_pages} páginas`
|
: `${p.num_pages} páginas`) || `${p.num_pages} páginas`
|
||||||
|
const title = displayTitle(p)
|
||||||
return ` <item>
|
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>
|
<link>https://worstscan.xyz/p/${p.slug}</link>
|
||||||
<guid isPermaLink="false">worstscan-${p.gid}</guid>
|
<guid isPermaLink="false">worstscan-${p.gid}</guid>
|
||||||
<pubDate>${pubDate}</pubDate>
|
<pubDate>${pubDate}</pubDate>
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import type { Post } from "@/lib/db"
|
import type { Post } from "@/lib/db"
|
||||||
import { formatDate } from "@/lib/utils"
|
import { formatDate } from "@/lib/utils"
|
||||||
import { TagBadge } from "@/components/tag-badge"
|
|
||||||
import { BookOpen, Clock } from "lucide-react"
|
import { BookOpen, Clock } from "lucide-react"
|
||||||
|
import { displayTitle } from "@/lib/title"
|
||||||
|
|
||||||
// Card estilo "pin" (Pinterest/manga sites): portada dominante, hover con zoom
|
// Card limpia: portada dominante, título (traducido al español si existe),
|
||||||
// y overlay de lectura, metadata compacta. Image-first para retención visual.
|
// 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 }) {
|
export function PostCard({ post, compact = false }: { post: Post; compact?: boolean }) {
|
||||||
const isAnthology =
|
const isAnthology =
|
||||||
post.tags?.some((t) =>
|
post.tags?.some((t) =>
|
||||||
["anthology", "compilation", "tankoubon"].some((k) => t.toLowerCase().includes(k))
|
["anthology", "compilation", "tankoubon"].some((k) => t.toLowerCase().includes(k))
|
||||||
) || post.num_pages >= 100
|
) || post.num_pages >= 100
|
||||||
|
|
||||||
// Duraciones de lectura aproximadas para dar contexto (hook de curiosidad).
|
const title = displayTitle(post)
|
||||||
const readMinutes = post.num_pages > 0 ? Math.max(1, Math.round(post.num_pages / 30)) : null
|
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
@@ -23,24 +22,18 @@ export function PostCard({ post, compact = false }: { post: Post; compact?: bool
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`/api/cover/${post.gid}`}
|
src={`/api/cover/${post.gid}`}
|
||||||
alt={post.title}
|
alt={title}
|
||||||
className="h-32 w-24 flex-shrink-0 object-cover"
|
className="h-32 w-24 flex-shrink-0 object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 flex-col gap-1.5 py-3 pr-4">
|
<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">
|
<h3 className="line-clamp-2 text-sm font-semibold leading-snug group-hover:text-[var(--accent)] transition-colors">
|
||||||
{post.title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
{post.artist && (
|
|
||||||
<p className="text-xs text-[var(--muted)]">{post.artist}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-1 items-end gap-2">
|
<div className="flex flex-1 items-end gap-2">
|
||||||
{isAnthology && (
|
{isAnthology && (
|
||||||
<span className="text-[9px] font-semibold text-[var(--accent)] uppercase">Tomo</span>
|
<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 && (
|
{post.published_at && (
|
||||||
<span className="text-[10px] text-[var(--muted)]">{formatDate(post.published_at)}</span>
|
<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)]">
|
<div className="relative aspect-[3/4] overflow-hidden bg-[var(--surface)]">
|
||||||
<img
|
<img
|
||||||
src={`/api/cover/${post.gid}`}
|
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]"
|
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.06]"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
@@ -87,23 +80,13 @@ export function PostCard({ post, compact = false }: { post: Post; compact?: bool
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metadatos */}
|
{/* Metadatos: solo título (traducido) + fecha */}
|
||||||
<div className="flex flex-col gap-1.5 p-3">
|
<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">
|
<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>
|
</h3>
|
||||||
<div className="flex items-center justify-between text-[10px] text-[var(--muted)]">
|
{post.published_at && (
|
||||||
<span className="truncate">{post.artist || post.parody || post.source || "worst-scan"}</span>
|
<p className="text-[10px] text-[var(--muted)]">{formatDate(post.published_at)}</p>
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
+11
-2
@@ -112,12 +112,18 @@ function migrate(db: Database.Database) {
|
|||||||
db.exec("ALTER TABLE posts ADD COLUMN series_id INTEGER REFERENCES series(id)")
|
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)")
|
db.exec("CREATE INDEX IF NOT EXISTS idx_posts_series ON posts(series_id)")
|
||||||
} catch {}
|
} 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 {
|
export interface Post {
|
||||||
id: number
|
id: number
|
||||||
gid: string
|
gid: string
|
||||||
title: string
|
title: string
|
||||||
|
title_es: string | null
|
||||||
title_jpn: string | null
|
title_jpn: string | null
|
||||||
artist: string | null
|
artist: string | null
|
||||||
parody: string | null
|
parody: string | null
|
||||||
@@ -137,6 +143,7 @@ export interface Post {
|
|||||||
export interface PostInput {
|
export interface PostInput {
|
||||||
gid: string
|
gid: string
|
||||||
title: string
|
title: string
|
||||||
|
title_es?: string
|
||||||
title_jpn?: string
|
title_jpn?: string
|
||||||
artist?: string
|
artist?: string
|
||||||
parody?: string
|
parody?: string
|
||||||
@@ -200,12 +207,13 @@ export function createPost(input: PostInput): Post {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stmt = d.prepare(`
|
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)
|
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_jpn, @artist, @parody, @tags, @num_pages, @source, @cover_url, @url, @summary, @slug, @published, ${publishedAt})
|
VALUES (@gid, @title, @title_es, @title_jpn, @artist, @parody, @tags, @num_pages, @source, @cover_url, @url, @summary, @slug, @published, ${publishedAt})
|
||||||
`)
|
`)
|
||||||
const result = stmt.run({
|
const result = stmt.run({
|
||||||
gid: input.gid,
|
gid: input.gid,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
|
title_es: input.title_es || null,
|
||||||
title_jpn: input.title_jpn || null,
|
title_jpn: input.title_jpn || null,
|
||||||
artist: input.artist || null,
|
artist: input.artist || null,
|
||||||
parody: input.parody || 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.
|
// por nombres de columna y el bypass de `published`/columnas internas.
|
||||||
const UPDATE_FIELDS = new Set([
|
const UPDATE_FIELDS = new Set([
|
||||||
"title",
|
"title",
|
||||||
|
"title_es",
|
||||||
"title_jpn",
|
"title_jpn",
|
||||||
"artist",
|
"artist",
|
||||||
"parody",
|
"parody",
|
||||||
|
|||||||
+4
-2
@@ -1,4 +1,5 @@
|
|||||||
import type { Post } from "./db"
|
import type { Post } from "./db"
|
||||||
|
import { displayTitle } from "./title"
|
||||||
|
|
||||||
// Notificación a Discord vía Webhook.
|
// Notificación a Discord vía Webhook.
|
||||||
// Si DISCORD_WEBHOOK_URL no está configurada, no hace nada (silencioso).
|
// 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> {
|
export async function notifyNewPost(post: Post): Promise<boolean> {
|
||||||
if (!WEBHOOK_URL) return false
|
if (!WEBHOOK_URL) return false
|
||||||
|
|
||||||
|
const title = displayTitle(post)
|
||||||
const description = post.summary && !post.summary.startsWith("**") && post.summary.length > 40
|
const description = post.summary && !post.summary.startsWith("**") && post.summary.length > 40
|
||||||
? post.summary.slice(0, 200)
|
? post.summary.slice(0, 200)
|
||||||
: `${post.num_pages} páginas${post.artist ? ` por ${post.artist}` : ""}`
|
: `${post.num_pages} páginas${post.artist ? ` por ${post.artist}` : ""}`
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
content: `📚 **Nuevo manga disponible:** ${post.title}`,
|
content: `📚 **Nuevo manga disponible:** ${title}`,
|
||||||
embeds: [
|
embeds: [
|
||||||
{
|
{
|
||||||
title: post.title,
|
title,
|
||||||
url: `https://worstscan.xyz/p/${post.slug}`,
|
url: `https://worstscan.xyz/p/${post.slug}`,
|
||||||
description,
|
description,
|
||||||
color: 0x6366f1, // indigo, acorde al tema
|
color: 0x6366f1, // indigo, acorde al tema
|
||||||
|
|||||||
+20
-2
@@ -6,6 +6,7 @@ import { cacheCover } from "./cover-cache"
|
|||||||
import { notifyNewPost } from "./discord"
|
import { notifyNewPost } from "./discord"
|
||||||
import { moderateTags, moderationReasonText } from "./moderation"
|
import { moderateTags, moderationReasonText } from "./moderation"
|
||||||
import { notifyQuarantine } from "./discord-moderation"
|
import { notifyQuarantine } from "./discord-moderation"
|
||||||
|
import { translateTitle } from "./translate"
|
||||||
|
|
||||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||||
const POLL_INTERVAL_MS = 60_000
|
const POLL_INTERVAL_MS = 60_000
|
||||||
@@ -67,9 +68,17 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
// Los posts moderados se crean en cuarentena (published=0)
|
// Los posts moderados se crean en cuarentena (published=0)
|
||||||
const isPublished = moderated ? 0 : 1
|
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({
|
await createPost({
|
||||||
gid,
|
gid,
|
||||||
title: cleanT,
|
title: cleanT,
|
||||||
|
title_es: titleEs || undefined,
|
||||||
title_jpn: summary.title_jpn,
|
title_jpn: summary.title_jpn,
|
||||||
artist: summary.artist,
|
artist: summary.artist,
|
||||||
parody: summary.parody,
|
parody: summary.parody,
|
||||||
@@ -96,11 +105,19 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
|
|||||||
await notifyNewPost(created).catch(() => {})
|
await notifyNewPost(created).catch(() => {})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Post existente: si se actualizó y ahora tiene tags dudosos,
|
// Post existente: si no tiene traducción, intentar traducirlo ahora.
|
||||||
// pasarlo a cuarentena (despublicar).
|
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) {
|
if (moderated) {
|
||||||
updatePost(existing.id, {
|
updatePost(existing.id, {
|
||||||
title: cleanT,
|
title: cleanT,
|
||||||
|
title_es: titleEs || undefined,
|
||||||
title_jpn: summary.title_jpn || undefined,
|
title_jpn: summary.title_jpn || undefined,
|
||||||
artist: summary.artist || undefined,
|
artist: summary.artist || undefined,
|
||||||
parody: summary.parody || undefined,
|
parody: summary.parody || undefined,
|
||||||
@@ -116,6 +133,7 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
|
|||||||
} else {
|
} else {
|
||||||
updatePost(existing.id, {
|
updatePost(existing.id, {
|
||||||
title: cleanT,
|
title: cleanT,
|
||||||
|
title_es: titleEs || undefined,
|
||||||
title_jpn: summary.title_jpn || undefined,
|
title_jpn: summary.title_jpn || undefined,
|
||||||
artist: summary.artist || undefined,
|
artist: summary.artist || undefined,
|
||||||
parody: summary.parody || undefined,
|
parody: summary.parody || undefined,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user