feat: moderacion lolicon - cuarentena + decision desde Discord (webhook links aprobar/rechazar)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
+38
-4
@@ -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,14 +80,39 @@ 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 {
|
||||
// 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,
|
||||
@@ -93,6 +126,7 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
|
||||
summary: finalSummary,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await cacheCover(gid)
|
||||
} catch {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
@@ -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(); });
|
||||
Reference in New Issue
Block a user