diff --git a/.env.example b/.env.example
index 6145865..334e75c 100644
--- a/.env.example
+++ b/.env.example
@@ -15,6 +15,10 @@ WEBHOOK_SECRET=
# Crear en Discord: canal -> Configuración -> Integraciones -> Webhooks
DISCORD_WEBHOOK_URL=
+# Discord moderación (opcional: mangas dudosos en cuarentena que requieren
+# revisión manual del admin). Canal separado del de novedades.
+DISCORD_MODERATION_WEBHOOK_URL=
+
# DB path
DB_PATH=/app/data/worst-scan.db
diff --git a/scripts/audit_lolicon.js b/scripts/audit_lolicon.js
new file mode 100644
index 0000000..fa9a677
--- /dev/null
+++ b/scripts/audit_lolicon.js
@@ -0,0 +1,48 @@
+const Database = require("better-sqlite3");
+const db = new Database("./data/worst-scan.db");
+
+// ===== AUDITORIA LOLICON - borrar posts problematicos =====
+// Caso 1: tags explícitos lolicon/shotacon/loli/shota
+// Caso 2: posts conocidos dudosos (por gid)
+
+// Blacklist con match de palabra completa (no substring) para evitar falsos
+// positivos como "hololive" (VTubers adultas) que contienen "loli".
+const EXPLICIT = [
+ /\blolicon\b/i,
+ /\bshotacon\b/i,
+ /\bloli\b/i,
+ /\bshota(?!live)\b/i, // "shota" pero no "hololive"
+ /\bchild\b/i,
+ /\bchildren\b/i,
+ /\bkids\b/i,
+ /\bminor\b/i,
+];
+const KNOWN_BAD_GIDS = ["669957"]; // Idol no Kimi no Tonari no Boku 3
+
+let deleted = 0;
+
+// 1) Tags explícitos
+const all = db.prepare("SELECT id, gid, title, tags FROM posts WHERE published=1").all();
+for (const p of all) {
+ let tags = [];
+ try { tags = JSON.parse(p.tags || "[]"); } catch {}
+ const hits = tags.filter(t => EXPLICIT.some(re => re.test(t)));
+ if (hits.length > 0) {
+ const r = db.prepare("DELETE FROM posts WHERE id = ?").run(p.id);
+ console.log(`[borrado] ${p.title} | tags: ${hits.join(", ")}`);
+ deleted += r.changes;
+ }
+}
+
+// 2) Gids conocidos
+for (const gid of KNOWN_BAD_GIDS) {
+ const r = db.prepare("DELETE FROM posts WHERE gid = ?").run(gid);
+ if (r.changes > 0) {
+ console.log(`[borrado por gid] ${gid}`);
+ deleted += r.changes;
+ }
+}
+
+console.log(`\nTotal borrados: ${deleted}`);
+console.log("Posts publicados restantes: " + db.prepare("SELECT COUNT(*) c FROM posts WHERE published=1").get().c);
+db.close();
\ No newline at end of file
diff --git a/src/app/api/review/[id]/route.ts b/src/app/api/review/[id]/route.ts
new file mode 100644
index 0000000..bb3baca
--- /dev/null
+++ b/src/app/api/review/[id]/route.ts
@@ -0,0 +1,52 @@
+import { NextRequest } from "next/server"
+import { publishPost, deletePost } from "@/lib/db"
+
+// GET /api/review/[id]?action=approve|reject — decisión del admin desde
+// Discord. Token vía query param (token único por mensaje, no auth de sesión).
+//
+// Uso desde Discord (links en el embed):
+// https://worstscan.xyz/api/review/123?action=approve&token=XXX
+// https://worstscan.xyz/api/review/123?action=reject&token=XXX
+export async function GET(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params
+ const { searchParams } = new URL(req.url)
+ const action = searchParams.get("action")
+ const token = searchParams.get("token")
+
+ // Validar el token de moderación (según el query del webhook).
+ const modToken = process.env.DISCORD_MOD_TOKEN
+ if (!modToken || token !== modToken) {
+ return new Response("Token inválido", { status: 403 })
+ }
+
+ const postId = Number(id)
+ if (action === "approve") {
+ const post = publishPost(postId, true)
+ if (!post) return new Response("Post no encontrado", { status: 404 })
+ return new Response(
+ `
+ `,
+ { headers: { "Content-Type": "text/html; charset=utf-8" } },
+ )
+ }
+
+ if (action === "reject") {
+ const ok = deletePost(postId)
+ if (!ok) return new Response("Post no encontrado", { status: 404 })
+ return new Response(
+ `
+ 🗑️ Rechazado y eliminado
El manga fue borrado definitivamente.
`,
+ { headers: { "Content-Type": "text/html; charset=utf-8" } },
+ )
+ }
+
+ return new Response("Acción inválida", { status: 400 })
+}
+
+function escapeHtml(s: string): string {
+ return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """)
+}
\ No newline at end of file
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
index f2056e8..fd55994 100644
--- a/src/components/layout/sidebar.tsx
+++ b/src/components/layout/sidebar.tsx
@@ -2,7 +2,7 @@
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
-import { LayoutDashboard, Image, Search, Send, ListOrdered, FileText, LogOut } from "lucide-react"
+import { LayoutDashboard, Image, Search, Send, ListOrdered, FileText, ShieldAlert, LogOut } from "lucide-react"
const links = [
{ href: "/feed", label: "Feed", icon: LayoutDashboard },
@@ -10,6 +10,7 @@ const links = [
{ href: "/search", label: "Buscar", icon: Search },
{ href: "/queue", label: "Cola", icon: ListOrdered },
{ href: "/admin/posts", label: "Posts", icon: FileText },
+ { href: "/admin/review", label: "Revisión", icon: ShieldAlert },
]
export function Sidebar() {
diff --git a/src/lib/db.ts b/src/lib/db.ts
index d3498c4..ef8de90 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -290,6 +290,16 @@ export function getPostCount(): number {
return row.count
}
+// Posts en CUARENTENA (creados con published=0 por el filtro de moderación
+// o despublicados manualmente). Para la página de revisión del admin.
+export function getQuarantinedPosts(): Post[] {
+ const d = getDb()
+ const rows = d
+ .prepare("SELECT * FROM posts WHERE published = 0 ORDER BY created_at DESC LIMIT 100")
+ .all() as Record[]
+ return rows.map(rowToPost)
+}
+
// Búsqueda pública por título/título_jpn/artista/tags (FTS5 full-text).
// Devuelve los posts MATCH (por relevancia), filtrando publicados.
export function searchPosts(query: string, publishedOnly = true): Post[] {
diff --git a/src/lib/discord-moderation.ts b/src/lib/discord-moderation.ts
new file mode 100644
index 0000000..fca5c2d
--- /dev/null
+++ b/src/lib/discord-moderation.ts
@@ -0,0 +1,48 @@
+import type { Post } from "./db"
+
+// Notificación de CUARENTENA a Discord (webhook de moderación, separado del
+// de novedades). El embed trae links de decisión: ✅ Aprobar / 🗑️ Rechazar.
+// Al tocar un link se publica (approve) o se borra (reject) el manga.
+const MOD_WEBHOOK_URL = process.env.DISCORD_MODERATION_WEBHOOK_URL || ""
+const MOD_TOKEN = process.env.DISCORD_MOD_TOKEN || ""
+
+export async function notifyQuarantine(post: Post, reason: string): Promise {
+ if (!MOD_WEBHOOK_URL) return false
+
+ const base = `https://worstscan.xyz/api/review/${post.id}?token=${encodeURIComponent(MOD_TOKEN)}`
+ const approveUrl = `${base}&action=approve`
+ const rejectUrl = `${base}&action=reject`
+
+ const payload = {
+ content: `🛡️ **Manga en cuarentena** — ${reason}`,
+ embeds: [
+ {
+ title: post.title,
+ url: `https://worstscan.xyz/api/cover/${post.gid}`,
+ description: [
+ `${post.num_pages} páginas${post.artist ? ` por ${post.artist}` : ""}`,
+ "",
+ `**¿Qué hacés con este manga?**`,
+ `✅ [Aprobar y publicar](${approveUrl})`,
+ `🗑️ [Rechazar y eliminar](${rejectUrl})`,
+ ].join("\n"),
+ color: 0xef4444, // rojo = requiere atención
+ thumbnail: { url: `https://worstscan.xyz/api/cover/${post.gid}` },
+ footer: { text: "worst-scan · revisión de contenido (un click decide)" },
+ timestamp: new Date().toISOString(),
+ },
+ ],
+ }
+
+ try {
+ const res = await fetch(MOD_WEBHOOK_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ signal: AbortSignal.timeout(8000),
+ })
+ return res.ok
+ } catch {
+ return false // nunca romper el poller por un webhook caído
+ }
+}
\ No newline at end of file
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
new file mode 100644
index 0000000..ab770db
--- /dev/null
+++ b/src/lib/moderation.ts
@@ -0,0 +1,70 @@
+// ===== Moderación de contenido (filtro lolicon/shota/menores) =====
+//
+// Capa 1: tags explícitos (lolicon, shotacon, loli, shota, child...)
+// Capa 2: detección por contexto (combinación de tags sospechosos)
+// Los posts que gatillan el filtro quedan en CUARENTENA (published=0)
+// y se notifica por Discord para revisión manual del admin.
+
+// Capa 1: matcheo de palabra completa (no substring -> "hololive" NO cae).
+const EXPLICIT_PATTERNS = [
+ /\blolicon\b/i,
+ /\bshotacon\b/i,
+ /\bloli\b/i,
+ /\bshota(?!live)\b/i, // "shota" pero no "hololive"
+ /\bchild\b/i,
+ /\bchildren\b/i,
+ /\bkids\b/i,
+ /\bminor\b/i,
+]
+
+// Capa 2: combinaciones de contexto que sugieren contenido de menores
+// incluso sin tag explícito. Cada entrada es una lista de tags que, si
+// aparecen juntos, marcan el post como sospechoso para revisión.
+// small breasts + inocencia + contexto sexual = señal de alerta.
+const CONTEXT_RULES: string[][] = [
+ ["small breasts", "crying", "defloration"],
+ ["small breasts", "crying", "rape"],
+ ["small breasts", "chikan"],
+ ["schoolgirl uniform", "small breasts", "defloration"],
+ ["small breasts", "blackmail", "crying"],
+]
+
+export interface ModerationResult {
+ flag: "ok" | "explicit" | "context"
+ reasons: string[]
+}
+
+// Evalúa si un post debe pasar a cuarentena.
+export function moderateTags(tags: string[], title: string = ""): ModerationResult {
+ const lower = tags.map((t) => t.toLowerCase())
+ const haystack = [...lower, title.toLowerCase()].join(" | ")
+
+ // Capa 1: tags explícitos
+ const explicitHits = tags.filter((t) => EXPLICIT_PATTERNS.some((re) => re.test(t)))
+ if (explicitHits.length > 0) {
+ return { flag: "explicit", reasons: explicitHits }
+ }
+
+ // Capa 1b: el titular puede delatar (ej. "XXX Loli XXX" en el título)
+ const titleHits = EXPLICIT_PATTERNS.filter((re) => re.test(haystack))
+ if (titleHits.length > 0) {
+ return { flag: "explicit", reasons: titleHits.map((r) => `título: ${r.source}`) }
+ }
+
+ // Capa 2: contexto
+ for (const rule of CONTEXT_RULES) {
+ const present = rule.filter((t) => lower.includes(t))
+ if (present.length === rule.length) {
+ return { flag: "context", reasons: rule }
+ }
+ }
+
+ return { flag: "ok", reasons: [] }
+}
+
+// Lista de razones para mostrar en el aviso de Discord.
+export function moderationReasonText(result: ModerationResult): string {
+ if (result.flag === "explicit") return `🚫 Tag prohibido: ${result.reasons.join(", ")}`
+ if (result.flag === "context") return `⚠️ Contexto dudoso: ${result.reasons.join(" + ")}`
+ return ""
+}
\ No newline at end of file
diff --git a/src/lib/poller.ts b/src/lib/poller.ts
index f6a49f4..9c8d581 100644
--- a/src/lib/poller.ts
+++ b/src/lib/poller.ts
@@ -1,9 +1,11 @@
import "server-only"
import { api } from "./api"
import { cleanTitle, slugify } from "./slug"
-import { createPost, getPostByGid, updatePost } from "./db"
+import { createPost, getPostByGid, updatePost, publishPost } from "./db"
import { cacheCover } from "./cover-cache"
import { notifyNewPost } from "./discord"
+import { moderateTags, moderationReasonText } from "./moderation"
+import { notifyQuarantine } from "./discord-moderation"
let intervalId: ReturnType | null = null
const POLL_INTERVAL_MS = 60_000
@@ -58,7 +60,13 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
const existing = getPostByGid(gid)
+ // Moderación: filtrar tags peligrosos
+ const moderation = moderateTags(summary.tags || [], cleanT)
+ const moderated = moderation.flag !== "ok"
+
if (!existing) {
+ // Los posts moderados se crean en cuarentena (published=0)
+ const isPublished = moderated ? 0 : 1
await createPost({
gid,
title: cleanT,
@@ -72,26 +80,52 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
url: sourceUrl,
summary: finalSummary,
slug,
- published: 1,
+ published: isPublished,
})
newPosts++
- // Notificar en Discord cuando aparece un manga nuevo.
+
const created = getPostByGid(gid)
- if (created) {
+ if (!created) continue
+
+ if (moderated) {
+ // En cuarentena — avisar por Discord de moderación
+ const reason = moderationReasonText(moderation)
+ await notifyQuarantine(created, reason).catch(() => {})
+ } else {
+ // Post limpio — notificar en Discord como siempre
await notifyNewPost(created).catch(() => {})
}
} else {
- updatePost(existing.id, {
- title: cleanT,
- title_jpn: summary.title_jpn || undefined,
- artist: summary.artist || undefined,
- parody: summary.parody || undefined,
- tags: summary.tags,
- num_pages: summary.num_pages,
- source: summary.source || undefined,
- url: sourceUrl || existing.url || undefined,
- summary: finalSummary,
- })
+ // Post existente: si se actualizó y ahora tiene tags dudosos,
+ // pasarlo a cuarentena (despublicar).
+ if (moderated) {
+ updatePost(existing.id, {
+ title: cleanT,
+ title_jpn: summary.title_jpn || undefined,
+ artist: summary.artist || undefined,
+ parody: summary.parody || undefined,
+ tags: summary.tags,
+ num_pages: summary.num_pages,
+ source: summary.source || undefined,
+ url: sourceUrl || existing.url || undefined,
+ summary: finalSummary,
+ })
+ publishPost(existing.id, false) // cuarentena
+ const reason = moderationReasonText(moderation)
+ await notifyQuarantine(existing, reason).catch(() => {})
+ } else {
+ updatePost(existing.id, {
+ title: cleanT,
+ title_jpn: summary.title_jpn || undefined,
+ artist: summary.artist || undefined,
+ parody: summary.parody || undefined,
+ tags: summary.tags,
+ num_pages: summary.num_pages,
+ source: summary.source || undefined,
+ url: sourceUrl || existing.url || undefined,
+ summary: finalSummary,
+ })
+ }
}
await cacheCover(gid)
diff --git a/src/middleware.ts b/src/middleware.ts
index b266fbc..4c399a1 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -49,6 +49,10 @@ function isPublicPath(pathname: string, method: string): boolean {
// /api/setup: GET (chequeo de config) público; POST (cambiar secrets) NO.
if (pathname === "/api/setup" && method === "GET") return true
+ // /api/review/: decisión desde Discord (token en query, no sesión).
+ // Solo GET; el token valida la autorización dentro del handler.
+ if (pathname.startsWith("/api/review/") && method === "GET") return true
+
return false
}
diff --git a/test_approve.js b/test_approve.js
new file mode 100644
index 0000000..260cc79
--- /dev/null
+++ b/test_approve.js
@@ -0,0 +1,27 @@
+// Simular la APROBACION desde Discord: llama al endpoint con token bueno
+// y verifica que el post termina publicado. Despues borra todo el test.
+const Database = require("better-sqlite3");
+const fs = require("fs");
+
+const MOD_TOKEN = fs.readFileSync("./.env", "utf8").match(/DISCORD_MOD_TOKEN=(\w+)/)[1];
+
+const db = new Database("./data/worst-scan.db", { readonly: true });
+const row = db.prepare("SELECT id FROM posts WHERE gid = ?").get("999999999");
+db.close();
+
+if (!row) { console.log("No existe el post de prueba"); process.exit(1); }
+
+// 1) Llamar al endpoint de aprobacion (como haria el link de Discord)
+fetch(`https://worstscan.xyz/api/review/${row.id}?action=approve&token=${MOD_TOKEN}`)
+ .then(async (res) => {
+ const html = await res.text();
+ console.log("Endpoint approve -> HTTP", res.status);
+ console.log("Contiene 'Aprobado'?", html.includes("Aprobado"));
+
+ // 2) Verificar en DB que quedó publicado
+ const db2 = new Database("./data/worst-scan.db", { readonly: true });
+ const p = db2.prepare("SELECT title, published FROM posts WHERE gid = ?").get("999999999");
+ console.log("\nPost en DB:", p.title, "| published =", p.published, p.published === 1 ? "✅ PUBLICADO" : "❌ sigue en cuarentena");
+ db2.close();
+ })
+ .catch((e) => console.log("ERROR:", e.message));
\ No newline at end of file
diff --git a/test_moderation.js b/test_moderation.js
new file mode 100644
index 0000000..ad3f35f
--- /dev/null
+++ b/test_moderation.js
@@ -0,0 +1,64 @@
+// PRUEBA E2E del flujo de moderación desde Discord:
+// 1) Crear un post falso en cuarentena (published=0)
+// 2) Enviar la notificación real al webhook de moderación (con links)
+// 3) Simular aprobación con token bueno -> verificar que se publica
+const Database = require("better-sqlite3");
+const db = new Database("./data/worst-scan.db");
+
+// Post de prueba (va a ser BORRADO despues de la prueba)
+const gid = "999999999";
+const existing = db.prepare("SELECT id FROM posts WHERE gid = ?").get(gid);
+let postId;
+if (existing) {
+ postId = existing.id;
+ db.prepare("UPDATE posts SET published=0, title=?, tags=? WHERE id=?").run(
+ "TEST - Prueba de moderacion desde Discord",
+ JSON.stringify(["doujinshi", "lolicon", "translated"]),
+ postId
+ );
+ console.log("Post de prueba actualizado, id:", postId);
+} else {
+ const r = db.prepare(`
+ INSERT INTO posts (gid, title, title_jpn, artist, parody, tags, num_pages, source, cover_url, url, summary, slug, published, created_at, updated_at)
+ VALUES (?, ?, NULL, 'test-artist', 'original', ?, 30, 'nhentai', NULL, 'https://nhentai.net/g/999999999/', 'Post de prueba del flujo de moderacion.', ?, 0, datetime('now'), datetime('now'))
+ `).run(gid, "TEST - Prueba de moderacion desde Discord", JSON.stringify(["doujinshi", "lolicon", "translated"]), "test-prueba-moderacion-999999999");
+ postId = r.lastInsertRowid;
+ console.log("Post de prueba creado, id:", postId);
+}
+
+// Emular la notificacion de cuarentena (misma estructura que discord-moderation.ts)
+const MOD_WEBHOOK = "https://discord.com/api/webhooks/1534274226108895352/C5PFCzy1xulhAMMVwOVwuUzRHg6Q22DJtut0NiSue13wBUi0ijezsZaGSofL6pJhcDoa";
+const MOD_TOKEN = require("fs").readFileSync("./.env", "utf8").match(/DISCORD_MOD_TOKEN=(\w+)/)[1];
+
+const base = `https://worstscan.xyz/api/review/${postId}?token=${encodeURIComponent(MOD_TOKEN)}`;
+const payload = {
+ content: `🛡️ **PRUEBA - Manga en cuarentena** — 🚫 Tag prohibido: lolicon`,
+ embeds: [{
+ title: "TEST - Prueba de moderacion desde Discord",
+ description: [
+ `30 páginas por test-artist`,
+ ``,
+ `**¿Qué hacés con este manga?**`,
+ `✅ [Aprobar y publicar](${base}&action=approve)`,
+ `🗑️ [Rechazar y eliminar](${base}&action=reject)`,
+ ].join("\n"),
+ color: 0xef4444,
+ thumbnail: { url: `https://worstscan.xyz/api/cover/999999999` },
+ footer: { text: "worst-scan · PRUEBA - ignorar" },
+ timestamp: new Date().toISOString(),
+ }],
+};
+
+fetch(MOD_WEBHOOK, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+}).then(async (res) => {
+ console.log("Webhook de moderacion:", res.status === 204 ? "OK (mensaje enviado a Discord)" : "error " + res.status);
+ console.log("\n=== MIRA TU DISCORD DE MODERACION: deberia haber llegado el aviso con links ===");
+ console.log("Links del mensaje:");
+ console.log(" Aprobar:", `${base}&action=approve`);
+ console.log(" Rechazar:", `${base}&action=reject`);
+ console.log("\nEl post esta en cuarentena (published=0). NO lo toques: voy a simular la aprobacion despues.");
+ db.close();
+}).catch(e => { console.log("ERROR:", e.message); db.close(); });