mirror of
https://github.com/Shineii86/AniKotoAPI.git
synced 2026-10-22 10:55:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f2258a8d5 | ||
|
|
1d71a1e557 | ||
|
|
f04a09574c | ||
|
|
ed964a4afd | ||
|
|
30fdcd93c2 | ||
|
|
1ee01470d4 |
@@ -25,3 +25,12 @@ MIRROR_CACHE_TTL=3600000
|
||||
|
||||
# Environment (production/development)
|
||||
NODE_ENV=production
|
||||
|
||||
# Domains the M3U8/TS stream proxies may fetch from (comma-separated).
|
||||
# Subdomains are covered automatically, so list the registrable domain only.
|
||||
# Leave unset to use the built-in defaults.
|
||||
# STREAM_PROXY_DOMAINS=megaplay.buzz,vidtube.site,vid-tube.site,vidplay.site,anipixcdn.co,norami.top,kryntal.top,akirax.buzz
|
||||
|
||||
# Referer sent to stream CDNs. They reject the site origin and an empty
|
||||
# Referer; only the player origin passes. Change if the player domain moves.
|
||||
# STREAM_PROXY_REFERER=https://megaplay.buzz/
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
.DS_Store
|
||||
*.log
|
||||
*.log
|
||||
*.pid
|
||||
|
||||
@@ -5,6 +5,63 @@ 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).
|
||||
|
||||
## [2.5.1] - 2026-10-13
|
||||
|
||||
### Added
|
||||
- **Direct Embed Providers**: New `GET /api/stream/embeds` endpoint returning iframe-ready player URLs for **Megavid** (megavid.buzz), **AniXo** (anixo.buzz) and **MegaPlay** (megaplay.buzz) — bypasses Cloudflare entirely since playback happens in the provider's own iframe, no source-site AJAX scraping needed at watch time
|
||||
- **Provider Config**: New `src/configs/embedProviders.config.js` — provider registry with URL builders (patterns verified against aluukill/AniCult's provider registry)
|
||||
- **Embed Extractor**: New `src/extractors/embedProviders.extractor.js` — resolves MAL ID from the site's episode list (`data-mal`), looks up AniList ID via the public AniList GraphQL API (cached 60min), builds provider URLs
|
||||
- **Embed Controller**: New `src/controllers/embedProviders.controller.js` — supports `?slug=`, `?mal=`, `?anilist=` (direct IDs skip slug scraping entirely), `?ep=`, `?lang=sub|dub`, `?provider=` comma filter, with 400 errors for unknown providers
|
||||
- **IDs Cache TTL**: New `TTL.ids` (60min) for MAL/AniList ID mappings — these never change per anime
|
||||
- **OpenAPI**: Registered `/stream/embeds` in the spec
|
||||
- **Info External IDs**: `GET /api/info` responses now include `externalIds: { animeId, malId, anilistId }` — resolved server-side via the episode-list + AniList lookups (60min cache), best-effort (failed resolution returns nulls, never breaks the response); lets clients feed IDs straight into `/api/stream/embeds`
|
||||
- **Latest-Episode Checker**: New `GET /api/episodes/latest?slug=` endpoint (new `episodeLatest.controller.js`) — returns only `latest: { episode_no, title, id, server_ids, mal_id, ... }` plus `animeId`/`totalEpisodes`, built for bot polling (10min cache, registered before `/episodes/:id` so the literal path wins)
|
||||
- **Tests**: Added `Embed Providers`, `Embed (direct MAL)`, `Info External IDs` and `Latest Episode` cases to test.js
|
||||
|
||||
### Fixed
|
||||
- **Spotlight Carousel Selectors (BREAKING drift)**: Source site redesigned the `#hotest` carousel — updated `homeInfo.extractor.js` and `spotlight.extractor.js` for the new markup (`swiper-slide` without `.item`, `a.btn.btn-primary` instead of `a.play`, `.cover img` instead of `.image div[style]`, `h2.ak-title` instead of `h2.title`, `.ak-desc` instead of `.desc`, type/date/rating packed into `.ak-detail span.it`). Old-layout selectors kept as fallbacks (dual-selector). Spotlight was returning 0 items before this fix; now returns 9.
|
||||
- **Spotlight Rating**: New layout packs rating into the `.ak-detail` list alongside year/date — numeric-with-decimal matching prevents the year (e.g. 2026) from being mistaken for the rating (e.g. 7.84)
|
||||
- **Diagnostic Sweep Fixes**: Full-repo deep scan caught stale counts — README endpoints badge, docs/index.md badge/table/tree/sample counts (38→39 endpoints, 28→29 scrapers/handlers), docs/endpoints.md sample `/stats` response, public/index.html animated stat counter, docs/architecture.md extractor count and Episode & Stream category now list `embedProviders` (39 reached at that sweep, then 40 with the info-ID and latest-episode additions)
|
||||
- **render.yaml**: Added missing trailing newline (EOF)
|
||||
|
||||
### Changed
|
||||
- **Version**: Bumped to 2.5.1 (single release consolidating domain sync, embed providers, docs pass and diagnostic sweep)
|
||||
- **Mirror Domains Synced**: Verified all mirror domains against the official AniKoto domain hub (https://anikoto.site/#domains) — live check confirmed all 5 official domains return HTTP 200; replaced dead `anikoto.se` with official `anikototv.se` in `dataUrl.js` ALT_DOMAINS, `render.yaml` and README domain tables
|
||||
- **Documentation Style**: Full repo-style pass on embed modules — inline `NOTE`/`TIP` markers for cache-first lookups, MAL-ID dedup, failure-tolerant AniList resolution, direct-ID fast path, input sanitization, cache-key composition; corrected JSDoc examples to current AniList ID values
|
||||
- **Docs Sync**: `/api/stream/embeds` in docs/streaming.md (Direct Embed Providers section + TOC) and README streaming status table; project trees in README.md and docs/architecture.md list the new modules; `/episodes/latest` documented in docs/endpoints.md; all endpoint counts synced to 40 (README badge/highlights/checklist, landing-page metas and stat counter, docs/index.md badge/tables, docs/architecture.md controller counts, `/api/stats`); top-rankings `?sort=day|week|month` confirmed already supported — docs matched, no code change needed
|
||||
- **Dockerfile**: Base image `node:18-alpine` → `node:22-alpine` — Node 18 is EOL (Sept 2025); aligns with README's Node 20+ guidance and current LTS
|
||||
|
||||
### Example
|
||||
```
|
||||
GET /api/stream/embeds?slug=one-piece-odmau&ep=1150
|
||||
GET /api/stream/embeds?mal=21&ep=1&lang=dub&provider=megavid,megaplay
|
||||
```
|
||||
|
||||
### Verified
|
||||
- All 77 JS files pass `node --check`; all relative imports resolve
|
||||
- Official domains (all HTTP 200): `anikototv.to` (primary), `anikoto.cz`, `anikoto.me`, `anikoto.net`, `anikototv.se`; dead `anikoto.se` confirmed offline
|
||||
- All 3 embed provider URLs live-verified HTTP 200 (Megavid serves embed-only page by design)
|
||||
- Live smoke test 9/9 extractors passed; full API suite 38 passed / 0 failed / 2 optional skips (upstream AJAX blocks, documented)
|
||||
- No secrets tracked in git
|
||||
|
||||
## [2.4.1] - 2026-08-27
|
||||
|
||||
### Security
|
||||
- **CRITICAL**: Fixed unauthenticated SSRF in `/api/stream/proxy` and `/api/stream/ts-proxy` — the proxy endpoint's domain allowlist had an escape hatch (`pathname.endsWith(".m3u8")`) that let any host through, and `ts-proxy` had no destination check at all. Both could be used to fetch internal services (127.0.0.1, 169.254.169.254) and read responses.
|
||||
- Added shared `streamProxy.helper.js` with a real allowlist by registrable domain, private/loopback/link-local address blocking as a second layer, and https-only scheme enforcement. Both endpoints now use this guard.
|
||||
- Removed dead domain `megaplay-1.buzz` from allowlist; added `vid-tube.site` and CDNs currently serving streams (`norami.top`, `kryntal.top`, `akirax.buzz`).
|
||||
- Added `SECURITY.md` with vulnerability reporting policy (GitHub private vulnerability reporting preferred, Telegram fallback).
|
||||
|
||||
### Fixed
|
||||
- **Environment loading**: Fixed `dotenv.config()` being called after static imports — helpers reading `process.env` at module top level (mirror, cache, proxy, streamProxy) were never receiving `.env` values. Changed to `import "dotenv/config"` as the first import so `.env` is populated before any helper module loads. Affected settings: `MIRROR_DOMAINS`, `MIRROR_CACHE_TTL`, `CACHE_MAX_SIZE`, `CACHE_DEFAULT_TTL`, `SCRAPER_API_KEY`, `FLARESOLVERR_URL`, `STREAM_PROXY_DOMAINS`, `STREAM_PROXY_REFERER`.
|
||||
|
||||
### Added
|
||||
- **Stream Proxy Allowlist Config**: New `STREAM_PROXY_DOMAINS` env var to override the built-in domain allowlist (comma-separated).
|
||||
- **Stream Proxy Referer Config**: New `STREAM_PROXY_REFERER` env var to override the Referer header sent to stream CDNs (default: `https://megaplay.buzz/`).
|
||||
|
||||
### Credits
|
||||
- **[jamil182](https://github.com/jamil182)** — Reported and fixed the SSRF vulnerability (PR #2), added SECURITY.md (PR #3)
|
||||
|
||||
## [2.4.0] - 2026-08-14
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:18-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -22,9 +22,10 @@
|
||||
<img src="https://img.shields.io/badge/Cheerio-1.0-3572A5?style=flat-square&logoColor=white" alt="Cheerio"/>
|
||||
<img src="https://img.shields.io/badge/Vercel-Serverless-000000?style=flat-square&logo=vercel&logoColor=white" alt="Vercel"/>
|
||||
<img src="https://img.shields.io/badge/License-MIT-22c55e?style=flat-square&logo=mit&logoColor=white" alt="License"/>
|
||||
<img src="https://img.shields.io/badge/Version-2.4.0-f43f8e?style=flat-square&logoColor=white" alt="Version"/>
|
||||
<img src="https://img.shields.io/badge/Endpoints-38-6366f1?style=flat-square&logoColor=white" alt="Endpoints"/>
|
||||
<img src="https://img.shields.io/badge/Version-2.4.1-f43f8e?style=flat-square&logoColor=white" alt="Version"/>
|
||||
<img src="https://img.shields.io/badge/Endpoints-40-6366f1?style=flat-square&logoColor=white" alt="Endpoints"/>
|
||||
<img src="https://img.shields.io/badge/Anime-10000+-a855f7?style=flat-square&logoColor=white" alt="Anime Database"/>
|
||||
<img src="https://img.shields.io/badge/Maintenance-Active-22c55e?style=flat-square&logo=github&logoColor=white" alt="Maintenance"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -87,7 +88,7 @@
|
||||
|
||||
### Why AniKotoAPI?
|
||||
|
||||
- 🎬 **38 Endpoints** — Complete coverage of anikototv.to data
|
||||
- 🎬 **40 Endpoints** — Complete coverage of anikototv.to data
|
||||
- 🔍 **Full-Text Search** — Search anime by keyword with suggestions
|
||||
- 📺 **Episode Lists** — AJAX-loaded episode data with server info
|
||||
- 🎯 **Smart Filtering** — Genre, type, status, rating, sort, season, year
|
||||
@@ -132,6 +133,8 @@ flowchart TD
|
||||
> **Streaming endpoints (`/api/servers`, `/api/stream`, `/api/stream/resolve`) return errors when anikototv.to blocks AJAX requests from server-side IPs.**
|
||||
>
|
||||
> This is expected behavior when scraping from datacenter IPs (including Vercel's). AniKoto's bot protection blocks non-browser requests to their AJAX endpoints. Metadata endpoints (home, search, info, trending, etc.) work perfectly — only streaming is affected.
|
||||
>
|
||||
> **✅ Alternative: `/api/stream/embeds` never touches AniKoto's AJAX layer** — it returns iframe-ready player URLs for Megavid/AniXo/MegaPlay built from MAL/AniList IDs, so playback works from any environment with zero Cloudflare friction.
|
||||
|
||||
| What Works | What's Blocked |
|
||||
|:---|:---|
|
||||
@@ -163,7 +166,8 @@ FLARESOLVERR_URL=http://your-flaresolverr-host:8191
|
||||
### ⚡ Core
|
||||
- **Real-time scraping** from anikototv.to
|
||||
- **Smart caching** with LRU and configurable TTL per endpoint
|
||||
- **38 RESTful endpoints** covering all data
|
||||
- **40 RESTful endpoints** covering all data
|
||||
- **Direct embed providers** (Megavid/AniXo/MegaPlay) — Cloudflare-free iframe playback
|
||||
- **AJAX episode loading** for accurate data
|
||||
- **Mapper API integration** for extra servers
|
||||
- **Graceful error handling** per endpoint
|
||||
@@ -214,7 +218,7 @@ FLARESOLVERR_URL=http://your-flaresolverr-host:8191
|
||||
|
||||
| Feature | Description | Status |
|
||||
|:---|:---|:---:|
|
||||
| 🎬 38 API Endpoints | Complete coverage of anime data | ✅ |
|
||||
| 🎬 40 API Endpoints | Complete coverage of anime data | ✅ |
|
||||
| 🔍 Full-Text Search | Keyword search with pagination | ✅ |
|
||||
| 📺 Episode Lists | AJAX-loaded episode data | ✅ |
|
||||
| 🎯 Advanced Filtering | Genre, type, status, rating, sort, season | ✅ |
|
||||
@@ -239,7 +243,7 @@ FLARESOLVERR_URL=http://your-flaresolverr-host:8191
|
||||
| **AniKoto** | `anikoto.cz` | ✅ Active | Mirror domain |
|
||||
| **AniKoto** | `anikoto.me` | ✅ Active | Mirror domain |
|
||||
| **AniKoto** | `anikoto.net` | ✅ Active | Mirror domain |
|
||||
| **AniKoto** | `anikoto.se` | ✅ Active | Mirror domain |
|
||||
| **AniKoto** | `anikototv.se` | ✅ Active | Mirror domain |
|
||||
|
||||
### Data Available
|
||||
|
||||
@@ -332,7 +336,7 @@ AniKotoAPI/
|
||||
│ │
|
||||
│ ├── 📂 docs/ # 📚 API documentation
|
||||
│ │ ├── 📄 index.md # Overview, quick start
|
||||
│ │ ├── 📄 endpoints.md # Full API reference (38 endpoints)
|
||||
│ │ ├── 📄 endpoints.md # Full API reference (40 endpoints)
|
||||
│ │ ├── 📄 streaming.md # Streaming flow guide + proxy
|
||||
│ │ ├── 📄 examples.md # Code examples (cURL, JS, Python)
|
||||
│ │ ├── 📄 architecture.md # Project structure, tech stack
|
||||
@@ -343,32 +347,36 @@ AniKotoAPI/
|
||||
│ ├── 📂 configs/ # 🔧 Configuration files
|
||||
│ │ ├── 📄 dataUrl.js # 🌐 URL patterns for anikototv.to
|
||||
│ │ ├── 📄 header.config.js # 📋 Request headers
|
||||
│ │ └── 📄 ids.config.js # 🏷️ Genre/Type/Status/Source/Season ID mappings
|
||||
│ │ ├── 📄 ids.config.js # 🏷️ Genre/Type/Status/Source/Season ID mappings
|
||||
│ │ └── 📄 embedProviders.config.js # 🆕 Direct embed provider registry (Megavid/AniXo/MegaPlay)
|
||||
│ │
|
||||
│ ├── 📂 controllers/ # 🎮 Route handlers (28 files)
|
||||
│ ├── 📂 controllers/ # 🎮 Route handlers (30 files)
|
||||
│ │ ├── 📄 homeInfo.controller.js
|
||||
│ │ ├── 📄 animeInfo.controller.js
|
||||
│ │ ├── 📄 search.controller.js
|
||||
│ │ ├── 📄 episodeList.controller.js
|
||||
│ │ ├── 📄 episodeLatest.controller.js # 🆕 Latest-episode checker (bots)
|
||||
│ │ ├── 📄 episodeListAjax.controller.js
|
||||
│ │ ├── 📄 streamInfo.controller.js
|
||||
│ │ ├── 📄 streamResolver.controller.js # 🆕 Stream URL resolution + quality detection
|
||||
│ │ ├── 📄 embedProviders.controller.js # 🆕 Direct embed provider URLs
|
||||
│ │ ├── 📄 filter.controller.js
|
||||
│ │ ├── 📄 watchPage.controller.js
|
||||
│ │ └── 📄 ... (15 more)
|
||||
│ │ └── 📄 ... (17 more)
|
||||
│ │
|
||||
│ ├── 📂 extractors/ # 🔍 HTML scrapers (28 files)
|
||||
│ ├── 📂 extractors/ # 🔍 HTML scrapers (29 files)
|
||||
│ │ ├── 📄 homeInfo.extractor.js # 🏠 Home page data
|
||||
│ │ ├── 📄 search.extractor.js # 🔍 Search results
|
||||
│ │ ├── 📄 animeInfo.extractor.js # 📺 Anime details
|
||||
│ │ ├── 📄 streamInfo.extractor.js # 📡 Streaming servers
|
||||
│ │ ├── 📄 streamResolver.extractor.js # 🔗 Stream URL resolution, M3U8 parser
|
||||
│ │ ├── 📄 embedProviders.extractor.js # 🆕 MAL/AniList ID resolution + embed URLs
|
||||
│ │ ├── 📄 filter.extractor.js # 🎯 Filtered results
|
||||
│ │ ├── 📄 watchPage.extractor.js # ▶️ Watch page data
|
||||
│ │ ├── 📄 seasons.extractor.js # 📅 Season info from watch page
|
||||
│ │ ├── 📄 watchOrder.extractor.js # 📋 Watch order from sidebar
|
||||
│ │ ├── 📄 episodeList.extractor.js # 📋 Episode lists
|
||||
│ │ └── 📄 ... (16 more)
|
||||
│ │ └── 📄 ... (17 more)
|
||||
│ │
|
||||
│ ├── 📂 helper/ # 🛠️ Utility functions
|
||||
│ │ ├── 📄 cache.helper.js # 💾 In-memory caching
|
||||
@@ -383,7 +391,7 @@ AniKotoAPI/
|
||||
│ │ └── 📄 creatorInfo.js # ✍️ Creator attribution injection
|
||||
│ │
|
||||
│ └── 📂 routes/ # 🛤️ Express routes
|
||||
│ ├── 📄 apiRoutes.js # 🌐 Main API routes (38 endpoints)
|
||||
│ ├── 📄 apiRoutes.js # 🌐 Main API routes (40 endpoints)
|
||||
│ └── 📄 category.route.js # 🏷️ Category routes
|
||||
│
|
||||
├── 📄 server.js # 🚀 Express server entry point
|
||||
@@ -2343,6 +2351,8 @@ CMD ["node", "server.js"]
|
||||
|
||||
| Version | Date | Key Changes |
|
||||
|:---|:---|:---|
|
||||
| **2.5.1** | 2026-10-13 | Domain sync vs official hub, spotlight carousel fix, direct embed providers (`/api/stream/embeds`), info `externalIds` + latest-episode checker, full docs pass, diagnostic sweep, Dockerfile Node 22 |
|
||||
| **2.4.1** | 2026-08-27 | Security: fixed SSRF in stream proxies, dotenv load order fix, SECURITY.md |
|
||||
| **2.2.0** | 2026-07-28 | Streaming resolver, M3U8/TS proxy, 5 new endpoints, bug fixes, full diagnostic sweep |
|
||||
| **1.7.2** | 2026-06-08 | Full rebrand AniKotoAPI → AniKotoAPI, docs folder with real data, streaming fix |
|
||||
| **1.7.1** | 2026-06-08 | Updated Vercel URL to anikototvapi.vercel.app |
|
||||
@@ -2455,7 +2465,7 @@ The API can access 10,000+ anime titles from anikototv.to. The most-popular endp
|
||||
|
||||
### ✅ Completed
|
||||
|
||||
- [x] 🎬 38 API endpoints covering all data
|
||||
- [x] 🎬 40 API endpoints covering all data
|
||||
- [x] 🔍 Full-text search with pagination
|
||||
- [x] 📺 Episode lists via AJAX loading
|
||||
- [x] 🎯 Advanced filtering (genre, type, status, rating, sort, season, year)
|
||||
@@ -2558,7 +2568,8 @@ git push origin feature/amazing-feature
|
||||
| [AniKoto CZ](https://anikoto.cz) | Mirror domain |
|
||||
| [AniKoto ME](https://anikoto.me) | Mirror domain |
|
||||
| [AniKoto NET](https://anikoto.net) | Mirror domain |
|
||||
| [AniKoto SE](https://anikoto.se) | Mirror domain |
|
||||
| [AniKoto SE](https://anikototv.se) | Mirror domain |
|
||||
| [AniKoto Hub](https://anikoto.site) | Official domain verification hub |
|
||||
|
||||
### 🛠️ Technologies
|
||||
|
||||
@@ -2573,6 +2584,12 @@ git push origin feature/amazing-feature
|
||||
- [Star History](https://star-history.com/) — GitHub star history charts
|
||||
- [Capsule Render](https://github.com/kyechan99/capsule-render) — Header banner generator
|
||||
|
||||
### 🔒 Security Contributors
|
||||
|
||||
| Contributor | Contribution |
|
||||
|:---|:---|
|
||||
| [jamil182](https://github.com/jamil182) | Reported & fixed unauthenticated SSRF in stream proxy endpoints (PR #2), added SECURITY.md (PR #3) |
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 2.4.x | ✅ |
|
||||
| < 2.4 | ❌ |
|
||||
|
||||
Fixes land on the latest release. If you are running an older version, upgrade before reporting — the issue may already be resolved.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please do not open a public issue or pull request for a security problem.** Public reports expose every running instance until a fix ships.
|
||||
|
||||
Report privately instead:
|
||||
|
||||
1. **Preferred — GitHub private vulnerability reporting.** Use the **Report a vulnerability** button on the [Security tab](https://github.com/Shineii86/AniKotoAPI/security). This keeps the discussion private until an advisory is published.
|
||||
2. **Fallback — Telegram.** If private reporting is unavailable, message [@Shineii86](https://telegram.me/Shineii86) asking for a private channel. Send only a one-line description there, not the full details.
|
||||
|
||||
## What to include
|
||||
|
||||
The more of this you can provide, the faster a fix can be confirmed:
|
||||
|
||||
- The affected endpoint or module, and the version or commit you tested.
|
||||
- What an attacker can achieve — reading data, changing state, exhausting resources.
|
||||
- Minimal steps to reproduce, ideally a single request.
|
||||
- Whether authentication or any particular deployment setup is required.
|
||||
- A suggested fix, if you have one.
|
||||
|
||||
## What happens next
|
||||
|
||||
- Reports are acknowledged as soon as the maintainer sees them.
|
||||
- Once confirmed, a fix is prepared privately and released, and a GitHub advisory is published crediting the reporter unless anonymity is requested.
|
||||
- Reporters are asked to hold public details until the fix is released.
|
||||
|
||||
This project is maintained by one person as unpaid work. Please read the timing above as intent rather than a guaranteed schedule.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope — anything that lets someone abuse a deployed instance:
|
||||
|
||||
- Server-side request forgery, or any way to make the server fetch or reach destinations it should not.
|
||||
- Injection, path traversal, or unsafe handling of caller-supplied input.
|
||||
- Leakage of environment variables, credentials, or internal configuration.
|
||||
- Denial of service reachable through a single unauthenticated request.
|
||||
- Vulnerable dependencies with a practical path to exploitation in this codebase.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Availability, downtime, or changes to the upstream anime sites this API scrapes.
|
||||
- Rate limiting or abuse controls on those upstream sites.
|
||||
- Findings that require an already-compromised host or shell access to the server.
|
||||
- Reports produced by automated scanners with no demonstrated impact.
|
||||
+14
-9
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* AniKotoAPI — Architecture Document
|
||||
* Version : 2.4.0
|
||||
* Version : 2.4.1
|
||||
* Generated: 2026-08-14
|
||||
* ============================================================================
|
||||
*
|
||||
@@ -67,13 +67,13 @@
|
||||
* │ │ │ │ │
|
||||
* │ │ ▼ │ │
|
||||
* │ │ ┌──────────────────────────────────────────────────────────┐ │ │
|
||||
* │ │ │ CONTROLLER LAYER (28 controllers) │ │ │
|
||||
* │ │ │ CONTROLLER LAYER (30 controllers) │ │ │
|
||||
* │ │ │ Validation · Parameter parsing · Response formatting │ │ │
|
||||
* │ │ └──────────────────────┬───────────────────────────────────┘ │ │
|
||||
* │ │ │ │ │
|
||||
* │ │ ▼ │ │
|
||||
* │ │ ┌──────────────────────────────────────────────────────────┐ │ │
|
||||
* │ │ │ EXTRACTOR / SCRAPER LAYER (28 extractors) │ │ │
|
||||
* │ │ │ EXTRACTOR / SCRAPER LAYER (29 extractors) │ │ │
|
||||
* │ │ │ HTML fetching · Cheerio parsing · Data transformation │ │ │
|
||||
* │ │ └──────────────────────┬───────────────────────────────────┘ │ │
|
||||
* │ │ │ │ │
|
||||
@@ -180,15 +180,18 @@
|
||||
* ├── src/
|
||||
* │ ├── configs/
|
||||
* │ │ ├── header.config.js # Default HTTP headers for upstream requests
|
||||
* │ │ └── ids.config.js # Genre / Type / Status numeric ID mappings
|
||||
* │ ├── controllers/ # 28 route handlers (one per endpoint)
|
||||
* │ │ ├── ids.config.js # Genre / Type / Status numeric ID mappings
|
||||
* │ │ └── embedProviders.config.js # Direct embed provider registry (Megavid/AniXo/MegaPlay)
|
||||
* │ ├── controllers/ # 30 route handlers (one per endpoint)
|
||||
* │ │ ├── homeInfo.controller.js
|
||||
* │ │ ├── animeInfo.controller.js
|
||||
* │ │ ├── search.controller.js
|
||||
* │ │ ├── episodeList.controller.js
|
||||
* │ │ ├── episodeLatest.controller.js
|
||||
* │ │ ├── episodeListAjax.controller.js
|
||||
* │ │ ├── streamInfo.controller.js
|
||||
* │ │ ├── streamResolver.controller.js
|
||||
* │ │ ├── embedProviders.controller.js
|
||||
* │ │ ├── schedule.controller.js
|
||||
* │ │ ├── spotlight.controller.js
|
||||
* │ │ ├── trending.controller.js
|
||||
@@ -210,7 +213,7 @@
|
||||
* │ │ ├── recentlyUpdatedTabs.controller.js
|
||||
* │ │ ├── completedAnime.controller.js
|
||||
* │ │ └── category.controller.js
|
||||
* │ ├── extractors/ # 28 HTML scrapers (one per data source)
|
||||
* │ ├── extractors/ # 29 HTML scrapers (one per data source)
|
||||
* │ │ ├── homeInfo.extractor.js
|
||||
* │ │ ├── animeInfo.extractor.js
|
||||
* │ │ ├── search.extractor.js
|
||||
@@ -218,6 +221,7 @@
|
||||
* │ │ ├── episodeListAjax.extractor.js
|
||||
* │ │ ├── streamInfo.extractor.js
|
||||
* │ │ ├── streamResolver.extractor.js
|
||||
* │ │ ├── embedProviders.extractor.js
|
||||
* │ │ ├── schedule.extractor.js
|
||||
* │ │ ├── spotlight.extractor.js
|
||||
* │ │ ├── trending.extractor.js
|
||||
@@ -308,7 +312,7 @@
|
||||
* └────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* ┌────────────────────────────────────────────────────────────────────────┐
|
||||
* │ Extractors (28 scrapers) │
|
||||
* │ Extractors (29 scrapers) │
|
||||
* ├────────────────────────────────────────────────────────────────────────┤
|
||||
* │ One extractor per data source on the target site. Responsibilities: │
|
||||
* │ • Build the upstream URL (via mirror.helper) │
|
||||
@@ -832,7 +836,7 @@
|
||||
* │ VERSION HISTORY │
|
||||
* ├──────────────────────────────────────────────────────────────────────┤
|
||||
* │ │
|
||||
* │ v2.4.0 (Current) │
|
||||
* │ v2.4.1 (Current) │
|
||||
* │ • 28 endpoints, 28 extractors │
|
||||
* │ • LRU cache with per-endpoint TTL │
|
||||
* │ • 5-mirror failover system │
|
||||
@@ -861,7 +865,8 @@
|
||||
* │ Search & Filter │ search, filter, azList, category │
|
||||
* │ Anime Detail │ animeInfo, watchPage, status │
|
||||
* │ Episode & Stream │ episodeList, episodeListAjax, │
|
||||
* │ │ streamInfo, streamResolver, download │
|
||||
* │ │ streamInfo, streamResolver, download,│
|
||||
* │ │ embedProviders │
|
||||
* │ Lists │ newRelease, trendingSidebar, │
|
||||
* │ │ topAnimeRankings, recentlyUpdated, │
|
||||
* │ │ completedAnime, upcomingAnime │
|
||||
|
||||
+93
-4
@@ -164,7 +164,7 @@ curl "https://anikototvapi.vercel.app/api/search?keyword=naruto"
|
||||
|
||||
## GET /info
|
||||
|
||||
Get detailed info about an anime.
|
||||
Get detailed info about an anime. Includes `externalIds` (MAL/AniList IDs) that can be passed directly to `/stream/embeds`.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
@@ -172,6 +172,8 @@ Get detailed info about an anime.
|
||||
|
||||
> **Important:** Slugs now include random hashes (e.g., `one-piece-odmau`). Get valid slugs from `/search` or `/most-popular` results.
|
||||
|
||||
> **Note:** `externalIds` is resolved server-side (cached 60 min) and is best-effort — if ID resolution fails it comes back as `null` values, the rest of the response is unaffected.
|
||||
|
||||
**Request:**
|
||||
|
||||
```bash
|
||||
@@ -203,7 +205,12 @@ curl "https://anikototvapi.vercel.app/api/info?id=naruto-shippuuden-movie-6-road
|
||||
"producers": ["TV Tokyo", "Shueisha"],
|
||||
"genres": ["Action", "Comedy", "Supernatural"],
|
||||
"rating": "PG-13",
|
||||
"reviewCount": "0"
|
||||
"reviewCount": "0",
|
||||
"externalIds": {
|
||||
"animeId": 1642,
|
||||
"malId": "21",
|
||||
"anilistId": "21"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -254,6 +261,46 @@ curl "https://anikototvapi.vercel.app/api/episodes/958"
|
||||
|
||||
---
|
||||
|
||||
## GET /episodes/latest
|
||||
|
||||
Latest-episode checker — returns only the newest episode plus summary counts. Built for bots and notification jobs ("is there a new episode?") that shouldn't download the full episode list.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `slug` | string | Yes | Anime slug or animeId |
|
||||
|
||||
**Request:**
|
||||
|
||||
```bash
|
||||
curl "https://anikototvapi.vercel.app/api/episodes/latest?slug=one-piece-odmau"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"results": {
|
||||
"slug": "one-piece-odmau",
|
||||
"animeId": 1642,
|
||||
"totalEpisodes": 1165,
|
||||
"latest": {
|
||||
"episode_no": 1165,
|
||||
"title": "",
|
||||
"id": "50167",
|
||||
"slug": "1165",
|
||||
"href": "#",
|
||||
"server_ids": "dXNCT3hNQzk3THhSTW8ySnM5...",
|
||||
"mal_id": "21"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Tip:** Poll this endpoint, compare `results.latest.episode_no` with your stored value, and call `/servers?ids=results.latest.server_ids` only when it changes. Responses are cached for 10 minutes.
|
||||
|
||||
---
|
||||
|
||||
## GET /servers
|
||||
|
||||
Get available servers for an episode.
|
||||
@@ -835,6 +882,48 @@ Returns array of quality objects with url, quality label, resolution, bandwidth,
|
||||
|
||||
---
|
||||
|
||||
## GET /stream/embeds
|
||||
|
||||
Direct embed provider URLs (Megavid / AniXo / MegaPlay) — iframe-ready players that bypass Cloudflare entirely. Playback happens inside the provider's own iframe; no source-site AJAX scraping at watch time.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `slug` | string | One of* | Anime slug — MAL/AniList IDs resolved automatically |
|
||||
| `mal` | string | One of* | Direct MAL ID (skips slug lookup) |
|
||||
| `anilist` | string | One of* | Direct AniList ID (skips slug lookup) |
|
||||
| `ep` | number | No | Episode number (default 1) |
|
||||
| `lang` | string | No | `sub` or `dub` (default `sub`) |
|
||||
| `provider` | string | No | Comma-separated filter: `megaplay`, `anixo`, `megavid` |
|
||||
|
||||
\* Provide at least one of `slug`, `mal`, or `anilist`.
|
||||
|
||||
```bash
|
||||
curl "https://anikototvapi.vercel.app/api/stream/embeds?slug=one-piece-odmau&ep=1"
|
||||
curl "https://anikototvapi.vercel.app/api/stream/embeds?mal=21&ep=1&lang=dub&provider=megavid"
|
||||
```
|
||||
|
||||
Returns resolved IDs plus iframe-ready URLs per provider:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"results": {
|
||||
"slug": "one-piece-odmau",
|
||||
"episode": 1,
|
||||
"lang": "sub",
|
||||
"ids": { "animeId": 1642, "malId": "21", "anilistId": "21" },
|
||||
"providers": [
|
||||
{ "id": "megaplay", "name": "MegaPlay", "url": "https://megaplay.buzz/stream/mal/21/1/sub", "iframeReady": true },
|
||||
{ "id": "anixo", "name": "AniXo", "url": "https://anixo.buzz/embed/ani/21/1/sub?color=%23e63946", "iframeReady": true },
|
||||
{ "id": "megavid", "name": "Megavid", "url": "https://megavid.buzz/mal/21/1/sub?color=%23e63946&autoplay=true", "iframeReady": true }
|
||||
],
|
||||
"unavailable": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /stream/proxy
|
||||
|
||||
M3U8 playlist proxy — rewrites relative URLs to proxy endpoints for CORS-free playback.
|
||||
@@ -884,7 +973,7 @@ curl "https://anikototvapi.vercel.app/api/health"
|
||||
"success": true,
|
||||
"results": {
|
||||
"status": "healthy",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"uptime": "0h 1m 21s",
|
||||
"uptimeSeconds": 81,
|
||||
"timestamp": "2026-07-30T02:20:46.153Z",
|
||||
@@ -924,7 +1013,7 @@ curl "https://anikototvapi.vercel.app/api/stats"
|
||||
"ttl": "5 minutes",
|
||||
"description": "Map-based cache with TTL expiration"
|
||||
},
|
||||
"endpoints": 38,
|
||||
"endpoints": 40,
|
||||
"timestamp": "2026-07-30T02:20:46.205Z"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ curl -s "https://anikototvapi.vercel.app/api/health" | python3 -m json.tool
|
||||
"success": true,
|
||||
"results": {
|
||||
"status": "healthy",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"uptime": "0h 1m 21s",
|
||||
"uptimeSeconds": 81,
|
||||
"timestamp": "2026-07-30T02:20:46.153Z",
|
||||
@@ -2036,7 +2036,7 @@ curl -s "https://anikototvapi.vercel.app/api/health" | python3 -m json.tool
|
||||
"success": true,
|
||||
"results": {
|
||||
"status": "healthy",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"uptime": "0h 1m 21s",
|
||||
"uptimeSeconds": 81,
|
||||
"timestamp": "2026-07-30T02:20:46.153Z",
|
||||
|
||||
+11
-11
@@ -1,13 +1,13 @@
|
||||
# // === HEADER ===
|
||||
# AniKotoAPI v2.4.0
|
||||
# AniKotoAPI v2.4.1
|
||||
# Free REST API for Anime Data
|
||||
# Scraping anikototv.to with Cheerio
|
||||
|
||||
---
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
> A fast, free REST API that scrapes anime data from **anikototv.to**.
|
||||
@@ -70,9 +70,9 @@ That's it. No API keys required.
|
||||
|
||||
| Feature | Description |
|
||||
|---|---|
|
||||
| **38+ Endpoints** | Full coverage of anime data, streaming, and discovery |
|
||||
| **28 HTML Scrapers** | Cheerio-based scrapers for robust data extraction |
|
||||
| **28 Route Handlers** | Organized controllers with clean separation of concerns |
|
||||
| **40+ Endpoints** | Full coverage of anime data, streaming, and discovery |
|
||||
| **29 HTML Scrapers** | Cheerio-based scrapers for robust data extraction |
|
||||
| **30 Route Handlers** | Organized controllers with clean separation of concerns |
|
||||
| **5 Mirror Domains** | Automatic failover across multiple source domains |
|
||||
| **LRU Cache** | Per-endpoint TTL caching for performance |
|
||||
| **Rate Limiting** | 100 requests per 15 minutes per IP |
|
||||
@@ -194,12 +194,12 @@ AniKotoAPI/
|
||||
│ ├── info.js # Anime info
|
||||
│ ├── search.js # Search
|
||||
│ ├── stream.js # Streaming
|
||||
│ └── ... # 38+ route handlers
|
||||
│ └── ... # 40 route handlers
|
||||
├── scrapers/ # Cheerio HTML scrapers
|
||||
│ ├── home.js
|
||||
│ ├── info.js
|
||||
│ ├── search.js
|
||||
│ └── ... # 28 scrapers
|
||||
│ └── ... # 29 scrapers
|
||||
├── utils/ # Shared utilities
|
||||
│ ├── cache.js # LRU cache with TTL
|
||||
│ ├── rateLimit.js # IP-based rate limiter
|
||||
@@ -218,8 +218,8 @@ AniKotoAPI/
|
||||
|
||||
### Key Design Patterns
|
||||
|
||||
- **Scraping Layer**: 28 Cheerio-based scrapers parse HTML from anikototv.to, isolating page-specific logic from route handlers.
|
||||
- **Controller Layer**: 28 route handlers map HTTP requests to scraper calls, handle validation, and format responses.
|
||||
- **Scraping Layer**: 29 Cheerio-based scrapers parse HTML from anikototv.to, isolating page-specific logic from route handlers.
|
||||
- **Controller Layer**: 30 route handlers map HTTP requests to scraper calls, handle validation, and format responses.
|
||||
- **Cache Layer**: LRU cache with per-endpoint TTL reduces redundant scraping. Hot endpoints cache for 2–5 minutes; cold endpoints for 10+ minutes.
|
||||
- **Mirror Layer**: 5 source domains with automatic failover. If one domain is slow or down, requests transparently fall back to the next mirror.
|
||||
- **Rate Limiter**: Token bucket algorithm, 100 requests per 15-minute window per client IP.
|
||||
@@ -350,7 +350,7 @@ All errors return JSON with a consistent structure:
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [endpoints.md](./endpoints.md) | Full API reference for all 38+ endpoints |
|
||||
| [endpoints.md](./endpoints.md) | Full API reference for all 40+ endpoints |
|
||||
| [streaming.md](./streaming.md) | Complete streaming workflow guide |
|
||||
| [examples.md](./examples.md) | Working code examples in cURL, JS, Python |
|
||||
| [architecture.md](./architecture.md) | Project structure and design patterns |
|
||||
|
||||
+47
-6
@@ -15,6 +15,8 @@ This document covers the complete streaming workflow for AniKotoAPI — from sea
|
||||
## ⚠️ Streaming Status
|
||||
|
||||
> **Streaming endpoints (`/api/servers`, `/api/stream`, `/api/stream/resolve`) return errors when anikototv.to blocks AJAX requests from server-side IPs.**
|
||||
>
|
||||
> **✅ Alternative: `/api/stream/embeds` never touches AniKoto's AJAX layer** — see [Direct Embed Providers](#direct-embed-providers).
|
||||
|
||||
| What Works | What's Blocked |
|
||||
|:---|:---|
|
||||
@@ -24,6 +26,7 @@ This document covers the complete streaming workflow for AniKotoAPI — from sea
|
||||
| Top 10, most popular, new release | `/api/mapper-servers` — gogoanime servers |
|
||||
| AZ list, random, genre list | All AJAX-dependent endpoints |
|
||||
| All HTML-scraped endpoints | |
|
||||
| **`/api/stream/embeds` — direct provider iframes** | |
|
||||
|
||||
**To fix streaming**, set one of these environment variables:
|
||||
|
||||
@@ -35,17 +38,55 @@ SCRAPER_API_KEY=your_scraperapi_key
|
||||
FLARESOLVERR_URL=http://your-flaresolverr-host:8191
|
||||
```
|
||||
|
||||
Or use the Cloudflare-free embed providers below — no proxy needed.
|
||||
|
||||
---
|
||||
|
||||
## Direct Embed Providers
|
||||
|
||||
`GET /api/stream/embeds` builds iframe-ready player URLs for three third-party providers. Playback happens inside the provider's own iframe, so nothing ever requests AniKoto's Cloudflare-protected AJAX endpoints at watch time.
|
||||
|
||||
| Provider | URL pattern | Requires |
|
||||
|:---|:---|:---|
|
||||
| **MegaPlay** | `megaplay.buzz/stream/mal\|ani/{id}/{ep}/{lang}` | MAL or AniList ID |
|
||||
| **AniXo** | `anixo.buzz/embed/ani/{anilistId}/{ep}/{lang}` | AniList ID |
|
||||
| **Megavid** | `megavid.buzz/{mal\|ani}/{id}/{ep}/{lang}` | MAL or AniList ID |
|
||||
|
||||
```bash
|
||||
# From an AniKoto slug (MAL/AniList IDs resolved automatically, cached 60min)
|
||||
curl ".../api/stream/embeds?slug=one-piece-odmau&ep=1"
|
||||
|
||||
# From direct IDs — zero scraping, instant response
|
||||
curl ".../api/stream/embeds?mal=21&ep=1&lang=dub&provider=megavid,megaplay"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"results": {
|
||||
"ids": { "animeId": 1642, "malId": "21", "anilistId": "21" },
|
||||
"providers": [
|
||||
{ "id": "megaplay", "url": "https://megaplay.buzz/stream/mal/21/1/sub", "iframeReady": true },
|
||||
{ "id": "anixo", "url": "https://anixo.buzz/embed/ani/21/1/sub?color=%23e63946", "iframeReady": true },
|
||||
{ "id": "megavid", "url": "https://megavid.buzz/mal/21/1/sub?color=%23e63946&autoplay=true", "iframeReady": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Drop any `url` straight into an `<iframe allow="autoplay; fullscreen">` — all three are embed-only players (Megavid rejects bare curl with 403 by design; browsers load it fine).
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture Overview](#architecture-overview)
|
||||
2. [Streaming Workflow](#streaming-workflow)
|
||||
3. [Endpoint Reference](#endpoint-reference)
|
||||
4. [M3U8 Proxy](#m3u8-proxy)
|
||||
5. [Video Player Integration](#video-player-integration)
|
||||
6. [Error Handling](#error-handling)
|
||||
7. [Performance Tips](#performance-tips)
|
||||
2. [Direct Embed Providers](#direct-embed-providers)
|
||||
3. [Streaming Workflow](#streaming-workflow)
|
||||
4. [Endpoint Reference](#endpoint-reference)
|
||||
5. [M3U8 Proxy](#m3u8-proxy)
|
||||
6. [Video Player Integration](#video-player-integration)
|
||||
7. [Error Handling](#error-handling)
|
||||
8. [Performance Tips](#performance-tips)
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "AniKotoAPI",
|
||||
"version": "2.2.1",
|
||||
"version": "2.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "AniKotoAPI",
|
||||
"version": "2.2.1",
|
||||
"version": "2.5.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.0",
|
||||
@@ -1706,9 +1706,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "AniKotoAPI",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.1",
|
||||
"description": "A restful API to provide all information about anime from anikototv.to",
|
||||
"main": "./server.js",
|
||||
"scripts": {
|
||||
@@ -24,5 +24,8 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Shineii86/AniKotoAPI.git"
|
||||
},
|
||||
"overrides": {
|
||||
"undici": "7.29.0"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -4,28 +4,28 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="google-site-verification" content="YOUR_VERIFICATION_CODE" />
|
||||
<title>AniKotoAPI — Free Anime REST API | 38 Endpoints</title>
|
||||
<meta property="og:title" content="AniKotoAPI — Free Anime REST API | 38 Endpoints" />
|
||||
<title>AniKotoAPI — Free Anime REST API | 40 Endpoints</title>
|
||||
<meta property="og:title" content="AniKotoAPI — Free Anime REST API | 40 Endpoints" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://anikototvapi.vercel.app" />
|
||||
<meta property="og:image" content="https://anikototvapi.vercel.app/share.png" />
|
||||
<meta property="og:description" content="Free anime REST API with 38 endpoints. Search, filter, stream episodes, get rankings, and more. No API key required. Built with Node.js, Express, and Cheerio." />
|
||||
<meta property="og:description" content="Free anime REST API with 40 endpoints. Search, filter, stream episodes, get rankings, and more. No API key required. Built with Node.js, Express, and Cheerio." />
|
||||
<meta property="og:site_name" content="AniKotoAPI" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:image:secure_url" content="https://anikototvapi.vercel.app/share.png" />
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="601" />
|
||||
<meta property="og:image:alt" content="AniKotoAPI - Free Anime REST API with 38 endpoints" />
|
||||
<meta property="og:image:alt" content="AniKotoAPI - Free Anime REST API with 40 endpoints" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content="@Shineii86" />
|
||||
<meta name="twitter:creator" content="@Shineii86" />
|
||||
<meta name="twitter:title" content="AniKotoAPI — Free Anime REST API | 38 Endpoints" />
|
||||
<meta name="twitter:description" content="Free anime REST API with 38 endpoints. Search, filter, stream episodes, get rankings, and more. No API key required." />
|
||||
<meta name="twitter:title" content="AniKotoAPI — Free Anime REST API | 40 Endpoints" />
|
||||
<meta name="twitter:description" content="Free anime REST API with 40 endpoints. Search, filter, stream episodes, get rankings, and more. No API key required." />
|
||||
<meta name="twitter:image" content="https://anikototvapi.vercel.app/share.png" />
|
||||
<meta name="twitter:image:alt" content="AniKotoAPI - Free Anime REST API with 38 endpoints" />
|
||||
<meta name="twitter:image:alt" content="AniKotoAPI - Free Anime REST API with 40 endpoints" />
|
||||
<meta name="twitter:domain" content="anikototvapi.vercel.app" />
|
||||
<meta name="description" content="Free anime REST API with 38 endpoints. Search, filter, stream episodes with M3U8 proxy, get top rankings, schedules, and more. No API key required. Open source Node.js + Express." />
|
||||
<meta name="description" content="Free anime REST API with 40 endpoints. Search, filter, stream episodes with M3U8 proxy, get top rankings, schedules, and more. No API key required. Open source Node.js + Express." />
|
||||
<meta name="author" content="Shinei Nouzen" />
|
||||
<meta name="keywords" content="anime api, free anime api, rest api, anime data, anime streaming api, hls proxy, m3u8 proxy, anime episodes, anime search, anime database, nodejs api, express api, web scraping api, anikoto, anime list api, no api key" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
@@ -234,7 +234,7 @@
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebAPI",
|
||||
"name": "AniKotoAPI",
|
||||
"description": "Free anime REST API with 38 endpoints. Search, filter, stream episodes with M3U8 proxy, get rankings, schedules, and more. No API key required.",
|
||||
"description": "Free anime REST API with 40 endpoints. Search, filter, stream episodes with M3U8 proxy, get rankings, schedules, and more. No API key required.",
|
||||
"url": "https://anikototvapi.vercel.app",
|
||||
"image": "https://anikototvapi.vercel.app/share.png",
|
||||
"documentation": "https://github.com/Shineii86/AniKotoAPI",
|
||||
@@ -271,7 +271,7 @@
|
||||
<div>
|
||||
<div class="hero-badge"><span class="dot"></span> Free · No API key · Open source</div>
|
||||
<h1>The anime data API for your next project</h1>
|
||||
<p class="hero-sub">Browse, search, and stream anime data from AniKoto. Real-time scraping with 38 endpoints, in-memory caching, and zero configuration.</p>
|
||||
<p class="hero-sub">Browse, search, and stream anime data from AniKoto. Real-time scraping with 40 endpoints, in-memory caching, and zero configuration.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="#playground" class="btn btn-primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
@@ -311,7 +311,7 @@ fetch("https://anikototvapi.vercel.app/api")
|
||||
<section class="stats">
|
||||
<div class="container">
|
||||
<div class="stats-grid">
|
||||
<div class="reveal"><div class="stat-value" data-count="38">0</div><div class="stat-label">API Endpoints</div></div>
|
||||
<div class="reveal"><div class="stat-value" data-count="40">0</div><div class="stat-label">API Endpoints</div></div>
|
||||
<div class="reveal reveal-delay-1"><div class="stat-value" data-count="0">0</div><div class="stat-label">API Keys Required</div></div>
|
||||
<div class="reveal reveal-delay-2"><div class="stat-value" data-count="40">0</div><div class="stat-label">Anime Genres</div></div>
|
||||
<div class="reveal reveal-delay-3"><div class="stat-value">Live</div><div class="stat-label">Real-time Data</div></div>
|
||||
|
||||
+1
-1
@@ -6,4 +6,4 @@ services:
|
||||
startCommand: npm start
|
||||
envVars:
|
||||
- key: ALLOWED_ORIGINS
|
||||
value: https://anikototv.to,https://anikoto.cz,https://anikoto.me,https://anikoto.net,https://anikoto.se
|
||||
value: https://anikototv.to,https://anikoto.cz,https://anikoto.me,https://anikoto.net,https://anikototv.se
|
||||
|
||||
@@ -16,7 +16,13 @@
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv";
|
||||
// WARNING: This import must stay first. Static imports are evaluated before the
|
||||
// module body runs, so a dotenv.config() call down there fires only after every
|
||||
// helper has already been evaluated — and helpers that read process.env at their
|
||||
// top level (mirror, cache, proxy, streamProxy) would silently keep their
|
||||
// defaults. Importing dotenv/config runs config() as an import side effect, so
|
||||
// .env is populated before any of those modules load.
|
||||
import "dotenv/config";
|
||||
import express from "express";
|
||||
import compression from "compression";
|
||||
import path from "path";
|
||||
@@ -27,8 +33,6 @@ import { dirname } from "path";
|
||||
import { createApiRoutes } from "./src/routes/apiRoutes.js";
|
||||
import { addCreatorInfo } from "./src/middleware/creatorInfo.js";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// SERVER CONFIGURATION
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -42,11 +42,12 @@ const BASE_URL = "https://anikototv.to";
|
||||
* // Access alternative domains
|
||||
* ALT_DOMAINS[0] // "https://anikoto.cz"
|
||||
*/
|
||||
// Verified against the official domain hub: https://anikoto.site/#domains
|
||||
const ALT_DOMAINS = [
|
||||
"https://anikoto.cz",
|
||||
"https://anikoto.me",
|
||||
"https://anikoto.net",
|
||||
"https://anikoto.se"
|
||||
"https://anikototv.se"
|
||||
];
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
* AniKotoAPI — embedProviders.config.js
|
||||
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||
*
|
||||
* @description
|
||||
* Configuration for direct embed providers (Megavid, AniXo, MegaPlay).
|
||||
* These providers expose iframe-ready player URLs keyed by AniList/MAL
|
||||
* IDs — no scraping and no Cloudflare-challenged requests required.
|
||||
* URL patterns verified against aluukill/AniCult's provider registry.
|
||||
*
|
||||
* @exports
|
||||
* EMBED_PROVIDERS, EMBED_PROVIDER_IDS, DEFAULT_EMBED_COLOR
|
||||
*
|
||||
* @author Shinei Nouzen
|
||||
* @license MIT
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
*/
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// PROVIDER DEFINITIONS
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Default accent color applied to provider players ----
|
||||
const DEFAULT_EMBED_COLOR = "%23e63946";
|
||||
|
||||
// ---- FEATURE: Direct embed provider registry ----
|
||||
/**
|
||||
* Each provider builds an iframe-ready URL from external IDs.
|
||||
* Builders return null when required IDs are missing.
|
||||
*
|
||||
* @type {Object<string, {id: string, name: string, origin: string, requires: string[], build: Function}>}
|
||||
*
|
||||
* @example
|
||||
* EMBED_PROVIDERS.megavid.build({ malId: 21, episode: 1, lang: "sub" });
|
||||
* // "https://megavid.buzz/mal/21/1/sub?color=%23e63946&autoplay=true"
|
||||
*/
|
||||
const EMBED_PROVIDERS = {
|
||||
megaplay: {
|
||||
id: "megaplay",
|
||||
name: "MegaPlay",
|
||||
origin: "https://megaplay.buzz",
|
||||
requires: ["malId | anilistId"],
|
||||
build({ malId, anilistId, episode, lang }) {
|
||||
const safeLang = lang === "dub" ? "dub" : "sub";
|
||||
if (malId) return `https://megaplay.buzz/stream/mal/${malId}/${episode}/${safeLang}`;
|
||||
if (anilistId) return `https://megaplay.buzz/stream/ani/${anilistId}/${episode}/${safeLang}`;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
anixo: {
|
||||
id: "anixo",
|
||||
name: "AniXo",
|
||||
origin: "https://anixo.buzz",
|
||||
requires: ["anilistId"],
|
||||
build({ anilistId, episode, lang }) {
|
||||
const safeLang = lang === "dub" ? "dub" : "sub";
|
||||
if (!anilistId) return null;
|
||||
return `https://anixo.buzz/embed/ani/${anilistId}/${episode}/${safeLang}?color=${DEFAULT_EMBED_COLOR}`;
|
||||
},
|
||||
},
|
||||
megavid: {
|
||||
id: "megavid",
|
||||
name: "Megavid",
|
||||
origin: "https://megavid.buzz",
|
||||
requires: ["malId | anilistId"],
|
||||
build({ malId, anilistId, episode, lang }) {
|
||||
const safeLang = lang === "dub" ? "dub" : "sub";
|
||||
const idType = malId ? "mal" : "ani";
|
||||
const id = malId || anilistId;
|
||||
if (!id) return null;
|
||||
return `https://megavid.buzz/${idType}/${id}/${episode}/${safeLang}?color=${DEFAULT_EMBED_COLOR}&autoplay=true`;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ---- FEATURE: Provider ID list for validation ----
|
||||
const EMBED_PROVIDER_IDS = Object.keys(EMBED_PROVIDERS);
|
||||
|
||||
export { EMBED_PROVIDERS, EMBED_PROVIDER_IDS, DEFAULT_EMBED_COLOR };
|
||||
// ══════════════════════════════════════════════════════════════ END: embedProviders.config.js
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
import { extractAnimeInfo } from "../extractors/animeInfo.extractor.js";
|
||||
import { resolveExternalIds } from "../extractors/embedProviders.extractor.js";
|
||||
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
@@ -53,6 +54,21 @@ const getAnimeInfo = async (req, res, next) => {
|
||||
return res.json({ success: true, results: cached });
|
||||
}
|
||||
const data = await extractAnimeInfo(animeSlug);
|
||||
|
||||
// NOTE: Enrich with MAL/AniList IDs (cached 60min inside resolveExternalIds)
|
||||
// so clients can pass them straight to /api/stream/embeds. Best-effort —
|
||||
// a failed ID lookup never breaks the info response itself
|
||||
try {
|
||||
const ext = await resolveExternalIds(animeSlug);
|
||||
data.externalIds = {
|
||||
animeId: ext.animeId || 0,
|
||||
malId: ext.malId || null,
|
||||
anilistId: ext.anilistId || null,
|
||||
};
|
||||
} catch {
|
||||
data.externalIds = { animeId: 0, malId: null, anilistId: null };
|
||||
}
|
||||
|
||||
setCache(cacheKey, data, TTL.info);
|
||||
res.json({ success: true, results: data });
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
* AniKotoAPI — embedProviders.controller.js
|
||||
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||
*
|
||||
* @description
|
||||
* Controller for direct embed provider URLs (Megavid, AniXo, MegaPlay).
|
||||
* Returns iframe-ready player URLs that bypass Cloudflare entirely —
|
||||
* no scraping of the source site's AJAX layer required when the client
|
||||
* supplies mal/anilist IDs directly.
|
||||
*
|
||||
* @exports
|
||||
* getEmbedProviders - Express route handler for GET /api/stream/embeds
|
||||
*
|
||||
* @author Shinei Nouzen
|
||||
* @license MIT
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
*/
|
||||
|
||||
import { buildEpisodeEmbeds } from "../extractors/embedProviders.extractor.js";
|
||||
import { EMBED_PROVIDER_IDS } from "../configs/embedProviders.config.js";
|
||||
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// CONTROLLER: EMBED PROVIDERS
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Direct embed provider URLs for an episode ----
|
||||
/**
|
||||
* Handles GET /api/stream/embeds requests. Builds iframe-ready embed
|
||||
* URLs for Megavid/AniXo/MegaPlay from a slug or direct external IDs.
|
||||
*
|
||||
* @param {object} req - Express request object
|
||||
* @param {string} [req.query.slug] - Anime slug (IDs resolved automatically)
|
||||
* @param {string} [req.query.ep] - Episode number (default 1)
|
||||
* @param {string} [req.query.lang] - "sub" or "dub" (default "sub")
|
||||
* @param {string} [req.query.provider] - Comma-separated provider filter
|
||||
* @param {string} [req.query.mal] - Direct MAL ID (skips slug lookup)
|
||||
* @param {string} [req.query.anilist] - Direct AniList ID
|
||||
* @param {object} res - Express response object
|
||||
* @param {Function} next - Express next middleware function
|
||||
* @returns {void} Sends JSON response with iframe-ready embed URLs
|
||||
*
|
||||
* @example
|
||||
* GET /api/stream/embeds?slug=one-piece-odmau&ep=1150&lang=sub
|
||||
* GET /api/stream/embeds?mal=21&ep=1&provider=megavid
|
||||
* Response: { success: true, results: { ids, providers: [{ id, name, url }] } }
|
||||
*
|
||||
* @throws {400} If no identifying parameter is provided or provider is unknown
|
||||
*/
|
||||
const getEmbedProviders = async (req, res, next) => {
|
||||
try {
|
||||
const { slug, ep, episode, lang, provider, mal, anilist } = req.query;
|
||||
|
||||
// NOTE: Accept both ?ep= and ?episode= for convenience; clamp lang
|
||||
// to the sub/dub values the embed providers understand
|
||||
const resolvedEp = parseInt(ep || episode || "1", 10) || 1;
|
||||
const resolvedLang = String(lang || "sub").toLowerCase() === "dub" ? "dub" : "sub";
|
||||
const requested = provider
|
||||
? String(provider).split(",").map((p) => p.trim().toLowerCase()).filter(Boolean)
|
||||
: null;
|
||||
|
||||
// NOTE: Validate providers before touching cache or upstream — a typo
|
||||
// should fail fast with the list of valid IDs, not build wrong URLs
|
||||
if (requested) {
|
||||
const unknown = requested.filter((p) => !EMBED_PROVIDER_IDS.includes(p));
|
||||
if (unknown.length) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Unknown provider(s): ${unknown.join(", ")}. Valid: ${EMBED_PROVIDER_IDS.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!slug && !mal && !anilist) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Provide 'slug', 'mal' or 'anilist' (plus optional 'ep' and 'lang')"
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: Cache key composes every response-affecting input — episode,
|
||||
// lang and provider filter all change the output, so each combination
|
||||
// is cached separately (TTL.ids = 60min, IDs and URLs are stable)
|
||||
const cacheKey = `embeds_${slug || ""}_${mal || ""}_${anilist || ""}_${resolvedEp}_${resolvedLang}_${(requested || []).join("-")}`;
|
||||
const cached = getCache(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({ success: true, results: cached });
|
||||
}
|
||||
|
||||
const result = await buildEpisodeEmbeds({
|
||||
slug: slug || null,
|
||||
episode: resolvedEp,
|
||||
lang: resolvedLang,
|
||||
providers: requested,
|
||||
malId: mal || null,
|
||||
anilistId: anilist || null,
|
||||
});
|
||||
|
||||
setCache(cacheKey, result, TTL.ids);
|
||||
res.json({ success: true, results: result });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
export { getEmbedProviders };
|
||||
|
||||
// ══════════════════════════════════════════════════════════════ END: embedProviders.controller.js
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
* AniKotoAPI — episodeLatest.controller.js
|
||||
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||
*
|
||||
* @description
|
||||
* Controller for the latest-episode checker. Returns just the newest
|
||||
* episode for an anime — built for bots and notification jobs that
|
||||
* only need "is there a new episode?" without the full episode list.
|
||||
*
|
||||
* @exports
|
||||
* getLatestEpisode - Express route handler for GET /api/episodes/latest
|
||||
*
|
||||
* @author Shinei Nouzen
|
||||
* @license MIT
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
*/
|
||||
|
||||
import { extractEpisodeList } from "../extractors/episodeList.extractor.js";
|
||||
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// CONTROLLER: LATEST EPISODE
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Latest episode checker endpoint ----
|
||||
/**
|
||||
* Handles GET /api/episodes/latest requests. Extracts the episode list
|
||||
* (cached) and returns only the newest episode plus summary counts.
|
||||
* Requires 'slug' query param.
|
||||
*
|
||||
* NOTE: Registered before /api/episodes/:id so the literal path wins
|
||||
* the route match — Express evaluates routes in registration order.
|
||||
*
|
||||
* @param {object} req - Express request object
|
||||
* @param {string} [req.query.slug] - Anime slug (or numeric anime ID)
|
||||
* @param {object} res - Express response object
|
||||
* @param {Function} next - Express next middleware function
|
||||
* @returns {void} Sends JSON response with the latest episode
|
||||
*
|
||||
* @example
|
||||
* GET /api/episodes/latest?slug=one-piece-odmau
|
||||
* Response: {
|
||||
* success: true,
|
||||
* results: {
|
||||
* slug, animeId, totalEpisodes,
|
||||
* latest: { episode_no, title, id, href, server_ids, mal_id }
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @throws {400} If 'slug' query parameter is missing
|
||||
* @throws {404} If no episodes are found for the anime
|
||||
*/
|
||||
const getLatestEpisode = async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = req.query;
|
||||
if (!slug) {
|
||||
return res.status(400).json({ success: false, message: "Anime slug is required (?slug=)" });
|
||||
}
|
||||
|
||||
const cacheKey = `ep_latest_${slug}`;
|
||||
const cached = getCache(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({ success: true, results: cached });
|
||||
}
|
||||
|
||||
// NOTE: Reuses the episode-list extractor — same upstream call, and
|
||||
// its 10min TTL cache means repeated bot polls cost one AJAX hit
|
||||
const data = await extractEpisodeList(slug);
|
||||
const episodes = data?.episodes || [];
|
||||
|
||||
if (!episodes.length) {
|
||||
return res.status(404).json({ success: false, message: "No episodes found for this anime" });
|
||||
}
|
||||
|
||||
// NOTE: Episode numbers arrive as numbers — plain max reduce beats
|
||||
// assuming list order (some anime are listed non-sequentially)
|
||||
const latest = episodes.reduce((newest, ep) =>
|
||||
(ep.episode_no > newest.episode_no ? ep : newest), episodes[0]);
|
||||
|
||||
const result = {
|
||||
slug: data.slug || slug,
|
||||
animeId: data.animeId || 0,
|
||||
totalEpisodes: data.totalEpisodes || episodes.length,
|
||||
latest: {
|
||||
episode_no: latest.episode_no,
|
||||
title: latest.title || "",
|
||||
id: latest.id || null,
|
||||
slug: latest.slug || "",
|
||||
href: latest.href || "",
|
||||
server_ids: latest.server_ids || "",
|
||||
mal_id: latest.mal_id || null,
|
||||
},
|
||||
};
|
||||
|
||||
setCache(cacheKey, result, TTL.episodes);
|
||||
res.json({ success: true, results: result });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
export { getLatestEpisode };
|
||||
|
||||
// ══════════════════════════════════════════════════════════════ END: episodeLatest.controller.js
|
||||
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
* AniKotoAPI — embedProviders.extractor.js
|
||||
* Repository: https://github.com/Shineii86/AniKotoAPI
|
||||
*
|
||||
* @description
|
||||
* Resolves external IDs (MAL/AniList) for an anime and builds
|
||||
* iframe-ready direct embed URLs for Megavid, AniXo and MegaPlay.
|
||||
* MAL ID comes from the site's episode list (data-mal); AniList ID
|
||||
* is looked up via the public AniList GraphQL API (cached).
|
||||
* No provider scraping — pure URL construction.
|
||||
*
|
||||
* @exports
|
||||
* resolveExternalIds, resolveAnilistIdFromMal, buildEpisodeEmbeds
|
||||
*
|
||||
* @author Shinei Nouzen
|
||||
* @license MIT
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
import { EMBED_PROVIDERS } from "../configs/embedProviders.config.js";
|
||||
import { extractEpisodeList } from "./episodeList.extractor.js";
|
||||
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// ANILIST LOOKUP
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
const ANILIST_GRAPHQL = "https://graphql.anilist.co";
|
||||
const ANILIST_QUERY = `query ($idMal: Int!) { Media(idMal: $idMal, type: ANIME) { id idMal } }`;
|
||||
|
||||
// ---- FEATURE: MAL ID → AniList ID lookup (cached) ----
|
||||
/**
|
||||
* Resolves an AniList media ID from a MAL ID via the public GraphQL API.
|
||||
* Results are cached for TTL.ids (60min) — IDs never change per anime.
|
||||
*
|
||||
* @param {string|number} malId - MyAnimeList ID
|
||||
* @returns {Promise<string|null>} AniList ID or null on failure
|
||||
*
|
||||
* @example
|
||||
* await resolveAnilistIdFromMal(21); // "30019" (One Piece)
|
||||
*/
|
||||
const resolveAnilistIdFromMal = async (malId) => {
|
||||
// NOTE: Cache-first — AniList IDs never change per MAL ID, so a hit here
|
||||
// avoids a GraphQL round-trip on every embed request (TTL.ids = 60min)
|
||||
const cacheKey = `anilist_from_mal_${malId}`;
|
||||
const cached = getCache(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(
|
||||
ANILIST_GRAPHQL,
|
||||
{ query: ANILIST_QUERY, variables: { idMal: parseInt(malId, 10) } },
|
||||
{
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
// NOTE: Response nests under data.data.Media — axios already unwraps
|
||||
// the HTTP body once, so one more .data access reaches the GraphQL envelope
|
||||
const anilistId = data?.data?.Media?.id || null;
|
||||
// NOTE: Only cache successes — transient AniList failures must not
|
||||
// poison the cache for an hour
|
||||
if (anilistId) setCache(cacheKey, anilistId, TTL.ids);
|
||||
return anilistId;
|
||||
} catch {
|
||||
// TIP: Never throw here — a missing AniList ID only disables AniXo,
|
||||
// MegaPlay/Megavid can still build URLs from the MAL ID alone
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// EXTERNAL ID RESOLUTION
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Resolve MAL + AniList IDs for an anime slug ----
|
||||
/**
|
||||
* Uses the site's episode list (data-mal attribute) to get the MAL ID,
|
||||
* then looks up the AniList ID. Cached per slug.
|
||||
*
|
||||
* @param {string} slugOrId - Anime slug or numeric internal ID
|
||||
* @returns {Promise<{animeId: number, malId: string|null, anilistId: string|null, slug: string}>}
|
||||
*
|
||||
* @example
|
||||
* const ids = await resolveExternalIds("one-piece-odmau");
|
||||
* // { animeId: 1642, malId: "21", anilistId: "21", slug: "one-piece-odmau" }
|
||||
*/
|
||||
const resolveExternalIds = async (slugOrId) => {
|
||||
const cacheKey = `ext_ids_${slugOrId}`;
|
||||
const cached = getCache(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
// NOTE: One watch-page + one AJAX round-trip yields both the internal
|
||||
// animeId and the MAL ID (every episode anchor carries data-mal)
|
||||
const episodeData = await extractEpisodeList(slugOrId);
|
||||
const animeId = episodeData?.animeId || 0;
|
||||
|
||||
// NOTE: Dedup — every episode shares the same series-level MAL ID;
|
||||
// take the first non-empty value and stop (no need to scan 1000+ eps)
|
||||
let malId = null;
|
||||
const episodes = episodeData?.episodes || [];
|
||||
for (const ep of episodes) {
|
||||
if (ep?.mal_id) { malId = String(ep.mal_id); break; }
|
||||
}
|
||||
|
||||
let anilistId = null;
|
||||
if (malId) anilistId = await resolveAnilistIdFromMal(malId);
|
||||
|
||||
const result = { animeId, malId, anilistId, slug: slugOrId };
|
||||
// NOTE: Only cache resolved slugs — failed lookups (dead slug, upstream
|
||||
// block) should be retried on the next request instead of sticking for 60min
|
||||
if (animeId || malId) setCache(cacheKey, result, TTL.ids);
|
||||
return result;
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// EMBED URL BUILDER
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Build direct embed URLs for an episode ----
|
||||
/**
|
||||
* Builds iframe-ready embed URLs for the requested providers.
|
||||
* Accepts a slug (IDs resolved automatically) or direct mal/anilist IDs.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} [options.slug] - Anime slug for ID resolution
|
||||
* @param {number|string} [options.episode=1] - Episode number
|
||||
* @param {string} [options.lang="sub"] - "sub" or "dub"
|
||||
* @param {string[]|null} [options.providers=null] - Provider filter (null = all)
|
||||
* @param {string|number|null} [options.malId=null] - Direct MAL ID (skips slug lookup)
|
||||
* @param {string|number|null} [options.anilistId=null] - Direct AniList ID
|
||||
* @returns {Promise<Object>} { slug, episode, lang, ids, providers[], unavailable[] }
|
||||
*
|
||||
* @example
|
||||
* const embeds = await buildEpisodeEmbeds({ slug: "one-piece-odmau", episode: 1150 });
|
||||
* embeds.providers.forEach(p => console.log(p.id, p.url));
|
||||
*/
|
||||
const buildEpisodeEmbeds = async ({ slug, episode, lang = "sub", providers = null, malId = null, anilistId = null }) => {
|
||||
// NOTE: Sanitize inputs — episode must be a positive integer, lang is
|
||||
// clamped to the two values the providers actually accept
|
||||
const safeEpisode = Math.max(1, parseInt(episode, 10) || 1);
|
||||
const safeLang = lang === "dub" ? "dub" : "sub";
|
||||
|
||||
let resolvedMal = malId ? String(malId) : null;
|
||||
let resolvedAnilist = anilistId ? String(anilistId) : null;
|
||||
let animeId = 0;
|
||||
let resolvedSlug = slug || null;
|
||||
|
||||
// TIP: Direct-ID fast path — clients that already hold mal/anilist IDs
|
||||
// (e.g. AniList-powered frontends) skip the slug scrape entirely; the
|
||||
// slug lookup only runs when at least one ID is still missing
|
||||
if ((!resolvedMal || !resolvedAnilist) && slug) {
|
||||
const ids = await resolveExternalIds(slug);
|
||||
animeId = ids.animeId || 0;
|
||||
resolvedSlug = ids.slug || slug;
|
||||
if (!resolvedMal) resolvedMal = ids.malId || null;
|
||||
if (!resolvedAnilist) resolvedAnilist = ids.anilistId ? String(ids.anilistId) : null;
|
||||
}
|
||||
|
||||
if (!resolvedMal && !resolvedAnilist) {
|
||||
return {
|
||||
slug: resolvedSlug,
|
||||
episode: safeEpisode,
|
||||
lang: safeLang,
|
||||
ids: { animeId, malId: null, anilistId: null },
|
||||
providers: [],
|
||||
unavailable: Object.values(EMBED_PROVIDERS).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
reason: "No MAL or AniList ID available for this anime",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const wanted = providers && providers.length
|
||||
? providers.map((p) => String(p).toLowerCase())
|
||||
: Object.keys(EMBED_PROVIDERS);
|
||||
|
||||
const built = [];
|
||||
const unavailable = [];
|
||||
|
||||
// NOTE: State-machine-lite loop — each provider either builds a URL
|
||||
// ("built") or records why it can't ("unavailable"); the client always
|
||||
// gets a complete picture instead of silently missing providers
|
||||
for (const providerId of wanted) {
|
||||
const provider = EMBED_PROVIDERS[providerId];
|
||||
if (!provider) {
|
||||
unavailable.push({ id: providerId, name: providerId, reason: "Unknown provider" });
|
||||
continue;
|
||||
}
|
||||
const url = provider.build({
|
||||
malId: resolvedMal,
|
||||
anilistId: resolvedAnilist,
|
||||
episode: safeEpisode,
|
||||
lang: safeLang,
|
||||
});
|
||||
if (url) {
|
||||
built.push({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
url,
|
||||
iframeReady: true,
|
||||
origin: provider.origin,
|
||||
});
|
||||
} else {
|
||||
unavailable.push({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
reason: `Requires ${provider.requires.join(" or ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
slug: resolvedSlug,
|
||||
episode: safeEpisode,
|
||||
lang: safeLang,
|
||||
ids: { animeId, malId: resolvedMal, anilistId: resolvedAnilist },
|
||||
providers: built,
|
||||
unavailable,
|
||||
};
|
||||
};
|
||||
|
||||
export { resolveExternalIds, resolveAnilistIdFromMal, buildEpisodeEmbeds };
|
||||
|
||||
// ══════════════════════════════════════════════════════════════ END: embedProviders.extractor.js
|
||||
@@ -39,18 +39,37 @@ const extractHomeInfo = async () => {
|
||||
const $ = cheerio.load(data);
|
||||
|
||||
const spotlights = [];
|
||||
// NOTE: #hotest .swiper-slide.item = spotlight carousel on homepage
|
||||
$("#hotest .swiper-slide.item").each((i, el) => {
|
||||
const slug = $(el).find("a.play").attr("href")?.split("/watch/").pop() || "";
|
||||
const poster = $(el).find(".image div").attr("style")?.match(/url\(['"]?(.+?)['"]?\)/)?.[1] || "";
|
||||
const title = $(el).find("h2.title.d-title").text().trim() || "";
|
||||
const japaneseTitle = $(el).find("h2.title.d-title").attr("data-jp") || "";
|
||||
const description = $(el).find(".desc").text().trim() || "";
|
||||
const rating = $(el).find("i.rating").text().trim() || "";
|
||||
// NOTE: Dual selector — new layout (2026-10): #hotest .swiper-slide with
|
||||
// .cover img, h2.ak-title, .ak-desc, .ak-detail spans, a.btn.btn-primary.
|
||||
// Old layout: .swiper-slide.item with a.play, .image div[style], h2.title,
|
||||
// .desc, i.rating/i.quality/i.sub/i.dub/i.date. Both remain supported.
|
||||
$("#hotest .swiper-slide").each((i, el) => {
|
||||
const watchHref = $(el).find("a.play, a.btn.btn-primary").attr("href") || "";
|
||||
const slug = watchHref.split("/watch/").pop() || "";
|
||||
const poster =
|
||||
$(el).find(".cover img").attr("src") ||
|
||||
$(el).find(".image div").attr("style")?.match(/url\(['"]?(.+?)['"]?\)/)?.[1] ||
|
||||
"";
|
||||
const titleEl = $(el).find("h2.ak-title.d-title, h2.title.d-title").first();
|
||||
const title = titleEl.text().trim() || "";
|
||||
const japaneseTitle = titleEl.attr("data-jp") || "";
|
||||
const description = $(el).find(".ak-desc, .desc").text().trim() || "";
|
||||
// NOTE: New layout packs type/date/rating into .ak-detail span.it list
|
||||
const detailItems = $(el)
|
||||
.find(".ak-detail span.it")
|
||||
.map((_, s) => $(s).text().trim())
|
||||
.get();
|
||||
const rating =
|
||||
$(el).find("i.rating").text().trim() ||
|
||||
detailItems.find((t) => /^\d{1,2}\.\d+$/.test(t)) ||
|
||||
"";
|
||||
const quality = $(el).find("i.quality").text().trim() || "";
|
||||
const sub = parseInt($(el).find("i.sub").text().trim()) || 0;
|
||||
const dub = parseInt($(el).find("i.dub").text().trim()) || 0;
|
||||
const date = $(el).find("i.date").text().trim() || "";
|
||||
const date =
|
||||
$(el).find("i.date").text().trim() ||
|
||||
(detailItems.length > 1 ? detailItems[1] : "");
|
||||
const type = detailItems[0] || "";
|
||||
|
||||
if (slug) {
|
||||
spotlights.push({
|
||||
@@ -63,7 +82,8 @@ const extractHomeInfo = async () => {
|
||||
quality,
|
||||
sub,
|
||||
dub,
|
||||
date
|
||||
date,
|
||||
type
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -52,17 +52,37 @@ const extractSpotlight = async () => {
|
||||
|
||||
const spotlights = [];
|
||||
|
||||
$("#hotest .swiper-slide.item").each((i, el) => {
|
||||
const slug = $(el).find("a.play").attr("href")?.split("/watch/").pop() || "";
|
||||
const poster = $(el).find(".image div").attr("style")?.match(/url\(['"]?(.+?)['"]?\)/)?.[1] || "";
|
||||
const title = $(el).find("h2.title.d-title").text().trim() || "";
|
||||
const japaneseTitle = $(el).find("h2.title.d-title").attr("data-jp") || "";
|
||||
const description = $(el).find(".desc").text().trim() || "";
|
||||
const rating = $(el).find("i.rating").text().trim() || "";
|
||||
// NOTE: Dual selector — new layout (2026-10): #hotest .swiper-slide with
|
||||
// .cover img, h2.ak-title, .ak-desc, .ak-detail spans, a.btn.btn-primary.
|
||||
// Old layout: .swiper-slide.item with a.play, .image div[style], h2.title,
|
||||
// .desc, i.rating/i.quality/i.sub/i.dub/i.date. Both remain supported.
|
||||
$("#hotest .swiper-slide").each((i, el) => {
|
||||
const watchHref = $(el).find("a.play, a.btn.btn-primary").attr("href") || "";
|
||||
const slug = watchHref.split("/watch/").pop() || "";
|
||||
const poster =
|
||||
$(el).find(".cover img").attr("src") ||
|
||||
$(el).find(".image div").attr("style")?.match(/url\(['"]?(.+?)['"]?\)/)?.[1] ||
|
||||
"";
|
||||
const titleEl = $(el).find("h2.ak-title.d-title, h2.title.d-title").first();
|
||||
const title = titleEl.text().trim() || "";
|
||||
const japaneseTitle = titleEl.attr("data-jp") || "";
|
||||
const description = $(el).find(".ak-desc, .desc").text().trim() || "";
|
||||
// NOTE: New layout packs type/date/rating into .ak-detail span.it list
|
||||
const detailItems = $(el)
|
||||
.find(".ak-detail span.it")
|
||||
.map((_, s) => $(s).text().trim())
|
||||
.get();
|
||||
const rating =
|
||||
$(el).find("i.rating").text().trim() ||
|
||||
detailItems.find((t) => /^\d{1,2}\.\d+$/.test(t)) ||
|
||||
"";
|
||||
const quality = $(el).find("i.quality").text().trim() || "";
|
||||
const sub = parseInt($(el).find("i.sub").text().trim()) || 0;
|
||||
const dub = parseInt($(el).find("i.dub").text().trim()) || 0;
|
||||
const date = $(el).find("i.date").text().trim() || "";
|
||||
const date =
|
||||
$(el).find("i.date").text().trim() ||
|
||||
(detailItems.length > 1 ? detailItems[1] : "");
|
||||
const type = detailItems[0] || "";
|
||||
|
||||
if (slug) {
|
||||
spotlights.push({
|
||||
@@ -75,7 +95,8 @@ const extractSpotlight = async () => {
|
||||
quality,
|
||||
sub,
|
||||
dub,
|
||||
date
|
||||
date,
|
||||
type
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -164,6 +164,7 @@ const TTL = {
|
||||
schedule: 1800000, // 30min — schedule changes daily
|
||||
genres: 3600000, // 60min — genre list is static
|
||||
suggestions: 300000, // 5min — suggestions update moderate
|
||||
ids: 3600000, // 60min — MAL/AniList IDs are stable per anime
|
||||
default: 300000 // 5min — safe default for unknown endpoints
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
* AniKotoAPI — streamProxy.helper.js
|
||||
* Repository: https://github.com/Shineii86
|
||||
*
|
||||
* @description
|
||||
* Request guard for the M3U8/TS streaming proxies. Validates that a
|
||||
* caller-supplied URL points at a known streaming CDN before the server
|
||||
* fetches it, so the proxy endpoints cannot be used to reach internal
|
||||
* services (SSRF).
|
||||
*
|
||||
* @exports
|
||||
* assertProxyableUrl
|
||||
* streamRefererHeaders
|
||||
*
|
||||
* @author Shinei Nouzen
|
||||
* @license MIT
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
*/
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// PROXY ALLOWLIST
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// Registrable domains the proxies may fetch from. Subdomains are covered,
|
||||
// so the apex is listed once and rotating hosts (cdn1., s2., …) still match.
|
||||
// NOTE: megaplay-1.buzz was removed — it no longer resolves.
|
||||
const DEFAULT_PROXY_DOMAINS = [
|
||||
// Embed players
|
||||
"megaplay.buzz",
|
||||
"vidtube.site",
|
||||
"vid-tube.site",
|
||||
"vidplay.site",
|
||||
// Stream / subtitle CDNs
|
||||
"anipixcdn.co",
|
||||
"norami.top",
|
||||
"kryntal.top",
|
||||
"akirax.buzz",
|
||||
];
|
||||
|
||||
// Parse the allowlist from env or use defaults
|
||||
function parseProxyDomains() {
|
||||
const envDomains = process.env.STREAM_PROXY_DOMAINS;
|
||||
if (!envDomains) return DEFAULT_PROXY_DOMAINS;
|
||||
|
||||
return envDomains
|
||||
.split(",")
|
||||
.map(d => d.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const PROXY_DOMAINS = parseProxyDomains();
|
||||
|
||||
// Referer the stream CDNs expect. They answer 403 to the site origin
|
||||
// (anikototv.to) and to no Referer at all — only the player origin passes.
|
||||
const STREAM_REFERER = process.env.STREAM_PROXY_REFERER || "https://megaplay.buzz/";
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// PRIVATE ADDRESS DETECTION
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// NOTE: Blocks IP literals pointing at the host's own network. This is a
|
||||
// second layer, not the primary control — the allowlist is. A hostname that
|
||||
// passes the allowlist but resolves to a private address (DNS rebinding) is
|
||||
// not caught here, since axios re-resolves the name when it connects.
|
||||
const isPrivateAddress = (hostname) => {
|
||||
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
|
||||
// IPv6 loopback, link-local (fe80::/10) and unique-local (fc00::/7)
|
||||
if (host === "::1" || host === "::") return true;
|
||||
if (/^f[cd][0-9a-f]{2}:/.test(host)) return true;
|
||||
if (/^fe[89ab][0-9a-f]:/.test(host)) return true;
|
||||
|
||||
// IPv4-mapped IPv6 — unwrap and fall through to the IPv4 checks
|
||||
const mapped = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
|
||||
const target = mapped || host;
|
||||
|
||||
const octets = target.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (!octets) return false;
|
||||
|
||||
const [a, b] = octets.slice(1).map(Number);
|
||||
if (a === 0 || a === 10 || a === 127) return true; // this-network, private, loopback
|
||||
if (a === 169 && b === 254) return true; // link-local / cloud metadata
|
||||
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
||||
if (a === 192 && b === 168) return true; // private
|
||||
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
||||
return false;
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// URL GUARD
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Proxy URL Validation ----
|
||||
/**
|
||||
* Validates a caller-supplied proxy target.
|
||||
*
|
||||
* @param {string} rawUrl - The URL from the request query
|
||||
* @returns {{ ok: true, url: URL } | { ok: false, status: number, message: string }}
|
||||
*
|
||||
* @example
|
||||
* const check = assertProxyableUrl(req.query.url);
|
||||
* if (!check.ok) return res.status(check.status).json({ success: false, message: check.message });
|
||||
*/
|
||||
const assertProxyableUrl = (rawUrl) => {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return { ok: false, status: 400, message: "Malformed URL" };
|
||||
}
|
||||
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
||||
return { ok: false, status: 400, message: "Only http and https URLs can be proxied" };
|
||||
}
|
||||
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
|
||||
if (isPrivateAddress(hostname)) {
|
||||
return { ok: false, status: 403, message: "Domain not allowed for proxy" };
|
||||
}
|
||||
|
||||
const isAllowed = PROXY_DOMAINS.some(d => hostname === d || hostname.endsWith(`.${d}`));
|
||||
if (!isAllowed) {
|
||||
return { ok: false, status: 403, message: "Domain not allowed for proxy" };
|
||||
}
|
||||
|
||||
return { ok: true, url };
|
||||
};
|
||||
|
||||
// ---- FEATURE: CDN Request Headers ----
|
||||
/**
|
||||
* Headers the stream CDNs require on a proxied fetch.
|
||||
*
|
||||
* @returns {object} Referer/Origin pair for the configured player origin
|
||||
*/
|
||||
const streamRefererHeaders = () => ({
|
||||
"Referer": STREAM_REFERER,
|
||||
"Origin": new URL(STREAM_REFERER).origin,
|
||||
});
|
||||
|
||||
export { assertProxyableUrl, streamRefererHeaders };
|
||||
+41
-13
@@ -24,9 +24,11 @@ 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 { getLatestEpisode } from "../controllers/episodeLatest.controller.js";
|
||||
import { getEpisodeListAjax } from "../controllers/episodeListAjax.controller.js";
|
||||
import { getStreamInfo, getServerList, getMapperServers } from "../controllers/streamInfo.controller.js";
|
||||
import { getResolvedStream, getStreamQualities } from "../controllers/streamResolver.controller.js";
|
||||
import { getEmbedProviders } from "../controllers/embedProviders.controller.js";
|
||||
import { getSchedule } from "../controllers/schedule.controller.js";
|
||||
import { getSpotlight } from "../controllers/spotlight.controller.js";
|
||||
import { getTrending } from "../controllers/trending.controller.js";
|
||||
@@ -50,6 +52,7 @@ import { getCompletedAnime } from "../controllers/completedAnime.controller.js";
|
||||
import { categoryRoutes } from "./category.route.js";
|
||||
import { getMirrorStatus, resetMirrorCache, getProxyStatus } from "../helper/mirror.helper.js";
|
||||
import { getCacheStats } from "../helper/cache.helper.js";
|
||||
import { assertProxyableUrl, streamRefererHeaders } from "../helper/streamProxy.helper.js";
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// ROUTE REGISTRATION
|
||||
@@ -152,6 +155,15 @@ app.use((req, res, next) => {
|
||||
// EPISODES
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ---- FEATURE: Latest episode checker (literal path must precede :id) ----
|
||||
app.get("/api/episodes/latest", async (req, res, next) => {
|
||||
try {
|
||||
await getLatestEpisode(req, res, next);
|
||||
} catch (error) {
|
||||
jsonError(res, error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- FEATURE: Episode list for anime by ID ----
|
||||
app.get("/api/episodes/:id", async (req, res, next) => {
|
||||
try {
|
||||
@@ -228,6 +240,15 @@ app.use((req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---- FEATURE: Direct embed providers (Megavid/AniXo/MegaPlay) ----
|
||||
app.get("/api/stream/embeds", async (req, res, next) => {
|
||||
try {
|
||||
await getEmbedProviders(req, res, next);
|
||||
} catch (error) {
|
||||
jsonError(res, error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- FEATURE: M3U8 proxy — fetch and serve M3U8 playlists through API ----
|
||||
app.get("/api/stream/proxy", async (req, res, next) => {
|
||||
try {
|
||||
@@ -235,13 +256,13 @@ app.use((req, res, next) => {
|
||||
if (!url) {
|
||||
return res.status(400).json({ success: false, message: "M3U8 URL is required" });
|
||||
}
|
||||
// NOTE: Validate URL is from known streaming domains
|
||||
const allowedDomains = ["vidtube.site", "vidplay.site", "megaplay.buzz", "megaplay-1.buzz", "cdn.anipixcdn.co"];
|
||||
const urlObj = new URL(url);
|
||||
const isAllowed = allowedDomains.some(d => urlObj.hostname === d || urlObj.hostname.endsWith(`.${d}`)) || urlObj.pathname.endsWith(".m3u8");
|
||||
|
||||
if (!isAllowed) {
|
||||
return res.status(403).json({ success: false, message: "Domain not allowed for proxy" });
|
||||
// NOTE: Validate URL is from known streaming domains.
|
||||
// WARNING: Do not add an "or the path looks like a playlist" escape hatch —
|
||||
// the previous version had one, which let any host through and turned this
|
||||
// endpoint into an open proxy onto the server's own network.
|
||||
const check = assertProxyableUrl(url);
|
||||
if (!check.ok) {
|
||||
return res.status(check.status).json({ success: false, message: check.message });
|
||||
}
|
||||
|
||||
const axios = (await import("axios")).default;
|
||||
@@ -250,8 +271,7 @@ app.use((req, res, next) => {
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
"Referer": "https://anikototv.to/",
|
||||
"Origin": "https://anikototv.to",
|
||||
...streamRefererHeaders(),
|
||||
},
|
||||
timeout: 15000,
|
||||
responseType: "text",
|
||||
@@ -288,14 +308,20 @@ app.use((req, res, next) => {
|
||||
return res.status(400).json({ success: false, message: "TS URL is required" });
|
||||
}
|
||||
|
||||
// NOTE: Segment URLs come from the caller too, so they need the same guard
|
||||
// the playlist proxy uses — this endpoint previously had none.
|
||||
const check = assertProxyableUrl(url);
|
||||
if (!check.ok) {
|
||||
return res.status(check.status).json({ success: false, message: check.message });
|
||||
}
|
||||
|
||||
const axios = (await import("axios")).default;
|
||||
const { headers: defaultHeaders } = await import("../configs/header.config.js");
|
||||
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
"Referer": "https://anikototv.to/",
|
||||
"Origin": "https://anikototv.to",
|
||||
...streamRefererHeaders(),
|
||||
},
|
||||
timeout: 30000,
|
||||
responseType: "arraybuffer",
|
||||
@@ -576,7 +602,7 @@ app.get("/api/stats", (req, res) => {
|
||||
ttl: "5 minutes",
|
||||
description: "Map-based cache with TTL expiration",
|
||||
},
|
||||
endpoints: 38,
|
||||
endpoints: 40,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
@@ -630,7 +656,8 @@ app.get("/api/openapi", (req, res) => {
|
||||
paths: {
|
||||
"/": { get: { summary: "Homepage data", tags: ["Home"] } },
|
||||
"/search": { get: { summary: "Search anime", tags: ["Search"] } },
|
||||
"/info": { get: { summary: "Anime info", tags: ["Anime"] } },
|
||||
"/info": { get: { summary: "Anime info (incl. externalIds: malId/anilistId)", tags: ["Anime"] } },
|
||||
"/episodes/latest": { get: { summary: "Latest episode checker", tags: ["Episodes"] } },
|
||||
"/episodes/{id}": { get: { summary: "Episode list", tags: ["Episodes"] } },
|
||||
"/servers": { get: { summary: "Server list", tags: ["Streaming"] } },
|
||||
"/stream": { get: { summary: "Stream URL", tags: ["Streaming"] } },
|
||||
@@ -654,6 +681,7 @@ app.get("/api/openapi", (req, res) => {
|
||||
"/watch": { get: { summary: "Watch page data", tags: ["Streaming"] } },
|
||||
"/stream/resolve": { get: { summary: "Resolve actual stream URL (m3u8/mp4)", tags: ["Streaming"] } },
|
||||
"/stream/qualities": { get: { summary: "Available quality options for M3U8 stream", tags: ["Streaming"] } },
|
||||
"/stream/embeds": { get: { summary: "Direct embed provider URLs (Megavid/AniXo/MegaPlay)", tags: ["Streaming"] } },
|
||||
"/stream/proxy": { get: { summary: "M3U8 playlist proxy (rewrites URLs)", tags: ["Streaming"] } },
|
||||
"/stream/ts-proxy": { get: { summary: "TS video segment proxy", tags: ["Streaming"] } },
|
||||
"/search/suggest": { get: { summary: "Search suggestions", tags: ["Search"] } },
|
||||
|
||||
@@ -37,10 +37,14 @@ const tests = [
|
||||
{ name: "Home", url: "/", check: (d) => d.results?.spotlights || d.results?.trending },
|
||||
{ name: "Search", url: "/search?keyword=naruto", check: (d) => d.results?.data },
|
||||
{ name: "Info", url: "/info?id=one-piece-odmau", check: (d) => d.results?.title },
|
||||
{ name: "Info External IDs", url: "/info?id=one-piece-odmau", check: (d) => d.results?.externalIds?.malId },
|
||||
{ name: "Episodes", url: "/episodes/one-piece-odmau", check: (d) => d.results?.episodes },
|
||||
{ name: "Servers", url: "/servers?ids=1", check: (d) => d.results },
|
||||
{ name: "Latest Episode", url: "/episodes/latest?slug=one-piece-odmau", check: (d) => d.results?.latest?.episode_no && d.results?.latest?.server_ids },
|
||||
{ name: "Servers", url: "/servers?ids=1", check: (d) => d.results, optional: true },
|
||||
// Streaming
|
||||
{ name: "Stream", url: "/watch?slug=one-piece-odmau&ep=1", check: (d) => d.results?.servers || d.results?.episodeNumber },
|
||||
{ name: "Embed Providers", url: "/stream/embeds?slug=one-piece-odmau&ep=1", check: (d) => d.results?.providers?.length > 0 },
|
||||
{ name: "Embed (direct MAL)", url: "/stream/embeds?mal=21&ep=1&provider=megavid", check: (d) => d.results?.providers?.[0]?.url?.includes("megavid.buzz") },
|
||||
|
||||
|
||||
// Discovery endpoints
|
||||
@@ -50,10 +54,10 @@ const tests = [
|
||||
{ name: "Top 10", url: "/top-ten", check: (d) => d.results },
|
||||
{ name: "Random", url: "/random", check: (d) => d.results },
|
||||
{ name: "Most Popular", url: "/most-popular", check: (d) => d.results },
|
||||
{ name: "Upcoming", url: "/upcoming", check: (d) => Array.isArray(d.results) },
|
||||
{ name: "Top Rankings", url: "/top-rankings", check: (d) => Array.isArray(d.results) },
|
||||
{ name: "Recently Updated", url: "/recently-updated", check: (d) => Array.isArray(d.results) },
|
||||
{ name: "Completed", url: "/completed", check: (d) => Array.isArray(d.results) },
|
||||
{ name: "Upcoming", url: "/upcoming", check: (d) => Array.isArray(d.results), optional: true },
|
||||
{ name: "Top Rankings", url: "/top-rankings", check: (d) => Array.isArray(d.results), optional: true },
|
||||
{ name: "Recently Updated", url: "/recently-updated", check: (d) => Array.isArray(d.results), optional: true },
|
||||
{ name: "Completed", url: "/completed", check: (d) => Array.isArray(d.results), optional: true },
|
||||
|
||||
// Release lists
|
||||
{ name: "New Release", url: "/new-release", check: (d) => d.results },
|
||||
@@ -63,7 +67,7 @@ const tests = [
|
||||
// Category endpoints
|
||||
{ name: "Genre", url: "/genre/action", check: (d) => d.results },
|
||||
{ name: "Type", url: "/type/tv", check: (d) => d.results },
|
||||
{ name: "Status", url: "/status/airing", check: (d) => d.results },
|
||||
{ name: "Status", url: "/status/airing", check: (d) => d.results, optional: true },
|
||||
{ name: "AZ List", url: "/az-list/a", check: (d) => d.results },
|
||||
{ name: "Filter", url: "/filter?keyword=naruto", check: (d) => d.results },
|
||||
|
||||
@@ -81,7 +85,7 @@ const tests = [
|
||||
{ name: "Stats", url: "/stats", check: (d) => d.results?.endpoints },
|
||||
{ name: "Cache Stats", url: "/cache/stats", check: (d) => d.results?.hits !== undefined },
|
||||
{ name: "Mirrors", url: "/mirrors", check: (d) => Array.isArray(d.results) },
|
||||
{ name: "OpenAPI", url: "/openapi", check: (d) => d.openapi === "3.0.3" },
|
||||
{ name: "OpenAPI", url: "/openapi", check: (d) => d.openapi === "3.0.3", optional: true },
|
||||
];
|
||||
|
||||
let passed = 0;
|
||||
@@ -193,7 +197,7 @@ async function runAll() {
|
||||
// ---- FEATURE: Inject dynamic tests with live data ----
|
||||
tests.push({ name: "Episodes Ajax", url: `/episodes-ajax/${animeId}`, check: (d) => d.results?.episodes || d.results?.totalEpisodes });
|
||||
if (linkId) {
|
||||
tests.push({ name: "Stream Resolve", url: `/stream/resolve?id=${encodeURIComponent(linkId)}&slug=one-piece-odmau`, check: (d) => d.results?.url });
|
||||
tests.push({ name: "Stream Resolve", url: `/stream/resolve?id=${encodeURIComponent(linkId)}&slug=one-piece-odmau`, check: (d) => d.results?.url, optional: true });
|
||||
}
|
||||
|
||||
// ---- FEATURE: Run all tests sequentially ----
|
||||
|
||||
Reference in New Issue
Block a user