From c16666035cbd80695a1b9cbd5c1859d2cb3c5fbf Mon Sep 17 00:00:00 2001 From: Renato Date: Tue, 4 Aug 2026 20:19:08 +0800 Subject: [PATCH] feat: notificacion automatica en Discord via webhook (nuevos mangas) --- .env.example | 4 +++ src/app/api/webhooks/manga/route.ts | 8 +++++ src/lib/discord.ts | 45 +++++++++++++++++++++++++++++ src/lib/poller.ts | 7 ++++- 4 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/lib/discord.ts diff --git a/.env.example b/.env.example index 4125a0f..6145865 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,10 @@ WEB_PASSWORD= # Webhook validation WEBHOOK_SECRET= +# Discord notification (opcional: avisa en canal cuando llega un manga nuevo) +# Crear en Discord: canal -> Configuración -> Integraciones -> Webhooks +DISCORD_WEBHOOK_URL= + # DB path DB_PATH=/app/data/worst-scan.db diff --git a/src/app/api/webhooks/manga/route.ts b/src/app/api/webhooks/manga/route.ts index 0cafcee..9feb4b2 100644 --- a/src/app/api/webhooks/manga/route.ts +++ b/src/app/api/webhooks/manga/route.ts @@ -3,6 +3,7 @@ import crypto from "crypto" import { createPost, getPostByGid, updatePost } from "@/lib/db" import { cleanTitle, slugify } from "@/lib/slug" import { cacheCover } from "@/lib/cover-cache" +import { notifyNewPost } from "@/lib/discord" function verifySignature(bodyText: string, signature: string, secret: string): boolean { try { @@ -44,6 +45,7 @@ export async function POST(req: NextRequest) { const slug = slugify(title, gid) let post = getPostByGid(gid) + let isNew = false if (!post) { post = createPost({ gid, @@ -60,6 +62,7 @@ export async function POST(req: NextRequest) { slug, published: 1, }) + isNew = true } else { post = updatePost(post.id, { title, @@ -77,6 +80,11 @@ export async function POST(req: NextRequest) { await cacheCover(gid) + // Notificar en Discord si es un manga nuevo. + if (isNew && post) { + await notifyNewPost(post).catch(() => {}) + } + return Response.json({ success: true, gid, diff --git a/src/lib/discord.ts b/src/lib/discord.ts new file mode 100644 index 0000000..4cee5c6 --- /dev/null +++ b/src/lib/discord.ts @@ -0,0 +1,45 @@ +import type { Post } from "./db" + +// Notificación a Discord vía Webhook. +// Si DISCORD_WEBHOOK_URL no está configurada, no hace nada (silencioso). +const WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL || "" + +export async function notifyNewPost(post: Post): Promise { + if (!WEBHOOK_URL) return false + + const description = post.summary && !post.summary.startsWith("**") && post.summary.length > 40 + ? post.summary.slice(0, 200) + : `${post.num_pages} páginas${post.artist ? ` por ${post.artist}` : ""}` + + const payload = { + content: `📚 **Nuevo manga disponible:** ${post.title}`, + embeds: [ + { + title: post.title, + url: `https://worstscan.xyz/p/${post.slug}`, + description, + color: 0x6366f1, // indigo, acorde al tema + thumbnail: { url: `https://worstscan.xyz/api/cover/${post.gid}` }, + footer: { text: "worst-scan fansub" }, + timestamp: new Date().toISOString(), + fields: [ + ...(post.artist ? [{ name: "Artista", value: post.artist, inline: true }] : []), + ...(post.parody ? [{ name: "Franquicia", value: post.parody, inline: true }] : []), + ...(post.num_pages > 0 ? [{ name: "Páginas", value: String(post.num_pages), inline: true }] : []), + ], + }, + ], + } + + try { + const res = await fetch(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 + } +} diff --git a/src/lib/poller.ts b/src/lib/poller.ts index e707456..f6a49f4 100644 --- a/src/lib/poller.ts +++ b/src/lib/poller.ts @@ -3,6 +3,7 @@ import { api } from "./api" import { cleanTitle, slugify } from "./slug" import { createPost, getPostByGid, updatePost } from "./db" import { cacheCover } from "./cover-cache" +import { notifyNewPost } from "./discord" let intervalId: ReturnType | null = null const POLL_INTERVAL_MS = 60_000 @@ -74,6 +75,11 @@ export async function pollOnce(): Promise<{ newPosts: number }> { published: 1, }) newPosts++ + // Notificar en Discord cuando aparece un manga nuevo. + const created = getPostByGid(gid) + if (created) { + await notifyNewPost(created).catch(() => {}) + } } else { updatePost(existing.id, { title: cleanT, @@ -88,7 +94,6 @@ export async function pollOnce(): Promise<{ newPosts: number }> { }) } - const port = process.env.PORT || "3000" await cacheCover(gid) } catch { continue