feat: moderacion lolicon - cuarentena + decision desde Discord (webhook links aprobar/rechazar)

This commit is contained in:
Renato
2026-08-05 03:14:53 +08:00
parent 702c6a5781
commit cebb026564
11 changed files with 378 additions and 16 deletions
+4
View File
@@ -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
+48
View File
@@ -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();
+52
View File
@@ -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(
`<!DOCTYPE html><html><body style="font-family:sans-serif;background:#09090b;color:#22c55e;display:flex;align-items:center;justify-content:center;height:100vh">
<div style="text-align:center"><h1>✅ Aprobado y publicado</h1><p>${escapeHtml(post.title)}</p>
<a href="https://worstscan.xyz/p/${post.slug}" style="color:#6366f1">Ver en la web →</a></div></body></html>`,
{ 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(
`<!DOCTYPE html><html><body style="font-family:sans-serif;background:#09090b;color:#ef4444;display:flex;align-items:center;justify-content:center;height:100vh">
<div style="text-align:center"><h1>🗑️ Rechazado y eliminado</h1><p>El manga fue borrado definitivamente.</p></div></body></html>`,
{ 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
}
+2 -1
View File
@@ -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() {
+10
View File
@@ -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<string, unknown>[]
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[] {
+48
View File
@@ -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<boolean> {
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
}
}
+70
View File
@@ -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 ""
}
+49 -15
View File
@@ -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<typeof setInterval> | 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)
+4
View File
@@ -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
}
+27
View File
@@ -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));
+64
View File
@@ -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(); });