From 3815521b0d549020491dbc2d862347f6ff7334c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CA=9C=C9=AA=C9=B4=E1=B4=87=C9=AA=20N=E1=B4=8F=E1=B4=9C?= =?UTF-8?q?=E1=B4=A2=E1=B4=87=C9=B4?= <157171073+Shineii86@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:08:22 +0530 Subject: [PATCH] feat: initial project setup for AniKatoAPI - Express.js server with Vercel deployment support - Complete API endpoints for anikototv.to anime data - Home, search, info, episodes, stream, schedule endpoints - Category filtering and genre support - Caching system with 5-minute TTL - CORS configuration - Public documentation page - Docker and Render deployment configs - MIT License --- .env.example | 5 + .gitignore | 4 + CHANGELOG.md | 35 ++++ Dockerfile | 13 ++ LICENSE | 21 +++ package.json | 29 +++ public/404.html | 65 +++++++ public/index.html | 218 ++++++++++++++++++++++ render.yaml | 9 + server.js | 63 +++++++ src/configs/dataUrl.js | 34 ++++ src/configs/header.config.js | 10 + src/controllers/animeInfo.controller.js | 25 +++ src/controllers/category.controller.js | 27 +++ src/controllers/episodeList.controller.js | 26 +++ src/controllers/filter.controller.js | 21 +++ src/controllers/homeInfo.controller.js | 20 ++ src/controllers/popular.controller.js | 21 +++ src/controllers/random.controller.js | 20 ++ src/controllers/schedule.controller.js | 26 +++ src/controllers/search.controller.js | 46 +++++ src/controllers/spotlight.controller.js | 20 ++ src/controllers/streamInfo.controller.js | 26 +++ src/controllers/suggestion.controller.js | 26 +++ src/controllers/topten.controller.js | 20 ++ src/controllers/trending.controller.js | 20 ++ src/extractors/animeInfo.extractor.js | 103 ++++++++++ src/extractors/category.extractor.js | 44 +++++ src/extractors/episodeList.extractor.js | 40 ++++ src/extractors/filter.extractor.js | 60 ++++++ src/extractors/homeInfo.extractor.js | 124 ++++++++++++ src/extractors/popular.extractor.js | 41 ++++ src/extractors/random.extractor.js | 40 ++++ src/extractors/schedule.extractor.js | 39 ++++ src/extractors/search.extractor.js | 67 +++++++ src/extractors/spotlight.extractor.js | 40 ++++ src/extractors/streamInfo.extractor.js | 58 ++++++ src/extractors/suggestion.extractor.js | 32 ++++ src/extractors/topten.extractor.js | 57 ++++++ src/extractors/trending.extractor.js | 30 +++ src/helper/cache.helper.js | 21 +++ src/helper/countPages.helper.js | 12 ++ src/helper/extractPages.helper.js | 16 ++ src/helper/formatTitle.helper.js | 9 + src/routes/apiRoutes.js | 132 +++++++++++++ src/routes/category.route.js | 13 ++ vercel.json | 18 ++ 47 files changed, 1846 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 package.json create mode 100644 public/404.html create mode 100644 public/index.html create mode 100644 render.yaml create mode 100644 server.js create mode 100644 src/configs/dataUrl.js create mode 100644 src/configs/header.config.js create mode 100644 src/controllers/animeInfo.controller.js create mode 100644 src/controllers/category.controller.js create mode 100644 src/controllers/episodeList.controller.js create mode 100644 src/controllers/filter.controller.js create mode 100644 src/controllers/homeInfo.controller.js create mode 100644 src/controllers/popular.controller.js create mode 100644 src/controllers/random.controller.js create mode 100644 src/controllers/schedule.controller.js create mode 100644 src/controllers/search.controller.js create mode 100644 src/controllers/spotlight.controller.js create mode 100644 src/controllers/streamInfo.controller.js create mode 100644 src/controllers/suggestion.controller.js create mode 100644 src/controllers/topten.controller.js create mode 100644 src/controllers/trending.controller.js create mode 100644 src/extractors/animeInfo.extractor.js create mode 100644 src/extractors/category.extractor.js create mode 100644 src/extractors/episodeList.extractor.js create mode 100644 src/extractors/filter.extractor.js create mode 100644 src/extractors/homeInfo.extractor.js create mode 100644 src/extractors/popular.extractor.js create mode 100644 src/extractors/random.extractor.js create mode 100644 src/extractors/schedule.extractor.js create mode 100644 src/extractors/search.extractor.js create mode 100644 src/extractors/spotlight.extractor.js create mode 100644 src/extractors/streamInfo.extractor.js create mode 100644 src/extractors/suggestion.extractor.js create mode 100644 src/extractors/topten.extractor.js create mode 100644 src/extractors/trending.extractor.js create mode 100644 src/helper/cache.helper.js create mode 100644 src/helper/countPages.helper.js create mode 100644 src/helper/extractPages.helper.js create mode 100644 src/helper/formatTitle.helper.js create mode 100644 src/routes/apiRoutes.js create mode 100644 src/routes/category.route.js create mode 100644 vercel.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7a01c72 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Origins you want to allow +ALLOWED_ORIGINS=https://site1.com,https://site2.com + +# Port (optional, defaults to 4444) +PORT=4444 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f48416b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +.DS_Store +*.log \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5e3cfc7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2024-12-19 + +### Added +- Initial project setup +- Express.js server with Vercel deployment support +- Home page API endpoint (`/api/`) +- Anime info endpoint (`/api/info?id={anime-id}`) +- Search endpoint (`/api/search?keyword={keyword}`) +- Search suggestions endpoint (`/api/search/suggest?keyword={keyword}`) +- Episode list endpoint (`/api/episodes/{anime-id}`) +- Stream info endpoint (`/api/stream?id={episode-id}&server={server}&type={type}`) +- Schedule endpoint (`/api/schedule?date={YYYY-MM-DD}`) +- Spotlight endpoint (`/api/spotlight`) +- Trending endpoint (`/api/trending`) +- Top 10 endpoint (`/api/top-ten`) +- Suggestions endpoint (`/api/suggestions?keyword={keyword}`) +- Random anime endpoint (`/api/random`) +- Most popular endpoint (`/api/most-popular?page={page}`) +- Filter endpoint (`/api/filter?{params}`) +- Category endpoints (`/api/{category}?page={page}`) +- Caching system with 5-minute TTL +- CORS configuration with allowed origins +- Public HTML documentation page +- 404 error page +- Dockerfile for containerization +- Render.yaml for Render deployment +- MIT License +- README with API documentation \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d9f967c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ + +RUN npm install + +COPY . . + +EXPOSE 4444 + +CMD ["npm", "start"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ef05b5d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..24cd242 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "AniKatoAPI", + "version": "1.0.0", + "description": "A restful API to provide all information about anime from anikototv.to", + "main": "./server.js", + "scripts": { + "start": "node ./server.js", + "dev": "nodemon server.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ", + "license": "MIT", + "dependencies": { + "axios": "^1.11.0", + "cheerio": "^1.0.0-rc.12", + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "dotenv": "^17.2.0", + "express": "^5.2.1" + }, + "type": "module", + "devDependencies": { + "nodemon": "^3.1.10" + }, + "repository": { + "type": "git", + "url": "https://github.com/Shineii86/AniKatoAPI.git" + } +} \ No newline at end of file diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..d6c04fe --- /dev/null +++ b/public/404.html @@ -0,0 +1,65 @@ + + + + + + 404 - Not Found | AniKatoAPI + + + +
+

404

+

Endpoint Not Found

+

The API endpoint you're looking for doesn't exist.

+ Go to API Documentation +
+ + \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..a020e24 --- /dev/null +++ b/public/index.html @@ -0,0 +1,218 @@ + + + + + + AniKatoAPI - Anime API + + + +
+
+

AniKatoAPI

+

RESTful API for Anime Data from anikototv.to

+
+ Node.js + Express + Cheerio + Vercel +
+
+ +
+

Base URL

+
+ https://anikatoapi.vercel.app/api +
+
+ +
+

API Endpoints

+ +
+ GET + /api/ +

Get home page data including spotlight, trending, top airing, etc.

+
+ +
+ GET + /api/info?id={anime-id} +

Get detailed information about a specific anime

+
+ +
+ GET + /api/search?keyword={keyword}&page={page} +

Search for anime by keyword

+
+ +
+ GET + /api/search/suggest?keyword={keyword} +

Get search suggestions

+
+ +
+ GET + /api/episodes/{anime-id} +

Get episode list for an anime

+
+ +
+ GET + /api/stream?id={episode-id}&server={server}&type={sub/dub} +

Get streaming information for an episode

+
+ +
+ GET + /api/schedule?date={YYYY-MM-DD} +

Get anime schedule for a specific date

+
+ +
+ GET + /api/spotlight +

Get spotlight/featured anime

+
+ +
+ GET + /api/trending +

Get trending anime

+
+ +
+ GET + /api/top-ten +

Get top 10 anime (today, week, month)

+
+ +
+ GET + /api/suggestions?keyword={keyword} +

Get anime suggestions

+
+ +
+ GET + /api/random +

Get random anime information

+
+ +
+ GET + /api/most-popular?page={page} +

Get most popular anime

+
+ +
+ GET + /api/filter?{params} +

Filter anime by type, status, genre, etc.

+
+ +
+ GET + /api/{category}?page={page} +

Get anime by category (movie, tv, ova, ona, etc.)

+
+
+ +
+

Categories

+
+ top-airing, most-popular, most-favorite, completed, recently-updated, recently-added, subbed-anime, dubbed-anime, movie, tv, ova, ona, special, music +
+
+ + +
+ + \ No newline at end of file diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..f298e02 --- /dev/null +++ b/render.yaml @@ -0,0 +1,9 @@ +services: + - type: web + name: AniKatoAPI + env: node + buildCommand: npm install + startCommand: npm start + envVars: + - key: ALLOWED_ORIGINS + value: https://anikototv.to,https://anikoto.cz,https://anikoto.me,https://anikoto.net,https://anikoto.se \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..ed9e92a --- /dev/null +++ b/server.js @@ -0,0 +1,63 @@ +import dotenv from "dotenv"; +import express from "express"; +import cors from "cors"; +import path from "path"; +import fs from "fs"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; +import { createApiRoutes } from "./src/routes/apiRoutes.js"; + +dotenv.config(); + +const app = express(); +const PORT = process.env.PORT || 4444; +const __filename = fileURLToPath(import.meta.url); +const publicDir = path.join(dirname(__filename), "public"); +const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(","); + +app.use( + cors({ + origin: allowedOrigins?.includes("*") ? "*" : allowedOrigins || [], + methods: ["GET"], + }) +); + +app.use((req, res, next) => { + const origin = req.headers.origin; + if ( + !allowedOrigins || + allowedOrigins.includes("*") || + (origin && allowedOrigins.includes(origin)) + ) { + res.setHeader("Access-Control-Allow-Origin", origin || "*"); + res.setHeader("Access-Control-Allow-Methods", "GET"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + return next(); + } + res + .status(403) + .json({ success: false, message: "Forbidden: Origin not allowed" }); +}); + +app.use(express.static(publicDir, { redirect: false })); + +const jsonResponse = (res, data, status = 200) => + res.status(status).json({ success: true, results: data }); + +const jsonError = (res, message = "Internal server error", status = 500) => + res.status(status).json({ success: false, message }); + +createApiRoutes(app, jsonResponse, jsonError); + +app.use((req, res) => { + const filePath = path.join(publicDir, "404.html"); + if (fs.existsSync(filePath)) { + res.status(404).sendFile(filePath); + } else { + res.status(404).json({ success: false, message: "Endpoint not found" }); + } +}); + +app.listen(PORT, () => { + console.info(`AniKatoAPI listening at ${PORT}`); +}); \ No newline at end of file diff --git a/src/configs/dataUrl.js b/src/configs/dataUrl.js new file mode 100644 index 0000000..6458939 --- /dev/null +++ b/src/configs/dataUrl.js @@ -0,0 +1,34 @@ +const BASE_URL = "https://anikototv.to"; +const ALT_DOMAINS = [ + "https://anikoto.cz", + "https://anikoto.me", + "https://anikototv.to", + "https://anikoto.net", + "https://anikoto.se" +]; + +const URLS = { + home: `${BASE_URL}/home`, + search: `${BASE_URL}/search`, + genre: (genre) => `${BASE_URL}/genre/${genre}`, + azList: (letter) => `${BASE_URL}/az-list/${letter}`, + category: (cat) => `${BASE_URL}/${cat}`, + anime: (id) => `${BASE_URL}/anime/${id}`, + episode: (id) => `${BASE_URL}/episode/${id}`, + schedule: (date) => `${BASE_URL}/schedule/${date}`, + topTen: `${BASE_URL}/top-ten`, + mostPopular: `${BASE_URL}/most-popular`, + mostFavorite: `${BASE_URL}/most-favorite`, + recentlyUpdated: `${BASE_URL}/recently-updated`, + recentlyAdded: `${BASE_URL}/recently-added`, + subbedAnime: `${BASE_URL}/subbed-anime`, + dubbedAnime: `${BASE_URL}/dubbed-anime`, + movie: `${BASE_URL}/movie`, + ova: `${BASE_URL}/ova`, + ona: `${BASE_URL}/ona`, + special: `${BASE_URL}/special`, + tv: `${BASE_URL}/tv`, + music: `${BASE_URL}/music` +}; + +export { BASE_URL, ALT_DOMAINS, URLS }; \ No newline at end of file diff --git a/src/configs/header.config.js b/src/configs/header.config.js new file mode 100644 index 0000000..f19bb46 --- /dev/null +++ b/src/configs/header.config.js @@ -0,0 +1,10 @@ +const headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1" +}; + +export { headers }; \ No newline at end of file diff --git a/src/controllers/animeInfo.controller.js b/src/controllers/animeInfo.controller.js new file mode 100644 index 0000000..b6c265e --- /dev/null +++ b/src/controllers/animeInfo.controller.js @@ -0,0 +1,25 @@ +import { extractAnimeInfo } from "../extractors/animeInfo.extractor.js"; +import { getCache, setCache } from "../helper/cache.helper.js"; + +const getAnimeInfo = async (req, res, next) => { + try { + const { id } = req.query; + if (!id) { + return res.status(400).json({ success: false, message: "Anime ID is required" }); + } + + const cacheKey = `anime_${id}`; + const cached = getCache(cacheKey); + if (cached) { + return res.json({ success: true, results: cached }); + } + + const data = await extractAnimeInfo(id); + setCache(cacheKey, data); + res.json({ success: true, results: data }); + } catch (error) { + next(error); + } +}; + +export { getAnimeInfo }; \ No newline at end of file diff --git a/src/controllers/category.controller.js b/src/controllers/category.controller.js new file mode 100644 index 0000000..47bb13a --- /dev/null +++ b/src/controllers/category.controller.js @@ -0,0 +1,27 @@ +import { extractCategory } from "../extractors/category.extractor.js"; +import { getCache, setCache } from "../helper/cache.helper.js"; + +const getCategory = async (req, res, next) => { + try { + const { category } = req.params; + const { page = 1 } = req.query; + + if (!category) { + return res.status(400).json({ success: false, message: "Category is required" }); + } + + const cacheKey = `category_${category}_${page}`; + const cached = getCache(cacheKey); + if (cached) { + return res.json({ success: true, results: cached }); + } + + const data = await extractCategory(category, page); + setCache(cacheKey, data); + res.json({ success: true, results: data }); + } catch (error) { + next(error); + } +}; + +export { getCategory }; \ No newline at end of file diff --git a/src/controllers/episodeList.controller.js b/src/controllers/episodeList.controller.js new file mode 100644 index 0000000..3feb520 --- /dev/null +++ b/src/controllers/episodeList.controller.js @@ -0,0 +1,26 @@ +import { extractEpisodeList } from "../extractors/episodeList.extractor.js"; +import { getCache, setCache } from "../helper/cache.helper.js"; + +const getEpisodeList = async (req, res, next) => { + try { + const { id } = req.params; + + if (!id) { + return res.status(400).json({ success: false, message: "Anime ID is required" }); + } + + const cacheKey = `episodes_${id}`; + const cached = getCache(cacheKey); + if (cached) { + return res.json({ success: true, results: cached }); + } + + const data = await extractEpisodeList(id); + setCache(cacheKey, data); + res.json({ success: true, results: data }); + } catch (error) { + next(error); + } +}; + +export { getEpisodeList }; \ No newline at end of file diff --git a/src/controllers/filter.controller.js b/src/controllers/filter.controller.js new file mode 100644 index 0000000..d1b65d9 --- /dev/null +++ b/src/controllers/filter.controller.js @@ -0,0 +1,21 @@ +import { extractFilter } from "../extractors/filter.extractor.js"; +import { getCache, setCache } from "../helper/cache.helper.js"; + +const getFilter = async (req, res, next) => { + try { + const params = req.query; + const cacheKey = `filter_${JSON.stringify(params)}`; + const cached = getCache(cacheKey); + if (cached) { + return res.json({ success: true, results: cached }); + } + + const data = await extractFilter(params); + setCache(cacheKey, data); + res.json({ success: true, results: data }); + } catch (error) { + next(error); + } +}; + +export { getFilter }; \ No newline at end of file diff --git a/src/controllers/homeInfo.controller.js b/src/controllers/homeInfo.controller.js new file mode 100644 index 0000000..43d8b9f --- /dev/null +++ b/src/controllers/homeInfo.controller.js @@ -0,0 +1,20 @@ +import { extractHomeInfo } from "../extractors/homeInfo.extractor.js"; +import { getCache, setCache } from "../helper/cache.helper.js"; + +const getHomeInfo = async (req, res, next) => { + try { + const cacheKey = "home"; + const cached = getCache(cacheKey); + if (cached) { + return res.json({ success: true, results: cached }); + } + + const data = await extractHomeInfo(); + setCache(cacheKey, data); + res.json({ success: true, results: data }); + } catch (error) { + next(error); + } +}; + +export { getHomeInfo }; \ No newline at end of file diff --git a/src/controllers/popular.controller.js b/src/controllers/popular.controller.js new file mode 100644 index 0000000..2cf6a76 --- /dev/null +++ b/src/controllers/popular.controller.js @@ -0,0 +1,21 @@ +import { extractPopular } from "../extractors/popular.extractor.js"; +import { getCache, setCache } from "../helper/cache.helper.js"; + +const getPopular = async (req, res, next) => { + try { + const { page = 1 } = req.query; + const cacheKey = `popular_${page}`; + const cached = getCache(cacheKey); + if (cached) { + return res.json({ success: true, results: cached }); + } + + const data = await extractPopular(page); + setCache(cacheKey, data); + res.json({ success: true, results: data }); + } catch (error) { + next(error); + } +}; + +export { getPopular }; \ No newline at end of file diff --git a/src/controllers/random.controller.js b/src/controllers/random.controller.js new file mode 100644 index 0000000..e20c54f --- /dev/null +++ b/src/controllers/random.controller.js @@ -0,0 +1,20 @@ +import { extractRandom } from "../extractors/random.extractor.js"; +import { getCache, setCache } from "../helper/cache.helper.js"; + +const getRandom = async (req, res, next) => { + try { + const cacheKey = "random"; + const cached = getCache(cacheKey); + if (cached) { + return res.json({ success: true, results: cached }); + } + + const data = await extractRandom(); + setCache(cacheKey, data); + res.json({ success: true, results: data }); + } catch (error) { + next(error); + } +}; + +export { getRandom }; \ No newline at end of file diff --git a/src/controllers/schedule.controller.js b/src/controllers/schedule.controller.js new file mode 100644 index 0000000..978fe23 --- /dev/null +++ b/src/controllers/schedule.controller.js @@ -0,0 +1,26 @@ +import { extractSchedule } from "../extractors/schedule.extractor.js"; +import { getCache, setCache } from "../helper/cache.helper.js"; + +const getSchedule = async (req, res, next) => { + try { + const { date } = req.query; + + if (!date) { + return res.status(400).json({ success: false, message: "Date is required (YYYY-MM-DD)" }); + } + + const cacheKey = `schedule_${date}`; + const cached = getCache(cacheKey); + if (cached) { + return res.json({ success: true, results: cached }); + } + + const data = await extractSchedule(date); + setCache(cacheKey, data); + res.json({ success: true, results: data }); + } catch (error) { + next(error); + } +}; + +export { getSchedule }; \ No newline at end of file diff --git a/src/controllers/search.controller.js b/src/controllers/search.controller.js new file mode 100644 index 0000000..d9e74f5 --- /dev/null +++ b/src/controllers/search.controller.js @@ -0,0 +1,46 @@ +import { extractSearchResults, extractSearchSuggestions } from "../extractors/search.extractor.js"; +import { getCache, setCache } from "../helper/cache.helper.js"; + +const getSearchResults = async (req, res, next) => { + try { + const { keyword, page = 1 } = req.query; + if (!keyword) { + return res.status(400).json({ success: false, message: "Keyword is required" }); + } + + const cacheKey = `search_${keyword}_${page}`; + const cached = getCache(cacheKey); + if (cached) { + return res.json({ success: true, results: cached }); + } + + const data = await extractSearchResults(keyword, page); + setCache(cacheKey, data); + res.json({ success: true, results: data }); + } catch (error) { + next(error); + } +}; + +const getSearchSuggestions = async (req, res, next) => { + try { + const { keyword } = req.query; + if (!keyword) { + return res.status(400).json({ success: false, message: "Keyword is required" }); + } + + const cacheKey = `suggest_${keyword}`; + const cached = getCache(cacheKey); + if (cached) { + return res.json({ success: true, results: cached }); + } + + const data = await extractSearchSuggestions(keyword); + setCache(cacheKey, data); + res.json({ success: true, results: data }); + } catch (error) { + next(error); + } +}; + +export { getSearchResults, getSearchSuggestions }; \ No newline at end of file diff --git a/src/controllers/spotlight.controller.js b/src/controllers/spotlight.controller.js new file mode 100644 index 0000000..4d26180 --- /dev/null +++ b/src/controllers/spotlight.controller.js @@ -0,0 +1,20 @@ +import { extractSpotlight } from "../extractors/spotlight.extractor.js"; +import { getCache, setCache } from "../helper/cache.helper.js"; + +const getSpotlight = async (req, res, next) => { + try { + const cacheKey = "spotlight"; + const cached = getCache(cacheKey); + if (cached) { + return res.json({ success: true, results: cached }); + } + + const data = await extractSpotlight(); + setCache(cacheKey, data); + res.json({ success: true, results: data }); + } catch (error) { + next(error); + } +}; + +export { getSpotlight }; \ No newline at end of file diff --git a/src/controllers/streamInfo.controller.js b/src/controllers/streamInfo.controller.js new file mode 100644 index 0000000..c13c812 --- /dev/null +++ b/src/controllers/streamInfo.controller.js @@ -0,0 +1,26 @@ +import { extractStreamInfo } from "../extractors/streamInfo.extractor.js"; +import { getCache, setCache } from "../helper/cache.helper.js"; + +const getStreamInfo = async (req, res, next) => { + try { + const { id, server = "hd-1", type = "sub" } = req.query; + + if (!id) { + return res.status(400).json({ success: false, message: "Episode ID is required" }); + } + + const cacheKey = `stream_${id}_${server}_${type}`; + const cached = getCache(cacheKey); + if (cached) { + return res.json({ success: true, results: cached }); + } + + const data = await extractStreamInfo(id, server, type); + setCache(cacheKey, data); + res.json({ success: true, results: data }); + } catch (error) { + next(error); + } +}; + +export { getStreamInfo }; \ No newline at end of file diff --git a/src/controllers/suggestion.controller.js b/src/controllers/suggestion.controller.js new file mode 100644 index 0000000..97dd4cf --- /dev/null +++ b/src/controllers/suggestion.controller.js @@ -0,0 +1,26 @@ +import { extractSuggestions } from "../extractors/suggestion.extractor.js"; +import { getCache, setCache } from "../helper/cache.helper.js"; + +const getSuggestions = async (req, res, next) => { + try { + const { keyword } = req.query; + + if (!keyword) { + return res.status(400).json({ success: false, message: "Keyword is required" }); + } + + const cacheKey = `suggest_${keyword}`; + const cached = getCache(cacheKey); + if (cached) { + return res.json({ success: true, results: cached }); + } + + const data = await extractSuggestions(keyword); + setCache(cacheKey, data); + res.json({ success: true, results: data }); + } catch (error) { + next(error); + } +}; + +export { getSuggestions }; \ No newline at end of file diff --git a/src/controllers/topten.controller.js b/src/controllers/topten.controller.js new file mode 100644 index 0000000..07ef1a6 --- /dev/null +++ b/src/controllers/topten.controller.js @@ -0,0 +1,20 @@ +import { extractTopTen } from "../extractors/topten.extractor.js"; +import { getCache, setCache } from "../helper/cache.helper.js"; + +const getTopTen = async (req, res, next) => { + try { + const cacheKey = "topTen"; + const cached = getCache(cacheKey); + if (cached) { + return res.json({ success: true, results: cached }); + } + + const data = await extractTopTen(); + setCache(cacheKey, data); + res.json({ success: true, results: data }); + } catch (error) { + next(error); + } +}; + +export { getTopTen }; \ No newline at end of file diff --git a/src/controllers/trending.controller.js b/src/controllers/trending.controller.js new file mode 100644 index 0000000..96d9fcb --- /dev/null +++ b/src/controllers/trending.controller.js @@ -0,0 +1,20 @@ +import { extractTrending } from "../extractors/trending.extractor.js"; +import { getCache, setCache } from "../helper/cache.helper.js"; + +const getTrending = async (req, res, next) => { + try { + const cacheKey = "trending"; + const cached = getCache(cacheKey); + if (cached) { + return res.json({ success: true, results: cached }); + } + + const data = await extractTrending(); + setCache(cacheKey, data); + res.json({ success: true, results: data }); + } catch (error) { + next(error); + } +}; + +export { getTrending }; \ No newline at end of file diff --git a/src/extractors/animeInfo.extractor.js b/src/extractors/animeInfo.extractor.js new file mode 100644 index 0000000..59a21a7 --- /dev/null +++ b/src/extractors/animeInfo.extractor.js @@ -0,0 +1,103 @@ +import * as cheerio from "cheerio"; +import axios from "axios"; +import { headers } from "../configs/header.config.js"; +import { BASE_URL } from "../configs/dataUrl.js"; + +const extractAnimeInfo = async (id) => { + try { + const url = `${BASE_URL}/anime/${id}`; + const { data } = await axios.get(url, { headers }); + const $ = cheerio.load(data); + + const title = $(".film-name h1, .anisc-detail .film-name").text().trim() || ""; + const japaneseTitle = $(".film-name .alternate-title, .anisc-detail .film-name small").text().trim() || ""; + const poster = $(".film-poster img").attr("src") || ""; + const showType = $(".film-info .row .col1 .item:first-child .name").text().trim() || ""; + const quality = $(".film-info .row .col1 .item:nth-child(2) .name").text().trim() || ""; + + const animeInfo = {}; + $(".anisc-info .item").each((i, el) => { + const label = $(el).find(".name").text().trim().replace(":", ""); + const value = $(el).find(".text").text().trim(); + if (label && value) { + animeInfo[label] = value; + } + }); + + const overview = $(".anisc-detail .description, .film-description .text").text().trim() || ""; + + const genres = []; + $(".anisc-info .item:last-child a, .genres a").each((i, el) => { + const genre = $(el).text().trim(); + if (genre) genres.push(genre); + }); + + const seasons = []; + $(".seasons .season-item, .ss-list a").each((i, el) => { + const seasonId = $(el).attr("href")?.split("/").pop() || ""; + const seasonTitle = $(el).text().trim() || ""; + const seasonPoster = $(el).find("img").attr("src") || ""; + + if (seasonId) { + seasons.push({ + id: seasonId, + title: seasonTitle, + poster: seasonPoster + }); + } + }); + + const relatedData = []; + $(".related-content .film-detail, .film-list-block .film-detail").each((i, el) => { + const relId = $(el).find("a").attr("href")?.split("/").pop() || ""; + const relPoster = $(el).find("img").attr("src") || ""; + const relTitle = $(el).find(".film-name a").text().trim() || ""; + const relType = $(el).find(".fd-infor .fdi-item:first-child").text().trim() || ""; + + if (relId) { + relatedData.push({ + id: relId, + poster: relPoster, + title: relTitle, + type: relType + }); + } + }); + + const recommendedData = []; + $(".recommend-content .film-detail, .film-list-block .film-detail").each((i, el) => { + const recId = $(el).find("a").attr("href")?.split("/").pop() || ""; + const recPoster = $(el).find("img").attr("src") || ""; + const recTitle = $(el).find(".film-name a").text().trim() || ""; + const recType = $(el).find(".fd-infor .fdi-item:first-child").text().trim() || ""; + + if (recId) { + recommendedData.push({ + id: recId, + poster: recPoster, + title: recTitle, + type: recType + }); + } + }); + + return { + id, + title, + japaneseTitle, + poster, + showType, + quality, + overview, + animeInfo, + genres, + seasons, + relatedData, + recommendedData + }; + } catch (error) { + throw error; + } +}; + +export { extractAnimeInfo }; \ No newline at end of file diff --git a/src/extractors/category.extractor.js b/src/extractors/category.extractor.js new file mode 100644 index 0000000..c261b8f --- /dev/null +++ b/src/extractors/category.extractor.js @@ -0,0 +1,44 @@ +import * as cheerio from "cheerio"; +import axios from "axios"; +import { headers } from "../configs/header.config.js"; +import { URLS } from "../configs/dataUrl.js"; +import { countPages } from "../helper/countPages.helper.js"; +import { extractPages } from "../helper/extractPages.helper.js"; + +const extractCategory = async (category, page = 1) => { + try { + const url = URLS.category(category); + const $ = await extractPages(url, page); + const totalPages = countPages($); + + const results = []; + $(".film_list-wrap .flw-item, .film-detail").each((i, el) => { + const id = $(el).find("a").attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".film-name a, .dynamic-name").text().trim() || ""; + const japaneseTitle = $(el).find(".fd-infor .fdi-item:first-child").text().trim() || ""; + const showType = $(el).find(".fd-infor .fdi-item:nth-child(2)").text().trim() || ""; + const sub = parseInt($(el).find(".tick-sub").text().trim()) || 0; + const dub = parseInt($(el).find(".tick-dub").text().trim()) || 0; + const eps = parseInt($(el).find(".tick-eps").text().trim()) || 0; + const adultContent = $(el).find(".tick-adult, .adult-badge").length > 0; + + if (id) { + results.push({ + id, + poster, + title, + japaneseTitle, + tvInfo: { showType, sub, dub, eps }, + adultContent + }); + } + }); + + return { totalPages, data: results }; + } catch (error) { + throw error; + } +}; + +export { extractCategory }; \ No newline at end of file diff --git a/src/extractors/episodeList.extractor.js b/src/extractors/episodeList.extractor.js new file mode 100644 index 0000000..d3343c2 --- /dev/null +++ b/src/extractors/episodeList.extractor.js @@ -0,0 +1,40 @@ +import * as cheerio from "cheerio"; +import axios from "axios"; +import { headers } from "../configs/header.config.js"; +import { BASE_URL } from "../configs/dataUrl.js"; + +const extractEpisodeList = async (id) => { + try { + const url = `${BASE_URL}/anime/${id}`; + const { data } = await axios.get(url, { headers }); + const $ = cheerio.load(data); + + const episodes = []; + $(".episodes-list .ep-item, .ss-list a").each((i, el) => { + const epId = $(el).attr("data-id") || $(el).attr("href")?.split("/").pop() || ""; + const epNumber = parseInt($(el).find(".ep-number, .ep-num").text().trim()) || i + 1; + const title = $(el).find(".ep-title, .ep-name").text().trim() || ""; + const japaneseTitle = $(el).find(".ep-jp").text().trim() || ""; + const filler = $(el).hasClass("filler") || false; + + if (epId) { + episodes.push({ + id: epId, + episode_no: epNumber, + title, + japaneseTitle, + filler + }); + } + }); + + return { + totalEpisodes: episodes.length, + episodes + }; + } catch (error) { + throw error; + } +}; + +export { extractEpisodeList }; \ No newline at end of file diff --git a/src/extractors/filter.extractor.js b/src/extractors/filter.extractor.js new file mode 100644 index 0000000..8244fbf --- /dev/null +++ b/src/extractors/filter.extractor.js @@ -0,0 +1,60 @@ +import * as cheerio from "cheerio"; +import axios from "axios"; +import { headers } from "../configs/header.config.js"; +import { BASE_URL } from "../configs/dataUrl.js"; +import { countPages } from "../helper/countPages.helper.js"; +import { extractPages } from "../helper/extractPages.helper.js"; + +const extractFilter = async (params) => { + try { + const queryParams = new URLSearchParams(); + + if (params.type) queryParams.set("type", params.type); + if (params.status) queryParams.set("status", params.status); + if (params.rated) queryParams.set("rated", params.rated); + if (params.score) queryParams.set("score", params.score); + if (params.season) queryParams.set("season", params.season); + if (params.language) queryParams.set("language", params.language); + if (params.genres) queryParams.set("genres", params.genres); + if (params.sort) queryParams.set("sort", params.sort); + if (params.sy) queryParams.set("sy", params.sy); + if (params.sm) queryParams.set("sm", params.sm); + if (params.sd) queryParams.set("sd", params.sd); + if (params.ey) queryParams.set("ey", params.ey); + if (params.em) queryParams.set("em", params.em); + if (params.ed) queryParams.set("ed", params.ed); + if (params.keyword) queryParams.set("keyword", params.keyword); + + const url = `${BASE_URL}/filter?${queryParams.toString()}`; + const $ = await extractPages(url, params.page || 1); + const totalPages = countPages($); + + const results = []; + $(".film_list-wrap .flw-item, .film-detail").each((i, el) => { + const id = $(el).find("a").attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".film-name a, .dynamic-name").text().trim() || ""; + const japaneseTitle = $(el).find(".fd-infor .fdi-item:first-child").text().trim() || ""; + const showType = $(el).find(".fd-infor .fdi-item:nth-child(2)").text().trim() || ""; + const sub = parseInt($(el).find(".tick-sub").text().trim()) || 0; + const dub = parseInt($(el).find(".tick-dub").text().trim()) || 0; + const eps = parseInt($(el).find(".tick-eps").text().trim()) || 0; + + if (id) { + results.push({ + id, + poster, + title, + japaneseTitle, + tvInfo: { showType, sub, dub, eps } + }); + } + }); + + return { totalPages, data: results }; + } catch (error) { + throw error; + } +}; + +export { extractFilter }; \ No newline at end of file diff --git a/src/extractors/homeInfo.extractor.js b/src/extractors/homeInfo.extractor.js new file mode 100644 index 0000000..d06b75f --- /dev/null +++ b/src/extractors/homeInfo.extractor.js @@ -0,0 +1,124 @@ +import * as cheerio from "cheerio"; +import axios from "axios"; +import { headers } from "../configs/header.config.js"; +import { URLS } from "../configs/dataUrl.js"; + +const extractHomeInfo = async () => { + try { + const { data } = await axios.get(URLS.home, { headers }); + const $ = cheerio.load(data); + + const spotlights = []; + $(".swiper-slide, .spotlight-item").each((i, el) => { + const id = $(el).find("a").attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".film-name a, .dynamic-name").text().trim() || ""; + const japaneseTitle = $(el).find(".fdi-item:nth-child(1)").text().trim() || ""; + const description = $(el).find(".fd-infor .desc, .film-description").text().trim() || ""; + const showType = $(el).find(".fdi-item:nth-child(2)").text().trim() || ""; + const duration = $(el).find(".fdi-item:nth-child(3)").text().trim() || ""; + const releaseDate = $(el).find(".fdi-item:nth-child(4)").text().trim() || ""; + + if (id) { + spotlights.push({ + id, + poster, + title, + japaneseTitle, + description, + tvInfo: { showType, duration, releaseDate } + }); + } + }); + + const trending = []; + $(".trending-list .film-detail, .trending-item").each((i, el) => { + const id = $(el).find("a").attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".film-name a, .dynamic-name").text().trim() || ""; + const number = parseInt($(el).find(".number").text().trim()) || i + 1; + + if (id) { + trending.push({ id, number, poster, title }); + } + }); + + const topAiring = []; + $(".section-id-02 .film-detail, .top-airing .film-item").each((i, el) => { + const id = $(el).find("a").attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".film-name a, .dynamic-name").text().trim() || ""; + const japaneseTitle = $(el).find(".fd-infor .fdi-item:first-child").text().trim() || ""; + + if (id) { + topAiring.push({ id, poster, title, japaneseTitle }); + } + }); + + const mostPopular = []; + $(".section-id-03 .film-detail, .popular .film-item").each((i, el) => { + const id = $(el).find("a").attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".film-name a, .dynamic-name").text().trim() || ""; + + if (id) { + mostPopular.push({ id, poster, title }); + } + }); + + const mostFavorite = []; + $(".section-id-04 .film-detail, .favorite .film-item").each((i, el) => { + const id = $(el).find("a").attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".film-name a, .dynamic-name").text().trim() || ""; + + if (id) { + mostFavorite.push({ id, poster, title }); + } + }); + + const latestCompleted = []; + $(".section-id-05 .film-detail, .completed .film-item").each((i, el) => { + const id = $(el).find("a").attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".film-name a, .dynamic-name").text().trim() || ""; + + if (id) { + latestCompleted.push({ id, poster, title }); + } + }); + + const latestEpisode = []; + $(".section-id-06 .film-detail, .latest .film-item").each((i, el) => { + const id = $(el).find("a").attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".film-name a, .dynamic-name").text().trim() || ""; + const episode = $(el).find(".tick-eps, .tick-sub").text().trim() || ""; + + if (id) { + latestEpisode.push({ id, poster, title, episode }); + } + }); + + const genres = []; + $(".genre-item, .genres a").each((i, el) => { + const genre = $(el).text().trim(); + if (genre) genres.push(genre); + }); + + return { + spotlights, + trending, + topAiring, + mostPopular, + mostFavorite, + latestCompleted, + latestEpisode, + genres + }; + } catch (error) { + throw error; + } +}; + +export { extractHomeInfo }; \ No newline at end of file diff --git a/src/extractors/popular.extractor.js b/src/extractors/popular.extractor.js new file mode 100644 index 0000000..2e073fb --- /dev/null +++ b/src/extractors/popular.extractor.js @@ -0,0 +1,41 @@ +import * as cheerio from "cheerio"; +import axios from "axios"; +import { headers } from "../configs/header.config.js"; +import { URLS } from "../configs/dataUrl.js"; +import { countPages } from "../helper/countPages.helper.js"; +import { extractPages } from "../helper/extractPages.helper.js"; + +const extractPopular = async (page = 1) => { + try { + const $ = await extractPages(URLS.mostPopular, page); + const totalPages = countPages($); + + const results = []; + $(".film_list-wrap .flw-item, .film-detail").each((i, el) => { + const id = $(el).find("a").attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".film-name a, .dynamic-name").text().trim() || ""; + const japaneseTitle = $(el).find(".fd-infor .fdi-item:first-child").text().trim() || ""; + const showType = $(el).find(".fd-infor .fdi-item:nth-child(2)").text().trim() || ""; + const sub = parseInt($(el).find(".tick-sub").text().trim()) || 0; + const dub = parseInt($(el).find(".tick-dub").text().trim()) || 0; + const eps = parseInt($(el).find(".tick-eps").text().trim()) || 0; + + if (id) { + results.push({ + id, + poster, + title, + japaneseTitle, + tvInfo: { showType, sub, dub, eps } + }); + } + }); + + return { totalPages, data: results }; + } catch (error) { + throw error; + } +}; + +export { extractPopular }; \ No newline at end of file diff --git a/src/extractors/random.extractor.js b/src/extractors/random.extractor.js new file mode 100644 index 0000000..89293fb --- /dev/null +++ b/src/extractors/random.extractor.js @@ -0,0 +1,40 @@ +import * as cheerio from "cheerio"; +import axios from "axios"; +import { headers } from "../configs/header.config.js"; +import { BASE_URL } from "../configs/dataUrl.js"; + +const extractRandom = async () => { + try { + const { data } = await axios.get(`${BASE_URL}/random`, { + headers, + maxRedirects: 5 + }); + const $ = cheerio.load(data); + + const title = $(".film-name h1, .anisc-detail .film-name").text().trim() || ""; + const japaneseTitle = $(".film-name .alternate-title, .anisc-detail .film-name small").text().trim() || ""; + const poster = $(".film-poster img").attr("src") || ""; + const showType = $(".film-info .row .col1 .item:first-child .name").text().trim() || ""; + + const animeInfo = {}; + $(".anisc-info .item").each((i, el) => { + const label = $(el).find(".name").text().trim().replace(":", ""); + const value = $(el).find(".text").text().trim(); + if (label && value) { + animeInfo[label] = value; + } + }); + + return { + title, + japaneseTitle, + poster, + showType, + animeInfo + }; + } catch (error) { + throw error; + } +}; + +export { extractRandom }; \ No newline at end of file diff --git a/src/extractors/schedule.extractor.js b/src/extractors/schedule.extractor.js new file mode 100644 index 0000000..2703090 --- /dev/null +++ b/src/extractors/schedule.extractor.js @@ -0,0 +1,39 @@ +import * as cheerio from "cheerio"; +import axios from "axios"; +import { headers } from "../configs/header.config.js"; +import { URLS } from "../configs/dataUrl.js"; + +const extractSchedule = async (date) => { + try { + const url = URLS.schedule(date); + const { data } = await axios.get(url, { headers }); + const $ = cheerio.load(data); + + const schedule = []; + $(".schedule-item, .anime-schedule .item").each((i, el) => { + const id = $(el).find("a").attr("href")?.split("/").pop() || ""; + const title = $(el).find(".film-name a, .dynamic-name").text().trim() || ""; + const japaneseTitle = $(el).find(".fd-infor .fdi-item:first-child").text().trim() || ""; + const time = $(el).find(".time, .schedule-time").text().trim() || ""; + const episodeNo = parseInt($(el).find(".episode-no, .ep-num").text().trim()) || 0; + const releaseDate = $(el).find(".date, .release-date").text().trim() || ""; + + if (id) { + schedule.push({ + id, + title, + japaneseTitle, + time, + episode_no: episodeNo, + releaseDate + }); + } + }); + + return schedule; + } catch (error) { + throw error; + } +}; + +export { extractSchedule }; \ No newline at end of file diff --git a/src/extractors/search.extractor.js b/src/extractors/search.extractor.js new file mode 100644 index 0000000..bf0251c --- /dev/null +++ b/src/extractors/search.extractor.js @@ -0,0 +1,67 @@ +import * as cheerio from "cheerio"; +import axios from "axios"; +import { headers } from "../configs/header.config.js"; +import { URLS } from "../configs/dataUrl.js"; +import { countPages } from "../helper/countPages.helper.js"; + +import { extractPages } from "../helper/extractPages.helper.js"; + +const extractSearchResults = async (keyword, page = 1) => { + try { + const url = `${URLS.search}?keyword=${encodeURIComponent(keyword)}`; + const $ = await extractPages(url, page); + const totalPages = countPages($); + + const results = []; + $(".film_list-wrap .flw-item, .search-list .film-detail").each((i, el) => { + const id = $(el).find("a").attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".film-name a, .dynamic-name").text().trim() || ""; + const japaneseTitle = $(el).find(".fd-infor .fdi-item:first-child").text().trim() || ""; + const showType = $(el).find(".fd-infor .fdi-item:nth-child(2)").text().trim() || ""; + const sub = parseInt($(el).find(".tick-sub").text().trim()) || 0; + const dub = parseInt($(el).find(".tick-dub").text().trim()) || 0; + const eps = parseInt($(el).find(".tick-eps").text().trim()) || 0; + + if (id) { + results.push({ + id, + poster, + title, + japaneseTitle, + tvInfo: { showType, sub, dub, eps } + }); + } + }); + + return { totalPages, data: results }; + } catch (error) { + throw error; + } +}; + +const extractSearchSuggestions = async (keyword) => { + try { + const url = `${URLS.search}?keyword=${encodeURIComponent(keyword)}`; + const { data } = await axios.get(url, { headers }); + const $ = cheerio.load(data); + + const suggestions = []; + $(".search-suggest .film-detail, .nav-item a").each((i, el) => { + const id = $(el).attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".film-name, .dynamic-name").text().trim() || ""; + const showType = $(el).find(".fdi-item:first-child").text().trim() || ""; + + if (id) { + suggestions.push({ id, poster, title, showType }); + } + }); + + return suggestions; + } catch (error) { + throw error; + } +}; + +export { extractSearchResults, extractSearchSuggestions }; \ No newline at end of file diff --git a/src/extractors/spotlight.extractor.js b/src/extractors/spotlight.extractor.js new file mode 100644 index 0000000..d4dd6a8 --- /dev/null +++ b/src/extractors/spotlight.extractor.js @@ -0,0 +1,40 @@ +import * as cheerio from "cheerio"; +import axios from "axios"; +import { headers } from "../configs/header.config.js"; +import { URLS } from "../configs/dataUrl.js"; + +const extractSpotlight = async () => { + try { + const { data } = await axios.get(URLS.home, { headers }); + const $ = cheerio.load(data); + + const spotlights = []; + $(".swiper-slide, .spotlight-item").each((i, el) => { + const id = $(el).find("a").attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".film-name a, .dynamic-name").text().trim() || ""; + const japaneseTitle = $(el).find(".fdi-item:nth-child(1)").text().trim() || ""; + const description = $(el).find(".fd-infor .desc, .film-description").text().trim() || ""; + const showType = $(el).find(".fdi-item:nth-child(2)").text().trim() || ""; + const duration = $(el).find(".fdi-item:nth-child(3)").text().trim() || ""; + const releaseDate = $(el).find(".fdi-item:nth-child(4)").text().trim() || ""; + + if (id) { + spotlights.push({ + id, + poster, + title, + japaneseTitle, + description, + tvInfo: { showType, duration, releaseDate } + }); + } + }); + + return spotlights; + } catch (error) { + throw error; + } +}; + +export { extractSpotlight }; \ No newline at end of file diff --git a/src/extractors/streamInfo.extractor.js b/src/extractors/streamInfo.extractor.js new file mode 100644 index 0000000..6d70eab --- /dev/null +++ b/src/extractors/streamInfo.extractor.js @@ -0,0 +1,58 @@ +import * as cheerio from "cheerio"; +import axios from "axios"; +import { headers } from "../configs/header.config.js"; +import { BASE_URL } from "../configs/dataUrl.js"; + +const extractStreamInfo = async (episodeId, server = "hd-1", type = "sub") => { + try { + const url = `${BASE_URL}/watch/${episodeId}`; + const { data } = await axios.get(url, { headers }); + const $ = cheerio.load(data); + + const servers = []; + $(".servers .server-item, .type-list .server-item").each((i, el) => { + const serverId = $(el).attr("data-id") || $(el).attr("data-server-id") || ""; + const serverName = $(el).text().trim() || ""; + const serverType = $(el).hasClass("sub") ? "sub" : "dub"; + + if (serverId) { + servers.push({ id: serverId, name: serverName, type: serverType }); + } + }); + + const sources = []; + $(".player-source .source-item, .embed-source").each((i, el) => { + const sourceId = $(el).attr("data-id") || ""; + const sourceUrl = $(el).attr("data-src") || $(el).find("iframe").attr("src") || ""; + const sourceType = $(el).attr("data-type") || "hls"; + + if (sourceId || sourceUrl) { + sources.push({ id: sourceId, url: sourceUrl, type: sourceType }); + } + }); + + const subtitles = []; + $(".player-sub .sub-item, .subtitle-item").each((i, el) => { + const subUrl = $(el).attr("data-url") || $(el).attr("src") || ""; + const subLabel = $(el).text().trim() || ""; + const subLang = $(el).attr("data-lang") || "en"; + + if (subUrl) { + subtitles.push({ url: subUrl, label: subLabel, language: subLang }); + } + }); + + return { + episodeId, + server, + type, + servers, + sources, + subtitles + }; + } catch (error) { + throw error; + } +}; + +export { extractStreamInfo }; \ No newline at end of file diff --git a/src/extractors/suggestion.extractor.js b/src/extractors/suggestion.extractor.js new file mode 100644 index 0000000..d6ab880 --- /dev/null +++ b/src/extractors/suggestion.extractor.js @@ -0,0 +1,32 @@ +import * as cheerio from "cheerio"; +import axios from "axios"; +import { headers } from "../configs/header.config.js"; +import { URLS } from "../configs/dataUrl.js"; + +const extractSuggestions = async (keyword) => { + try { + const url = `${URLS.search}?keyword=${encodeURIComponent(keyword)}`; + const { data } = await axios.get(url, { headers }); + const $ = cheerio.load(data); + + const suggestions = []; + $(".search-suggest .film-detail, .nav-item a").each((i, el) => { + const id = $(el).attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".film-name, .dynamic-name").text().trim() || ""; + const japaneseTitle = $(el).find(".fd-infor .fdi-item:first-child").text().trim() || ""; + const showType = $(el).find(".fdi-item:first-child").text().trim() || ""; + const releaseDate = $(el).find(".fdi-item:nth-child(2)").text().trim() || ""; + + if (id) { + suggestions.push({ id, poster, title, japaneseTitle, showType, releaseDate }); + } + }); + + return suggestions; + } catch (error) { + throw error; + } +}; + +export { extractSuggestions }; \ No newline at end of file diff --git a/src/extractors/topten.extractor.js b/src/extractors/topten.extractor.js new file mode 100644 index 0000000..1a28f76 --- /dev/null +++ b/src/extractors/topten.extractor.js @@ -0,0 +1,57 @@ +import * as cheerio from "cheerio"; +import axios from "axios"; +import { headers } from "../configs/header.config.js"; +import { URLS } from "../configs/dataUrl.js"; + +const extractTopTen = async () => { + try { + const { data } = await axios.get(URLS.topTen, { headers }); + const $ = cheerio.load(data); + + const today = []; + const week = []; + const month = []; + + $(".top-ten-list.today .top-ten-item, #top-viewed-day .top-list-item").each((i, el) => { + const id = $(el).find("a").attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const name = $(el).find(".film-name a, .dynamic-name").text().trim() || ""; + const number = parseInt($(el).find(".number").text().trim()) || i + 1; + const showType = $(el).find(".fd-infor .fdi-item:first-child").text().trim() || ""; + + if (id) { + today.push({ id, number, name, poster, tvInfo: { showType } }); + } + }); + + $(".top-ten-list.week .top-ten-item, #top-viewed-week .top-list-item").each((i, el) => { + const id = $(el).find("a").attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const name = $(el).find(".film-name a, .dynamic-name").text().trim() || ""; + const number = parseInt($(el).find(".number").text().trim()) || i + 1; + const showType = $(el).find(".fd-infor .fdi-item:first-child").text().trim() || ""; + + if (id) { + week.push({ id, number, name, poster, tvInfo: { showType } }); + } + }); + + $(".top-ten-list.month .top-ten-item, #top-viewed-month .top-list-item").each((i, el) => { + const id = $(el).find("a").attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const name = $(el).find(".film-name a, .dynamic-name").text().trim() || ""; + const number = parseInt($(el).find(".number").text().trim()) || i + 1; + const showType = $(el).find(".fd-infor .fdi-item:first-child").text().trim() || ""; + + if (id) { + month.push({ id, number, name, poster, tvInfo: { showType } }); + } + }); + + return { today, week, month }; + } catch (error) { + throw error; + } +}; + +export { extractTopTen }; \ No newline at end of file diff --git a/src/extractors/trending.extractor.js b/src/extractors/trending.extractor.js new file mode 100644 index 0000000..7ecdc4c --- /dev/null +++ b/src/extractors/trending.extractor.js @@ -0,0 +1,30 @@ +import * as cheerio from "cheerio"; +import axios from "axios"; +import { headers } from "../configs/header.config.js"; +import { URLS } from "../configs/dataUrl.js"; + +const extractTrending = async () => { + try { + const { data } = await axios.get(URLS.home, { headers }); + const $ = cheerio.load(data); + + const trending = []; + $(".trending-list .film-detail, .trending-item").each((i, el) => { + const id = $(el).find("a").attr("href")?.split("/").pop() || ""; + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".film-name a, .dynamic-name").text().trim() || ""; + const japaneseTitle = $(el).find(".fd-infor .fdi-item:first-child").text().trim() || ""; + const number = parseInt($(el).find(".number").text().trim()) || i + 1; + + if (id) { + trending.push({ id, number, poster, title, japaneseTitle }); + } + }); + + return trending; + } catch (error) { + throw error; + } +}; + +export { extractTrending }; \ No newline at end of file diff --git a/src/helper/cache.helper.js b/src/helper/cache.helper.js new file mode 100644 index 0000000..86e5812 --- /dev/null +++ b/src/helper/cache.helper.js @@ -0,0 +1,21 @@ +const cache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; + +const getCache = (key) => { + const item = cache.get(key); + if (item && Date.now() - item.timestamp < CACHE_TTL) { + return item.data; + } + cache.delete(key); + return null; +}; + +const setCache = (key, data) => { + cache.set(key, { data, timestamp: Date.now() }); +}; + +const clearCache = () => { + cache.clear(); +}; + +export { getCache, setCache, clearCache }; \ No newline at end of file diff --git a/src/helper/countPages.helper.js b/src/helper/countPages.helper.js new file mode 100644 index 0000000..b7b1b42 --- /dev/null +++ b/src/helper/countPages.helper.js @@ -0,0 +1,12 @@ +const countPages = ($) => { + const pages = []; + $(".pagination li a").each((i, el) => { + const text = $(el).text().trim(); + if (!isNaN(text) && text !== "") { + pages.push(parseInt(text)); + } + }); + return pages.length > 0 ? Math.max(...pages) : 1; +}; + +export { countPages }; \ No newline at end of file diff --git a/src/helper/extractPages.helper.js b/src/helper/extractPages.helper.js new file mode 100644 index 0000000..677dced --- /dev/null +++ b/src/helper/extractPages.helper.js @@ -0,0 +1,16 @@ +import * as cheerio from "cheerio"; +import axios from "axios"; +import { headers } from "../configs/header.config.js"; + +const extractPages = async (url, page = 1) => { + try { + const finalUrl = page > 1 ? `${url}?page=${page}` : url; + const { data } = await axios.get(finalUrl, { headers }); + const $ = cheerio.load(data); + return $; + } catch (error) { + throw error; + } +}; + +export { extractPages }; \ No newline at end of file diff --git a/src/helper/formatTitle.helper.js b/src/helper/formatTitle.helper.js new file mode 100644 index 0000000..eb438d2 --- /dev/null +++ b/src/helper/formatTitle.helper.js @@ -0,0 +1,9 @@ +const formatTitle = (title) => { + if (!title) return ""; + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +}; + +export { formatTitle }; \ No newline at end of file diff --git a/src/routes/apiRoutes.js b/src/routes/apiRoutes.js new file mode 100644 index 0000000..36867f1 --- /dev/null +++ b/src/routes/apiRoutes.js @@ -0,0 +1,132 @@ +import { getHomeInfo } from "../controllers/homeInfo.controller.js"; +import { getAnimeInfo } from "../controllers/animeInfo.controller.js"; +import { getSearchResults, getSearchSuggestions } from "../controllers/search.controller.js"; +import { getEpisodeList } from "../controllers/episodeList.controller.js"; +import { getStreamInfo } from "../controllers/streamInfo.controller.js"; +import { getSchedule } from "../controllers/schedule.controller.js"; +import { getSpotlight } from "../controllers/spotlight.controller.js"; +import { getTrending } from "../controllers/trending.controller.js"; +import { getTopTen } from "../controllers/topten.controller.js"; +import { getSuggestions } from "../controllers/suggestion.controller.js"; +import { getRandom } from "../controllers/random.controller.js"; +import { getPopular } from "../controllers/popular.controller.js"; +import { getFilter } from "../controllers/filter.controller.js"; +import { categoryRoutes } from "./category.route.js"; + +const createApiRoutes = (app, jsonResponse, jsonError) => { + app.get("/api/", async (req, res, next) => { + try { + await getHomeInfo(req, res, next); + } catch (error) { + jsonError(res, error.message); + } + }); + + app.get("/api/info", async (req, res, next) => { + try { + await getAnimeInfo(req, res, next); + } catch (error) { + jsonError(res, error.message); + } + }); + + app.get("/api/search", async (req, res, next) => { + try { + await getSearchResults(req, res, next); + } catch (error) { + jsonError(res, error.message); + } + }); + + app.get("/api/search/suggest", async (req, res, next) => { + try { + await getSearchSuggestions(req, res, next); + } catch (error) { + jsonError(res, error.message); + } + }); + + app.get("/api/episodes/:id", async (req, res, next) => { + try { + await getEpisodeList(req, res, next); + } catch (error) { + jsonError(res, error.message); + } + }); + + app.get("/api/stream", async (req, res, next) => { + try { + await getStreamInfo(req, res, next); + } catch (error) { + jsonError(res, error.message); + } + }); + + app.get("/api/schedule", async (req, res, next) => { + try { + await getSchedule(req, res, next); + } catch (error) { + jsonError(res, error.message); + } + }); + + app.get("/api/spotlight", async (req, res, next) => { + try { + await getSpotlight(req, res, next); + } catch (error) { + jsonError(res, error.message); + } + }); + + app.get("/api/trending", async (req, res, next) => { + try { + await getTrending(req, res, next); + } catch (error) { + jsonError(res, error.message); + } + }); + + app.get("/api/top-ten", async (req, res, next) => { + try { + await getTopTen(req, res, next); + } catch (error) { + jsonError(res, error.message); + } + }); + + app.get("/api/suggestions", async (req, res, next) => { + try { + await getSuggestions(req, res, next); + } catch (error) { + jsonError(res, error.message); + } + }); + + app.get("/api/random", async (req, res, next) => { + try { + await getRandom(req, res, next); + } catch (error) { + jsonError(res, error.message); + } + }); + + app.get("/api/most-popular", async (req, res, next) => { + try { + await getPopular(req, res, next); + } catch (error) { + jsonError(res, error.message); + } + }); + + app.get("/api/filter", async (req, res, next) => { + try { + await getFilter(req, res, next); + } catch (error) { + jsonError(res, error.message); + } + }); + + categoryRoutes(app, jsonResponse, jsonError); +}; + +export { createApiRoutes }; \ No newline at end of file diff --git a/src/routes/category.route.js b/src/routes/category.route.js new file mode 100644 index 0000000..f7c7921 --- /dev/null +++ b/src/routes/category.route.js @@ -0,0 +1,13 @@ +import { getCategory } from "../controllers/category.controller.js"; + +const categoryRoutes = (app, jsonResponse, jsonError) => { + app.get("/api/:category", async (req, res, next) => { + try { + await getCategory(req, res, next); + } catch (error) { + jsonError(res, error.message); + } + }); +}; + +export { categoryRoutes }; \ No newline at end of file diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..549ebf7 --- /dev/null +++ b/vercel.json @@ -0,0 +1,18 @@ +{ + "version": 2, + "builds": [ + { + "src": "server.js", + "use": "@vercel/node" + } + ], + "routes": [ + { + "src": "/(.*)", + "dest": "server.js", + "headers": { + "Access-Control-Allow-Origin": "*" + } + } + ] +} \ No newline at end of file