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
|
# Crear en Discord: canal -> Configuración -> Integraciones -> Webhooks
|
||||||
DISCORD_WEBHOOK_URL=
|
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
|
||||||
DB_PATH=/app/data/worst-scan.db
|
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 Link from "next/link"
|
||||||
import { usePathname, useRouter } from "next/navigation"
|
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 = [
|
const links = [
|
||||||
{ href: "/feed", label: "Feed", icon: LayoutDashboard },
|
{ href: "/feed", label: "Feed", icon: LayoutDashboard },
|
||||||
@@ -10,6 +10,7 @@ const links = [
|
|||||||
{ href: "/search", label: "Buscar", icon: Search },
|
{ href: "/search", label: "Buscar", icon: Search },
|
||||||
{ href: "/queue", label: "Cola", icon: ListOrdered },
|
{ href: "/queue", label: "Cola", icon: ListOrdered },
|
||||||
{ href: "/admin/posts", label: "Posts", icon: FileText },
|
{ href: "/admin/posts", label: "Posts", icon: FileText },
|
||||||
|
{ href: "/admin/review", label: "Revisión", icon: ShieldAlert },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
|
|||||||
@@ -290,6 +290,16 @@ export function getPostCount(): number {
|
|||||||
return row.count
|
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).
|
// Búsqueda pública por título/título_jpn/artista/tags (FTS5 full-text).
|
||||||
// Devuelve los posts MATCH (por relevancia), filtrando publicados.
|
// Devuelve los posts MATCH (por relevancia), filtrando publicados.
|
||||||
export function searchPosts(query: string, publishedOnly = true): Post[] {
|
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 "server-only"
|
||||||
import { api } from "./api"
|
import { api } from "./api"
|
||||||
import { cleanTitle, slugify } from "./slug"
|
import { cleanTitle, slugify } from "./slug"
|
||||||
import { createPost, getPostByGid, updatePost } from "./db"
|
import { createPost, getPostByGid, updatePost, publishPost } from "./db"
|
||||||
import { cacheCover } from "./cover-cache"
|
import { cacheCover } from "./cover-cache"
|
||||||
import { notifyNewPost } from "./discord"
|
import { notifyNewPost } from "./discord"
|
||||||
|
import { moderateTags, moderationReasonText } from "./moderation"
|
||||||
|
import { notifyQuarantine } from "./discord-moderation"
|
||||||
|
|
||||||
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
|
||||||
@@ -58,7 +60,13 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
|
|||||||
|
|
||||||
const existing = getPostByGid(gid)
|
const existing = getPostByGid(gid)
|
||||||
|
|
||||||
|
// Moderación: filtrar tags peligrosos
|
||||||
|
const moderation = moderateTags(summary.tags || [], cleanT)
|
||||||
|
const moderated = moderation.flag !== "ok"
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
|
// Los posts moderados se crean en cuarentena (published=0)
|
||||||
|
const isPublished = moderated ? 0 : 1
|
||||||
await createPost({
|
await createPost({
|
||||||
gid,
|
gid,
|
||||||
title: cleanT,
|
title: cleanT,
|
||||||
@@ -72,14 +80,39 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
|
|||||||
url: sourceUrl,
|
url: sourceUrl,
|
||||||
summary: finalSummary,
|
summary: finalSummary,
|
||||||
slug,
|
slug,
|
||||||
published: 1,
|
published: isPublished,
|
||||||
})
|
})
|
||||||
newPosts++
|
newPosts++
|
||||||
// Notificar en Discord cuando aparece un manga nuevo.
|
|
||||||
const created = getPostByGid(gid)
|
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(() => {})
|
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 {
|
} else {
|
||||||
updatePost(existing.id, {
|
updatePost(existing.id, {
|
||||||
title: cleanT,
|
title: cleanT,
|
||||||
@@ -93,6 +126,7 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
|
|||||||
summary: finalSummary,
|
summary: finalSummary,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await cacheCover(gid)
|
await cacheCover(gid)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ function isPublicPath(pathname: string, method: string): boolean {
|
|||||||
// /api/setup: GET (chequeo de config) público; POST (cambiar secrets) NO.
|
// /api/setup: GET (chequeo de config) público; POST (cambiar secrets) NO.
|
||||||
if (pathname === "/api/setup" && method === "GET") return true
|
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
|
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