| | |
| |
|
| | require("dotenv").config(); |
| | const express = require("express"); |
| | const cors = require("cors"); |
| | const cron = require("node-cron"); |
| | const { getDB } = require("./firebase"); |
| | const { |
| | scrapeAnimeList, |
| | scrapeAnimeDetails, |
| | scrapeIncrementalUpdates, |
| | scrapeSearch, |
| | parseAnimeDetail, |
| | parseEpisodePage, |
| | fetchPage, |
| | extractSlug, |
| | } = require("./scraper"); |
| |
|
| | const app = express(); |
| | |
| | const PORT = process.env.PORT || 7860; |
| | const BASE_URL = process.env.BASE_URL || "https://anichin.cafe"; |
| |
|
| | app.use(cors()); |
| | app.use(express.json()); |
| | app.use(express.static("public")); |
| |
|
| | |
| |
|
| | const scrapeState = { |
| | isRunning: false, |
| | lastRun: null, |
| | progress: { current: 0, total: 0, stage: "idle" }, |
| | log: [], |
| | }; |
| |
|
| | function addLog(msg) { |
| | const entry = { time: new Date().toISOString(), msg }; |
| | scrapeState.log.unshift(entry); |
| | scrapeState.log = scrapeState.log.slice(0, 100); |
| | console.log(msg); |
| | } |
| |
|
| | |
| |
|
| | |
| | app.get("/api/status", (req, res) => { |
| | res.json({ |
| | status: "ok", |
| | scrapeRunning: scrapeState.isRunning, |
| | lastRun: scrapeState.lastRun, |
| | progress: scrapeState.progress, |
| | }); |
| | }); |
| |
|
| | |
| | app.get("/api/logs", (req, res) => { |
| | res.json(scrapeState.log); |
| | }); |
| |
|
| | |
| | app.post("/api/scrape/full", async (req, res) => { |
| | if (scrapeState.isRunning) { |
| | return res.status(409).json({ error: "Scrape lagi jalan, tunggu dulu!" }); |
| | } |
| |
|
| | const { pages = 10 } = req.body; |
| |
|
| | res.json({ message: `Full scrape dimulai β ${pages} halaman`, ok: true }); |
| |
|
| | |
| | scrapeState.isRunning = true; |
| | scrapeState.progress = { current: 0, total: pages, stage: "list" }; |
| |
|
| | (async () => { |
| | try { |
| | addLog(`π Full scrape dimulai β ${pages} halaman`); |
| | const animes = await scrapeAnimeList(`${BASE_URL}/anime/`, pages); |
| | addLog(`π Dapet ${animes.length} anime dari list`); |
| |
|
| | scrapeState.progress.stage = "detail"; |
| | scrapeState.progress.total = animes.length; |
| |
|
| | await scrapeAnimeDetails(animes); |
| | addLog(`β
Full scrape selesai!`); |
| | scrapeState.lastRun = new Date().toISOString(); |
| | } catch (err) { |
| | addLog(`β Error: ${err.message}`); |
| | } finally { |
| | scrapeState.isRunning = false; |
| | scrapeState.progress.stage = "idle"; |
| | } |
| | })(); |
| | }); |
| |
|
| | |
| | app.post("/api/scrape/update", async (req, res) => { |
| | if (scrapeState.isRunning) { |
| | return res.status(409).json({ error: "Scrape lagi jalan!" }); |
| | } |
| |
|
| | res.json({ message: "Incremental update dimulai", ok: true }); |
| |
|
| | scrapeState.isRunning = true; |
| | scrapeState.progress.stage = "update"; |
| |
|
| | (async () => { |
| | try { |
| | addLog("π Incremental update dimulai"); |
| | await scrapeIncrementalUpdates(); |
| | addLog("β
Update selesai!"); |
| | scrapeState.lastRun = new Date().toISOString(); |
| | } catch (err) { |
| | addLog(`β Error: ${err.message}`); |
| | } finally { |
| | scrapeState.isRunning = false; |
| | scrapeState.progress.stage = "idle"; |
| | } |
| | })(); |
| | }); |
| |
|
| | |
| | app.post("/api/scrape/single", async (req, res) => { |
| | const { url } = req.body; |
| | if (!url) return res.status(400).json({ error: "URL diperlukan" }); |
| |
|
| | try { |
| | addLog(`π― Scrape single: ${url}`); |
| | const html = await fetchPage(url); |
| | const detail = parseAnimeDetail(html, url); |
| | const db = getDB(); |
| | const slug = extractSlug(url); |
| |
|
| | const { episodes, ...animeData } = detail; |
| | await db |
| | .collection("animes") |
| | .doc(slug) |
| | .set({ ...animeData, slug, updatedAt: new Date().toISOString() }, { merge: true }); |
| |
|
| | |
| | if (episodes.length > 0) { |
| | const batch = db.batch(); |
| | episodes.forEach((ep) => { |
| | const epId = `ep-${String(ep.number || 0).padStart(4, "0")}`; |
| | batch.set( |
| | db.collection("animes").doc(slug).collection("episodes").doc(epId), |
| | ep, |
| | { merge: true } |
| | ); |
| | }); |
| | await batch.commit(); |
| | } |
| |
|
| | addLog(`β
${detail.title} berhasil discrape (${episodes.length} eps)`); |
| | res.json({ success: true, data: detail }); |
| | } catch (err) { |
| | addLog(`β ${err.message}`); |
| | res.status(500).json({ error: err.message }); |
| | } |
| | }); |
| |
|
| | |
| | app.get("/api/scrape/search", async (req, res) => { |
| | const { q } = req.query; |
| | if (!q) return res.status(400).json({ error: "Query diperlukan" }); |
| |
|
| | try { |
| | const results = await scrapeSearch(q); |
| | res.json({ query: q, count: results.length, results }); |
| | } catch (err) { |
| | res.status(500).json({ error: err.message }); |
| | } |
| | }); |
| |
|
| | |
| |
|
| | |
| | app.get("/api/animes", async (req, res) => { |
| | try { |
| | const db = getDB(); |
| | const { limit = 20, page = 1, status, genre, type, sort = "title" } = req.query; |
| | const lim = Math.min(parseInt(limit), 100); |
| |
|
| | let query = db.collection("animes").where("hasDetails", "==", true); |
| |
|
| | if (status) query = query.where("status", "==", status); |
| | if (type) query = query.where("type", "==", type); |
| |
|
| | const snapshot = await query.limit(lim * parseInt(page)).get(); |
| | const all = []; |
| | snapshot.forEach((doc) => all.push({ id: doc.id, ...doc.data() })); |
| |
|
| | |
| | const start = (parseInt(page) - 1) * lim; |
| | const data = all.slice(start, start + lim); |
| |
|
| | res.json({ total: all.length, page: parseInt(page), limit: lim, data }); |
| | } catch (err) { |
| | res.status(500).json({ error: err.message }); |
| | } |
| | }); |
| |
|
| | |
| | app.get("/api/animes/:slug", async (req, res) => { |
| | try { |
| | const db = getDB(); |
| | const doc = await db.collection("animes").doc(req.params.slug).get(); |
| | if (!doc.exists) return res.status(404).json({ error: "Anime not found" }); |
| |
|
| | |
| | const epsSnap = await db |
| | .collection("animes") |
| | .doc(req.params.slug) |
| | .collection("episodes") |
| | .orderBy("number", "asc") |
| | .get(); |
| |
|
| | const episodes = []; |
| | epsSnap.forEach((ep) => episodes.push(ep.data())); |
| |
|
| | res.json({ id: doc.id, ...doc.data(), episodes }); |
| | } catch (err) { |
| | res.status(500).json({ error: err.message }); |
| | } |
| | }); |
| |
|
| | |
| | app.get("/api/animes/search/local", async (req, res) => { |
| | try { |
| | const { q } = req.query; |
| | if (!q) return res.status(400).json({ error: "Query diperlukan" }); |
| |
|
| | const db = getDB(); |
| | |
| | |
| | const snapshot = await db |
| | .collection("animes") |
| | .orderBy("title") |
| | .startAt(q) |
| | .endAt(q + "\uf8ff") |
| | .limit(20) |
| | .get(); |
| |
|
| | const results = []; |
| | snapshot.forEach((doc) => results.push({ id: doc.id, ...doc.data() })); |
| | res.json({ query: q, count: results.length, results }); |
| | } catch (err) { |
| | res.status(500).json({ error: err.message }); |
| | } |
| | }); |
| |
|
| | |
| | app.get("/api/stats", async (req, res) => { |
| | try { |
| | const db = getDB(); |
| | const totalSnap = await db.collection("animes").count().get(); |
| | const ongoingSnap = await db |
| | .collection("animes") |
| | .where("status", "in", ["Ongoing", "ongoing", "Airing"]) |
| | .count() |
| | .get(); |
| |
|
| | res.json({ |
| | totalAnimes: totalSnap.data().count, |
| | ongoingAnimes: ongoingSnap.data().count, |
| | lastScrape: scrapeState.lastRun, |
| | }); |
| | } catch (err) { |
| | res.status(500).json({ error: err.message }); |
| | } |
| | }); |
| |
|
| | |
| | |
| | cron.schedule("0 23 * * *", async () => { |
| | if (!scrapeState.isRunning) { |
| | addLog("β° Scheduled incremental update jalan..."); |
| | scrapeState.isRunning = true; |
| | try { |
| | await scrapeIncrementalUpdates(); |
| | scrapeState.lastRun = new Date().toISOString(); |
| | } catch (err) { |
| | addLog(`β Scheduled error: ${err.message}`); |
| | } finally { |
| | scrapeState.isRunning = false; |
| | } |
| | } |
| | }); |
| |
|
| | |
| | app.listen(PORT, "0.0.0.0", () => { |
| | console.log(` |
| | βββββββββββββββββββββββββββββββββββββββββββββββββ |
| | β π₯ Anichin Scraper β Hugging Face Spaces β |
| | β ββββββββββββββββββββββββββββββββββββββββββββββββ£ |
| | β Port : ${PORT} β |
| | β Mode : ${process.env.NODE_ENV || "development"} β |
| | β Firebase: ${process.env.FIREBASE_PROJECT_ID || "NOT SET β οΈ"} β |
| | βββββββββββββββββββββββββββββββββββββββββββββββββ |
| | `); |
| | }); |
| |
|